mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-09-20 06:58:39 +03:00
console: new "add remote schema" page (with GQL customization)
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/4428 GitOrigin-RevId: d8f82ffe86fd59abb43afa195b40c2fdafabb847
This commit is contained in:
parent
f7b4d32941
commit
db07dcf60d
@ -17,6 +17,7 @@
|
||||
- console: add remote database relationships for views
|
||||
- console: bug fixes for RS-to-RS relationships
|
||||
- console: allow users to remove prefix / suffix / root field namespace from a remote schema
|
||||
- console: new "add remote schema" page (with GQL customization)
|
||||
- cli: avoid exporting hasura-specific schemas during hasura init (#8352)
|
||||
- cli: fix performance regression in `migrate status` command (fix #8398)
|
||||
|
||||
|
14
console/package-lock.json
generated
14
console/package-lock.json
generated
@ -21566,6 +21566,15 @@
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"@types/lodash.pickby": {
|
||||
"version": "4.6.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash.pickby/-/lodash.pickby-4.6.7.tgz",
|
||||
"integrity": "sha512-4ebXRusuLflfscbD0PUX4eVknDHD9Yf+uMtBIvA/hrnTqeAzbuHuDjvnYriLjUrI9YrhCPVKUf4wkRSXJQ6gig==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"@types/mdast": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
|
||||
@ -35839,6 +35848,11 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
|
||||
},
|
||||
"lodash.pickby": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz",
|
||||
"integrity": "sha1-feoh2MGNdwOifHBMFdO4SmfjOv8="
|
||||
},
|
||||
"lodash.restparam": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.restparam/-/lodash.restparam-3.6.1.tgz",
|
||||
|
@ -88,6 +88,7 @@
|
||||
"less": "3.11.1",
|
||||
"lodash.get": "4.4.2",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.pickby": "^4.6.0",
|
||||
"moment": "^2.26.0",
|
||||
"piping": "0.3.2",
|
||||
"prop-types": "15.7.2",
|
||||
@ -170,6 +171,7 @@
|
||||
"@types/jwt-decode": "2.2.1",
|
||||
"@types/lodash": "^4.14.159",
|
||||
"@types/lodash.merge": "^4.6.6",
|
||||
"@types/lodash.pickby": "^4.6.7",
|
||||
"@types/mini-css-extract-plugin": "0.9.1",
|
||||
"@types/optimize-css-assets-webpack-plugin": "5.0.1",
|
||||
"@types/react": "17.0.39",
|
||||
|
@ -1,52 +1,75 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import Common from '../Common/Common';
|
||||
|
||||
import { addRemoteSchema, RESET } from './addRemoteSchemaReducer';
|
||||
import Helmet from 'react-helmet';
|
||||
import Button from '../../../Common/Button/Button';
|
||||
import { RemoteSchema } from '@/features/RemoteSchema';
|
||||
import { appPrefix, pageTitle } from '../constants';
|
||||
import {
|
||||
availableFeatureFlagIds,
|
||||
FeatureFlagToast,
|
||||
useIsFeatureFlagEnabled,
|
||||
} from '@/features/FeatureFlags';
|
||||
import { exportMetadata } from '@/metadata/actions';
|
||||
import _push from '../../Data/push';
|
||||
|
||||
import { pageTitle } from '../constants';
|
||||
const Add = ({ isRequesting, dispatch, ...props }) => {
|
||||
const styles = require('../RemoteSchema.scss');
|
||||
|
||||
class Add extends React.Component {
|
||||
componentWillUnmount() {
|
||||
this.props.dispatch({ type: RESET });
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dispatch({ type: RESET });
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { isLoading, enabled } = useIsFeatureFlagEnabled(
|
||||
availableFeatureFlagIds.addRemoteSchemaId
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return 'Loading...';
|
||||
}
|
||||
|
||||
render() {
|
||||
const styles = require('../RemoteSchema.scss');
|
||||
|
||||
const { isRequesting, dispatch } = this.props;
|
||||
|
||||
if (enabled) {
|
||||
return (
|
||||
<div className={styles.addWrapper}>
|
||||
<Helmet title={`Add ${pageTitle} - ${pageTitle}s | Hasura`} />
|
||||
<div className={styles.heading_text}>Add a new remote schema</div>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
dispatch(addRemoteSchema());
|
||||
}}
|
||||
>
|
||||
<Common isNew {...this.props} />
|
||||
<div className={styles.commonBtn}>
|
||||
<Button
|
||||
type="submit"
|
||||
color="yellow"
|
||||
size="sm"
|
||||
// disabled={isRequesting} // TODO
|
||||
data-test="add-remote-schema-submit"
|
||||
>
|
||||
{isRequesting ? 'Adding...' : 'Add Remote Schema'}
|
||||
</Button>
|
||||
{/*
|
||||
<button className={styles.default_button}>Cancel</button>
|
||||
*/}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<RemoteSchema.Create
|
||||
onSuccess={remoteSchemaName => {
|
||||
// This only exists right now because the sidebar is reading from redux state
|
||||
dispatch(exportMetadata()).then(() => {
|
||||
dispatch(_push(`${appPrefix}/manage/${remoteSchemaName}/details`));
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.addWrapper}>
|
||||
<Helmet title={`Add ${pageTitle} - ${pageTitle}s | Hasura`} />
|
||||
<div className={styles.heading_text}>Add a new remote schema</div>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
dispatch(addRemoteSchema());
|
||||
}}
|
||||
>
|
||||
<Common isNew {...props} dispatch={dispatch} />
|
||||
<div className={styles.commonBtn}>
|
||||
<Button
|
||||
type="submit"
|
||||
color="yellow"
|
||||
size="sm"
|
||||
data-test="add-remote-schema-submit"
|
||||
>
|
||||
{isRequesting ? 'Adding...' : 'Add Remote Schema'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<FeatureFlagToast flagId={availableFeatureFlagIds.addRemoteSchemaId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { FeatureFlagDefinition } from './types';
|
||||
|
||||
const relationshipTabTablesId = '0bea35ff-d3e9-45e9-af1b-59923bf82fa9';
|
||||
const addRemoteSchemaId = 'bf57c2ba-cab2-11ec-9d64-0242ac120002';
|
||||
|
||||
export const availableFeatureFlagIds = {
|
||||
relationshipTabTablesId,
|
||||
addRemoteSchemaId,
|
||||
};
|
||||
|
||||
export const availableFeatureFlags: FeatureFlagDefinition[] = [
|
||||
@ -18,4 +20,14 @@ export const availableFeatureFlags: FeatureFlagDefinition[] = [
|
||||
// defaultValue: false,
|
||||
// discussionUrl: '',
|
||||
// },
|
||||
{
|
||||
id: addRemoteSchemaId,
|
||||
title: 'New create remote schema page',
|
||||
description:
|
||||
'Try out the new Add Remote Schema page that supports GraphQL customization',
|
||||
section: 'remote schemas',
|
||||
status: 'alpha',
|
||||
defaultValue: false,
|
||||
discussionUrl: '',
|
||||
},
|
||||
];
|
||||
|
@ -35,6 +35,7 @@ export const allowedMetadataTypesArr = [
|
||||
'create_remote_schema_remote_relationship',
|
||||
'update_remote_schema_remote_relationship',
|
||||
'delete_remote_schema_remote_relationship',
|
||||
'add_remote_schema',
|
||||
'bulk',
|
||||
] as const;
|
||||
|
||||
|
@ -0,0 +1,93 @@
|
||||
import React, { useReducer } from 'react';
|
||||
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
|
||||
import { ComponentMeta, Story } from '@storybook/react';
|
||||
import { RemoteSchema } from '@/features/RemoteSchema';
|
||||
import { ReduxDecorator } from '@/storybook/decorators/redux-decorator';
|
||||
import { within, userEvent } from '@storybook/testing-library';
|
||||
import { expect } from '@storybook/jest';
|
||||
import { handlers } from './mocks/handlers.mock';
|
||||
|
||||
export default {
|
||||
title: 'RemoteSchema/components/Create',
|
||||
component: RemoteSchema.Create,
|
||||
decorators: [ReactQueryDecorator(), ReduxDecorator({})],
|
||||
parameters: {
|
||||
msw: handlers(),
|
||||
},
|
||||
} as ComponentMeta<typeof RemoteSchema.Create>;
|
||||
|
||||
export const Playground: Story = () => {
|
||||
const [formSuccess, toggle] = useReducer(s => !s, false);
|
||||
return (
|
||||
<>
|
||||
<RemoteSchema.Create onSuccess={() => toggle()} />
|
||||
{formSuccess ? (
|
||||
<p data-testid="form-result">Form saved succesfully!</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Playground.play = async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
userEvent.click(await canvas.findByTestId('submit'));
|
||||
|
||||
// expect error messages
|
||||
expect(
|
||||
await canvas.findByText('Remote Schema name is a required field!')
|
||||
).toBeInTheDocument();
|
||||
expect(await canvas.findByText('Invalid url')).toBeInTheDocument();
|
||||
|
||||
// Fill up the fields
|
||||
// const nameInput = await canvas.findByLabelText('Name');
|
||||
userEvent.type(await canvas.findByTestId('name'), 'test');
|
||||
userEvent.type(await canvas.findByTestId('url'), 'http://example.com');
|
||||
userEvent.type(await canvas.findByTestId('timeout_seconds'), '180');
|
||||
userEvent.click(await canvas.findByTestId('forward_client_headers'));
|
||||
|
||||
userEvent.click(await canvas.findByTestId('open_customization'));
|
||||
|
||||
userEvent.type(
|
||||
await canvas.findByTestId('customization.root_fields_namespace'),
|
||||
'root_field_namespace_example'
|
||||
);
|
||||
userEvent.type(
|
||||
await canvas.findByTestId('customization.type_prefix'),
|
||||
'type_prefix_example_'
|
||||
);
|
||||
userEvent.type(
|
||||
await canvas.findByTestId('customization.type_suffix'),
|
||||
'_type_suffix_example'
|
||||
);
|
||||
userEvent.type(
|
||||
await canvas.findByTestId('customization.query_root.parent_type'),
|
||||
'query_root_parent_type_example_'
|
||||
);
|
||||
userEvent.type(
|
||||
await canvas.findByTestId('customization.query_root.prefix'),
|
||||
'query_root_prefix_example_'
|
||||
);
|
||||
userEvent.type(
|
||||
await canvas.findByTestId('customization.query_root.suffix'),
|
||||
'_query_root_suffix_example'
|
||||
);
|
||||
userEvent.type(
|
||||
await canvas.findByTestId('customization.mutation_root.parent_type'),
|
||||
'mutation_root_parent_type_example_'
|
||||
);
|
||||
userEvent.type(
|
||||
await canvas.findByTestId('customization.mutation_root.prefix'),
|
||||
'mutation_root_prefix_example_'
|
||||
);
|
||||
userEvent.type(
|
||||
await canvas.findByTestId('customization.mutation_root.suffix'),
|
||||
'_mutation_root_suffix_example'
|
||||
);
|
||||
|
||||
userEvent.click(await canvas.findByTestId('submit'));
|
||||
|
||||
expect(
|
||||
await canvas.findByText('Form saved succesfully!')
|
||||
).toBeInTheDocument();
|
||||
};
|
416
console/src/features/RemoteSchema/components/Create/Create.tsx
Normal file
416
console/src/features/RemoteSchema/components/Create/Create.tsx
Normal file
@ -0,0 +1,416 @@
|
||||
import { useMetadataMigration } from '@/features/MetadataAPI';
|
||||
import { Button } from '@/new-components/Button';
|
||||
import { FieldError } from 'react-hook-form';
|
||||
import { Form, InputField } from '@/new-components/Form';
|
||||
import { fireNotification } from '@/new-components/Notifications';
|
||||
import { ToolTip } from '@/new-components/Tooltip';
|
||||
import get from 'lodash.get';
|
||||
import { APIError } from '@/hooks/error';
|
||||
import React, { useState } from 'react';
|
||||
import { FaExclamationCircle, FaPlusCircle } from 'react-icons/fa';
|
||||
import { Headers } from '../Headers';
|
||||
import { schema, Schema } from './schema';
|
||||
import { transformFormData } from './utils';
|
||||
|
||||
type Props = {
|
||||
onSuccess?: (remoteSchemaName?: string) => void;
|
||||
};
|
||||
|
||||
export const Create = ({ onSuccess }: Props) => {
|
||||
const [remoteSchemaName, setRemoteSchemaName] = useState('');
|
||||
|
||||
const mutation = useMetadataMigration(
|
||||
{
|
||||
onError: (error: APIError) => {
|
||||
fireNotification({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: error?.message ?? 'Unable to create Remote Schema',
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
fireNotification({
|
||||
type: 'success',
|
||||
title: 'Success!',
|
||||
message: 'Remote Schema created successfully',
|
||||
});
|
||||
|
||||
if (onSuccess) onSuccess(remoteSchemaName);
|
||||
},
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
const onSubmit = (values: Schema) => {
|
||||
const args = transformFormData(values);
|
||||
setRemoteSchemaName(values.name);
|
||||
|
||||
mutation.mutate({
|
||||
source: '',
|
||||
query: { type: 'add_remote_schema', args },
|
||||
migrationName: 'createRemoteSchema',
|
||||
});
|
||||
};
|
||||
const defaultValues: Schema = {
|
||||
name: '',
|
||||
url: '',
|
||||
headers: [],
|
||||
forward_client_headers: false,
|
||||
timeout_seconds: '',
|
||||
comment: '',
|
||||
customization: {
|
||||
root_fields_namespace: '',
|
||||
type_prefix: '',
|
||||
type_suffix: '',
|
||||
query_root: {
|
||||
parent_type: '',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
},
|
||||
mutation_root: {
|
||||
parent_type: '',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [openCustomizationWidget, setOpenCustomizationWidget] = useState(false);
|
||||
|
||||
return (
|
||||
<Form
|
||||
schema={schema}
|
||||
onSubmit={onSubmit}
|
||||
options={{ defaultValues }}
|
||||
className="overflow-y-hidden p-4"
|
||||
>
|
||||
{options => {
|
||||
const queryRootError = get(
|
||||
options.formState.errors,
|
||||
'customization.query_root'
|
||||
) as FieldError | undefined;
|
||||
|
||||
const mutationRootError = get(
|
||||
options.formState.errors,
|
||||
'customization.mutation_root'
|
||||
) as FieldError | undefined;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl">
|
||||
<h1 className="text-xl leading-6 font-semibold mb-lg">
|
||||
Add Remote Schema
|
||||
</h1>
|
||||
<div className="mb-md w-6/12">
|
||||
<InputField
|
||||
name="name"
|
||||
label="Remote Schema Name"
|
||||
placeholder="Name..."
|
||||
tooltip="give this GraphQL schema a friendly name"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-md w-6/12">
|
||||
<InputField
|
||||
name="comment"
|
||||
label="Comment / Description"
|
||||
placeholder="Comment / Description..."
|
||||
tooltip="A statement to help describe the remote schema in brief"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-md w-6/12">
|
||||
<InputField
|
||||
name="url"
|
||||
label="GraphQL Service URL"
|
||||
placeholder="https://myservice.com/graphql"
|
||||
description="Note: Specifying the server URL via an environmental variable is
|
||||
recommended if you have different URLs for multiple
|
||||
environments."
|
||||
tooltip="Remote GraphQL server’s URL. E.g. https://my-domain/v1/graphql"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-lg w-4/12">
|
||||
<label className="block flex items-center text-gray-600 font-semibold mb-xs">
|
||||
GraphQL Server Timeout
|
||||
<ToolTip message="Configure timeout for your remote GraphQL server. Defaults to 60 seconds." />
|
||||
</label>
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full shadow-sm rounded pr-10 border-gray-300 hover:border-gray-400 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
|
||||
placeholder="60"
|
||||
{...options.register('timeout_seconds')}
|
||||
data-testid="timeout_seconds"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex text-gray-400 items-center pointer-events-none">
|
||||
Seconds
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-lg w-8/12">
|
||||
<h2 className="text-lg font-semibold text-gray-600 ">Headers</h2>
|
||||
|
||||
<div className="items-center mr-sm mb-sm my-sm flex">
|
||||
<input
|
||||
{...options.register('forward_client_headers')}
|
||||
type="checkbox"
|
||||
className="mr-sm border-gray-400 rounded focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-400"
|
||||
value="true"
|
||||
data-testid="forward_client_headers"
|
||||
/>
|
||||
<label className="pl-3 flex items-center">
|
||||
Forward all headers from client
|
||||
<ToolTip message="Custom headers to be sent to the remote GraphQL server" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Headers name="headers" />
|
||||
</div>
|
||||
<div className="mb-lg w-8/12">
|
||||
<h2 className="text-lg font-semibold flex items-center">
|
||||
GraphQL Customizations
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mb-sm">
|
||||
Individual Types and Fields will be editable after saving.
|
||||
<br />
|
||||
<a href="https://spec.graphql.org/June2018/#example-e2969">
|
||||
Read more
|
||||
</a>{' '}
|
||||
about Type and Field naming conventions in the official GraphQL
|
||||
spec
|
||||
</p>
|
||||
|
||||
{openCustomizationWidget ? (
|
||||
<div className="w-full rounded border bg-white border-gray-300 p-4">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => setOpenCustomizationWidget(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
|
||||
<div className="grid gap-3 grid-cols-12 mb-md">
|
||||
<div className="flex items-center col-span-4">
|
||||
<label className="block text-gray-600 font-medium">
|
||||
Root Field Namespace
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-span-8">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full block h-10 shadow-sm rounded border-gray-300 hover:border-gray-400 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
|
||||
placeholder="namespace_"
|
||||
{...options.register(
|
||||
'customization.root_fields_namespace'
|
||||
)}
|
||||
data-testid="customization.root_fields_namespace"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-xs items-center flex">
|
||||
Types
|
||||
<ToolTip message="add a prefix / suffix to all types of the remote schema" />
|
||||
</h2>
|
||||
|
||||
<div className="grid gap-3 grid-cols-12 mb-md">
|
||||
<div className="flex items-center col-span-4">
|
||||
<label className="block text-gray-600 font-medium">
|
||||
Prefix
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-span-8">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full block h-10 shadow-sm rounded border-gray-300 hover:border-gray-400 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
|
||||
placeholder="prefix_"
|
||||
{...options.register('customization.type_prefix')}
|
||||
data-testid="customization.type_prefix"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 grid-cols-12 mb-md">
|
||||
<div className="flex items-center col-span-4">
|
||||
<label className="block text-gray-600 font-medium">
|
||||
Suffix
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-span-8">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full block h-10 shadow-sm rounded border-gray-300 hover:border-gray-400 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
|
||||
placeholder="_suffix"
|
||||
{...options.register('customization.type_suffix')}
|
||||
data-testid="customization.type_suffix"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-xs flex items-center">
|
||||
Fields
|
||||
<ToolTip message="add a prefix / suffix to the fields of the query / mutation root fields" />
|
||||
</h2>
|
||||
|
||||
<h3 className="font-semibold mb-xs text-gray-600 text-lg">
|
||||
Query root
|
||||
</h3>
|
||||
{queryRootError?.message && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-label={queryRootError.message}
|
||||
className="mt-xs text-red-600 flex items-center"
|
||||
>
|
||||
<FaExclamationCircle className="fill-current h-4 mr-xs" />
|
||||
{queryRootError.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-3 grid-cols-12 mb-md">
|
||||
<div className="flex items-center col-span-4">
|
||||
<label className="block text-gray-600 font-medium">
|
||||
Type Name
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-span-8">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full block h-10 shadow-sm rounded border-gray-300 hover:border-gray-400 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
|
||||
placeholder="Query/query_root"
|
||||
{...options.register(
|
||||
'customization.query_root.parent_type'
|
||||
)}
|
||||
data-testid="customization.query_root.parent_type"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 grid-cols-12 mb-md">
|
||||
<div className="flex items-center col-span-4">
|
||||
<label className="block text-gray-600 font-medium">
|
||||
Prefix
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-span-8">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full block h-10 shadow-sm rounded border-gray-300 hover:border-gray-400 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
|
||||
placeholder="prefix_"
|
||||
{...options.register('customization.query_root.prefix')}
|
||||
data-testid="customization.query_root.prefix"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 grid-cols-12 mb-md">
|
||||
<div className="flex items-center col-span-4">
|
||||
<label className="block text-gray-600 font-medium">
|
||||
Suffix
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-span-8">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full block h-10 shadow-sm rounded border-gray-300 hover:border-gray-400 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
|
||||
placeholder="_suffix"
|
||||
{...options.register('customization.query_root.suffix')}
|
||||
data-testid="customization.query_root.suffix"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold mb-xs text-gray-600 text-lg">
|
||||
Mutation root
|
||||
</h3>
|
||||
{mutationRootError?.message && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-label={mutationRootError.message}
|
||||
className="mt-xs text-red-600 flex items-center"
|
||||
>
|
||||
<FaExclamationCircle className="fill-current h-4 mr-xs" />
|
||||
{mutationRootError.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 grid-cols-12 mb-md">
|
||||
<div className="flex items-center col-span-4">
|
||||
<label className="block text-gray-600 font-medium">
|
||||
Type Name
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-span-8">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full block h-10 shadow-sm rounded border-gray-300 hover:border-gray-400 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
|
||||
placeholder="Mutation/mutation_root"
|
||||
{...options.register(
|
||||
'customization.mutation_root.parent_type'
|
||||
)}
|
||||
data-testid="customization.mutation_root.parent_type"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 grid-cols-12 mb-md">
|
||||
<div className="flex items-center col-span-4">
|
||||
<label className="block text-gray-600 font-medium">
|
||||
Prefix
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-span-8">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full block h-10 shadow-sm rounded border-gray-300 hover:border-gray-400 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
|
||||
placeholder="prefix_"
|
||||
{...options.register(
|
||||
'customization.mutation_root.prefix'
|
||||
)}
|
||||
data-testid="customization.mutation_root.prefix"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 grid-cols-12">
|
||||
<div className="flex items-center col-span-4">
|
||||
<label className="block text-gray-600 font-medium">
|
||||
Suffix
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-span-8">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full block h-10 shadow-sm rounded border-gray-300 hover:border-gray-400 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
|
||||
placeholder="_suffix"
|
||||
{...options.register(
|
||||
'customization.mutation_root.suffix'
|
||||
)}
|
||||
data-testid="customization.mutation_root.suffix"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
icon={<FaPlusCircle />}
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => setOpenCustomizationWidget(true)}
|
||||
data-testid="open_customization"
|
||||
>
|
||||
Add GQL Customization
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center mb-lg">
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid="submit"
|
||||
mode="primary"
|
||||
isLoading={mutation.isLoading}
|
||||
>
|
||||
Add Remote Schema
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export { Create } from './Create';
|
@ -0,0 +1,7 @@
|
||||
import { rest } from 'msw';
|
||||
|
||||
export const handlers = () => [
|
||||
rest.post('http://localhost:8080/v1/metadata', (req, res, ctx) => {
|
||||
return res(ctx.json({ message: 'success' }));
|
||||
}),
|
||||
];
|
@ -0,0 +1,51 @@
|
||||
import { z } from 'zod';
|
||||
import { headersSchema } from '../Headers';
|
||||
|
||||
export const schema = z.object({
|
||||
name: z.string().min(1, 'Remote Schema name is a required field!'),
|
||||
url: z.string().url().min(1, 'URL is a required field!'),
|
||||
headers: headersSchema,
|
||||
forward_client_headers: z.preprocess(val => {
|
||||
if (val === 'true') return true;
|
||||
return false;
|
||||
}, z.boolean()),
|
||||
timeout_seconds: z.string().optional(),
|
||||
customization: z.object({
|
||||
root_fields_namespace: z.string(),
|
||||
type_prefix: z.string(),
|
||||
type_suffix: z.string(),
|
||||
query_root: z
|
||||
.object({
|
||||
parent_type: z.string(),
|
||||
prefix: z.string(),
|
||||
suffix: z.string(),
|
||||
})
|
||||
.refine(
|
||||
data => {
|
||||
if ((data.prefix || data.suffix) && !data.parent_type) return false;
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Query type name cannot be empty!',
|
||||
}
|
||||
),
|
||||
mutation_root: z
|
||||
.object({
|
||||
parent_type: z.string(),
|
||||
prefix: z.string(),
|
||||
suffix: z.string(),
|
||||
})
|
||||
.refine(
|
||||
data => {
|
||||
if ((data.prefix || data.suffix) && !data.parent_type) return false;
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Mutation type name cannot be empty!',
|
||||
}
|
||||
),
|
||||
}),
|
||||
comment: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Schema = z.infer<typeof schema>;
|
84
console/src/features/RemoteSchema/components/Create/utils.ts
Normal file
84
console/src/features/RemoteSchema/components/Create/utils.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import pickBy from 'lodash.pickby';
|
||||
import { Schema } from './schema';
|
||||
|
||||
export const transformFormData = (values: Schema) => {
|
||||
const {
|
||||
name,
|
||||
url,
|
||||
headers,
|
||||
forward_client_headers,
|
||||
comment,
|
||||
timeout_seconds,
|
||||
customization: {
|
||||
root_fields_namespace,
|
||||
type_prefix,
|
||||
type_suffix,
|
||||
query_root,
|
||||
mutation_root,
|
||||
},
|
||||
} = values;
|
||||
|
||||
const customization: Record<string, any> = {};
|
||||
|
||||
/* if root field namespace is present */
|
||||
if (root_fields_namespace)
|
||||
customization.root_fields_namespace = root_fields_namespace;
|
||||
|
||||
/* if type prefix & suffix are present */
|
||||
if (type_prefix || type_suffix)
|
||||
customization.type_names = pickBy(
|
||||
{
|
||||
prefix: type_prefix,
|
||||
suffix: type_suffix,
|
||||
},
|
||||
value => value.length > 0
|
||||
);
|
||||
|
||||
/* if Query root customization is present */
|
||||
if (query_root.parent_type && (query_root.prefix || query_root.suffix)) {
|
||||
customization.field_names = [
|
||||
...(customization.field_names ?? []),
|
||||
pickBy(
|
||||
{
|
||||
parent_type: query_root.parent_type,
|
||||
prefix: query_root.prefix,
|
||||
suffix: query_root.suffix,
|
||||
},
|
||||
value => value.length > 0
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/* if Mutation root customization is present */
|
||||
if (
|
||||
mutation_root.parent_type &&
|
||||
(mutation_root.prefix || mutation_root.suffix)
|
||||
) {
|
||||
customization.field_names = [
|
||||
...(customization.field_names ?? []),
|
||||
pickBy(
|
||||
{
|
||||
parent_type: mutation_root.parent_type,
|
||||
prefix: mutation_root.prefix,
|
||||
suffix: mutation_root.suffix,
|
||||
},
|
||||
value => value.length > 0
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const definition: Record<string, any> = {
|
||||
url,
|
||||
forward_client_headers,
|
||||
comment,
|
||||
headers: headers.map(header => {
|
||||
if (header.type === 'from_env')
|
||||
return { name: header.name, value_from_env: header.value };
|
||||
return { name: header.name, value: header.value };
|
||||
}),
|
||||
timeout_seconds: timeout_seconds ? parseInt(timeout_seconds, 10) : 60,
|
||||
customization,
|
||||
};
|
||||
|
||||
return { name, definition };
|
||||
};
|
@ -0,0 +1,83 @@
|
||||
import { Button } from '@/new-components/Button';
|
||||
import React from 'react';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { FaPlusCircle } from 'react-icons/fa';
|
||||
import { RiCloseCircleFill } from 'react-icons/ri';
|
||||
import { THeaders } from './schema';
|
||||
|
||||
export const Headers = ({ name }: { name: string }) => {
|
||||
const { register } = useFormContext();
|
||||
|
||||
const { fields, append, remove } = useFieldArray<Record<string, THeaders>>({
|
||||
name,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{fields.length ? (
|
||||
<div className="grid gap-3 grid-cols-2 mb-sm">
|
||||
<label
|
||||
htmlFor="table_name"
|
||||
className="block text-gray-600 font-medium mb-xs"
|
||||
>
|
||||
Key
|
||||
</label>
|
||||
|
||||
<label
|
||||
htmlFor="table_name"
|
||||
className="block text-gray-600 font-medium mb-xs"
|
||||
>
|
||||
Value
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-2 mb-sm" key={field.id}>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full block h-10 shadow-sm rounded border-gray-300 hover:border-gray-400 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
|
||||
placeholder="Key..."
|
||||
{...register(`${name}[${index}].name`)}
|
||||
/>
|
||||
|
||||
<div className="flex shadow-sm rounded">
|
||||
<select
|
||||
{...register(`${name}[${index}].type`)}
|
||||
className="inline-flex rounded-l border border-r-0 border-gray-300 bg-white hover:border-gray-400 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
|
||||
>
|
||||
<option value="from_value">Value</option>
|
||||
<option value="from_env">Env Var</option>
|
||||
</select>
|
||||
<input
|
||||
{...register(`${name}[${index}].value`)}
|
||||
type="text"
|
||||
className="flex-1 min-w-0 block w-full px-3 py-2 rounded-r border-gray-300 hover:border-gray-400 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
|
||||
placeholder="user-id"
|
||||
/>
|
||||
<div className="col-span-1 flex items-center justify-center pl-1.5">
|
||||
<RiCloseCircleFill
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
data-testid={`${name}_add_new_row`}
|
||||
icon={<FaPlusCircle />}
|
||||
onClick={() => {
|
||||
append({ name: '', value: '', type: 'from_value' });
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
Add additonal header
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
export * from './Headers';
|
||||
export { headersSchema } from './schema';
|
@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const headersSchema = z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
type: z.literal('from_env').or(z.literal('from_value')),
|
||||
})
|
||||
);
|
||||
|
||||
export type THeaders = z.infer<typeof headersSchema>;
|
5
console/src/features/RemoteSchema/index.ts
Normal file
5
console/src/features/RemoteSchema/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Create } from './components/Create';
|
||||
|
||||
export const RemoteSchema = {
|
||||
Create,
|
||||
};
|
@ -25,7 +25,7 @@ const buttonModesStyles: Record<ButtonModes, string> = {
|
||||
destructive:
|
||||
'text-red-600 bg-gray-50 from-transparent to-white border-gray-300 hover:border-gray-400 disabled:border-gray-300 focus:from-bg-gray-50 focus:to-bg-gray-50',
|
||||
primary:
|
||||
'text-gray-600 bg-primary from-primary to-primary-light border-primary-dark hover:border-primary-darker focus:from-primary focus:to-primary disabled:border-primary-dark',
|
||||
'text-gray-600 from-primary to-primary-light border-primary-dark hover:border-primary-darker focus:from-primary focus:to-primary disabled:border-primary-dark',
|
||||
};
|
||||
|
||||
const sharedButtonStyle =
|
||||
|
@ -2,7 +2,6 @@ import React, { ReactElement } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash.get';
|
||||
import { FieldError, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { FieldWrapper, FieldWrapperPassThroughProps } from './FieldWrapper';
|
||||
|
||||
export type InputFieldProps = FieldWrapperPassThroughProps & {
|
||||
@ -33,7 +32,6 @@ export const InputField = ({
|
||||
} = useFormContext();
|
||||
|
||||
const maybeError = get(errors, name) as FieldError | undefined;
|
||||
|
||||
return (
|
||||
<FieldWrapper id={name} {...wrapperProps} error={maybeError}>
|
||||
<div
|
||||
@ -58,8 +56,8 @@ export const InputField = ({
|
||||
className={clsx(
|
||||
'block w-full max-w-xl h-input shadow-sm rounded border border-gray-300 hover:border-gray-400 focus:outline-0 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400 placeholder-gray-500',
|
||||
maybeError
|
||||
? 'border-red-600 hover:border-red-700 placeholder-red-600 '
|
||||
: 'border-gray-300 placeholder-gray-600',
|
||||
? 'border-red-600 hover:border-red-700'
|
||||
: 'border-gray-300',
|
||||
disabled
|
||||
? 'cursor-not-allowed bg-gray-100 border-gray-100'
|
||||
: 'hover:border-gray-400',
|
||||
@ -71,6 +69,7 @@ export const InputField = ({
|
||||
placeholder={placeholder}
|
||||
{...register(name)}
|
||||
disabled={disabled}
|
||||
data-testid={name}
|
||||
/>
|
||||
{iconPosition === 'end' && icon ? (
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user