From fcdc90f2d1b7e00d9179f98f05688a068ba5829c Mon Sep 17 00:00:00 2001 From: Vijay Prasanna Date: Thu, 21 Apr 2022 16:10:56 +0530 Subject: [PATCH] console: create a reusable database selector widget PR-URL: https://github.com/hasura/graphql-engine-mono/pull/4295 GitOrigin-RevId: 962e0f1e25d0100eb452cd609401ca593963e188 --- .../DatabaseSelector/DataSelector.stories.tsx | 181 ++++++++++++++++++ .../DatabaseSelector/DatabaseSelector.tsx | 172 +++++++++++++++++ .../components/DatabaseSelector/Select.tsx | 50 +++++ .../DatabaseSelector/hooks/useSourceTree.ts | 34 ++++ .../Data/components/DatabaseSelector/index.ts | 1 + .../DatabaseSelector/mocks/handlers.mock.ts | 180 +++++++++++++++++ console/src/features/Data/components/index.ts | 1 + console/src/features/Data/index.ts | 1 + console/src/new-components/Form/index.ts | 1 + 9 files changed, 621 insertions(+) create mode 100644 console/src/features/Data/components/DatabaseSelector/DataSelector.stories.tsx create mode 100644 console/src/features/Data/components/DatabaseSelector/DatabaseSelector.tsx create mode 100644 console/src/features/Data/components/DatabaseSelector/Select.tsx create mode 100644 console/src/features/Data/components/DatabaseSelector/hooks/useSourceTree.ts create mode 100644 console/src/features/Data/components/DatabaseSelector/index.ts create mode 100644 console/src/features/Data/components/DatabaseSelector/mocks/handlers.mock.ts create mode 100644 console/src/features/Data/components/index.ts create mode 100644 console/src/features/Data/index.ts diff --git a/console/src/features/Data/components/DatabaseSelector/DataSelector.stories.tsx b/console/src/features/Data/components/DatabaseSelector/DataSelector.stories.tsx new file mode 100644 index 00000000000..b1fc069695a --- /dev/null +++ b/console/src/features/Data/components/DatabaseSelector/DataSelector.stories.tsx @@ -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; + +export const Playground: Story = () => { + return ( + 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 ( + console.log(v)} + name="source" + className="border-l-4 border-l-green-600" + disabledKeys={['database', 'schema', 'table']} + /> + ); +}; + +export const BqWithDisabledInputs: Story = () => { + return ( + console.log(v)} + name="source" + className="border-l-4 border-l-green-600" + disabledKeys={['database', 'schema', 'table']} + /> + ); +}; + +export const WithHiddenInputs: Story = () => { + return ( + console.log(v)} + name="source" + className="border-l-4 border-l-green-600" + hiddenKeys={['database']} + disabledKeys={['schema']} + /> + ); +}; + +type Schema = z.infer; + +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(); + + return ( + <> + ( + + )} + /> + + ); +}; + +export const WithReactFormHookNested: Story = () => { + const submit = (values: Schema) => { + console.log(JSON.stringify(values)); + }; + + return ( +
+ {() => { + return ( + <> + + + + ); + }} + + ); +}; + +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'); +}; diff --git a/console/src/features/Data/components/DatabaseSelector/DatabaseSelector.tsx b/console/src/features/Data/components/DatabaseSelector/DatabaseSelector.tsx new file mode 100644 index 00000000000..1a7d07ef043 --- /dev/null +++ b/console/src/features/Data/components/DatabaseSelector/DatabaseSelector.tsx @@ -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; + className?: string; + hiddenKeys?: string[]; + labels?: Record; +} + +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 ( +
+
+ { + 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={} + label={ + (tree[localValue.database]?.kind === 'bigquery' + ? labels?.dataset + : labels?.schema) ?? 'Schema' + } + /> +
+
+ + {placeholder ? ( + + ) : null} + + {options.map((op, i) => ( + + ))} + + +); diff --git a/console/src/features/Data/components/DatabaseSelector/hooks/useSourceTree.ts b/console/src/features/Data/components/DatabaseSelector/hooks/useSourceTree.ts new file mode 100644 index 00000000000..71a9426a080 --- /dev/null +++ b/console/src/features/Data/components/DatabaseSelector/hooks/useSourceTree.ts @@ -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 }; +}; diff --git a/console/src/features/Data/components/DatabaseSelector/index.ts b/console/src/features/Data/components/DatabaseSelector/index.ts new file mode 100644 index 00000000000..48de6a569aa --- /dev/null +++ b/console/src/features/Data/components/DatabaseSelector/index.ts @@ -0,0 +1 @@ +export { DatabaseSelector } from './DatabaseSelector'; diff --git a/console/src/features/Data/components/DatabaseSelector/mocks/handlers.mock.ts b/console/src/features/Data/components/DatabaseSelector/mocks/handlers.mock.ts new file mode 100644 index 00000000000..0c2d436a3ac --- /dev/null +++ b/console/src/features/Data/components/DatabaseSelector/mocks/handlers.mock.ts @@ -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', + }, + }, + }, + ], + }, + }) + ); + }), +]; diff --git a/console/src/features/Data/components/index.ts b/console/src/features/Data/components/index.ts new file mode 100644 index 00000000000..48de6a569aa --- /dev/null +++ b/console/src/features/Data/components/index.ts @@ -0,0 +1 @@ +export { DatabaseSelector } from './DatabaseSelector'; diff --git a/console/src/features/Data/index.ts b/console/src/features/Data/index.ts new file mode 100644 index 00000000000..07635cbbc8e --- /dev/null +++ b/console/src/features/Data/index.ts @@ -0,0 +1 @@ +export * from './components'; diff --git a/console/src/new-components/Form/index.ts b/console/src/new-components/Form/index.ts index d6f385c5e8d..f59936524c9 100644 --- a/console/src/new-components/Form/index.ts +++ b/console/src/new-components/Form/index.ts @@ -1,3 +1,4 @@ export * from './Form'; export * from './InputField'; export * from './Select'; +export * from './FieldWrapper';