mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 08:02:15 +03:00
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:
parent
abb57e58c8
commit
fcdc90f2d1
@ -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');
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
@ -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 };
|
||||
};
|
@ -0,0 +1 @@
|
||||
export { DatabaseSelector } from './DatabaseSelector';
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
}),
|
||||
];
|
1
console/src/features/Data/components/index.ts
Normal file
1
console/src/features/Data/components/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { DatabaseSelector } from './DatabaseSelector';
|
1
console/src/features/Data/index.ts
Normal file
1
console/src/features/Data/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './components';
|
@ -1,3 +1,4 @@
|
||||
export * from './Form';
|
||||
export * from './InputField';
|
||||
export * from './Select';
|
||||
export * from './FieldWrapper';
|
||||
|
Loading…
Reference in New Issue
Block a user