mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
feature (console): add GDC connect DB widget
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/7893 Co-authored-by: Matthew Goodwin <49927862+m4ttheweric@users.noreply.github.com> GitOrigin-RevId: c0b5fa4b89a83b953781f78025e7da9c29843c74
This commit is contained in:
parent
fd0ba0013d
commit
50627a3af2
@ -0,0 +1,32 @@
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { ConnectGDCSourceWidget } from './ConnectGDCSourceWidget';
|
||||
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
|
||||
import { handlers } from '../../mocks/handlers.mock';
|
||||
|
||||
export default {
|
||||
component: ConnectGDCSourceWidget,
|
||||
decorators: [ReactQueryDecorator()],
|
||||
parameters: {
|
||||
msw: handlers(),
|
||||
},
|
||||
} as ComponentMeta<typeof ConnectGDCSourceWidget>;
|
||||
|
||||
export const CreateConnection: ComponentStory<
|
||||
typeof ConnectGDCSourceWidget
|
||||
> = () => {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<ConnectGDCSourceWidget driver="sqlite" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditConnection: ComponentStory<
|
||||
typeof ConnectGDCSourceWidget
|
||||
> = () => {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<ConnectGDCSourceWidget driver="sqlite" dataSourceName="sqlite_test" />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,185 @@
|
||||
import { DataSource, Feature } from '@/features/DataSource';
|
||||
import { useHttpClient } from '@/features/Network';
|
||||
import { OpenApi3Form } from '@/features/OpenApi3Form';
|
||||
import { Button } from '@/new-components/Button';
|
||||
import { transformSchemaToZodObject } from '@/features/OpenApi3Form/utils';
|
||||
import { InputField, useConsoleForm } from '@/new-components/Form';
|
||||
import { Tabs } from '@/new-components/Tabs';
|
||||
import { get } from 'lodash';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FaExclamationTriangle } from 'react-icons/fa';
|
||||
import { useQuery } from 'react-query';
|
||||
import { z, ZodSchema } from 'zod';
|
||||
import { graphQLCustomizationSchema } from '../GraphQLCustomization/schema';
|
||||
import { GraphQLCustomization } from '../GraphQLCustomization/GraphQLCustomization';
|
||||
import { useMetadata } from '@/features/hasura-metadata-api';
|
||||
import { adaptGraphQLCustomization } from '../GraphQLCustomization/utils/adaptResponse';
|
||||
import { generateGDCRequestPayload } from './utils/generateRequest';
|
||||
import { hasuraToast } from '@/new-components/Toasts';
|
||||
import { useManageDatabaseConnection } from '../../hooks/useManageDatabaseConnection';
|
||||
import { capitaliseFirstLetter } from '@/components/Common/ConfigureTransformation/utils';
|
||||
|
||||
interface ConnectGDCSourceWidgetProps {
|
||||
driver: string;
|
||||
dataSourceName?: string;
|
||||
}
|
||||
|
||||
const useFormValidationSchema = (driver: string) => {
|
||||
const httpClient = useHttpClient();
|
||||
return useQuery({
|
||||
queryKey: ['form-schema', driver],
|
||||
queryFn: async () => {
|
||||
const configSchemas = await DataSource(
|
||||
httpClient
|
||||
).connectDB.getConfigSchema(driver);
|
||||
|
||||
if (!configSchemas || configSchemas === Feature.NotImplemented)
|
||||
throw Error('Could not retrive config schema info for driver');
|
||||
|
||||
const validationSchema = z.object({
|
||||
name: z.string().min(1, 'Name is a required field!'),
|
||||
configuration: transformSchemaToZodObject(
|
||||
configSchemas.configSchema,
|
||||
configSchemas.otherSchemas
|
||||
),
|
||||
customization: graphQLCustomizationSchema.optional(),
|
||||
});
|
||||
|
||||
return { validationSchema, configSchemas };
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const ConnectGDCSourceWidget = (props: ConnectGDCSourceWidgetProps) => {
|
||||
const { driver, dataSourceName } = props;
|
||||
const [tab, setTab] = useState('connection_details');
|
||||
|
||||
const { data: metadataSource } = useMetadata(m =>
|
||||
m.metadata.sources.find(source => source.name === dataSourceName)
|
||||
);
|
||||
|
||||
const { createConnection, editConnection, isLoading } =
|
||||
useManageDatabaseConnection({
|
||||
onSuccess: () => {
|
||||
hasuraToast({
|
||||
type: 'success',
|
||||
title: isEditMode
|
||||
? 'Database updated successfully!'
|
||||
: 'Database added successfully!',
|
||||
});
|
||||
},
|
||||
onError: err => {
|
||||
hasuraToast({
|
||||
type: 'error',
|
||||
title: 'An error occurred while adding database',
|
||||
children: JSON.stringify(err),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isEditMode = !!dataSourceName;
|
||||
|
||||
const { data } = useFormValidationSchema(driver);
|
||||
|
||||
const [schema, setSchema] = useState<ZodSchema>(z.any());
|
||||
|
||||
const {
|
||||
Form,
|
||||
methods: { formState, reset },
|
||||
} = useConsoleForm({
|
||||
schema,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.validationSchema) setSchema(data.validationSchema);
|
||||
}, [data?.validationSchema]);
|
||||
|
||||
useEffect(() => {
|
||||
if (metadataSource)
|
||||
reset({
|
||||
name: metadataSource?.name,
|
||||
// This is a particularly weird case with metadata only valid for GDC sources.
|
||||
configuration: (metadataSource?.configuration as any).value,
|
||||
customization: adaptGraphQLCustomization(
|
||||
metadataSource?.customization ?? {}
|
||||
),
|
||||
});
|
||||
}, [metadataSource, reset]);
|
||||
|
||||
if (!data?.configSchemas) return null;
|
||||
|
||||
const handleSubmit = (formValues: any) => {
|
||||
const payload = generateGDCRequestPayload({
|
||||
driver,
|
||||
values: formValues,
|
||||
});
|
||||
|
||||
if (isEditMode) {
|
||||
editConnection(payload);
|
||||
} else {
|
||||
createConnection(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const connectionDetailsTabErrors = [
|
||||
get(formState.errors, 'name'),
|
||||
get(formState.errors, 'configuration.connectionInfo'),
|
||||
].filter(Boolean);
|
||||
|
||||
console.log(formState.errors);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xl text-gray-600 font-semibold">
|
||||
{isEditMode
|
||||
? `Edit ${capitaliseFirstLetter(driver)} Connection`
|
||||
: `Connect New ${capitaliseFirstLetter(driver)} Database`}
|
||||
</div>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={value => setTab(value)}
|
||||
items={[
|
||||
{
|
||||
value: 'connection_details',
|
||||
label: 'Connection Details',
|
||||
icon: connectionDetailsTabErrors.length ? (
|
||||
<FaExclamationTriangle className="text-red-800" />
|
||||
) : undefined,
|
||||
content: (
|
||||
<div className="mt-sm">
|
||||
<InputField
|
||||
name="name"
|
||||
label="Database Name"
|
||||
placeholder="Database name"
|
||||
/>
|
||||
<OpenApi3Form
|
||||
name="configuration"
|
||||
schemaObject={data?.configSchemas.configSchema}
|
||||
references={data?.configSchemas.otherSchemas}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'customization',
|
||||
label: 'GraphQL Customization',
|
||||
content: <GraphQLCustomization name="customization" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
mode="primary"
|
||||
isLoading={isLoading}
|
||||
loadingText="Saving"
|
||||
>
|
||||
{isEditMode ? 'Update Connection' : 'Connect Database'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,23 @@
|
||||
import { DatabaseConnection } from '@/features/ConnectDBRedesign/types';
|
||||
import { cleanEmpty } from '../../ConnectPostgresWidget/utils/helpers';
|
||||
import { generateGraphQLCustomizationInfo } from '../../GraphQLCustomization';
|
||||
|
||||
export const generateGDCRequestPayload = ({
|
||||
driver,
|
||||
values,
|
||||
}: {
|
||||
driver: string;
|
||||
values: any;
|
||||
}): DatabaseConnection => {
|
||||
const payload = {
|
||||
driver,
|
||||
details: {
|
||||
name: values.name,
|
||||
configuration: values.configuration,
|
||||
customization: generateGraphQLCustomizationInfo(
|
||||
values.customization ?? {}
|
||||
),
|
||||
},
|
||||
};
|
||||
return cleanEmpty(payload);
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
export { GraphQLCustomization } from './GraphQLCustomization';
|
||||
export { adaptGraphQLCustomization } from './utils/adaptResponse';
|
||||
export { generateGraphQLCustomizationInfo } from './utils/generateRequest';
|
||||
export {
|
||||
GraphQLCustomizationSchema,
|
||||
graphQLCustomizationSchema,
|
||||
} from './schema';
|
@ -6,6 +6,21 @@ const mockMetadata: Metadata = {
|
||||
metadata: {
|
||||
version: 3,
|
||||
sources: [
|
||||
{
|
||||
name: 'sqlite_test',
|
||||
kind: 'sqlite',
|
||||
tables: [],
|
||||
configuration: {
|
||||
template: null,
|
||||
timeout: null,
|
||||
value: {
|
||||
db: './chinook.db',
|
||||
explicit_main_schema: false,
|
||||
include_sqlite_meta_tables: false,
|
||||
tables: ['Album', 'Artist', 'Genre', 'Track'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'chinook',
|
||||
kind: 'postgres',
|
||||
@ -77,6 +92,93 @@ const mockMetadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export const mockCapabilitiesResponse = {
|
||||
capabilities: {
|
||||
comparisons: { subquery: { supports_relations: true } },
|
||||
data_schema: { supports_foreign_keys: true, supports_primary_keys: true },
|
||||
explain: {},
|
||||
queries: {},
|
||||
raw: {},
|
||||
relationships: {},
|
||||
scalar_types: {
|
||||
DateTime: {
|
||||
comparison_operators: { _in_year: 'int' },
|
||||
graphql_type: 'String',
|
||||
},
|
||||
bool: {
|
||||
comparison_operators: {
|
||||
_and: 'bool',
|
||||
_nand: 'bool',
|
||||
_or: 'bool',
|
||||
_xor: 'bool',
|
||||
},
|
||||
graphql_type: 'Boolean',
|
||||
},
|
||||
decimal: {
|
||||
aggregate_functions: { max: 'decimal', min: 'decimal', sum: 'decimal' },
|
||||
comparison_operators: { _modulus_is_zero: 'decimal' },
|
||||
graphql_type: 'Float',
|
||||
update_column_operators: {
|
||||
dec: { argument_type: 'decimal' },
|
||||
inc: { argument_type: 'decimal' },
|
||||
},
|
||||
},
|
||||
number: {
|
||||
aggregate_functions: { max: 'number', min: 'number', sum: 'number' },
|
||||
comparison_operators: { _modulus_is_zero: 'number' },
|
||||
graphql_type: 'Float',
|
||||
update_column_operators: {
|
||||
dec: { argument_type: 'number' },
|
||||
inc: { argument_type: 'number' },
|
||||
},
|
||||
},
|
||||
string: {
|
||||
aggregate_functions: { max: 'string', min: 'string' },
|
||||
comparison_operators: { _glob: 'string', _like: 'string' },
|
||||
graphql_type: 'String',
|
||||
},
|
||||
},
|
||||
},
|
||||
config_schema_response: {
|
||||
config_schema: {
|
||||
nullable: false,
|
||||
properties: {
|
||||
DEBUG: {
|
||||
additionalProperties: true,
|
||||
description: 'For debugging.',
|
||||
nullable: true,
|
||||
type: 'object',
|
||||
},
|
||||
db: { description: 'The SQLite database file to use.', type: 'string' },
|
||||
explicit_main_schema: {
|
||||
default: false,
|
||||
description: "Prefix all tables with the 'main' schema",
|
||||
nullable: true,
|
||||
type: 'boolean',
|
||||
},
|
||||
include_sqlite_meta_tables: {
|
||||
description:
|
||||
'By default index tables, etc are not included, set this to true to include them.',
|
||||
nullable: true,
|
||||
type: 'boolean',
|
||||
},
|
||||
tables: {
|
||||
description:
|
||||
'List of tables to make available in the schema and for querying',
|
||||
items: { $ref: '#/other_schemas/TableName' },
|
||||
nullable: true,
|
||||
type: 'array',
|
||||
},
|
||||
},
|
||||
required: ['db'],
|
||||
type: 'object',
|
||||
},
|
||||
other_schemas: { TableName: { nullable: false, type: 'string' } },
|
||||
},
|
||||
display_name: 'Hasura SQLite',
|
||||
options: { uri: 'http://host.docker.internal:8100' },
|
||||
};
|
||||
|
||||
export const handlers = () => [
|
||||
rest.post('http://localhost:8080/v1/metadata', (req, res, ctx) => {
|
||||
const requestBody = req.body as Record<string, any>;
|
||||
@ -84,6 +186,9 @@ export const handlers = () => [
|
||||
if (requestBody.type === 'export_metadata')
|
||||
return res(ctx.json(mockMetadata));
|
||||
|
||||
if (requestBody.type === 'get_source_kind_capabilities')
|
||||
return res(ctx.json(mockCapabilitiesResponse));
|
||||
|
||||
return res(ctx.json({}));
|
||||
}),
|
||||
rest.get(`http://localhost:8080/v1alpha1/config`, (req, res, ctx) => {
|
||||
|
2
frontend/package-lock.json
generated
2
frontend/package-lock.json
generated
@ -95339,7 +95339,7 @@
|
||||
"@emotion/core": "^10.0.22",
|
||||
"@mdx-js/react": "^1.5.2",
|
||||
"codemirror": "5.51.0",
|
||||
"codemirror-graphql": "0.12.2",
|
||||
"codemirror-graphql": "^0.12.0-alpha.0",
|
||||
"copy-to-clipboard": "^3.2.0",
|
||||
"entities": "^2.0.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
|
@ -35,11 +35,11 @@
|
||||
"libs/console/legacy-ce/src/exports/main.js"
|
||||
],
|
||||
"@hasura/internal-plugin": ["libs/nx/internal-plugin/src/index.ts"],
|
||||
"unplugin-dynamic-asset-loader": [
|
||||
"libs/nx/unplugin-dynamic-asset-loader/src/index.ts"
|
||||
],
|
||||
"storybook-addon-console-env": [
|
||||
"libs/nx/storybook-addon-console-env/src/index.ts"
|
||||
],
|
||||
"unplugin-dynamic-asset-loader": [
|
||||
"libs/nx/unplugin-dynamic-asset-loader/src/index.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
@ -10,7 +10,7 @@
|
||||
"console-legacy-ee": "libs/console/legacy-ee",
|
||||
"nx-internal-plugin": "libs/nx/internal-plugin",
|
||||
"nx-internal-plugin-e2e": "apps/nx/internal-plugin-e2e",
|
||||
"nx-unplugin-dynamic-asset-loader": "libs/nx/unplugin-dynamic-asset-loader",
|
||||
"nx-storybook-addon-console-env": "libs/nx/storybook-addon-console-env"
|
||||
"nx-storybook-addon-console-env": "libs/nx/storybook-addon-console-env",
|
||||
"nx-unplugin-dynamic-asset-loader": "libs/nx/unplugin-dynamic-asset-loader"
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user