feature (console): add storybook components for local relationships for GDC

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/6290
GitOrigin-RevId: aadac5dc29db2d1fa6550a604e04a6baa3b7c661
This commit is contained in:
Vijay Prasanna 2022-10-18 10:26:22 +05:30 committed by hasura-bot
parent 5c51ff4288
commit 2298c21ae0
29 changed files with 1087 additions and 34 deletions

View File

@ -144,3 +144,8 @@ export const QueryDialog = ({
</div>
);
};
QueryDialog.defaultProps = {
filters: [],
sorts: [],
};

View File

@ -102,8 +102,8 @@ export const ManageTable = (props: ManageTableProps) => {
],
]}
>
<div className="flex gap-0.5 items-center">
<h1 className="inline-flex items-center text-xl font-semibold mb-1">
<div className="flex items-center">
<h1 className="inline-flex items-center text-xl font-semibold mb-1 pr-xs">
{tableName}
</h1>
<FaChevronDown className="text-gray-400 text-sm transition-transform group-radix-state-open:rotate-180" />

View File

@ -1,10 +1,11 @@
import { DataSource } from '@/features/DataSource';
import { useHttpClient } from '@/features/Network';
import { AxiosError } from 'axios';
import { useQuery } from 'react-query';
export const useDatabaseHierarchy = (dataSourceName: string) => {
const httpClient = useHttpClient();
return useQuery({
return useQuery<string[], AxiosError>({
queryKey: [dataSourceName, 'hierarchy'],
queryFn: async () => {
const hierarcy = await DataSource(httpClient).getDatabaseHierarchy({
@ -12,5 +13,6 @@ export const useDatabaseHierarchy = (dataSourceName: string) => {
});
return hierarcy;
},
refetchOnWindowFocus: false,
});
};

View File

@ -0,0 +1,30 @@
import React, { useState } from 'react';
import { Story, Meta } from '@storybook/react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ManualLocalRelationshipWidget } from './ManualLocalRelationshipWidget';
import { handlers } from './__mocks__/localrelationships.mock';
export default {
title: 'Relationships/Manual Relationship 🧬',
component: ManualLocalRelationshipWidget,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers(),
},
} as Meta;
export const Primary: Story<ManualLocalRelationshipWidget> = () => {
const [formState, updateFormState] = useState('');
return (
<div className="w-2/3">
<ManualLocalRelationshipWidget
dataSourceName="sqlite_test"
table={['Album']}
onSuccess={() => {
updateFormState('success');
}}
/>
<div>{formState}</div>
</div>
);
};

View File

@ -0,0 +1,105 @@
import {
ManualArrayRelationship,
ManualObjectRelationship,
Table,
} from '@/features/MetadataAPI';
import { Button } from '@/new-components/Button';
import { UpdatedForm } from '@/new-components/Form';
import React from 'react';
import { schema } from './schema';
import { MapColumns } from './parts/MapColumns';
import { Name } from './parts/Name';
import { RelationshipType } from './parts/RelationshipType';
import { SourceTable } from './parts/SourceTable';
import { TargetTable } from './parts/TargetTable';
import { useCreateManualLocalRelationship } from './hooks/useCreateManualLocalRelationship';
import { LinkBlockHorizontal } from './parts/LinkBlockHorizontal';
import { LinkBlockVertical } from './parts/LinkBlockVertical';
export type ManualLocalRelationshipWidget = {
dataSourceName: string;
table: Table;
onSuccess: () => void;
existingRelationship?: ManualArrayRelationship | ManualObjectRelationship;
};
export const ManualLocalRelationshipWidget = (
props: ManualLocalRelationshipWidget
) => {
// Get current relationship if any for this table
const { dataSourceName, table, onSuccess } = props;
const { createManualLocalRelationship, isLoading: isSaving } =
useCreateManualLocalRelationship({ onSuccess });
return (
<UpdatedForm
schema={schema}
onSubmit={values => {
createManualLocalRelationship({
relationshipName: values.name,
relationshipType: values.relationship_type,
fromSource: values.source_name,
fromTable: values.source_table,
toTable: values.target_table,
columnMapping: values.column_mapping,
});
}}
options={{
defaultValues: {
name: '',
relationship_type: 'object',
source_name: dataSourceName,
source_table: table,
target_name: dataSourceName,
column_mapping: [{}],
},
}}
>
{() => (
<>
<div className="w-full sm:w-6/12 mb-md">
<div className="mb-sm">
<Name />
</div>
<div className="mb-sm">
<RelationshipType />
</div>
</div>
<div className="grid grid-cols-12">
<div className="col-span-5">
<div className="rounded bg-gray-50 border border-gray-300 p-md gap-y-4 border-l-4 border-l-green-600">
<SourceTable />
</div>
</div>
<LinkBlockHorizontal />
<div className="col-span-5">
<div className="rounded bg-gray-50 border border-gray-300 p-md gap-y-4 border-l-4 border-l-indigo-600">
<TargetTable />
</div>
</div>
</div>
<LinkBlockVertical title="Columns Mapped To" />
<MapColumns />
<Button
mode="primary"
type="submit"
loadingText="Saving relationship"
data-testid="add-local-db-relationship"
isLoading={isSaving}
>
Save Relationship
</Button>
</>
)}
</UpdatedForm>
);
};

View File

@ -0,0 +1,200 @@
import { rest } from 'msw';
import { Metadata } from '@/features/MetadataAPI';
import { TableInfo } from '@hasura/dc-api-types';
export const schemaList = {
result_type: 'TuplesOk',
result: [['schema_name'], ['public'], ['default']],
};
const metadata: Metadata = {
resource_version: 1,
metadata: {
version: 3,
sources: [
{
name: 'sqlite_test',
kind: 'sqlite',
tables: [{ table: ['Album'] }, { table: ['Artist'] }],
configuration: {
foo: 'bar',
},
},
],
},
};
const AlbumTableResponse: TableInfo = {
name: ['Album'],
columns: [
{
name: 'AlbumId',
type: 'number',
nullable: false,
},
{
name: 'Title',
type: 'string',
nullable: false,
},
{
name: 'ArtistId',
type: 'number',
nullable: false,
},
],
primary_key: ['AlbumId'],
foreign_keys: {
'ArtistId->Artist.ArtistId': {
foreign_table: ['Artist'],
column_mapping: {
ArtistId: 'ArtistId',
},
},
},
description:
'CREATE TABLE [Album]\n(\n [AlbumId] INTEGER NOT NULL,\n [Title] NVARCHAR(160) NOT NULL,\n [ArtistId] INTEGER NOT NULL,\n CONSTRAINT [PK_Album] PRIMARY KEY ([AlbumId]),\n FOREIGN KEY ([ArtistId]) REFERENCES [Artist] ([ArtistId]) \n\t\tON DELETE NO ACTION ON UPDATE NO ACTION\n)',
};
const ArtistTableRespone: TableInfo = {
name: ['Artist'],
columns: [
{
name: 'ArtistId',
type: 'number',
nullable: false,
},
{
name: 'Name',
type: 'string',
nullable: true,
},
],
primary_key: ['ArtistId'],
description:
'CREATE TABLE [Artist]\n(\n [ArtistId] INTEGER NOT NULL,\n [Name] NVARCHAR(120),\n CONSTRAINT [PK_Artist] PRIMARY KEY ([ArtistId])\n)',
};
export const introspectionQueryResponse = {
data: {
__schema: {
types: [
{
kind: 'OBJECT',
name: 'Album',
description:
'CREATE TABLE [Album]\n(\n [AlbumId] INTEGER NOT NULL,\n [Title] NVARCHAR(160) NOT NULL,\n [ArtistId] INTEGER NOT NULL,\n CONSTRAINT [PK_Album] PRIMARY KEY ([AlbumId]),\n FOREIGN KEY ([ArtistId]) REFERENCES [Artist] ([ArtistId]) \n\t\tON DELETE NO ACTION ON UPDATE NO ACTION\n)',
fields: [
{
name: 'AlbumId',
description: null,
args: [],
type: {
kind: 'NON_NULL',
name: null,
ofType: {
kind: 'SCALAR',
name: 'decimal',
ofType: null,
},
},
isDeprecated: false,
deprecationReason: null,
},
{
name: 'ArtistId',
description: null,
args: [],
type: {
kind: 'NON_NULL',
name: null,
ofType: {
kind: 'SCALAR',
name: 'decimal',
ofType: null,
},
},
isDeprecated: false,
deprecationReason: null,
},
{
name: 'Title',
description: null,
args: [],
type: {
kind: 'NON_NULL',
name: null,
ofType: {
kind: 'SCALAR',
name: 'String',
ofType: null,
},
},
isDeprecated: false,
deprecationReason: null,
},
],
inputFields: null,
interfaces: [],
enumValues: null,
possibleTypes: null,
},
{
kind: 'OBJECT',
name: 'Artist',
description:
'CREATE TABLE [Artist]\n(\n [ArtistId] INTEGER NOT NULL,\n [Name] NVARCHAR(120),\n CONSTRAINT [PK_Artist] PRIMARY KEY ([ArtistId])\n)',
fields: [
{
name: 'ArtistId',
description: null,
type: {
kind: 'NON_NULL',
name: null,
ofType: {
kind: 'SCALAR',
name: 'decimal',
ofType: null,
},
},
isDeprecated: false,
deprecationReason: null,
},
{
name: 'Name',
description: null,
type: {
kind: 'SCALAR',
name: 'String',
ofType: null,
},
isDeprecated: false,
deprecationReason: null,
},
],
inputFields: null,
interfaces: [],
enumValues: null,
possibleTypes: null,
},
],
},
},
};
export const handlers = (url = 'http://localhost:8080') => [
rest.post(`${url}/v1/metadata`, async (_req, res, ctx) => {
const body = await _req.json();
if (body.type === 'get_table_info' && body.args.table.includes('Album'))
return res(ctx.json(AlbumTableResponse));
if (body.type === 'get_table_info' && body.args.table.includes('Artist'))
return res(ctx.json(ArtistTableRespone));
return res(ctx.json(metadata));
}),
rest.post(`${url}/v1/graphql`, async (_req, res, ctx) => {
const body = await _req.json();
if (body.operationName === 'IntrospectionQuery')
return res(ctx.json(introspectionQueryResponse));
return res(ctx.json({}));
}),
];

View File

@ -0,0 +1,88 @@
import { exportMetadata } from '@/features/DataSource';
import { Table, useMetadataMigration } from '@/features/MetadataAPI';
import { useHttpClient } from '@/features/Network';
import { useFireNotification } from '@/new-components/Notifications';
import { useCallback } from 'react';
type CreateManualLocalRelationshipPayload = {
relationshipName: string;
relationshipType: 'object' | 'array';
fromTable: Table;
fromSource: string;
toTable: Table;
columnMapping: { from: string; to: string }[];
};
export const useCreateManualLocalRelationship = (props: {
onSuccess?: () => void;
}) => {
const { mutate, ...rest } = useMetadataMigration();
const httpClient = useHttpClient();
const { fireNotification } = useFireNotification();
const createManualLocalRelationship = useCallback(
async (values: CreateManualLocalRelationshipPayload) => {
const { metadata, resource_version } = await exportMetadata({
httpClient,
});
if (!metadata) throw Error('Unable to fetch metadata');
const metadataSource = metadata.sources.find(
s => s.name === values.fromSource
);
if (!metadataSource) throw Error('Unable to fetch metadata source');
const driver = metadataSource.kind;
const type =
values.relationshipType === 'object'
? 'create_object_relationship'
: 'create_array_relationship';
mutate(
{
query: {
resource_version,
type: `${driver}_${type}`,
args: {
table: values.fromTable,
source: values.fromSource,
name: values.relationshipName,
using: {
manual_configuration: {
remote_table: values.toTable,
column_mapping: values.columnMapping.reduce(
(acc, val) => ({ ...acc, [val.from]: val.to }),
{}
),
},
},
},
},
},
{
onSuccess: () => {
props.onSuccess?.();
fireNotification({
type: 'success',
title: 'Success!',
message: 'A relationship was added to Hasura succesfull!',
});
},
onError: err => {
fireNotification({
type: 'error',
title: 'failed to run SQL statement',
message: err?.message,
});
},
}
);
},
[fireNotification, httpClient, mutate, props]
);
return { createManualLocalRelationship, ...rest };
};

View File

@ -0,0 +1,98 @@
import { renderHook } from '@testing-library/react-hooks';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { wrapper } from '../../../../../hooks/__tests__/common/decorator';
import { Metadata, Table } from '../../../../MetadataAPI';
import { useGetTargetOptions } from './useGetTargetOptions';
describe('useGetTargetOptions', () => {
const mockMetadata: Metadata = {
resource_version: 54,
metadata: {
version: 3,
sources: [
{
name: 'chinook',
kind: 'postgres',
tables: [
{
table: {
name: 'Album',
schema: 'public',
},
},
{
table: {
name: 'Artist',
schema: 'public',
},
},
],
configuration: {
connection_info: {
database_url:
'postgres://postgres:test@host.docker.internal:6001/chinook',
isolation_level: 'read-committed',
use_prepared_statements: false,
},
},
},
],
},
};
const server = setupServer(
rest.post('http://localhost/v1/metadata', (req, res, ctx) => {
return res(ctx.status(200), ctx.json(mockMetadata));
})
);
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
});
it('when invoked, fetches all tables when no option excludeTable options is provided', async () => {
const { result, waitFor } = renderHook(
() => useGetTargetOptions('chinook'),
{ wrapper }
);
const expectedResult: Table[] = [
{
name: 'Album',
schema: 'public',
},
{
name: 'Artist',
schema: 'public',
},
];
await waitFor(() => result.current.isSuccess);
expect(result.current.data).toEqual(expectedResult);
});
it('when invoked, fetches the filtered list of tables when an option excludeTable options is provided', async () => {
const { result, waitFor } = renderHook(
() =>
useGetTargetOptions('chinook', {
name: 'Album',
schema: 'public',
}),
{ wrapper }
);
const expectedResult: Table[] = [
{
name: 'Artist',
schema: 'public',
},
];
await waitFor(() => result.current.isSuccess);
expect(result.current.data).toEqual(expectedResult);
});
});

View File

@ -0,0 +1,35 @@
import { useHttpClient } from '@/features/Network';
import { areTablesEqual } from '@/features/RelationshipsTable';
import { exportMetadata } from '@/features/DataSource';
import { useQuery } from 'react-query';
import { Table } from '@/features/MetadataAPI';
import { AxiosError } from 'axios';
export const useGetTargetOptions = (
dataSourceName: string,
excludeTable?: Table
) => {
const httpClient = useHttpClient();
return useQuery<Table[], AxiosError>({
queryKey: ['local-db-relationships-target-options', dataSourceName],
queryFn: async () => {
const { metadata } = await exportMetadata({ httpClient });
if (!metadata) throw Error('Unable to fetch sources from metadata');
const metadataSource = metadata.sources.find(
source => source.name === dataSourceName
);
if (!metadataSource) throw Error('Unable to find the source in metadata');
const tables = metadataSource.tables.map(t => t.table);
if (excludeTable)
return tables.filter(t => !areTablesEqual(t, excludeTable));
return tables;
},
refetchOnWindowFocus: false,
});
};

View File

@ -0,0 +1,27 @@
import { useHttpClient } from '@/features/Network';
import { useQuery } from 'react-query';
import { DataSource, TableColumn } from '@/features/DataSource';
import { Table } from '@/features/MetadataAPI';
import { AxiosError } from 'axios';
export const useTableColumns = ({
dataSourceName,
table,
}: {
dataSourceName: string;
table: Table;
}) => {
const httpClient = useHttpClient();
return useQuery<TableColumn[], AxiosError>({
queryKey: ['tableColumns', dataSourceName, table],
queryFn: () => {
const columns = DataSource(httpClient).getTableColumns({
dataSourceName,
table,
});
return columns;
},
refetchOnWindowFocus: false,
enabled: !!table && !!dataSourceName,
});
};

View File

@ -0,0 +1,20 @@
import React from 'react';
import { FaLink } from 'react-icons/fa';
export const LinkBlockHorizontal = () => {
return (
<div className="col-span-2 flex relative items-center justify-center w-full py-md">
<div
className="flex z-10 items-center justify-center border border-gray-300 bg-white"
style={{
height: 32,
width: 32,
borderRadius: 100,
}}
>
<FaLink />
</div>
<div className="absolute w-full border-b border-gray-300" />
</div>
);
};

View File

@ -0,0 +1,24 @@
import React from 'react';
import { FaLink } from 'react-icons/fa';
export const LinkBlockVertical = ({ title }: { title: string }) => {
return (
<div
className="flex items-center w-full ml-lg border-l border-gray-300 py-lg t-"
style={{ marginTop: '0px' }}
>
<div
className="flex items-center justify-center border border-gray-300 bg-white mr-md"
style={{
height: 32,
width: 32,
marginLeft: -17,
borderRadius: 100,
}}
>
<FaLink />
</div>
<p className="font-semibold text-muted">{title}</p>
</div>
);
};

View File

@ -0,0 +1,148 @@
import React from 'react';
import Skeleton from 'react-loading-skeleton';
import { FieldError, useFieldArray, useFormContext } from 'react-hook-form';
import { Select } from '@/new-components/Form';
import { FaArrowRight, FaCircle, FaTrashAlt } from 'react-icons/fa';
import { Button } from '@/new-components/Button';
import { IndicatorCard } from '@/new-components/IndicatorCard';
import { Schema } from '../schema';
import { useTableColumns } from '../hooks/useTableColumns';
export const MapColumns = () => {
const { fields, append } = useFieldArray<Schema>({ name: 'column_mapping' });
const {
watch,
setValue,
formState: { errors },
} = useFormContext<Schema>();
const formErrorMessage = (errors?.column_mapping as unknown as FieldError)
?.message;
const sourceTable = watch('source_table');
const sourceDataSourceName = watch('source_name');
const targetTable = watch('target_table');
const targetDataSourceName = watch('target_name');
const columnMappings = watch('column_mapping');
const {
data: sourceTableColumns,
isLoading: areSourceColumnsLoading,
error: sourceColumnsFetchError,
} = useTableColumns({
dataSourceName: sourceDataSourceName,
table: sourceTable,
});
const {
data: targetTableColumns,
isLoading: areTargetColumnsLoading,
error: targetColumnsFetchError,
} = useTableColumns({
dataSourceName: targetDataSourceName,
// This condition is wait for targetTable to get set/unset, until then `undefined` is passed to react-query, which will stop the
// unnessacary call from being made
table: targetTable ? JSON.parse(targetTable) : undefined,
});
if (sourceColumnsFetchError || targetColumnsFetchError)
return (
<div className="rounded bg-gray-50 border border-gray-300 p-md mb-md mt-0 h">
<div className="items-center mb-sm font-semibold text-gray-600">
<IndicatorCard
status="negative"
headline="Errors while fetching columns"
showIcon
>
<ul>
{!!sourceColumnsFetchError && (
<li>
source table error:{' '}
{JSON.stringify(sourceColumnsFetchError.response?.data)}
</li>
)}
{!!targetColumnsFetchError && (
<li>
target table error:
{JSON.stringify(targetColumnsFetchError.response?.data)}
</li>
)}
</ul>
</IndicatorCard>
</div>
</div>
);
return (
<div className="rounded bg-gray-50 border border-gray-300 p-md mb-md mt-0 h">
<div className="grid grid-cols-12 items-center mb-sm font-semibold text-gray-600">
<div className="col-span-6">
<FaCircle className="text-green-600" /> Source Column
</div>
<div className="col-span-6">
<FaCircle className="text-indigo-600" /> Reference Column
</div>
</div>
<div className="text-red-500">{formErrorMessage}</div>
{fields.map((field, index) => {
return (
<div
className="grid grid-cols-12 items-center mb-sm"
key={`${index}_column_map_row`}
>
<div className="col-span-5">
{areSourceColumnsLoading ? (
<Skeleton height={35} />
) : (
<Select
options={(sourceTableColumns ?? []).map(column => ({
label: column.name,
value: column.name,
}))}
name={`column_mapping.${index}.from`}
placeholder="Select source column"
noErrorPlaceholder={false}
/>
)}
</div>
<div className="flex justify-around">
<FaArrowRight />
</div>
<div className="col-span-5">
{areTargetColumnsLoading ? (
<Skeleton height={35} />
) : (
<Select
options={(targetTableColumns ?? []).map(column => ({
label: column.name,
value: column.name,
}))}
name={`column_mapping.${index}.to`}
placeholder="Select reference column"
noErrorPlaceholder={false}
/>
)}
</div>
<div className="flex justify-around">
<Button
type="button"
icon={<FaTrashAlt />}
onClick={() => {
setValue(
'column_mapping',
columnMappings.filter((_, i) => index !== i)
);
}}
/>
</div>
</div>
);
})}
<div className="my-4">
<Button type="button" onClick={() => append({})}>
Add New Mapping
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,13 @@
import { InputField } from '@/new-components/Form';
import React from 'react';
export const Name = () => {
return (
<InputField
name="name"
label="Name"
placeholder="Relationship name"
dataTest="local-db-to-db-rel-name"
/>
);
};

View File

@ -0,0 +1,23 @@
import { Select } from '@/new-components/Form';
import React from 'react';
export const RelationshipType = () => {
return (
<Select
name="relationship_type"
label="Relationship Type"
dataTest="local-db-to-db-select-rel-type"
placeholder="Select a relationship type..."
options={[
{
label: 'Object Relationship',
value: 'object',
},
{
label: 'Array Relationship',
value: 'array',
},
]}
/>
);
};

View File

@ -0,0 +1,58 @@
import { useDatabaseHierarchy, getTableName } from '@/features/Data';
import { Table } from '@/features/MetadataAPI';
import { InputField, Select } from '@/new-components/Form';
import React from 'react';
import { useWatch } from 'react-hook-form';
import { FaDatabase, FaTable } from 'react-icons/fa';
import Skeleton from 'react-loading-skeleton';
export const SourceTable = () => {
const table = useWatch<Record<string, Table>>({ name: 'source_table' });
const dataSourceName = useWatch<Record<string, string>>({
name: 'source_name',
});
const {
data: databaseHierarchy,
isLoading: isDatabaseHierarchyLoading,
error: databaseHierarchyError,
} = useDatabaseHierarchy(dataSourceName);
if (databaseHierarchyError)
return (
<div className="h-full">
{JSON.stringify(databaseHierarchyError.response?.data)}
</div>
);
if (isDatabaseHierarchyLoading)
return (
<div className="h-full">
<div data-testid="target_table_loading">
<Skeleton count={3} height={40} className="my-3" />
</div>
</div>
);
return (
<div>
<InputField
name="source_name"
label="Database"
labelIcon={<FaDatabase />}
disabled
/>
<Select
name="source_table"
options={[table].map(t => ({
value: t,
label: getTableName(t, databaseHierarchy ?? []),
}))}
label="Table"
labelIcon={<FaTable />}
disabled
/>
</div>
);
};

View File

@ -0,0 +1,47 @@
import React, { useState } from 'react';
import { Story, Meta } from '@storybook/react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { z } from 'zod';
import { UpdatedForm } from '@/new-components/Form';
import { TargetTable } from './TargetTable';
import { handlers } from '../__mocks__/localrelationships.mock';
export default {
title: 'Relationships/TargetTable 🧬',
component: TargetTable,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers(),
},
} as Meta;
export const Basic: Story<typeof TargetTable> = () => {
const [formState, updateFormState] = useState<any>('');
return (
<>
<UpdatedForm
schema={z.object({
target_name: z.string().min(1, 'Reference source must be provided!'),
target_table: z.any().transform(value => {
try {
return JSON.parse(value);
} catch {
return null;
}
}),
})}
onSubmit={data => {
updateFormState(data);
}}
options={{
defaultValues: {
target_name: 'sqlite_test',
},
}}
>
{() => <TargetTable />}
</UpdatedForm>
<div>{formState}</div>
</>
);
};

View File

@ -0,0 +1,68 @@
import { useDatabaseHierarchy, getTableName } from '@/features/Data';
import { Table } from '@/features/MetadataAPI';
import { InputField, Select } from '@/new-components/Form';
import React from 'react';
import { useWatch } from 'react-hook-form';
import { FaDatabase, FaTable } from 'react-icons/fa';
import Skeleton from 'react-loading-skeleton';
import { useGetTargetOptions } from '../hooks/useGetTargetOptions';
export const TargetTable = () => {
const table = useWatch<Record<string, Table>>({ name: 'source_table' });
const dataSourceName = useWatch<Record<string, string>>({
name: 'target_name',
});
const {
data: databaseHierarchy,
isLoading: isDatabaseHierarchyLoading,
error: databaseHierarchyError,
} = useDatabaseHierarchy(dataSourceName);
const {
data: options,
isLoading: areTargetOptionsLoading,
error: targetOptionError,
} = useGetTargetOptions(dataSourceName, table);
if (databaseHierarchyError)
return (
<div className="h-full">
{JSON.stringify(databaseHierarchyError.response?.data)}
</div>
);
if (targetOptionError)
return (
<div className="h-full">
{JSON.stringify(targetOptionError.response?.data)}
</div>
);
if (areTargetOptionsLoading || isDatabaseHierarchyLoading)
return (
<div className="h-full">
<div data-testid="target_table_loading">
<Skeleton count={3} height={40} className="my-3" />
</div>
</div>
);
return (
<div className="h-full">
<InputField
name="target_name"
label="Reference Database"
labelIcon={<FaDatabase />}
disabled
/>
<Select
name="target_table"
label="Reference Table"
labelIcon={<FaTable />}
options={(options ?? []).map(t => ({
value: JSON.stringify(t),
label: getTableName(t, databaseHierarchy ?? []),
}))}
/>
</div>
);
};

View File

@ -0,0 +1,34 @@
import { z } from 'zod';
export const schema = z.object({
name: z.string().min(1, 'Name is a required field!'),
relationship_type: z.union([z.literal('object'), z.literal('array')], {
required_error: 'Relationship type is required field!',
}),
source_table: z.any().transform(value => {
try {
return JSON.parse(value);
} catch {
return value;
}
}),
source_name: z.string().min(1, 'Source source must be provided!'),
target_name: z.string().min(1, 'Reference source must be provided!'),
target_table: z.any().transform(value => {
try {
return JSON.parse(value);
} catch {
return null;
}
}),
column_mapping: z
.array(
z.object({
from: z.string().min(1, 'Please provide a column!'),
to: z.string().min(1, 'Please provide a column!'),
})
)
.min(1, 'Please provide at least one column mapping!'),
});
export type Schema = z.infer<typeof schema>;

View File

@ -55,8 +55,12 @@ export const convertToTreeData = (
return [
...uniqueLevelValues.map(levelValue => {
const { database, ...rest } = JSON.parse(name);
// eslint-disable-next-line no-underscore-dangle
const _key = JSON.stringify({ ...JSON.parse(name), [key]: levelValue });
const _key = JSON.stringify({
database,
table: { ...rest.table, [key]: levelValue },
});
const children = convertToTreeData(
tables.filter((t: any) => t[key] === levelValue),
hierarchy.slice(1),

View File

@ -50,7 +50,7 @@ export const getTableColumns = async (props: GetTableColumnsProps) => {
sourceCustomization: metadataSource?.customization,
configuration: metadataTable.configuration,
});
console.log(queryRoot);
// eslint-disable-next-line no-underscore-dangle
const graphQLFields =
introspectionResult.data.__schema.types.find(

View File

@ -1,12 +1,35 @@
import { rest } from 'msw';
import { metadata } from './metadata';
import { queryData } from './querydata';
import { Metadata } from '../../metadataTypes';
const baseUrl = 'http://localhost:8080';
export const metadata: Metadata = {
resource_version: 1,
metadata: {
version: 3,
sources: [
{
name: 'sqlite_test',
kind: 'sqlite_test',
tables: [
{
table: ['Album'],
},
{
table: ['Artist'],
},
],
configuration: {
some_value: true,
},
},
],
},
};
export const handlers = (url = baseUrl) => [
rest.post(`${url}/v2/query`, (req, res, ctx) => {
return res(ctx.json(queryData));
return res(ctx.json({}));
}),
rest.post(`${url}/v1/metadata`, (req, res, ctx) => {

View File

@ -10,7 +10,6 @@ import {
FaTable,
FaTrash,
} from 'react-icons/fa';
import { Button } from '@/new-components/Button';
import { CardedTable } from '@/new-components/CardedTable';
import { IndicatorCard } from '@/new-components/IndicatorCard';
import { Relationship } from './types';
@ -20,6 +19,8 @@ import { useListAllRelationshipsFromMetadata } from './hooks/useListAllRelations
export const columns = ['NAME', 'SOURCE', 'TYPE', 'RELATIONSHIP', null];
const getTableDisplayName = (table: Table): string => {
if (Array.isArray(table)) return table.join();
if (!table) return 'Empty Object';
if (typeof table === 'string') return table;
@ -131,17 +132,7 @@ export const DatabaseRelationshipsTable = ({
{relationships.map(relationship => (
<CardedTable.TableBodyRow key={relationship.name}>
<CardedTable.TableBodyCell>
<Button
onClick={() =>
onEditRow({
dataSourceName,
table,
relationship,
})
}
>
{relationship.name}
</Button>
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>

View File

@ -82,7 +82,7 @@ export const expectedManualLocalRelationshipOutput: Relationship & {
name: 'Employee',
schema: 'public',
},
relationship_type: 'Object',
relationship_type: 'Array',
mapping: {
from: {
source: 'chinook',

View File

@ -105,7 +105,8 @@ describe('test adapters', () => {
},
},
},
}
},
'Array'
);
expect(result).toEqual(expectedManualLocalRelationshipOutput);
});
@ -147,7 +148,8 @@ describe('test adapters', () => {
column: ['EmployeeId'],
},
},
]
],
'Object'
);
expect(result).toEqual(expectedLocalTableRelationships);
});

View File

@ -113,7 +113,8 @@ export const useListAllRelationshipsFromMetadata = (
return adaptManualRelationship(
dataSourceName,
table,
relationship
relationship,
'Object'
);
/**
@ -124,7 +125,8 @@ export const useListAllRelationshipsFromMetadata = (
dataSourceName,
table,
relationship,
fkRelationships
fkRelationships,
'Object'
);
/**
@ -153,14 +155,16 @@ export const useListAllRelationshipsFromMetadata = (
return adaptManualRelationship(
dataSourceName,
table,
relationship
relationship,
'Array'
);
return adaptLocalTableRelationship(
dataSourceName,
table,
relationship,
fkRelationships
fkRelationships,
'Array'
);
}
),

View File

@ -106,13 +106,16 @@ export const adaptRemoteDBRelationship = (
export const adaptManualRelationship = (
dataSourceName: string,
table: Table,
relationship: ManualObjectRelationship | ManualArrayRelationship
): Relationship & { type: 'toLocalTableManual' } => {
relationship: ManualObjectRelationship | ManualArrayRelationship,
relationship_type: 'Object' | 'Array'
): Relationship & {
type: 'toLocalTableManual';
} => {
return {
name: relationship.name,
type: 'toLocalTableManual',
toLocalTable: table,
relationship_type: 'Object',
relationship_type,
mapping: {
from: {
source: dataSourceName,
@ -136,7 +139,8 @@ export const adaptLocalTableRelationship = (
dataSourceName: string,
table: Table,
relationship: LocalTableObjectRelationship | LocalTableArrayRelationship,
fkRelationships: TableFkRelationships[]
fkRelationships: TableFkRelationships[],
relationship_type: 'Array' | 'Object'
): Relationship & { type: 'toLocalTableFk' } => {
const columns = isLegacyFkConstraint(
relationship.using.foreign_key_constraint_on
@ -148,7 +152,7 @@ export const adaptLocalTableRelationship = (
name: relationship.name,
type: 'toLocalTableFk',
toLocalTable: table,
relationship_type: 'Object',
relationship_type,
mapping: {
from: {
source: dataSourceName,

View File

@ -123,7 +123,7 @@ export const UpdatedForm = <FormSchema extends Schema>(
<FormProvider {...methods}>
<form
id={id}
className={`space-y-md bg-legacybg ${className || ''}`}
className={`bg-legacybg ${className || ''}`}
onSubmit={methods.handleSubmit(onSubmit)}
{...rest}
>

View File

@ -6,7 +6,7 @@ import { FieldWrapper, FieldWrapperPassThroughProps } from './FieldWrapper';
type SelectItem = {
label: ReactText;
value: ReactText;
value: any;
disabled?: boolean;
};