console: create a reusable database selector widget

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/4295
GitOrigin-RevId: 962e0f1e25d0100eb452cd609401ca593963e188
This commit is contained in:
Vijay Prasanna 2022-04-21 16:10:56 +05:30 committed by hasura-bot
parent abb57e58c8
commit fcdc90f2d1
9 changed files with 621 additions and 0 deletions

View File

@ -0,0 +1,181 @@
import { Button } from '@/new-components/Button';
import { Form } from '@/new-components/Form';
import { userEvent, within } from '@storybook/testing-library';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ComponentMeta, Story } from '@storybook/react';
import React from 'react';
import { expect } from '@storybook/jest';
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { DatabaseSelector } from '@/features/Data';
import { handlers } from './mocks/handlers.mock';
export default {
title: 'Data/components/DatabaseSelector',
component: DatabaseSelector,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers(),
},
} as ComponentMeta<typeof DatabaseSelector>;
export const Playground: Story = () => {
return (
<DatabaseSelector
value={{ database: 'chinook', schema: 'public', table: 'Album' }}
onChange={v => console.log(v)}
name="source"
className="border-l-4 border-l-green-600"
labels={{
database: 'My Database',
schema: 'My Schema',
dataset: 'My Dataset',
table: 'My Table',
}}
/>
);
};
export const WithDisabledInputs: Story = () => {
return (
<DatabaseSelector
value={{ database: 'chinook', schema: 'public', table: 'Album' }}
onChange={v => console.log(v)}
name="source"
className="border-l-4 border-l-green-600"
disabledKeys={['database', 'schema', 'table']}
/>
);
};
export const BqWithDisabledInputs: Story = () => {
return (
<DatabaseSelector
value={{ database: 'bigquery_test', dataset: 'sensei', table: 'table1' }}
onChange={v => console.log(v)}
name="source"
className="border-l-4 border-l-green-600"
disabledKeys={['database', 'schema', 'table']}
/>
);
};
export const WithHiddenInputs: Story = () => {
return (
<DatabaseSelector
value={{ database: 'bigquery_test', dataset: 'sensei', table: '' }}
onChange={v => console.log(v)}
name="source"
className="border-l-4 border-l-green-600"
hiddenKeys={['database']}
disabledKeys={['schema']}
/>
);
};
type Schema = z.infer<typeof schema>;
const schema = z.object({
destination: z.object({
database: z.string().min(1, 'Database is a required field!'),
schema: z.string().optional(),
dataset: z.string().optional(),
table: z.string().min(1, 'Table is a required field!'),
}),
});
const FormElements = () => {
const { control } = useFormContext<Schema>();
return (
<>
<Controller
control={control}
name="destination"
render={({ field: { onChange, value }, formState: { errors } }) => (
<DatabaseSelector
value={value}
onChange={onChange}
name="destination"
errors={errors}
className="border-l-4 border-l-green-600"
/>
)}
/>
</>
);
};
export const WithReactFormHookNested: Story = () => {
const submit = (values: Schema) => {
console.log(JSON.stringify(values));
};
return (
<Form
options={{
defaultValues: {
destination: {
database: '',
schema: '',
table: '',
},
},
}}
onSubmit={submit}
schema={schema}
>
{() => {
return (
<>
<FormElements />
<Button type="submit" data-testid="submit">
Submit
</Button>
</>
);
}}
</Form>
);
};
WithReactFormHookNested.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const sourceLabel = await canvas.findByText('Source');
const schemaLabel = await canvas.findByText('Schema');
const tableLabel = await canvas.findByText('Table');
// expect labels
expect(sourceLabel).toBeInTheDocument();
expect(schemaLabel).toBeInTheDocument();
expect(tableLabel).toBeInTheDocument();
const submitButton = await canvas.getByTestId('submit');
// update fields
const dbInput = await canvas.getByTestId('destination_database');
userEvent.click(submitButton);
expect(
await canvas.findByText('Database is a required field!')
).toBeInTheDocument();
userEvent.selectOptions(dbInput, 'chinook');
const schemaInput = await canvas.getByTestId('destination_schema');
userEvent.selectOptions(schemaInput, 'public');
userEvent.click(submitButton);
expect(
await canvas.findByText('Table is a required field!')
).toBeInTheDocument();
const tableInput = await canvas.getByTestId('destination_table');
userEvent.selectOptions(tableInput, 'Album');
// select a bigquery source
userEvent.selectOptions(dbInput, 'bigquery_test');
userEvent.selectOptions(schemaInput, 'sensei');
userEvent.selectOptions(tableInput, 'table1');
};

View File

@ -0,0 +1,172 @@
import { DataTarget } from '@/features/Datasources';
import clsx from 'clsx';
import get from 'lodash.get';
import React, { useState } from 'react';
import { FieldError } from 'react-hook-form';
import { FaDatabase, FaFolder, FaTable } from 'react-icons/fa';
import { useSourcesTree } from './hooks/useSourceTree';
import { Select } from './Select';
interface Props {
value: DataTarget;
disabledKeys?: string[];
onChange?: (value: DataTarget) => void;
name: string;
errors?: Record<string, any>;
className?: string;
hiddenKeys?: string[];
labels?: Record<string, string>;
}
export const DatabaseSelector = (props: Props) => {
const {
value,
onChange,
name,
errors,
disabledKeys,
hiddenKeys,
labels,
} = props;
const maybeError = get(errors, name) as
| { database?: FieldError; schema?: FieldError; table?: FieldError }
| undefined;
const [localValue, setLocalValue] = useState<{
database: string;
schema: string;
table: string;
}>({
database: value.database,
schema: (value as any).schema ?? (value as any).dataset,
table: value.table,
});
const { tree, isLoading } = useSourcesTree();
const schemaOptions = tree[localValue.database]
? Object.keys(tree[localValue.database].children)
: [];
const tableOptions = tree[localValue.database]?.children?.[localValue.schema]
? tree[localValue.database]?.children?.[localValue.schema]
: [];
const updateParent = (val: {
database: string;
schema: string;
table: string;
}) => {
if (!onChange) return;
if (tree[val.database].kind !== 'bigquery')
onChange({
database: val.database,
schema: val.schema,
table: val.table,
});
else
onChange({
database: val.database,
dataset: val.schema,
table: val.table,
});
};
if (isLoading) return <>Loading data ...</>;
return (
<div
id="source"
className={clsx(
`grid grid-cols-12 rounded bg-gray-50 border border-gray-300 p-md gap-y-4 ${props.className}`
)}
>
<div
className={clsx(
'col-span-12',
hiddenKeys?.includes('database') ? 'hidden' : ''
)}
>
<Select
options={Object.keys(tree)}
value={localValue.database}
disabled={!Object.keys(tree) || disabledKeys?.includes('database')}
onChange={e => {
e.persist();
const temp = {
database: e.target.value,
schema: '',
table: '',
};
setLocalValue(temp);
updateParent(temp);
}}
placeholder="Select a database..."
error={maybeError?.database}
data-testid={`${name}_database`}
icon={<FaDatabase />}
label={labels?.database ?? 'Source'}
/>
</div>
<div
className={clsx(
'col-span-12',
hiddenKeys?.includes('schema') ? 'hidden' : ''
)}
>
<Select
options={schemaOptions}
value={(localValue as any).schema ?? (localValue as any).dataset}
disabled={!schemaOptions || disabledKeys?.includes('schema')}
onChange={e => {
e.persist();
const temp = {
...localValue,
schema: e.target.value,
table: '',
};
setLocalValue(temp);
updateParent(temp);
}}
placeholder="Select a schema..."
error={maybeError?.schema}
data-testid={`${name}_schema`}
icon={<FaFolder />}
label={
(tree[localValue.database]?.kind === 'bigquery'
? labels?.dataset
: labels?.schema) ?? 'Schema'
}
/>
</div>
<div
className={clsx(
'col-span-12',
hiddenKeys?.includes('table') ? 'hidden' : ''
)}
>
<Select
options={tableOptions}
value={localValue.table}
disabled={!tableOptions || disabledKeys?.includes('table')}
onChange={e => {
e.persist();
const temp = {
...localValue,
table: e.target.value,
};
setLocalValue(temp);
updateParent(temp);
}}
placeholder="Select a table..."
error={maybeError?.table}
data-testid={`${name}_table`}
icon={<FaTable />}
label={labels?.table || 'Table'}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,50 @@
import { FieldWrapper } from '@/new-components/Form';
import clsx from 'clsx';
import React from 'react';
import { FieldError } from 'react-hook-form';
interface TSelect extends React.ComponentProps<'select'> {
options: string[];
error?: FieldError;
icon?: React.ReactElement;
label: string;
}
export const Select = ({
options,
placeholder,
disabled,
error,
...props
}: TSelect) => (
<FieldWrapper
id={props.name}
error={error}
labelIcon={props.icon}
label={props.label}
>
<select
{...props}
className={clsx(
'block w-full 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',
disabled
? 'cursor-not-allowed bg-gray-100 border-gray-100'
: 'hover:border-gray-400'
)}
disabled={disabled}
value={props.value}
>
{placeholder ? (
<option disabled value="">
{placeholder}
</option>
) : null}
{options.map((op, i) => (
<option value={op} key={i}>
{op}
</option>
))}
</select>
</FieldWrapper>
);

View File

@ -0,0 +1,34 @@
import { useSources } from '@/features/MetadataAPI';
import { useMemo } from 'react';
export const useSourcesTree = () => {
const { data, isLoading } = useSources();
const tree = useMemo(() => {
if (!data) return {};
const temp = data.reduce((databases, source) => {
return {
...databases,
[source.name]: {
kind: source.kind,
children: source.tables.reduce((schemas, { table }) => {
const key = source.kind === 'bigquery' ? 'dataset' : 'schema';
const child = table.schema ?? (table as any).dataset;
return {
...schemas,
[child]: source.tables
.filter((t: any) => t.table[key] === child)
.map(t => t.table.name),
};
}, {}),
},
};
}, {});
return temp as any;
}, [data]);
return { tree, isLoading };
};

View File

@ -0,0 +1 @@
export { DatabaseSelector } from './DatabaseSelector';

View File

@ -0,0 +1,180 @@
import { rest } from 'msw';
export const handlers = () => [
rest.post(`http://localhost:8080/v1/metadata`, (req, res, ctx) => {
return res(
ctx.json({
resource_version: 30,
metadata: {
version: 3,
sources: [
{
name: 'bigquery_test',
kind: 'bigquery',
tables: [
{
table: {
dataset: 'sensei',
name: 'table1',
},
},
],
},
{
name: 'bikes',
kind: 'mssql',
tables: [
{
table: {
schema: 'production',
name: 'brands',
},
},
{
table: {
schema: 'production',
name: 'categories',
},
},
{
table: {
schema: 'sales',
name: 'customers',
},
},
{
table: {
schema: 'sales',
name: 'order_items',
},
},
{
table: {
schema: 'sales',
name: 'orders',
},
},
{
table: {
schema: 'production',
name: 'products',
},
},
{
table: {
schema: 'sales',
name: 'staffs',
},
},
{
table: {
schema: 'production',
name: 'stocks',
},
},
{
table: {
schema: 'sales',
name: 'stores',
},
},
],
},
{
name: 'chinook',
kind: 'postgres',
tables: [
{
table: {
schema: 'public',
name: 'Album',
},
},
{
table: {
schema: 'public',
name: 'Artist',
},
},
{
table: {
schema: 'public',
name: 'Customer',
},
},
{
table: {
schema: 'public',
name: 'Employee',
},
},
{
table: {
schema: 'public',
name: 'Genre',
},
},
{
table: {
schema: 'public',
name: 'Invoice',
},
},
{
table: {
schema: 'public',
name: 'InvoiceLine',
},
},
{
table: {
schema: 'public',
name: 'MediaType',
},
},
{
table: {
schema: 'public',
name: 'Playlist',
},
},
{
table: {
schema: 'public',
name: 'PlaylistTrack',
},
},
{
table: {
schema: 'public',
name: 'Track',
},
},
{
table: {
schema: 'public',
name: 'ax',
},
},
{
table: {
schema: 'public',
name: 'ideav',
},
},
],
configuration: {
connection_info: {
use_prepared_statements: false,
database_url:
'postgres://postgres:test@host.docker.internal:6001/chinook',
isolation_level: 'read-committed',
},
},
},
],
},
})
);
}),
];

View File

@ -0,0 +1 @@
export { DatabaseSelector } from './DatabaseSelector';

View File

@ -0,0 +1 @@
export * from './components';

View File

@ -1,3 +1,4 @@
export * from './Form';
export * from './InputField';
export * from './Select';
export * from './FieldWrapper';