console: tree nav storybook component

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9225
Co-authored-by: Matthew Goodwin <49927862+m4ttheweric@users.noreply.github.com>
GitOrigin-RevId: b4772448e7ebceee49c0b49cd7fcf2091ddf88c9
This commit is contained in:
Vijay Prasanna 2023-05-31 13:07:43 +05:30 committed by hasura-bot
parent 4c0a7cc46a
commit 03daf994b5
19 changed files with 1220 additions and 17 deletions

View File

@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react';
import { NavTree } from './NavTree';
import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query';
import { handlers } from './mocks/handlers';
export default {
component: NavTree,
decorators: [ReactQueryDecorator()],
} satisfies Meta<typeof NavTree>;
type Story = StoryObj<typeof NavTree>;
export const Primary: Story = {
render: () => (
<NavTree
defaultSelection={{
dataSourceName: 'chinook',
table: { name: 'Album', schema: 'public' },
}}
/>
),
parameters: {
msw: handlers(),
},
};

View File

@ -0,0 +1,85 @@
import { Input } from '../../../../new-components/Form';
import { useState } from 'react';
import { FaSearch } from 'react-icons/fa';
import {
useInconsistentMetadata,
useMetadata,
} from '../../../../features/hasura-metadata-api';
import Skeleton from 'react-loading-skeleton';
import {
QualifiedFunction,
Table,
} from '../../../../features/hasura-metadata-types';
import { useAvailableDrivers } from '../../../../features/ConnectDB';
import { TreeComponent } from './components/TreeComponent';
import {
adaptInconsistentObjects,
adaptSourcesIntoTreeData,
} from './selectors';
type Selection = {
dataSourceName: string;
} & (
| {
table: Table;
function?: never;
}
| {
table?: never;
function: QualifiedFunction;
}
);
type Props = {
defaultSelection?: Selection;
handleTableClick?: (table: Table) => void;
handleDatabaseClick?: (dataSourceName: string) => void;
};
export const NavTree = (props: Props) => {
const [term, setTerm] = useState('');
const selection = JSON.stringify(props?.defaultSelection ?? {});
const { data: drivers = [], isFetching: isDriverInfoFetching } =
useAvailableDrivers();
const {
data: inconsistentData = {
inconsistentSources: [],
inconsistentTables: [],
inconsistentFunctions: [],
},
isFetching,
} = useInconsistentMetadata(adaptInconsistentObjects);
const { data: treeData = [], isLoading } = useMetadata(
m => adaptSourcesIntoTreeData(m)(drivers, inconsistentData),
{
enabled: !isFetching && !isDriverInfoFetching,
}
);
if (isLoading) return <Skeleton height={30} count={8} />;
console.log(treeData, selection);
return (
<div id="tree-stuff">
<div>
<Input
name="search"
onChange={e => setTerm(e.target.value)}
icon={<FaSearch />}
/>
</div>
<TreeComponent
treeData={treeData}
onDatabaseClick={data => console.log(data)}
onLeafNodeClick={data => console.log(data)}
selection={selection}
term={term}
/>
</div>
);
};

View File

@ -0,0 +1,124 @@
import { NodeRendererProps } from 'react-arborist';
import { DataSourceNode } from '../types';
import { FaAngleDown, FaAngleRight, FaDatabase } from 'react-icons/fa';
import clsx from 'clsx';
import { Tooltip } from '../../../../../new-components/Tooltip';
import { Badge } from '../../../../../new-components/Badge';
import { GetHighlightedText } from './GetHighlightedText';
const StatusReport = ({
node,
tree,
style,
}: NodeRendererProps<DataSourceNode>) => {
const details = {
Driver: <div>{node.data.driver}</div>,
Release: (
<div>
<Badge>
{node.data.releaseType === 'GA' ? 'Stable' : node.data.releaseType}
</Badge>
</div>
),
Status: node.data.inconsistentObject ? (
<span className="text-red-400">Inconsistent</span>
) : (
<span className="text-green-400">Active</span>
),
Errors: node.data.inconsistentObject ? (
<div className="bg-white break-words max-h-[320px] max-w-sm overflow-y-scroll p-2 rounded text-black whitespace-pre-line">
{JSON.stringify(node.data.inconsistentObject)}
</div>
) : null,
};
return (
<table className="border-separate">
<tbody>
{Object.entries(details)
.filter(([key, value]) => !!value)
.map(([key, value]) => {
return (
<tr className="row">
<td className="col flex">
<div className="font-bold">{key}</div>
</td>
<td className="col">{value}</td>
</tr>
);
})}
</tbody>
</table>
);
};
export const DatabaseNodeItem = (props: NodeRendererProps<DataSourceNode>) => {
const { node, tree, style } = props;
return (
<div className="flex gap-0.5 items-center" style={style}>
{node.children?.length ? (
node.isOpen ? (
<FaAngleDown
data-testid={`${node.data.name}-close`}
className="text-gray-600 hover:cursor-pointer"
onClick={() => {
node.toggle();
}}
/>
) : (
<FaAngleRight
data-testid={`${node.data.name}-expand`}
className="text-gray-600 hover:cursor-pointer"
onClick={() => node.toggle()}
/>
)
) : (
<FaAngleRight className="text-gray-400" />
)}
<div
className={clsx(
'flex items-center py-1.5 hover:rounded hover:bg-gray-200 hover:cursor-pointer',
node.isSelected ? 'bg-gray-200 rounded' : ''
)}
onClick={() => {
node.data?.onClick?.();
}}
>
<span className="relative">
{node.data.inconsistentObject && (
<span
id="dot"
className="rounded-lg absolute h-[10px] w-[10px] top-0 right-[-5px] bg-red-600"
data-testid={`${node.data.name}-error-indicator`}
/>
)}
<Tooltip
tooltipContentChildren={<StatusReport {...props} />}
side="bottom"
align="start"
>
<FaDatabase className="text-gray-600 text-gray-600 text-lg" />
</Tooltip>
</span>
<span className="ml-sm">
<GetHighlightedText
text={node.data.name}
highlight={tree.searchTerm}
/>
</span>
{node.children?.length ? (
<span
className="bg-gray-300 ml-1.5 mr-1.5 px-1.5 py-0.5 rounded text-xs"
data-testId={`${node.data.name}-object-count`}
>
{node.children?.length}
</span>
) : null}
</div>
</div>
);
};

View File

@ -0,0 +1,34 @@
import { TbMathFunction } from 'react-icons/tb';
import { FunctionNode } from '../types';
import clsx from 'clsx';
import { NodeRendererProps } from 'react-arborist';
import { GetHighlightedText } from './GetHighlightedText';
export const FunctionNodeItem = ({
node,
tree,
style,
}: NodeRendererProps<FunctionNode>) => {
return (
<div className="flex gap-0.5 items-center" style={style}>
<div
className={clsx(
'flex items-center px-1.5 py-1.5 hover:rounded hover:bg-gray-200 hover:cursor-pointer',
node.isSelected ? 'bg-gray-200 rounded' : ''
)}
onClick={() => {
node.data?.onClick?.();
}}
>
<TbMathFunction className="text-gray-600" />
<span className="ml-1.5">
<GetHighlightedText
text={node.data.name}
highlight={tree.searchTerm}
/>
</span>
</div>
</div>
);
};

View File

@ -0,0 +1,21 @@
export const GetHighlightedText = ({
text,
highlight,
}: {
text: string;
highlight: string;
}) => {
// Split text on highlight term, include term itself into parts, ignore case
const parts = text.split(new RegExp(`(${highlight})`, 'gi'));
return (
<div data-testid={text}>
{parts.map((part, index) =>
part.toLowerCase() === highlight.toLowerCase() ? (
<b key={index}>{part}</b>
) : (
part
)
)}
</div>
);
};

View File

@ -0,0 +1,34 @@
import { TableNode } from '../types';
import clsx from 'clsx';
import { NodeRendererProps } from 'react-arborist';
import { FaTable } from 'react-icons/fa';
import { GetHighlightedText } from './GetHighlightedText';
export const TableNodeItem = ({
node,
tree,
style,
}: NodeRendererProps<TableNode>) => {
return (
<div className="flex gap-0.5 items-center" style={style}>
<div
className={clsx(
'flex items-center px-1.5 py-1.5 hover:rounded hover:bg-gray-200 hover:cursor-pointer',
node.isSelected ? 'bg-gray-200 rounded' : ''
)}
onClick={() => {
node.data?.onClick?.();
}}
>
<FaTable className="text-gray-600" />
<span className="ml-1.5">
<GetHighlightedText
text={node.data.name}
highlight={tree.searchTerm}
/>
</span>
</div>
</div>
);
};

View File

@ -0,0 +1,299 @@
import type { Meta, StoryObj } from '@storybook/react';
import { TreeComponent } from './TreeComponent';
import { ReactQueryDecorator } from '../../../../../storybook/decorators/react-query';
import { ReleaseType } from '../../../../../features/DataSource';
import { userEvent, within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import { hasuraToast } from '../../../../../new-components/Toasts';
import { getQualifiedTable } from '../../../../../features/Data/ManageTable/utils';
const meta: Meta<typeof TreeComponent> = {
component: TreeComponent,
decorators: [ReactQueryDecorator()],
};
export default meta;
type Story = StoryObj<typeof TreeComponent>;
const mockData = [
{
id: '{"dataSourceName":"chinook"}',
dataSourceName: 'chinook',
name: 'chinook',
driver: 'postgres',
releaseType: 'GA' as ReleaseType,
children: [
{
id: '{"dataSourceName":"chinook","table":{"name":"Album","schema":"public"}}',
table: {
name: 'Album',
schema: 'public',
},
name: 'public / Album',
},
{
id: '{"dataSourceName":"chinook","table":{"name":"Artist","schema":"public"}}',
table: {
name: 'Artist',
schema: 'public',
},
name: 'public / Artist',
},
{
id: '{"dataSourceName":"chinook","table":{"name":"Customer","schema":"public"}}',
table: {
name: 'Customer',
schema: 'public',
},
name: 'public / Customer',
},
{
id: '{"dataSourceName":"chinook","table":{"name":"Employee","schema":"public"}}',
table: {
name: 'Employee',
schema: 'public',
},
name: 'public / Employee',
},
{
id: '{"dataSourceName":"chinook","table":{"name":"Genre","schema":"public"}}',
table: {
name: 'Genre',
schema: 'public',
},
name: 'public / Genre',
},
{
id: '{"dataSourceName":"chinook","table":{"name":"Invoice","schema":"public"}}',
table: {
name: 'Invoice',
schema: 'public',
},
name: 'public / Invoice',
},
{
id: '{"dataSourceName":"chinook","table":{"name":"InvoiceLine","schema":"public"}}',
table: {
name: 'InvoiceLine',
schema: 'public',
},
name: 'public / InvoiceLine',
},
{
id: '{"dataSourceName":"chinook","table":{"name":"MediaType","schema":"public"}}',
table: {
name: 'MediaType',
schema: 'public',
},
name: 'public / MediaType',
},
{
id: '{"dataSourceName":"chinook","table":{"name":"Playlist","schema":"public"}}',
table: {
name: 'Playlist',
schema: 'public',
},
name: 'public / Playlist',
},
{
id: '{"dataSourceName":"chinook","table":{"name":"PlaylistTrack","schema":"public"}}',
table: {
name: 'PlaylistTrack',
schema: 'public',
},
name: 'public / PlaylistTrack',
},
{
id: '{"dataSourceName":"chinook","table":{"name":"Track","schema":"public"}}',
table: {
name: 'Track',
schema: 'public',
},
name: 'public / Track',
},
{
id: '{"dataSourceName":"chinook","function":{"name":"all_albums","schema":"public"}}',
function: {
name: 'all_albums',
schema: 'public',
},
name: 'public / all_albums',
},
{
id: '{"dataSourceName":"chinook","function":{"name":"all_albums_1","schema":"public"}}',
function: {
name: 'all_albums_1',
schema: 'public',
},
name: 'public / all_albums_1',
},
],
},
{
id: '{"dataSourceName":"snowflake_test"}',
dataSourceName: 'snowflake_test',
name: 'snowflake_test',
driver: 'snowflake',
releaseType: 'GA' as ReleaseType,
inconsistentObject: {
definition: 'snowflake_test',
name: 'source snowflake_test',
reason:
'Inconsistent object: Data connector named "snowflake" was not found in the data connector backend info',
type: 'source',
},
children: [
{
id: '{"dataSourceName":"snowflake_test","table":["ALBUM"]}',
table: ['ALBUM'],
name: 'ALBUM',
},
{
id: '{"dataSourceName":"snowflake_test","table":["ARTIST"]}',
table: ['ARTIST'],
name: 'ARTIST',
},
{
id: '{"dataSourceName":"snowflake_test","table":["CUSTOMER"]}',
table: ['CUSTOMER'],
name: 'CUSTOMER',
},
{
id: '{"dataSourceName":"snowflake_test","table":["EMPLOYEE"]}',
table: ['EMPLOYEE'],
name: 'EMPLOYEE',
},
{
id: '{"dataSourceName":"snowflake_test","table":["GENRE"]}',
table: ['GENRE'],
name: 'GENRE',
},
{
id: '{"dataSourceName":"snowflake_test","table":["INVOICE"]}',
table: ['INVOICE'],
name: 'INVOICE',
},
{
id: '{"dataSourceName":"snowflake_test","table":["INVOICELINE"]}',
table: ['INVOICELINE'],
name: 'INVOICELINE',
},
{
id: '{"dataSourceName":"snowflake_test","table":["MEDIATYPE"]}',
table: ['MEDIATYPE'],
name: 'MEDIATYPE',
},
{
id: '{"dataSourceName":"snowflake_test","table":["PLAYLIST"]}',
table: ['PLAYLIST'],
name: 'PLAYLIST',
},
],
},
];
export const Primary: Story = {
render: () => {
return (
<TreeComponent
treeData={mockData}
onDatabaseClick={dataSourceName =>
hasuraToast({
title: `${dataSourceName} was clicked`,
})
}
onLeafNodeClick={data => {
const qualifiedName =
'table' in data
? getQualifiedTable(data.table)
: getQualifiedTable(data.function);
const type = 'table' in data ? 'table' : 'function';
hasuraToast({
title: `${type} ${data.dataSourceName} / ${qualifiedName.join(
' / '
)} was clicked`,
});
}}
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// open the database
await expect(await canvas.getByTestId('chinook')).toBeVisible();
// check if the number of tables inside has been rendered properly
await expect(
await canvas.getByTestId('chinook-object-count')
).toHaveTextContent('13');
// The tree should expand on click
await userEvent.click(await canvas.findByTestId('chinook-expand'));
// this is a surprise tool that will help us later
const copyOfAlbumTableReference = await canvas.getByTestId(
'public / Album'
);
// check all the tables one-by-one.
await expect(await canvas.getByTestId('public / Album')).toBeVisible();
await expect(await canvas.getByTestId('public / Artist')).toBeVisible();
await expect(await canvas.getByTestId('public / Customer')).toBeVisible();
await expect(await canvas.getByTestId('public / Employee')).toBeVisible();
await expect(await canvas.getByTestId('public / Genre')).toBeVisible();
await expect(await canvas.getByTestId('public / Invoice')).toBeVisible();
await expect(
await canvas.getByTestId('public / InvoiceLine')
).toBeVisible();
await expect(await canvas.getByTestId('public / MediaType')).toBeVisible();
await expect(await canvas.getByTestId('public / Playlist')).toBeVisible();
await expect(
await canvas.getByTestId('public / PlaylistTrack')
).toBeVisible();
await expect(await canvas.getByTestId('public / Track')).toBeVisible();
await expect(await canvas.getByTestId('public / all_albums')).toBeVisible();
await expect(
await canvas.getByTestId('public / all_albums_1')
).toBeVisible();
// click on a table and function and check if the callback return the right values
await userEvent.click(await canvas.getByTestId('public / Album'));
await expect(
await canvas.getByText('table chinook / public / Album was clicked')
).toBeInTheDocument();
await userEvent.click(await canvas.getByTestId('public / all_albums'));
await expect(
await canvas.getByText(
'function chinook / public / all_albums was clicked'
)
).toBeInTheDocument();
// Check the snowflake database
await expect(await canvas.getByTestId('snowflake_test')).toBeVisible();
await expect(
await canvas.getByTestId('snowflake_test-object-count')
).toHaveTextContent('9');
// this one has an inconsistency. A red dot is shown on the DB icon.
await expect(
await canvas.getByTestId('snowflake_test-error-indicator')
).toBeVisible();
await userEvent.click(await canvas.findByTestId('snowflake_test-expand'));
await expect(await canvas.getByTestId('ALBUM')).toBeVisible();
await expect(await canvas.getByTestId('ARTIST')).toBeVisible();
await expect(await canvas.getByTestId('CUSTOMER')).toBeVisible();
await expect(await canvas.getByTestId('EMPLOYEE')).toBeVisible();
await expect(await canvas.getByTestId('GENRE')).toBeVisible();
await expect(await canvas.getByTestId('INVOICE')).toBeVisible();
await expect(await canvas.getByTestId('INVOICELINE')).toBeVisible();
await expect(await canvas.getByTestId('MEDIATYPE')).toBeVisible();
await expect(await canvas.getByTestId('PLAYLIST')).toBeVisible();
// close one of the sources and check if the tables are visible, they shouldn't be
await userEvent.click(await canvas.findByTestId('chinook-close'));
await expect(copyOfAlbumTableReference).not.toBeVisible();
await expect(await canvas.getByTestId('ALBUM')).toBeVisible();
},
};

View File

@ -0,0 +1,92 @@
import { NodeRendererProps, Tree } from 'react-arborist';
import {
DataSourceNode,
FunctionNode,
TableNode,
isDataSourceNode,
isFunctionNode,
// isTableNode,
} from '../types';
import { DatabaseNodeItem } from './DatabaseNodeItem';
import { FunctionNodeItem } from './FunctionNodeItem';
import { TableNodeItem } from './TableNodeItem';
import {
QualifiedFunction,
Table,
} from '../../../../../features/hasura-metadata-types';
function Node(
props:
| NodeRendererProps<DataSourceNode>
| NodeRendererProps<TableNode>
| NodeRendererProps<FunctionNode>
) {
// const nodeData = node.data;
if (isDataSourceNode(props)) return <DatabaseNodeItem {...props} />;
if (isFunctionNode(props)) return <FunctionNodeItem {...props} />;
return <TableNodeItem {...props} />;
}
export const TreeComponent = (props: {
treeData: DataSourceNode[];
selection?: string;
term?: string;
onDatabaseClick?: (dataSourceName: string) => void;
onLeafNodeClick?: (
leaf:
| {
dataSourceName: string;
table: Table;
}
| {
dataSourceName: string;
function: QualifiedFunction;
}
) => void;
}) => {
const data: DataSourceNode[] = props.treeData.map(source => {
return {
...source,
onClick: () => props.onDatabaseClick?.(source.dataSourceName),
children: source.children?.map(leaf => ({
...leaf,
onClick: () => {
if ('table' in leaf)
props.onLeafNodeClick?.({
dataSourceName: source.dataSourceName,
table: leaf.table,
});
else
props.onLeafNodeClick?.({
dataSourceName: source.dataSourceName,
function: leaf.function,
});
},
})),
};
});
return (
<Tree
initialData={data}
openByDefault={false}
width={'inherit'}
indent={24}
rowHeight={36}
height={800}
overscanCount={1}
searchTerm={props.term}
padding={15}
selection={props.selection}
disableMultiSelection={true}
searchMatch={(node, term) =>
node.data.name.toLowerCase().includes(term.toLowerCase())
}
>
{Node}
</Tree>
);
};

View File

@ -0,0 +1,20 @@
import { rest } from 'msw';
import {
mockInconsistentMetadata,
mockListSourceKindsResponse,
mockMetadata,
} from './mockData';
const baseUrl = 'http://localhost:8080';
export const handlers = (url = baseUrl) => [
rest.post(`${url}/v1/metadata`, async (_req, res, ctx) => {
const reqBody = (await _req.json()) as Record<string, any>;
if (reqBody.type === 'list_source_kinds')
return res(ctx.json(mockListSourceKindsResponse));
if (reqBody.type === 'export_metadata') return res(ctx.json(mockMetadata));
if (reqBody.type === 'get_inconsistent_metadata')
return res(ctx.json(mockInconsistentMetadata));
}),
];

View File

@ -0,0 +1,164 @@
import { Metadata } from '../../../../../features/hasura-metadata-types';
export const mockMetadata: Metadata = {
resource_version: 24,
metadata: {
version: 3,
sources: [
{
name: 'bikes',
kind: 'mssql',
tables: [
{ table: { name: 'brands', schema: 'production' } },
{ table: { name: 'categories', schema: 'production' } },
{ table: { name: 'customers', schema: 'sales' } },
{ table: { name: 'order_items', schema: 'sales' } },
{ table: { name: 'orders', schema: 'sales' } },
{ table: { name: 'products', schema: 'production' } },
{ table: { name: 'staffs', schema: 'sales' } },
{ table: { name: 'stocks', schema: 'production' } },
{ table: { name: 'stores', schema: 'sales' } },
],
configuration: {
connection_info: {
connection_string:
'DRIVER={ODBC Driver 17 for SQL Server};SERVER=host.docker.internal;DATABASE=bikes;Uid=SA;Pwd=reallyStrongPwd123',
pool_settings: {
idle_timeout: 5,
total_max_connections: null,
},
},
},
},
{
name: 'chinook',
kind: 'postgres',
tables: [
{ table: { name: 'Album', schema: 'public' } },
{ table: { name: 'Artist', schema: 'public' } },
{ table: { name: 'Customer', schema: 'public' } },
{ table: { name: 'Employee', schema: 'public' } },
{ table: { name: 'Genre', schema: 'public' } },
{ table: { name: 'Invoice', schema: 'public' } },
{ table: { name: 'InvoiceLine', schema: 'public' } },
{ table: { name: 'MediaType', schema: 'public' } },
{ table: { name: 'Playlist', schema: 'public' } },
{ table: { name: 'PlaylistTrack', schema: 'public' } },
{ table: { name: 'Track', schema: 'public' } },
],
configuration: {
connection_info: {
database_url:
'postgres://postgres:test@host.docker.internal:6001/chinook',
isolation_level: 'read-committed',
use_prepared_statements: false,
},
},
},
{
name: 'snowflake_test',
kind: 'snowflake',
tables: [
{ table: ['ALBUM'] },
{ table: ['ARTIST'] },
{ table: ['CUSTOMER'] },
{ table: ['EMPLOYEE'] },
{ table: ['GENRE'] },
{ table: ['INVOICE'] },
{ table: ['INVOICELINE'] },
{ table: ['MEDIATYPE'] },
{ table: ['PLAYLIST'] },
{ table: ['PLAYLISTTRACK'] },
{ table: ['TRACK'] },
],
configuration: {
template: null,
timeout: null,
value: {
fully_qualify_all_names: false,
jdbc_url: 'jdbc_url',
},
},
},
],
backend_configs: {
dataconnector: {
athena: { uri: 'http://host.docker.internal:8081/api/v1/athena' },
mariadb: { uri: 'http://host.docker.internal:8081/api/v1/mariadb' },
mysql8: { uri: 'http://host.docker.internal:8081/api/v1/mysql' },
oracle: { uri: 'http://host.docker.internal:8081/api/v1/oracle' },
snowflake: { uri: 'http://host.docker.internal:8081/api/v1/snowflake' },
},
},
},
};
export const mockListSourceKindsResponse = {
sources: [
{ available: true, builtin: true, display_name: 'pg', kind: 'pg' },
{
available: true,
builtin: true,
display_name: 'citus',
kind: 'citus',
},
{
available: true,
builtin: true,
display_name: 'cockroach',
kind: 'cockroach',
},
{
available: true,
builtin: true,
display_name: 'mssql',
kind: 'mssql',
},
{
available: true,
builtin: true,
display_name: 'bigquery',
kind: 'bigquery',
},
{
available: true,
builtin: false,
display_name: 'Amazon Athena',
kind: 'athena',
release_name: 'Beta',
},
{
available: true,
builtin: false,
display_name: 'MariaDB',
kind: 'mariadb',
release_name: 'Beta',
},
{
available: true,
builtin: false,
display_name: 'MySQL',
kind: 'mysql8',
release_name: 'Beta',
},
{
available: true,
builtin: false,
display_name: 'Oracle',
kind: 'oracle',
release_name: 'Beta',
},
{
available: true,
builtin: false,
display_name: 'Snowflake',
kind: 'snowflake',
release_name: 'Beta',
},
],
};
export const mockInconsistentMetadata = {
inconsistent_objects: [],
is_consistent: true,
};

View File

@ -0,0 +1,67 @@
import { getQualifiedTable } from '../../../../features/Data/ManageTable/utils';
import { DriverInfo } from '../../../../features/DataSource';
import {
InconsistentMetadata,
InconsistentObject,
} from '../../../../features/hasura-metadata-api';
import { Metadata } from '../../../../features/hasura-metadata-types';
import { DataSourceNode } from './types';
type InconsistentData = {
inconsistentSources: InconsistentObject[];
inconsistentTables: InconsistentObject[];
inconsistentFunctions: InconsistentObject[];
};
export const adaptInconsistentObjects = (m: InconsistentMetadata) =>
m.inconsistent_objects.reduce<InconsistentData>(
(acc, entry) => {
if (entry.type === 'source') acc.inconsistentSources.push(entry);
if (entry.type === 'table') acc.inconsistentTables.push(entry);
if (entry.type === 'function') acc.inconsistentFunctions.push(entry);
return acc;
},
{
inconsistentSources: [],
inconsistentTables: [],
inconsistentFunctions: [],
}
);
export const adaptSourcesIntoTreeData =
(m: Metadata) =>
(drivers: DriverInfo[], inconsistentData: InconsistentData) =>
m.metadata.sources.map<DataSourceNode>(source => {
return {
id: JSON.stringify({ dataSourceName: source.name }),
dataSourceName: source.name,
name: source.name,
driver: source.kind,
releaseType: drivers?.find(driver => source.kind === driver.name)
?.release,
inconsistentObject: inconsistentData.inconsistentSources.find(
i => i.definition === source.name
),
children: [
...source.tables.map(t => ({
id: JSON.stringify({
dataSourceName: source.name,
table: t.table,
}),
table: t.table,
name: getQualifiedTable(t.table).join(' / '),
})),
...(source.functions ?? []).map(f => ({
id: JSON.stringify({
dataSourceName: source.name,
function: f.function,
}),
function: f.function,
name: getQualifiedTable(f.function).join(' / '),
})),
],
};
});

View File

@ -0,0 +1,62 @@
import { NodeRendererProps } from 'react-arborist';
import {
QualifiedFunction,
Table,
} from '../../../../features/hasura-metadata-types';
import { InconsistentObject } from '../../../../features/hasura-metadata-api';
import { ReleaseType } from '../../../../features/DataSource';
export type TableNode = {
id: string;
table: Table;
onClick?: () => void;
// needed for search
name: string;
};
export type FunctionNode = {
id: string;
function: QualifiedFunction;
onClick?: () => void;
// needed for search
name: string;
};
export type DataSourceNode = {
id: string;
dataSourceName: string;
driver: string;
releaseType?: ReleaseType;
onClick?: () => void;
children?: (TableNode | FunctionNode)[];
inconsistentObject?: InconsistentObject;
// needed for search
name: string;
};
export const isDataSourceNode = (
value:
| NodeRendererProps<TableNode>
| NodeRendererProps<DataSourceNode>
| NodeRendererProps<FunctionNode>
): value is NodeRendererProps<DataSourceNode> => {
return 'dataSourceName' in value.node.data;
};
export const isFunctionNode = (
value:
| NodeRendererProps<TableNode>
| NodeRendererProps<DataSourceNode>
| NodeRendererProps<FunctionNode>
): value is NodeRendererProps<FunctionNode> => {
return 'function' in value.node.data;
};
export const isTableNode = (
value:
| NodeRendererProps<TableNode>
| NodeRendererProps<DataSourceNode>
| NodeRendererProps<FunctionNode>
): value is NodeRendererProps<TableNode> => {
return 'table' in value.node.data;
};

View File

@ -0,0 +1,79 @@
import { Table } from '../../../../features/hasura-metadata-types';
export const adaptTableObject = (table: Table): string[] => {
if (Array.isArray(table)) return table;
// This is a safe assumption to make because the only native database that supports functions is postgres( and variants)
if (typeof table === 'string') return ['public', table];
const postgresOrMssqlTable = table as {
schema: string;
name: string;
};
if ('schema' in postgresOrMssqlTable)
return [postgresOrMssqlTable.schema, postgresOrMssqlTable.name];
const bigQueryTable = table as { dataset: string; name: string };
if ('dataset' in bigQueryTable)
return [bigQueryTable.dataset, bigQueryTable.name];
return [];
};
export function convertToTreeDataForGDCSource(
tables: string[][],
key: string[],
dataSourceName: string,
tableLevelClick: (t: Table) => void
): any {
console.log(tables);
if (tables.length === 0) return [];
if (tables[0].length === 1) {
const leafNodes: any = tables.map(table => {
return {
id: JSON.stringify({
database: dataSourceName,
table: [...key, table[0]],
}),
title: table[0],
onTableSelect: tableLevelClick,
isTable: true,
};
});
return leafNodes;
}
const uniqueLevelValues = Array.from(new Set(tables.map(table => table[0])));
const acc: any = [];
const values = uniqueLevelValues.reduce((_acc, levelValue) => {
// eslint-disable-next-line no-underscore-dangle
const _childTables = tables
.filter(table => table[0] === levelValue)
.map<string[]>(table => {
console.log(table);
return table.slice(1);
});
return [
..._acc,
{
id: JSON.stringify([...key, levelValue[0]]),
title: levelValue,
children: convertToTreeDataForGDCSource(
_childTables,
[...key, levelValue],
dataSourceName,
tableLevelClick
),
},
];
}, acc);
return values;
}

View File

@ -1,5 +1,5 @@
import { expect } from '@storybook/jest';
import { StoryObj } from '@storybook/react';
import { Meta, StoryObj } from '@storybook/react';
import { screen, userEvent, within } from '@storybook/testing-library';
import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query';
import { dismissToast } from '../../../../utils/StoryUtils';
@ -19,7 +19,7 @@ export default {
}),
layout: 'fullscreen',
},
};
} satisfies Meta<typeof AddNativeQuery>;
const fillAndSubmitForm: Story['play'] = async ({ canvasElement }) => {
const c = within(canvasElement);

View File

@ -59,7 +59,7 @@ export const AddNativeQuery = ({
s => s.name === selectedSource
)?.logical_models;
const { trackNativeQuery } = useTrackNativeQuery();
const { trackNativeQuery, isLoading } = useTrackNativeQuery();
const [isLogicalModelsDialogOpen, setIsLogicalModelsDialogOpen] =
React.useState(false);
@ -208,7 +208,12 @@ export const AddNativeQuery = ({
Slack thread: https://hasurahq.slack.com/archives/C04LV93JNSH/p1682965503376129
*/}
{/* <Button icon={<FaPlay />}>Validate</Button> */}
<Button type="submit" icon={<FaSave />} mode="primary">
<Button
type="submit"
icon={<FaSave />}
mode="primary"
isLoading={isLoading}
>
Save
</Button>
</div>

View File

@ -3,6 +3,7 @@ import { Metadata } from '../hasura-metadata-types';
import { useHttpClient } from '../Network';
import { useCallback } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { APIError } from '../../hooks/error';
export const DEFAULT_STALE_TIME = 5 * 60000; // 5 minutes as default stale time
@ -24,22 +25,31 @@ export const useInvalidateMetadata = () => {
return invalidate;
};
export const useMetadata = <T = Metadata>(
selector?: (m: Metadata) => T,
staleTime: number = DEFAULT_STALE_TIME
type Options = {
staleTime?: number;
enabled?: boolean;
};
export const useMetadata = <FinalResult = Metadata>(
selector?: (m: Metadata) => FinalResult,
options: Options = {
staleTime: DEFAULT_STALE_TIME,
enabled: true,
}
) => {
const httpClient = useHttpClient();
const invalidateMetadata = useInvalidateMetadata();
const queryReturn = useQuery({
const queryReturn = useQuery<Metadata, APIError, FinalResult>({
queryKey: [METADATA_QUERY_KEY],
queryFn: async () => {
const result = await exportMetadata({ httpClient });
return result;
},
staleTime: staleTime || DEFAULT_STALE_TIME,
staleTime: options.staleTime,
refetchOnWindowFocus: false,
select: selector,
enabled: options.enabled,
});
return {

View File

@ -1,16 +1,14 @@
import React from 'react';
import { DecoratorFn } from '@storybook/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import merge from 'lodash/merge';
import { RootState } from '../../store';
import { Provider } from 'react-redux';
import reducer from '../../reducer';
import { RootState } from '../../store';
import { DeepPartial } from '../../types';
import { Decorator } from '@storybook/react';
export const ReduxDecorator = (
mockValues: DeepPartial<RootState>
): DecoratorFn => {
): Decorator => {
// Dispatch manually inside reducer with undefined as state and a action that shouldn't be used
// So we have the base state of the app
const baseState = reducer(undefined, { type: '@@INIT_MOCK_STORYBOOK@@' });

View File

@ -112,6 +112,7 @@
"react": "17.0.2",
"react-ace": "8.0.0",
"react-apollo": "^3.0.1",
"react-arborist": "^3.0.2",
"react-autosize-textarea": "7.1.0",
"react-autosuggest": "10.0.2",
"react-bootstrap": "0.32.4",

View File

@ -6292,6 +6292,27 @@ __metadata:
languageName: node
linkType: hard
"@react-dnd/asap@npm:^4.0.0":
version: 4.0.1
resolution: "@react-dnd/asap@npm:4.0.1"
checksum: 757db3b5c436a95383b74f187f503321092909401ce9665d9cc1999308a44de22809bf8dbe82c9126bd73b72dd6665bbc4a788e864fc3c243c59f65057a4f87f
languageName: node
linkType: hard
"@react-dnd/invariant@npm:^2.0.0":
version: 2.0.0
resolution: "@react-dnd/invariant@npm:2.0.0"
checksum: ef1e989920d70b15c80dccb01af9b598081d76993311aa22d2e9a3ec41d10a88540eeec4b4de7a8b2a2ea52dfc3495ab45e39192c2d27795a9258bd6b79d000e
languageName: node
linkType: hard
"@react-dnd/shallowequal@npm:^2.0.0":
version: 2.0.0
resolution: "@react-dnd/shallowequal@npm:2.0.0"
checksum: b5bbdc795d65945bb7ba2322bed5cf8d4c6fe91dced98c3b10e3d16822c438f558751135ff296f8d1aa1eaa9d0037dacab2b522ca5eb812175123b9996966dcb
languageName: node
linkType: hard
"@reduxjs/toolkit@npm:^1.5.1":
version: 1.9.5
resolution: "@reduxjs/toolkit@npm:1.9.5"
@ -16227,6 +16248,17 @@ __metadata:
languageName: node
linkType: hard
"dnd-core@npm:14.0.1":
version: 14.0.1
resolution: "dnd-core@npm:14.0.1"
dependencies:
"@react-dnd/asap": ^4.0.0
"@react-dnd/invariant": ^2.0.0
redux: ^4.1.1
checksum: dbc50727f53baad1cb1e0430a2a1b81c5c291389322f90fdc46edeb2fd49cc206ce4fa30a95afb53d88238945228e34866d7465f7ea49285c296baf883551301
languageName: node
linkType: hard
"dns-equal@npm:^1.0.0":
version: 1.0.0
resolution: "dns-equal@npm:1.0.0"
@ -18855,6 +18887,7 @@ __metadata:
react-a11y: 0.2.8
react-ace: 8.0.0
react-apollo: ^3.0.1
react-arborist: ^3.0.2
react-autosize-textarea: 7.1.0
react-autosuggest: 10.0.2
react-bootstrap: 0.32.4
@ -29159,6 +29192,22 @@ __metadata:
languageName: node
linkType: hard
"react-arborist@npm:^3.0.2":
version: 3.0.2
resolution: "react-arborist@npm:3.0.2"
dependencies:
react-dnd: ^14.0.3
react-dnd-html5-backend: ^14.0.1
react-window: ^1.8.6
redux: ^4.1.1
use-sync-external-store: ^1.2.0
peerDependencies:
react: ">= 16.14"
react-dom: ">= 16.14"
checksum: 8fa035db59f3d6adcbf06e0d140cb5a483edb5f55d9db2f2a13e691acb7ba0a45a89d7c24953756b9f2c263d629dbdf1ed1c5d0a116de5587f6abf0c12ecbc3a
languageName: node
linkType: hard
"react-autosize-textarea@npm:7.1.0":
version: 7.1.0
resolution: "react-autosize-textarea@npm:7.1.0"
@ -29320,6 +29369,40 @@ __metadata:
languageName: node
linkType: hard
"react-dnd-html5-backend@npm:^14.0.1":
version: 14.1.0
resolution: "react-dnd-html5-backend@npm:14.1.0"
dependencies:
dnd-core: 14.0.1
checksum: 6aa8d62c6b2288893b3f216d476d2f84495b40d33578ba9e3a5051dc093a71dc59700e6927ed7ac596ff8d7aa3b3f29404f7d173f844bd6144ed633403dd8e96
languageName: node
linkType: hard
"react-dnd@npm:^14.0.3":
version: 14.0.5
resolution: "react-dnd@npm:14.0.5"
dependencies:
"@react-dnd/invariant": ^2.0.0
"@react-dnd/shallowequal": ^2.0.0
dnd-core: 14.0.1
fast-deep-equal: ^3.1.3
hoist-non-react-statics: ^3.3.2
peerDependencies:
"@types/hoist-non-react-statics": ">= 3.3.1"
"@types/node": ">= 12"
"@types/react": ">= 16"
react: ">= 16.14"
peerDependenciesMeta:
"@types/hoist-non-react-statics":
optional: true
"@types/node":
optional: true
"@types/react":
optional: true
checksum: 464e231de8c2b79546049a1600b67b1df0b7f762f23c688d3e9aeddbf334b1e64931ef91d0129df3c8be255f0af76e89426729dbcbefe4bdc09b0f665d2da368
languageName: node
linkType: hard
"react-docgen-typescript@npm:^2.2.2":
version: 2.2.2
resolution: "react-docgen-typescript@npm:2.2.2"
@ -30047,7 +30130,7 @@ __metadata:
languageName: node
linkType: hard
"react-window@npm:^1":
"react-window@npm:^1, react-window@npm:^1.8.6":
version: 1.8.9
resolution: "react-window@npm:1.8.9"
dependencies:
@ -30348,7 +30431,7 @@ __metadata:
languageName: node
linkType: hard
"redux@npm:^4.0.0, redux@npm:^4.0.4, redux@npm:^4.0.5, redux@npm:^4.2.1":
"redux@npm:^4.0.0, redux@npm:^4.0.4, redux@npm:^4.0.5, redux@npm:^4.1.1, redux@npm:^4.2.1":
version: 4.2.1
resolution: "redux@npm:4.2.1"
dependencies: