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:
Vijay Prasanna 2023-02-17 11:01:28 +05:30 committed by hasura-bot
parent fd0ba0013d
commit 50627a3af2
9 changed files with 358 additions and 6 deletions

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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);
};

View File

@ -0,0 +1,7 @@
export { GraphQLCustomization } from './GraphQLCustomization';
export { adaptGraphQLCustomization } from './utils/adaptResponse';
export { generateGraphQLCustomizationInfo } from './utils/generateRequest';
export {
GraphQLCustomizationSchema,
graphQLCustomizationSchema,
} from './schema';

View File

@ -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) => {

View File

@ -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",

View File

@ -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"
]
}
},

View File

@ -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"
}
}