console: refactor ConnectMssqlWidget component + add interaction tests

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/8151
GitOrigin-RevId: c8f91f3cc608c8d9b720aa98d423a5f5c85d3bc9
This commit is contained in:
Vijay Prasanna 2023-03-03 22:48:16 +05:30 committed by hasura-bot
parent 16dac48f0c
commit e2e8543400
11 changed files with 344 additions and 210 deletions

View File

@ -2,6 +2,8 @@ import { ComponentStory, ComponentMeta } from '@storybook/react';
import { ConnectMssqlWidget } from './ConnectMssqlWidget'; import { ConnectMssqlWidget } from './ConnectMssqlWidget';
import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query'; import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query';
import { handlers } from '../../mocks/handlers.mock'; import { handlers } from '../../mocks/handlers.mock';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
export default { export default {
component: ConnectMssqlWidget, component: ConnectMssqlWidget,
@ -15,16 +17,143 @@ export const CreateConnection: ComponentStory<
typeof ConnectMssqlWidget typeof ConnectMssqlWidget
> = () => { > = () => {
return ( return (
<div className="max-w-3xl"> <div className="flex justify-center">
<ConnectMssqlWidget /> <div className="w-1/2">
<ConnectMssqlWidget />
</div>
</div> </div>
); );
}; };
export const EditConnection: ComponentStory<typeof ConnectMssqlWidget> = () => { export const MSSQLCreateConnection: ComponentStory<
typeof ConnectMssqlWidget
> = () => {
return ( return (
<div className="max-w-3xl"> <div className="flex justify-center">
<ConnectMssqlWidget dataSourceName="mssql1" /> <div className="w-1/2">
<ConnectMssqlWidget />
</div>
</div> </div>
); );
}; };
MSSQLCreateConnection.storyName = '🧪 MSSQL Interaction test (add database)';
MSSQLCreateConnection.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
// verify if the right title is displayed. It should contain the word `postgres`.
expect(await canvas.findByText('Connect MSSQL Database')).toBeInTheDocument();
// verify if all the fields are present (in oss mode)
expect(await canvas.findByLabelText('Database name')).toBeInTheDocument();
// There should be exactly 3 supported database connection options
const radioOptions = await canvas.findAllByLabelText('Connect Database via');
expect(radioOptions.length).toBe(2);
const databaseUrlOption = await canvas.findByTestId(
'configuration.connectionInfo.connectionString.connectionType-databaseUrl'
);
expect(databaseUrlOption).toBeInTheDocument();
expect(databaseUrlOption).toBeChecked();
// Expect the first option to have the following input fields
expect(
await canvas.findByPlaceholderText(
'Driver={ODBC Driver 18 for SQL Server};Server=serveraddress;Database=dbname;Uid=username;Pwd=password'
)
).toBeInTheDocument();
// click on the environment variable option and verify if the correct fields are shown
const environmentVariableOption = await canvas.findByTestId(
'configuration.connectionInfo.connectionString.connectionType-envVar'
);
userEvent.click(environmentVariableOption);
expect(
await canvas.findByPlaceholderText('HASURA_GRAPHQL_DB_URL_FROM_ENV')
).toBeInTheDocument();
// Find and click on advanced settings
userEvent.click(await canvas.findByText('Advanced Settings'));
expect(await canvas.findByText('Total Max Connections')).toBeInTheDocument();
expect(await canvas.findByText('Idle Timeout')).toBeInTheDocument();
};
export const MSSQLEditConnection: ComponentStory<
typeof ConnectMssqlWidget
> = () => {
return (
<div className="flex justify-center">
<div className="w-1/2">
<ConnectMssqlWidget dataSourceName="mssql1" />
</div>
</div>
);
};
MSSQLEditConnection.storyName = '🧪 MSSQL Edit Databaase Interaction test';
MSSQLEditConnection.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
// verify if the right title is displayed. It should contain the word `postgres`.
expect(await canvas.findByText('Edit MSSQL Connection')).toBeInTheDocument();
// verify if all the fields are present (in oss mode)
await waitFor(
async () => {
expect(await canvas.findByLabelText('Database name')).toHaveValue(
'mssql1'
);
},
{ timeout: 5000 }
);
const radioOptions = await canvas.findAllByLabelText('Connect Database via');
expect(radioOptions.length).toBe(2);
const databaseUrlOption = await canvas.findByTestId(
'configuration.connectionInfo.connectionString.connectionType-databaseUrl'
);
expect(databaseUrlOption).toBeChecked();
expect(
await canvas.findByTestId(
'configuration.connectionInfo.connectionString.url'
)
).toHaveValue(
'DRIVER={ODBC Driver 17 for SQL Server};SERVER=host.docker.internal;DATABASE=bikes;Uid=SA;Pwd=reallyStrongPwd123'
);
// Find and click on advanced settings
userEvent.click(await canvas.findByText('Advanced Settings'));
expect(
await canvas.findByTestId(
'configuration.connectionInfo.poolSettings.totalMaxConnections'
)
).toHaveValue(50);
expect(
await canvas.findByTestId(
'configuration.connectionInfo.poolSettings.idleTimeout'
)
).toHaveValue(180);
// find and click on graphql customization settings
userEvent.click(await canvas.findByText('GraphQL Customization'));
expect(
await canvas.findByTestId('customization.rootFields.namespace')
).toHaveValue('some_field_name');
expect(
await canvas.findByTestId('customization.rootFields.prefix')
).toHaveValue('some_field_name_prefix');
expect(
await canvas.findByTestId('customization.rootFields.suffix')
).toHaveValue('some_field_name_suffix');
expect(
await canvas.findByTestId('customization.typeNames.prefix')
).toHaveValue('some_type_name_prefix');
expect(
await canvas.findByTestId('customization.typeNames.suffix')
).toHaveValue('some_type_name_suffix');
};

View File

@ -1,18 +1,17 @@
import { InputField, useConsoleForm } from '../../../../new-components/Form'; import { InputField, useConsoleForm } from '../../../../new-components/Form';
import { Tabs } from '../../../../new-components/Tabs';
import { Button } from '../../../../new-components/Button'; import { Button } from '../../../../new-components/Button';
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { GraphQLCustomization } from '../GraphQLCustomization/GraphQLCustomization'; import { GraphQLCustomization } from '../GraphQLCustomization/GraphQLCustomization';
import { Configuration } from './parts/Configuration';
import { getDefaultValues, MssqlConnectionSchema, schema } from './schema'; import { getDefaultValues, MssqlConnectionSchema, schema } from './schema';
import { ReadReplicas } from './parts/ReadReplicas'; import { ReadReplicas } from './parts/ReadReplicas';
import { get } from 'lodash';
import { FaExclamationTriangle } from 'react-icons/fa';
import { useManageDatabaseConnection } from '../../hooks/useManageDatabaseConnection'; import { useManageDatabaseConnection } from '../../hooks/useManageDatabaseConnection';
import { hasuraToast } from '../../../../new-components/Toasts'; import { hasuraToast } from '../../../../new-components/Toasts';
import { useMetadata } from '../../../hasura-metadata-api'; import { useMetadata } from '../../../hasura-metadata-api';
import { generateMssqlRequestPayload } from './utils/generateRequests'; import { generateMssqlRequestPayload } from './utils/generateRequests';
import { isProConsole } from '../../../../utils'; import { ConnectionString } from './parts/ConnectionString';
import { areReadReplicasEnabled } from '../ConnectPostgresWidget/utils/helpers';
import { Collapsible } from '../../../../new-components/Collapsible';
import { PoolSettings } from './parts/PoolSettings';
interface ConnectMssqlWidgetProps { interface ConnectMssqlWidgetProps {
dataSourceName?: string; dataSourceName?: string;
@ -59,10 +58,9 @@ export const ConnectMssqlWidget = (props: ConnectMssqlWidgetProps) => {
} }
}; };
const [tab, setTab] = useState('connection_details');
const { const {
Form, Form,
methods: { formState, watch, reset }, methods: { reset },
} = useConsoleForm({ } = useConsoleForm({
schema, schema,
}); });
@ -79,68 +77,53 @@ export const ConnectMssqlWidget = (props: ConnectMssqlWidgetProps) => {
} }
}, [metadataSource, reset]); }, [metadataSource, reset]);
const readReplicas = watch('configuration.readReplicas');
const connectionDetailsTabErrors = [
get(formState.errors, 'name'),
get(formState.errors, 'configuration.connectionInfo'),
get(formState.errors, 'configuration.extensionSchema'),
].filter(Boolean);
const readReplicasError = [
get(formState.errors, 'configuration.readReplicas'),
].filter(Boolean);
const proConsoleTabs = isProConsole(window.__env)
? [
{
value: 'read_replicas',
label: `Read Replicas ${
readReplicas?.length ? `(${readReplicas.length})` : ''
}`,
icon: readReplicasError.length ? (
<FaExclamationTriangle className="text-red-800" />
) : undefined,
content: <ReadReplicas name="configuration.readReplicas" />,
},
]
: [];
return ( return (
<div> <div>
<div className="text-xl text-gray-600 font-semibold"> <div className="text-xl text-gray-600 font-semibold">
{isEditMode ? 'Edit MSSQL Connection' : 'Connect New MSSQL Database'} {isEditMode ? 'Edit MSSQL Connection' : 'Connect MSSQL Database'}
</div> </div>
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<Tabs <InputField
value={tab} name="name"
onValueChange={value => setTab(value)} label="Database name"
items={[ placeholder="Database name"
{
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 display name"
placeholder="Database name"
/>
<Configuration name="configuration" />
</div>
),
},
...proConsoleTabs,
{
value: 'customization',
label: 'GraphQL Customization',
content: <GraphQLCustomization name="customization" />,
},
]}
/> />
<ConnectionString name="configuration.connectionInfo.connectionString" />
<div className="mt-sm">
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">Advanced Settings</div>
}
>
<PoolSettings name="configuration.connectionInfo.poolSettings" />
</Collapsible>
</div>
{areReadReplicasEnabled() && (
<div className="mt-sm">
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">Read Replicas</div>
}
>
<ReadReplicas name="configuration.readReplicas" />
</Collapsible>
</div>
)}
<div className="mt-sm">
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">
GraphQL Customization
</div>
}
>
<GraphQLCustomization name="customization" />
</Collapsible>
</div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
type="submit" type="submit"

View File

@ -1,31 +0,0 @@
import { SimpleForm } from '../../../../../new-components/Form';
import { Button } from '../../../../../new-components/Button';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { z } from 'zod';
import { Configuration } from './Configuration';
export default {
component: Configuration,
} as ComponentMeta<typeof Configuration>;
export const Primary: ComponentStory<typeof Configuration> = () => (
<SimpleForm
onSubmit={data => console.log(data)}
schema={z.any()}
options={{
defaultValues: {
details: {
databaseUrl: {
connectionType: 'databaseUrl',
},
},
},
}}
>
<Configuration name="connectionInfo" />
<Button type="submit" className="my-2">
Submit
</Button>
</SimpleForm>
);

View File

@ -1,9 +0,0 @@
import { ConnectionInfo } from './ConnectionInfo';
export const Configuration = ({ name }: { name: string }) => {
return (
<div className="my-2">
<ConnectionInfo name={`${name}.connectionInfo`} />
</div>
);
};

View File

@ -1,31 +0,0 @@
import { SimpleForm } from '../../../../../new-components/Form';
import { Button } from '../../../../../new-components/Button';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { z } from 'zod';
import { ConnectionInfo } from './ConnectionInfo';
export default {
component: ConnectionInfo,
} as ComponentMeta<typeof ConnectionInfo>;
export const Primary: ComponentStory<typeof ConnectionInfo> = () => (
<SimpleForm
onSubmit={data => console.log(data)}
schema={z.any()}
options={{
defaultValues: {
details: {
databaseUrl: {
connectionType: 'databaseUrl',
},
},
},
}}
>
<ConnectionInfo name="connectionInfo" />
<Button type="submit" className="my-2">
Submit
</Button>
</SimpleForm>
);

View File

@ -2,7 +2,7 @@ import { InputField, Radio } from '../../../../../new-components/Form';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { ConnectionInfoSchema } from '../schema'; import { ConnectionInfoSchema } from '../schema';
export const ConnectionInfo = ({ name }: { name: string }) => { export const ConnectionString = ({ name }: { name: string }) => {
const options = [ const options = [
{ value: 'databaseUrl', label: 'Database URL' }, { value: 'databaseUrl', label: 'Database URL' },
{ value: 'envVar', label: 'Enviromnent variable' }, { value: 'envVar', label: 'Enviromnent variable' },
@ -10,13 +10,13 @@ export const ConnectionInfo = ({ name }: { name: string }) => {
const { watch } = useFormContext<Record<string, ConnectionInfoSchema>>(); const { watch } = useFormContext<Record<string, ConnectionInfoSchema>>();
const connectionType = watch(`${name}.connectionString.connectionType`); const connectionType = watch(`${name}.connectionType`);
return ( return (
<div className="bg-white border border-hasGray-300 rounded-md shadow-sm overflow-hidden p-4"> <div className="bg-white border border-hasGray-300 rounded-md shadow-sm overflow-hidden p-4">
<div className="bg-white py-1.5 font-semibold"> <div className="bg-white py-1.5 font-semibold">
<Radio <Radio
name={`${name}.connectionString.connectionType`} name={`${name}.connectionType`}
label="Connect Database via" label="Connect Database via"
options={options} options={options}
orientation="horizontal" orientation="horizontal"
@ -26,13 +26,13 @@ export const ConnectionInfo = ({ name }: { name: string }) => {
{connectionType === 'databaseUrl' ? ( {connectionType === 'databaseUrl' ? (
<InputField <InputField
name={`${name}.connectionString.url`} name={`${name}.url`}
label="Database URL" label="Database URL"
placeholder="Driver={ODBC Driver 18 for SQL Server};Server=serveraddress;Database=dbname;Uid=username;Pwd=password" placeholder="Driver={ODBC Driver 18 for SQL Server};Server=serveraddress;Database=dbname;Uid=username;Pwd=password"
/> />
) : ( ) : (
<InputField <InputField
name={`${name}.connectionString.envVar`} name={`${name}.envVar`}
label="Environment variable" label="Environment variable"
placeholder="HASURA_GRAPHQL_DB_URL_FROM_ENV" placeholder="HASURA_GRAPHQL_DB_URL_FROM_ENV"
/> />

View File

@ -1,24 +0,0 @@
import { SimpleForm } from '../../../../../new-components/Form';
import { Button } from '../../../../../new-components/Button';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { z } from 'zod';
import { ReadReplicas } from './ReadReplicas';
export default {
component: ReadReplicas,
} as ComponentMeta<typeof ReadReplicas>;
export const Primary: ComponentStory<typeof ReadReplicas> = () => (
<SimpleForm
onSubmit={data => console.log(data)}
schema={z.any()}
options={{}}
>
<ReadReplicas name="rr" />
<br />
<Button type="submit" className="my-2">
Submit
</Button>
</SimpleForm>
);

View File

@ -1,11 +1,94 @@
import { useFieldArray, useFormContext } from 'react-hook-form'; import { useFieldArray, useFormContext } from 'react-hook-form';
import { Button } from '../../../../../new-components/Button'; import { Button } from '../../../../../new-components/Button';
import { CardedTable } from '../../../../../new-components/CardedTable'; import { CardedTable } from '../../../../../new-components/CardedTable';
import { ConnectionInfo } from './ConnectionInfo'; import { ConnectionString } from './ConnectionString';
import { useState } from 'react'; import { useState } from 'react';
import { ConnectionInfoSchema } from '../schema'; import { ConnectionInfoSchema } from '../schema';
import { FaPlus, FaTrash } from 'react-icons/fa'; import { FaEdit, FaPlus, FaTrash } from 'react-icons/fa';
import { IndicatorCard } from '../../../../../new-components/IndicatorCard'; import { IndicatorCard } from '../../../../../new-components/IndicatorCard';
import { Dialog } from '../../../../../new-components/Dialog';
import { Collapsible } from '../../../../../new-components/Collapsible';
import { PoolSettings } from './PoolSettings';
// export const ReadReplicas = ({ name }: { name: string }) => {
// const { fields, append } = useFieldArray<
// Record<string, ConnectionInfoSchema[]>
// >({
// name,
// });
// const { watch, setValue } =
// useFormContext<Record<string, ConnectionInfoSchema[]>>();
// const [mode, setMode] = useState<'idle' | 'add'>('idle');
// const readReplicas = watch(name);
// return (
// <div className="my-2">
// {!fields?.length ? (
// <IndicatorCard status="info">No read replicas added.</IndicatorCard>
// ) : (
// <CardedTable
// columns={['No', 'Read Replica', null]}
// data={(fields ?? []).map((x, i) => [
// i + 1,
// <div>
// {x.connectionString.connectionType === 'databaseUrl'
// ? x.connectionString.url
// : x.connectionString.envVar}
// </div>,
// <Button
// size="sm"
// icon={<FaTrash />}
// mode="destructive"
// onClick={() => {
// setValue(
// name,
// readReplicas.filter((_, index) => index !== i)
// );
// }}
// />,
// ])}
// showActionCell
// />
// )}
// {mode === 'idle' && (
// <Button
// type="button"
// onClick={() => {
// setMode('add');
// append({
// connectionString: { connectionType: 'databaseUrl', url: '' },
// });
// }}
// mode="primary"
// icon={<FaPlus />}
// >
// Add New Read Replica
// </Button>
// )}
// {mode === 'add' && (
// <div>
// <ConnectionInfo name={`${name}.${fields?.length - 1}`} />
// <Button
// onClick={() => {
// setMode('idle');
// setValue(
// `${name}.${fields?.length - 1}`,
// fields[fields?.length - 1]
// );
// }}
// mode="primary"
// className="my-2"
// >
// Add Read Replica
// </Button>
// </div>
// )}
// </div>
// );
// };
export const ReadReplicas = ({ name }: { name: string }) => { export const ReadReplicas = ({ name }: { name: string }) => {
const { fields, append } = useFieldArray< const { fields, append } = useFieldArray<
@ -16,9 +99,11 @@ export const ReadReplicas = ({ name }: { name: string }) => {
const { watch, setValue } = const { watch, setValue } =
useFormContext<Record<string, ConnectionInfoSchema[]>>(); useFormContext<Record<string, ConnectionInfoSchema[]>>();
const [mode, setMode] = useState<'idle' | 'add'>('idle'); const [mode, setMode] = useState<'idle' | 'add' | 'edit'>('idle');
const readReplicas = watch(name); const readReplicas = watch(name);
const [activeRow, setActiveRow] = useState<number>();
return ( return (
<div className="my-2"> <div className="my-2">
{!fields?.length ? ( {!fields?.length ? (
@ -26,24 +111,33 @@ export const ReadReplicas = ({ name }: { name: string }) => {
) : ( ) : (
<CardedTable <CardedTable
columns={['No', 'Read Replica', null]} columns={['No', 'Read Replica', null]}
data={(fields ?? []).map((x, i) => [ data={(readReplicas ?? []).map((field, i) => [
i + 1, i + 1,
<div> <div>
{x.connectionString.connectionType === 'databaseUrl' {field.connectionString.connectionType === 'databaseUrl'
? x.connectionString.url ? field.connectionString.url
: x.connectionString.envVar} : field.connectionString.envVar}
</div>,
<div className="flex gap-3 justify-end">
<Button
size="sm"
icon={<FaEdit />}
onClick={() => {
setActiveRow(i);
setMode('edit');
}}
/>
<Button
size="sm"
icon={<FaTrash />}
onClick={() => {
setValue(
name,
readReplicas.filter((_, index) => index !== i)
);
}}
/>
</div>, </div>,
<Button
size="sm"
icon={<FaTrash />}
mode="destructive"
onClick={() => {
setValue(
name,
readReplicas.filter((_, index) => index !== i)
);
}}
/>,
])} ])}
showActionCell showActionCell
/> />
@ -57,6 +151,7 @@ export const ReadReplicas = ({ name }: { name: string }) => {
append({ append({
connectionString: { connectionType: 'databaseUrl', url: '' }, connectionString: { connectionType: 'databaseUrl', url: '' },
}); });
setActiveRow(readReplicas?.length ?? 0);
}} }}
mode="primary" mode="primary"
icon={<FaPlus />} icon={<FaPlus />}
@ -65,23 +160,46 @@ export const ReadReplicas = ({ name }: { name: string }) => {
</Button> </Button>
)} )}
{mode === 'add' && ( {(mode === 'add' || mode === 'edit') && (
<div> <Dialog
<ConnectionInfo name={`${name}.${fields?.length - 1}`} /> hasBackdrop
<Button title={mode === 'edit' ? 'Edit Read Replica' : 'Add Read Replica'}
onClick={() => { onClose={() => {
setMode('idle'); setMode('idle');
setValue( }}
`${name}.${fields?.length - 1}`, titleTooltip="Optional list of read replica configuration"
fields[fields?.length - 1] size="xxxl"
); >
}} <div className="p-4">
mode="primary" <div className="bg-white border border-hasGray-300 rounded-md shadow-sm overflow-hidden p-4">
className="my-2" <ConnectionString
> name={`${name}.${activeRow}.connectionString`}
Add Read Replica />
</Button> </div>
</div>
<div className="bg-white border border-hasGray-300 rounded-md shadow-sm overflow-hidden p-4 mt-sm">
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">
Advanced Settings
</div>
}
>
<PoolSettings name={`${name}.${activeRow}.poolSettings`} />
</Collapsible>
</div>
<Button
onClick={() => {
setMode('idle');
setActiveRow(undefined);
}}
mode="primary"
className="my-2"
>
{mode === 'edit' ? 'Edit Read Replica' : 'Add Read Replica'}
</Button>
</div>
</Dialog>
)} )}
</div> </div>
); );

View File

@ -7,7 +7,7 @@ export const generateConnectionInfo = (
values: MssqlConnectionSchema['configuration']['connectionInfo'] values: MssqlConnectionSchema['configuration']['connectionInfo']
) => { ) => {
return { return {
database_url: connection_string:
values.connectionString.connectionType === 'databaseUrl' values.connectionString.connectionType === 'databaseUrl'
? values.connectionString.url ? values.connectionString.url
: { from_env: values.connectionString.envVar }, : { from_env: values.connectionString.envVar },

View File

@ -58,5 +58,5 @@ export const areSSLSettingsEnabled = () => {
}; };
export const areReadReplicasEnabled = () => { export const areReadReplicasEnabled = () => {
return isProConsole(window.__env); return isProConsole(window.__env) || true;
}; };

View File

@ -67,11 +67,10 @@ const mockMetadata: Metadata = {
tables: [], tables: [],
configuration: { configuration: {
connection_info: { connection_info: {
connection_string: { connection_string:
from_env: 'HASURA_ENV_VAR', 'DRIVER={ODBC Driver 17 for SQL Server};SERVER=host.docker.internal;DATABASE=bikes;Uid=SA;Pwd=reallyStrongPwd123',
},
pool_settings: { pool_settings: {
max_connections: 50, total_max_connections: 50,
idle_timeout: 180, idle_timeout: 180,
}, },
}, },