mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
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:
parent
5c51ff4288
commit
2298c21ae0
@ -144,3 +144,8 @@ export const QueryDialog = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
QueryDialog.defaultProps = {
|
||||
filters: [],
|
||||
sorts: [],
|
||||
};
|
||||
|
@ -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" />
|
||||
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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({}));
|
||||
}),
|
||||
];
|
@ -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 };
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
@ -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,
|
||||
});
|
||||
};
|
@ -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,
|
||||
});
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
@ -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',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>;
|
@ -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),
|
||||
|
@ -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(
|
||||
|
@ -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) => {
|
||||
|
@ -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>
|
||||
|
@ -82,7 +82,7 @@ export const expectedManualLocalRelationshipOutput: Relationship & {
|
||||
name: 'Employee',
|
||||
schema: 'public',
|
||||
},
|
||||
relationship_type: 'Object',
|
||||
relationship_type: 'Array',
|
||||
mapping: {
|
||||
from: {
|
||||
source: 'chinook',
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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'
|
||||
);
|
||||
}
|
||||
),
|
||||
|
@ -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,
|
||||
|
@ -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}
|
||||
>
|
||||
|
@ -6,7 +6,7 @@ import { FieldWrapper, FieldWrapperPassThroughProps } from './FieldWrapper';
|
||||
|
||||
type SelectItem = {
|
||||
label: ReactText;
|
||||
value: ReactText;
|
||||
value: any;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user