feature (console): streamlined UI for DB-to-X relationships

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/7678
Co-authored-by: Nicolas Beaussart <7281023+beaussan@users.noreply.github.com>
GitOrigin-RevId: 2bc3e26efa6037341157e01c27aca8e532353f36
This commit is contained in:
Vijay Prasanna 2023-02-01 23:43:58 +05:30 committed by hasura-bot
parent ac985c2235
commit c663cb9879
109 changed files with 3479 additions and 9252 deletions

View File

@ -7,7 +7,7 @@ import {
useFeatureFlags,
availableFeatureFlagIds,
} from '@/features/FeatureFlags';
import { DatabaseRelationshipsTab } from '@/features/DataRelationships';
import { DatabaseRelationshipsTab } from '@/features/DatabaseRelationships';
import { Button } from '@/new-components/Button';
import TableHeader from '../TableCommon/TableHeader';
import {

View File

@ -6,7 +6,7 @@ import {
useFeatureFlags,
availableFeatureFlagIds,
} from '@/features/FeatureFlags';
import { DatabaseRelationshipsTab } from '@/features/DataRelationships';
import { DatabaseRelationshipsTab } from '@/features/DatabaseRelationships';
import TableHeader from '../TableCommon/TableHeader';
import { getObjArrRelList } from './utils';
import { setTable, UPDATE_REMOTE_SCHEMA_MANUAL_REL } from '../DataActions';

View File

@ -1,242 +0,0 @@
import React from 'react';
import { RightContainer } from '@/components/Common/Layout/RightContainer';
import { Button } from '@/new-components/Button';
import { useFireNotification } from '@/new-components/Notifications';
import { useQueryClient } from 'react-query';
import { getConfirmation } from '@/components/Common/utils/jsUtils';
import { Driver } from '@/dataSources';
import { FaPlusCircle } from 'react-icons/fa';
import { NormalizedTable } from '@/dataSources/types';
import { Table } from '@/features/hasura-metadata-types';
import {
DataSourceDriver,
getDataSourcePrefix,
} from '@/metadata/dataSource.utils';
import TableHeader from '../../components/Services/Data/TableCommon/TableHeader';
import { FeatureFlagFloatingButton } from '../FeatureFlags/components/FeatureFlagFloatingButton';
import { DatabaseRelationshipsTable } from './components/DatabaseRelationshipsTable';
import { allowedMetadataTypes, useMetadataMigration } from '../MetadataAPI';
import { Form } from './components/Form/Form';
import { Relationship } from './components/DatabaseRelationshipsTable/types';
const useFormState = (currentSource: string) => {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = React.useState(false);
const [existingRelationship, setExistingRelationship] =
React.useState<Relationship>();
const { fireNotification } = useFireNotification();
const mutation = useMetadataMigration({
onSuccess: () => {
fireNotification({
title: 'Success!',
message: 'Relationship deleted successfully',
type: 'success',
});
},
onError: () => {
fireNotification({
title: 'Error',
message: 'Error while deleting the relationship',
type: 'error',
});
},
});
const onOpen = () => {
setExistingRelationship(undefined);
setIsOpen(true);
};
const editRelationship = (relationship: Relationship) => {
setExistingRelationship(relationship);
setIsOpen(true);
};
const closeForm = () => {
setExistingRelationship(undefined);
setIsOpen(false);
};
const onSuccess = () => {
queryClient.invalidateQueries([currentSource, 'list_all_relationships']);
};
const deleteRelationship = (row: Relationship) => {
setExistingRelationship(undefined);
setIsOpen(false);
const confirmMessage = `This will permanently delete the ${row?.name} from Hasura`;
const isOk = getConfirmation(confirmMessage, true, row?.name);
if (!isOk) {
return;
}
const sourcePrefix = getDataSourcePrefix(currentSource as DataSourceDriver);
if (row.type === 'toRemoteSchema') {
mutation.mutate(
{
query: {
type: `${sourcePrefix}delete_remote_relationship` as allowedMetadataTypes,
args: {
name: row.name,
source: currentSource,
table: row.mapping.from.table,
},
},
},
{ onSuccess }
);
return;
}
if (row.type === 'toSource') {
mutation.mutate(
{
query: {
type: `${sourcePrefix}delete_remote_relationship` as allowedMetadataTypes,
args: {
name: row.name,
source: currentSource,
table: row.mapping.from.table,
},
},
},
{ onSuccess }
);
return;
}
/**
* It must be a local/self table relationship
*/
mutation.mutate(
{
query: {
type: `${sourcePrefix}drop_relationship` as allowedMetadataTypes,
args: {
relationship: row.name,
table: row.toLocalTable,
source: currentSource,
},
},
},
{
onSuccess,
}
);
};
const openForm = () => onOpen();
const onClick = ({ type, row }: { type: string; row: Relationship }) => {
switch (type) {
case 'delete':
deleteRelationship(row);
break;
default:
setIsOpen(false);
}
};
return {
isOpen,
onClick,
existingRelationship,
openForm,
closeForm,
deleteRelationship,
editRelationship,
};
};
export const DatabaseRelationshipsTab = ({
table,
currentSource,
migrationMode,
driver,
metadataTable,
}: {
table: NormalizedTable;
currentSource: string;
migrationMode: boolean;
driver: Driver;
metadataTable: Table;
}) => {
const {
isOpen,
existingRelationship,
editRelationship,
openForm,
closeForm,
deleteRelationship,
} = useFormState(currentSource);
const queryClient = useQueryClient();
const onComplete = ({
type,
}: {
title?: string;
message?: string;
type: 'success' | 'error' | 'cancel';
}) => {
if (type === 'success' || type === 'cancel') {
closeForm();
queryClient.refetchQueries([currentSource, 'list_all_relationships'], {
exact: true,
});
}
};
return (
<RightContainer>
<TableHeader
dispatch={() => {}}
table={table}
source={currentSource}
tabName="relationships"
migrationMode={migrationMode}
readOnlyMode={false}
count={null}
isCountEstimated
/>
<div className="py-4">
<h2 className="text-md font-semibold">Data Relationships</h2>
</div>
<DatabaseRelationshipsTable
dataSourceName={currentSource}
table={metadataTable}
onEditRow={({ relationship }) => {
editRelationship(relationship);
}}
onDeleteRow={({ relationship }) => {
deleteRelationship(relationship);
}}
/>
{isOpen ? null : (
<Button onClick={() => openForm()} icon={<FaPlusCircle />}>
Add New Relationship
</Button>
)}
{isOpen ? (
<Form
existingRelationship={existingRelationship}
sourceTableInfo={{
database: currentSource,
[driver === 'bigquery' ? 'dataset' : 'schema']: table.table_schema, // TODO find a better way to handle this so that GDC can work
table: table.table_name,
}}
onComplete={onComplete}
driver={driver}
onClose={closeForm}
/>
) : null}
<FeatureFlagFloatingButton />
</RightContainer>
);
};

View File

@ -1,102 +0,0 @@
import { RelationshipFields } from '@/features/RemoteRelationships';
export const customer_columns = [
'id',
'firstName',
'lastName',
'age',
'countryCode',
'country',
];
export const remote_rel_definition: any = {
definition: {
remote_field: {
testUser_aggregate: {
field: {
aggregate: {
field: {
count: {
arguments: {
columns: '$firstName',
},
},
},
arguments: {},
},
},
arguments: {},
},
},
hasura_fields: ['name'],
remote_schema: 'hasura_cloud',
},
name: 'remoteSchema1',
};
export const relationship_fields: RelationshipFields[] = [
{
key: '__query',
depth: 0,
checkable: false,
argValue: null,
type: 'field',
},
{
key: '__query.testUser_aggregate',
depth: 1,
checkable: false,
argValue: null,
type: 'field',
},
{
key: '__query.testUser_aggregate.arguments.where',
depth: 1,
checkable: false,
argValue: null,
type: 'arg',
},
{
key: '__query.testUser_aggregate.arguments.where.id',
depth: 2,
checkable: false,
argValue: null,
type: 'arg',
},
{
key: '__query.testUser_aggregate.arguments.where.id._eq',
depth: 3,
checkable: true,
argValue: {
kind: 'field',
value: 'id',
type: 'String',
},
type: 'arg',
},
{
key: '__query.testUser_aggregate.field.aggregate',
depth: 2,
checkable: false,
argValue: null,
type: 'field',
},
{
key: '__query.testUser_aggregate.field.aggregate.field.count',
depth: 3,
checkable: false,
argValue: null,
type: 'field',
},
{
key: '__query.testUser_aggregate.field.aggregate.field.count.arguments.distinct',
depth: 3,
checkable: true,
argValue: {
kind: 'field',
value: 'id',
type: 'String',
},
type: 'arg',
},
];

View File

@ -1,92 +0,0 @@
import { rest } from 'msw';
import { schema } from './schema';
import { countries } from './countries_schema';
import { metadata } from './metadata';
import {
albumTableColumnsResult,
userAddressTableColumnsResult,
userInfoTableColumnsResult,
artistTableColumnsResult,
} from './tables';
const baseUrl = 'http://localhost:8080';
export const handlers = (url = baseUrl) => [
rest.post(`${url}/v1/metadata`, (req, res, ctx) => {
const body = req.body as Record<string, any>;
if (
body.type === 'introspect_remote_schema' &&
body?.args?.name === 'source_remote_schema'
) {
return res(ctx.json(schema));
}
if (
body.type === 'introspect_remote_schema' &&
body?.args?.name === 'with_default_values'
) {
return res(ctx.json(schema));
}
if (
body.type === 'introspect_remote_schema' &&
body?.args?.name === 'remoteSchema2'
) {
return res(ctx.json(countries));
}
if (
body.type === 'introspect_remote_schema' &&
body?.args?.name === 'remoteSchema1'
) {
return res(ctx.json(schema));
}
if (
body.type === 'introspect_remote_schema' &&
body?.args?.name === 'remoteSchema3'
) {
return res(ctx.json(schema));
}
if (body.type === 'create_remote_schema_remote_relationship') {
return res(ctx.json({ message: 'success' }));
}
if (
body.type === 'introspect_remote_schema' &&
body?.args?.name === 'countries'
) {
return res(ctx.json(countries));
}
if (body.type === 'export_metadata') {
return res(ctx.json(metadata));
}
if (body.type === 'create_remote_schema_remote_relationship') {
return res(ctx.json({ message: 'success' }));
}
if (body.type === 'pg_create_object_relationship') {
return res(ctx.json({ message: 'success' }));
}
if (body.type === 'pg_create_array_relationship') {
return res(ctx.json({ message: 'success' }));
}
return res(ctx.json([{ message: 'success' }]));
}),
rest.post(`${url}/v2/query`, (req, res, ctx) => {
const reqSql: string = (req?.body as Record<string, any>)?.args?.sql;
if (reqSql.toLowerCase().includes('album')) {
return res(ctx.json(albumTableColumnsResult));
} else if (reqSql.toLowerCase().includes('address')) {
return res(ctx.json(userAddressTableColumnsResult));
} else if (reqSql.toLowerCase().includes('artist')) {
return res(ctx.json(artistTableColumnsResult));
}
return res(ctx.json(userInfoTableColumnsResult));
}),
];

View File

@ -1,3 +0,0 @@
export { handlers } from './handlers.mock';
export * from './constants';
export * from './schema';

View File

@ -1,316 +0,0 @@
export const metadata = {
resource_version: 1,
metadata: {
version: 3,
remote_schemas: [
{
name: 'source_remote_schema',
definition: {
url: 'https://countries.trevorblades.com/',
timeout_seconds: 60,
forward_client_headers: true,
},
comment: '',
},
{
name: 'remoteSchema1',
definition: {
url: 'https://some-graph-other-endpoint.com/api/graphql',
timeout_seconds: 60,
forward_client_headers: true,
},
comment: '',
},
{
name: 'with_default_values',
definition: {
url: 'https://some-graph-other-endpoint.com/api/graphql',
timeout_seconds: 60,
forward_client_headers: true,
},
comment: '',
remote_relationships: [
{
relationships: [
{
definition: {
to_source: {
relationship_type: 'object',
source: 'default',
table: {
schema: 'public',
name: 'resident',
},
field_mapping: {
name: 'Title',
},
},
},
name: 'testRemoteRelationship',
},
{
definition: {
to_remote_schema: {
remote_field: {
country: {
arguments: {
code: '$code',
},
},
},
remote_schema: 'source_remote_schema',
lhs_fields: ['code'],
},
},
name: 'an_example_rs_to_rs_relationship',
},
],
type_name: 'Country',
},
],
},
],
sources: [
{
name: 'default',
kind: 'postgres',
tables: [
{
table: {
schema: 'public',
name: 'user',
},
remote_relationships: [
{
definition: {
to_remote_schema: {
remote_field: {
continents: {
arguments: { filter: { code: { eq: '$id' } } },
},
},
remote_schema: 'remoteSchema1',
lhs_fields: ['id'],
},
},
name: 'new_payload',
},
{
definition: {
remote_field: {
continents: {
arguments: { filter: { code: { eq: '$Age' } } },
},
},
hasura_fields: ['Age'],
remote_schema: 'remoteSchema1',
},
name: 'legacy_payload',
},
{
definition: {
to_source: {
relationship_type: 'object',
source: 'chinook',
table: {
schema: 'public',
name: 'Album',
},
field_mapping: {
id: 'AlbumId',
},
},
},
name: 'object_relationship',
},
{
definition: {
to_source: {
relationship_type: 'array',
source: 'chinook',
table: {
schema: 'public',
name: 'Album',
},
field_mapping: {
name: 'id',
},
},
},
name: 'array_relationship',
},
],
},
],
configuration: {
connection_info: {
use_prepared_statements: true,
database_url: {
from_env: 'HASURA_GRAPHQL_DATABASE_URL',
},
isolation_level: 'read-committed',
pool_settings: {
connection_lifetime: 600,
retries: 1,
idle_timeout: 180,
max_connections: 50,
},
},
},
},
{
name: 'chinook',
kind: 'postgres',
tables: [
{
table: {
schema: 'public',
name: 'Album',
},
object_relationships: [
{
name: 'relt1obj',
using: {
manual_configuration: {
remote_table: {
schema: 'public',
name: 'user',
},
insertion_order: null,
column_mapping: {
AlbumId: 'Id',
Title: 'Country',
},
},
},
},
],
array_relationships: [
{
name: 'relt1array',
using: {
manual_configuration: {
remote_table: {
schema: 'public',
name: 'Artist',
},
insertion_order: null,
column_mapping: {
AlbumId: 'Id',
Title: 'Name',
},
},
},
},
],
remote_relationships: [
{
definition: {
to_source: {
relationship_type: 'object',
source: 'default',
table: {
schema: 'public',
name: 'resident',
},
field_mapping: {
id: 'Id',
updated_at: 'FirstName',
},
},
},
name: 'AlbumToResident',
},
],
},
{
table: {
schema: 'public',
name: 'Artist',
},
},
{
table: {
schema: 'public',
name: 'Customer',
},
},
{
table: {
schema: 'public',
name: 'Employee',
},
},
{
table: {
schema: 'public',
name: 'Genre',
},
},
{
table: {
schema: 'public',
name: 'Invoice',
},
},
{
table: {
schema: 'public',
name: 'InvoiceLine',
},
},
{
table: {
schema: 'public',
name: 'MediaType',
},
},
{
table: {
schema: 'public',
name: 'Playlist',
},
},
{
table: {
schema: 'public',
name: 'PlaylistTrack',
},
},
{
table: {
schema: 'public',
name: 'Track',
},
},
{
table: {
schema: 'public',
name: 'comedies',
},
},
{
table: {
schema: 'user',
name: 'userAddress',
},
},
{
table: {
schema: 'user',
name: 'userInfo',
},
},
],
configuration: {
connection_info: {
use_prepared_statements: false,
database_url:
'postgres://postgres:test@host.docker.internal:6001/chinook',
isolation_level: 'read-committed',
},
},
},
],
},
};

View File

@ -1,4 +0,0 @@
export const schemaList = {
result_type: 'TuplesOk',
result: [['schema_name'], ['public'], ['schema2']],
};

View File

@ -1,53 +0,0 @@
export const tables = {
result_type: 'TuplesOk',
result: [
['tables'],
[
'[{"table_schema":"public","table_name":"user","table_type":"TABLE","comment":null,"columns":[{"comment": null, "data_type": "integer", "table_name": "user", "column_name": "id", "is_nullable": "NO", "table_schema": "public", "column_default": "nextval(\'user_id_seq\'::regclass)", "data_type_name": "int4", "ordinal_position": 1}, {"comment": null, "data_type": "text", "table_name": "user", "column_name": "description", "is_nullable": "NO", "table_schema": "public", "column_default": null, "data_type_name": "text", "ordinal_position": 3}, {"comment": null, "data_type": "text", "table_name": "user", "column_name": "name", "is_nullable": "NO", "table_schema": "public", "column_default": null, "data_type_name": "text", "ordinal_position": 2}],"triggers":[],"view_info":null}, {"table_schema":"public","table_name":"stuff","table_type":"TABLE","comment":null,"columns":[{"comment": null, "data_type": "integer", "table_name": "stuff", "column_name": "id", "is_nullable": "NO", "table_schema": "public", "column_default": "nextval(\'stuff_id_seq\'::regclass)", "data_type_name": "int4", "ordinal_position": 1}, {"comment": null, "data_type": "text", "table_name": "stuff", "column_name": "name", "is_nullable": "NO", "table_schema": "public", "column_default": null, "data_type_name": "text", "ordinal_position": 2}],"triggers":[],"view_info":null}, {"table_schema":"schema2","table_name":"things","table_type":"TABLE","comment":null,"columns":[{"comment": null, "data_type": "integer", "table_name": "things", "column_name": "id", "is_nullable": "NO", "table_schema": "schema2", "column_default": "nextval(\'schema2.things_id_seq\'::regclass)", "data_type_name": "int4", "ordinal_position": 1}, {"comment": null, "data_type": "text", "table_name": "things", "column_name": "description", "is_nullable": "NO", "table_schema": "schema2", "column_default": null, "data_type_name": "text", "ordinal_position": 3}, {"comment": null, "data_type": "text", "table_name": "things", "column_name": "name", "is_nullable": "NO", "table_schema": "schema2", "column_default": null, "data_type_name": "text", "ordinal_position": 2}],"triggers":[],"view_info":null}]',
],
],
};
export const albumTableColumnsResult = {
result_type: 'TuplesOk',
result: [
['database', 'table_schema', 'table_name', 'column_name', 'data_type'],
['chinook', 'public', 'Album', 'AlbumId', 'integer'],
['chinook', 'public', 'Album', 'Title', 'character varying'],
['chinook', 'public', 'Album', 'ArtistId', 'integer'],
],
};
export const artistTableColumnsResult = {
result_type: 'TuplesOk',
result: [
['database', 'table_schema', 'table_name', 'column_name', 'data_type'],
['chinook', 'public', 'Artist', 'Id', 'integer'],
['chinook', 'public', 'Artist', 'Name', 'character varying'],
['chinook', 'public', 'Artist', 'Age', 'integer'],
],
};
export const userInfoTableColumnsResult = {
result_type: 'TuplesOk',
result: [
['database', 'table_schema', 'table_name', 'column_name', 'data_type'],
['chinook', 'user', 'userInfo', 'Id', 'integer'],
['chinook', 'user', 'userInfo', 'FirstName', 'character varying'],
['chinook', 'user', 'userInfo', 'LastName', 'character varying'],
['chinook', 'user', 'userInfo', 'Age', 'integer'],
],
};
export const userAddressTableColumnsResult = {
result_type: 'TuplesOk',
result: [
['database', 'table_schema', 'table_name', 'column_name', 'data_type'],
['chinook', 'user', 'userAddress', 'Id', 'integer'],
['chinook', 'user', 'userAddress', 'Block', 'character varying'],
['chinook', 'user', 'userAddress', 'Street', 'character varying'],
['chinook', 'user', 'userAddress', 'City', 'character varying'],
['chinook', 'user', 'userAddress', 'Country', 'character varying'],
['chinook', 'user', 'userAddress', 'CountryCode', 'integer'],
],
};

View File

@ -1,41 +0,0 @@
import React from 'react';
import { rest } from 'msw';
import { Meta, Story } from '@storybook/react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import {
DatabaseRelationshipsTable,
DatabaseRelationshipsTableProps,
} from './DatabaseRelationshipTable';
import { metadata, relationshipQueryResponse } from './mocks';
const url = 'http://localhost:8080';
export default {
title: 'GDC Console/Relationships/View Database Relationships',
component: DatabaseRelationshipsTable,
decorators: [ReactQueryDecorator()],
parameters: {
msw: [
rest.post(`${url}/v1/metadata`, (_req, res, ctx) =>
res(ctx.json(metadata))
),
rest.post(`${url}/v2/query`, (_req, res, ctx) =>
res(ctx.json(relationshipQueryResponse))
),
],
},
} as Meta;
export const Primary: Story<DatabaseRelationshipsTableProps> = args => (
<DatabaseRelationshipsTable {...args} />
);
Primary.args = {
dataSourceName: 'chinook',
table: {
name: 'Album',
schema: 'public',
},
};

View File

@ -1,205 +0,0 @@
import React from 'react';
import { Table } from '@/features/hasura-metadata-types';
import {
FaArrowRight,
FaColumns,
FaDatabase,
FaEdit,
FaFont,
FaPlug,
FaTable,
FaTrash,
} from 'react-icons/fa';
import Skeleton from 'react-loading-skeleton';
import { CardedTable } from '@/new-components/CardedTable';
import { IndicatorCard } from '@/new-components/IndicatorCard';
import { Relationship } from './types';
import Legends from './Legends';
import { useListAllRelationshipsFromMetadata } from './hooks/useListAllRelationshipsFromMetadata';
export const columns = ['NAME', 'SOURCE', 'TYPE', 'RELATIONSHIP', null];
const getTableDisplayName = (table: Table): string => {
/*
this function isn't entirely generic but it will hold for the current set of native DBs we have & GDC as well
*/
if (Array.isArray(table)) return table.join('.');
if (!table) return 'Empty Object';
if (typeof table === 'string') return table;
if (typeof table === 'object' && 'name' in table)
return (table as { name: string }).name;
return JSON.stringify(table);
};
function TargetName(props: { relationship: Relationship }) {
const { relationship } = props;
if (relationship.type === 'toRemoteSchema')
return (
<>
<FaPlug /> <span>{relationship.mapping.to.remoteSchema}</span>
</>
);
return (
<>
<FaDatabase /> <span>{relationship.mapping.to.source}</span>
</>
);
}
const RelationshipMapping = ({
relationship,
}: {
relationship: Relationship;
}) => {
return (
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<FaTable />
<span>{getTableDisplayName(relationship.mapping.from.table)}</span>
/
<FaColumns /> {relationship.mapping.from.columns.join(',')}
</div>
<FaArrowRight />
<div className="flex items-center gap-2">
{relationship.type === 'toRemoteSchema' ? (
<>
<FaPlug />
<span>{relationship.mapping.to.remoteSchema}</span> /
<FaFont /> {relationship.mapping.to.fields.join(',')}
</>
) : (
<>
<FaTable />
<span>{getTableDisplayName(relationship.mapping.to.table)}</span> /
<FaColumns />
{!relationship.mapping.to.columns.length
? '[could not find target columns]'
: relationship.mapping.to.columns.join(',')}
</>
)}
</div>
</div>
);
};
export interface DatabaseRelationshipsTableProps {
dataSourceName: string;
table: Table;
onEditRow: (props: {
dataSourceName: string;
table: Table;
relationship: Relationship;
}) => void;
onDeleteRow: (props: {
dataSourceName: string;
table: Table;
relationship: Relationship;
}) => void;
}
export const DatabaseRelationshipsTable = ({
dataSourceName,
table,
onEditRow,
onDeleteRow,
}: DatabaseRelationshipsTableProps) => {
const {
data: relationships,
isLoading,
isError,
} = useListAllRelationshipsFromMetadata(dataSourceName, table);
if (isError)
return (
<IndicatorCard
status="negative"
headline="Error while fetching relationships"
/>
);
if (isLoading)
return (
<div className="my-4">
<Skeleton height={30} count={8} className="my-2" />
</div>
);
if ((relationships ?? []).length === 0)
return <IndicatorCard status="info" headline="No relationships found" />;
return (
<>
<CardedTable.Table>
<CardedTable.Header columns={columns} />
<CardedTable.TableBody>
{(relationships ?? []).map(relationship => (
<CardedTable.TableBodyRow key={relationship.name}>
<CardedTable.TableBodyCell>
{relationship.name}
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>
<div className="flex items-center gap-2">
<TargetName relationship={relationship} />
</div>
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>
{relationship.relationship_type}
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>
<RelationshipMapping relationship={relationship} />
</CardedTable.TableBodyCell>
<CardedTable.TableBodyActionCell>
<div className="flex items-center justify-end whitespace-nowrap text-right">
<button
onClick={() =>
onEditRow({
dataSourceName,
table,
relationship,
})
}
className="flex px-2 py-0.5 items-center font-semibold rounded text-secondary mr-0.5 hover:bg-indigo-50 focus:bg-indigo-100"
>
<FaEdit className="fill-current mr-1" />
{[
'toLocalTableFk',
'toSameTableFk',
'toLocalTableManual',
].includes(relationship.type)
? 'Rename'
: 'Edit'}
</button>
<button
onClick={() =>
onDeleteRow({
dataSourceName,
table,
relationship,
})
}
className="flex px-2 py-0.5 items-center font-semibold rounded text-red-700 hover:bg-red-50 focus:bg-red-100"
>
<FaTrash className="fill-current mr-1" />
Remove
</button>
</div>
</CardedTable.TableBodyActionCell>
</CardedTable.TableBodyRow>
))}
</CardedTable.TableBody>
</CardedTable.Table>
<Legends />
</>
);
};

View File

@ -1,49 +0,0 @@
import React from 'react';
import { IconType } from 'react-icons';
import { FaColumns, FaDatabase, FaFont, FaPlug, FaTable } from 'react-icons/fa';
import { FiType } from 'react-icons/fi';
const legend: { Icon: IconType; name: string }[] = [
{
Icon: FaPlug,
name: 'Remote Schema',
},
{
Icon: FiType,
name: 'Type',
},
{
Icon: FaFont,
name: 'Field',
},
{
Icon: FaDatabase,
name: 'Database',
},
{
Icon: FaTable,
name: 'Table',
},
{
Icon: FaColumns,
name: 'Column',
},
];
const Legends = () => {
return (
<div className="text-right mb-4">
{legend.map(item => {
const { Icon, name } = item;
return (
<span key={name}>
<Icon className="mr-1 ml-4 text-sm" style={{ strokeWidth: 4.5 }} />
{name}
</span>
);
})}
</div>
);
};
export default Legends;

View File

@ -1,191 +0,0 @@
import { Relationship } from '../types';
export const expectedRemoteSchemaRelationshipOutput: Relationship & {
type: 'toRemoteSchema';
} = {
name: 'new_rs_to_db',
type: 'toRemoteSchema',
toRemoteSchema: 'rs',
relationship_type: 'Remote Schema',
mapping: {
from: {
source: 'chinook',
table: {
name: 'Employee',
schema: 'public',
},
columns: ['EmployeeId'],
},
to: {
remoteSchema: 'rs',
fields: ['country'],
},
},
};
export const expectedLegacyRemoteSchemaRelationshipsOutput: Relationship & {
type: 'toRemoteSchema';
} = {
name: 'legacy_db_to_rs',
type: 'toRemoteSchema',
toRemoteSchema: 'rs',
relationship_type: 'Remote Schema',
mapping: {
from: {
source: 'chinook',
table: {
name: 'Employee',
schema: 'public',
},
columns: ['EmployeeId'],
},
to: {
remoteSchema: 'rs',
fields: ['country'],
},
},
};
export const expectedRemoteDBRelationshipOutput: Relationship & {
type: 'toSource';
} = {
name: 'remote_db_object_rel',
type: 'toSource',
toSource: 'bikes',
relationship_type: 'object',
mapping: {
from: {
source: 'chinook',
table: {
name: 'Employee',
schema: 'public',
},
columns: ['EmployeeId'],
},
to: {
source: 'bikes',
table: {
name: 'brands',
schema: 'production',
},
columns: ['brand_id'],
},
},
};
export const expectedManualLocalRelationshipOutput: Relationship & {
type: 'toLocalTableManual';
} = {
name: 'local_array_rel',
type: 'toLocalTableManual',
toLocalTable: {
name: 'Employee',
schema: 'public',
},
relationship_type: 'Array',
mapping: {
from: {
source: 'chinook',
table: {
name: 'Employee',
schema: 'public',
},
columns: ['EmployeeId'],
},
to: {
source: 'chinook',
table: {
name: 'Album',
schema: 'public',
},
columns: ['AlbumId'],
},
},
definition: {
name: 'local_array_rel',
using: {
manual_configuration: {
column_mapping: {
EmployeeId: 'AlbumId',
},
insertion_order: null,
remote_table: {
name: 'Album',
schema: 'public',
},
},
},
},
};
export const expectedLocalTableRelationships: Relationship & {
type: 'toLocalTableFk';
} = {
name: 'Employees',
type: 'toLocalTableFk',
toLocalTable: {
name: 'Employee',
schema: 'public',
},
relationship_type: 'Object',
mapping: {
from: {
source: 'chinook',
table: {
name: 'Employee',
schema: 'public',
},
columns: ['ReportsTo'],
},
to: {
source: 'chinook',
table: {
name: 'Employee',
schema: 'public',
},
columns: ['EmployeeId'],
},
},
definition: {
name: 'Employees',
using: {
foreign_key_constraint_on: {
column: 'ReportsTo',
table: {
name: 'Employee',
schema: 'public',
},
},
},
},
};
export const expectedSameTableObjectRelationships: Relationship & {
type: 'toSameTableFk';
} = {
name: 'Employee',
type: 'toSameTableFk',
toLocalTable: {
name: 'Employee',
schema: 'public',
},
relationship_type: 'Object',
mapping: {
from: {
source: 'chinook',
table: {
name: 'Employee',
schema: 'public',
},
columns: ['ReportsTo'],
},
to: {
source: 'chinook',
table: {
name: 'Employee',
schema: 'public',
},
columns: ['EmployeeId'],
},
},
};

View File

@ -1,192 +0,0 @@
import {
expectedRemoteSchemaRelationshipOutput,
expectedLegacyRemoteSchemaRelationshipsOutput,
expectedRemoteDBRelationshipOutput,
expectedManualLocalRelationshipOutput,
expectedLocalTableRelationships,
expectedSameTableObjectRelationships,
} from './mocks';
import {
adaptRemoteSchemaRelationship,
adaptLegacyRemoteSchemaRelationship,
adaptRemoteDBRelationship,
adaptManualRelationship,
adaptLocalTableRelationship,
adaptSameTableObjectRelationship,
} from '../hooks/useListAllRelationshipsFromMetadata/utils';
describe('test adapters', () => {
it('for remote schema relationship', async () => {
const result = adaptRemoteSchemaRelationship(
'chinook',
{ name: 'Employee', schema: 'public' },
{
definition: {
to_remote_schema: {
lhs_fields: ['EmployeeId'],
remote_field: {
country: {
arguments: {
code: '$EmployeeId',
},
},
},
remote_schema: 'rs',
},
},
name: 'new_rs_to_db',
}
);
expect(result).toEqual(expectedRemoteSchemaRelationshipOutput);
});
it('for legacy remote schema relationship', async () => {
const result = adaptLegacyRemoteSchemaRelationship(
'chinook',
{ name: 'Employee', schema: 'public' },
{
definition: {
hasura_fields: ['EmployeeId'],
remote_field: {
country: {
arguments: {
code: '$EmployeeId',
},
},
},
remote_schema: 'rs',
},
name: 'legacy_db_to_rs',
}
);
expect(result).toEqual(expectedLegacyRemoteSchemaRelationshipsOutput);
});
it('for remote db relationship', async () => {
const result = adaptRemoteDBRelationship(
'chinook',
{ name: 'Employee', schema: 'public' },
{
definition: {
to_source: {
field_mapping: {
EmployeeId: 'brand_id',
},
relationship_type: 'object',
source: 'bikes',
table: {
name: 'brands',
schema: 'production',
},
},
},
name: 'remote_db_object_rel',
}
);
expect(result).toEqual(expectedRemoteDBRelationshipOutput);
});
it('for local relationships created manually', async () => {
const result = adaptManualRelationship(
'chinook',
{ name: 'Employee', schema: 'public' },
{
name: 'local_array_rel',
using: {
manual_configuration: {
column_mapping: {
EmployeeId: 'AlbumId',
},
insertion_order: null,
remote_table: {
name: 'Album',
schema: 'public',
},
},
},
},
'Array'
);
expect(result).toEqual(expectedManualLocalRelationshipOutput);
});
it('for local relationships created via foreign keys', async () => {
const result = adaptLocalTableRelationship(
'chinook',
{ name: 'Employee', schema: 'public' },
{
name: 'Employees',
using: {
foreign_key_constraint_on: {
column: 'ReportsTo',
table: {
name: 'Employee',
schema: 'public',
},
},
},
},
[
{
from: {
table: 'Employee',
column: ['ReportsTo'],
},
to: {
table: 'Employee',
column: ['EmployeeId'],
},
},
{
from: {
table: 'Customer',
column: ['SupportRepId'],
},
to: {
table: 'Employee',
column: ['EmployeeId'],
},
},
],
'Object'
);
expect(result).toEqual(expectedLocalTableRelationships);
});
it('for same table relationships created via foreign keys', async () => {
const result = adaptSameTableObjectRelationship(
'chinook',
{ name: 'Employee', schema: 'public' },
{
name: 'Employee',
using: {
foreign_key_constraint_on: 'ReportsTo',
},
},
[
{
from: {
table: 'Employee',
column: ['ReportsTo'],
},
to: {
table: 'Employee',
column: ['EmployeeId'],
},
},
{
from: {
table: 'Customer',
column: ['SupportRepId'],
},
to: {
table: 'Employee',
column: ['EmployeeId'],
},
},
]
);
expect(result).toEqual(expectedSameTableObjectRelationships);
});
});

View File

@ -1,49 +0,0 @@
import { exportMetadata } from '@/features/DataSource';
import { Table } from '@/features/hasura-metadata-types';
import { useHttpClient } from '@/features/Network';
import { useQuery } from 'react-query';
import { areTablesEqual } from '../../../../RelationshipsTable/utils';
export const useFindRelationship = ({
dataSourceName,
relationshipName,
table,
}: {
dataSourceName: string;
table: Table;
relationshipName: string;
}) => {
const httpClient = useHttpClient();
return useQuery({
queryKey: ['get_existing_relationship', dataSourceName, relationshipName],
queryFn: async () => {
const { metadata } = await exportMetadata({ httpClient });
const metadataSource = metadata.sources.find(
source => source.name === dataSourceName
);
if (!metadataSource) throw Error('source not found');
const metadataTable = metadataSource.tables.find(t =>
areTablesEqual(table, t.table)
);
if (!metadataTable) throw Error('table not found');
/**
* Look for the relationship inside array_relationship, object_relationships & remote_relationships
*/
const allRelationships = [
...(metadataTable.object_relationships ?? []),
...(metadataTable.array_relationships ?? []),
...(metadataTable.remote_relationships ?? []),
];
const relationship = allRelationships.find(
rel => rel.name === relationshipName
);
return relationship;
},
});
};

View File

@ -1 +0,0 @@
export { useListAllRelationshipsFromMetadata } from './useListAllRelationshipsFromMetadata';

View File

@ -1,176 +0,0 @@
import {
exportMetadata,
isLocalTableObjectRelationship,
isManualArrayRelationship,
isManualObjectRelationship,
isRemoteDBRelationship,
isRemoteSchemaRelationship,
DataSource,
} from '@/features/DataSource';
import { Table } from '@/features/hasura-metadata-types';
import { useHttpClient } from '@/features/Network';
import { useQuery } from 'react-query';
import { areTablesEqual } from '../../../../../RelationshipsTable/utils';
import { Relationship } from '../../types';
import {
adaptLegacyRemoteSchemaRelationship,
adaptLocalTableRelationship,
adaptManualRelationship,
adaptRemoteDBRelationship,
adaptRemoteSchemaRelationship,
adaptSameTableObjectRelationship,
} from './utils';
export const useListAllRelationshipsFromMetadata = (
dataSourceName: string,
table: Table
) => {
const httpClient = useHttpClient();
return useQuery({
queryKey: [dataSourceName, 'list_all_relationships'],
refetchOnWindowFocus: false,
queryFn: async () => {
const { metadata } = await exportMetadata({ httpClient });
const metadataSource = metadata.sources.find(
source => source.name === dataSourceName
);
const fkRelationships = await DataSource(
httpClient
).getTableFkRelationships({
dataSourceName,
table,
});
/**
* If I can't find the source in the metadata, then something is inconsistent. Panic and throw error
*/
if (!metadataSource)
throw Error(
'useListAllRelationshipsFromMetadata: unable to find a metadata.source that matches dataSourceName'
);
// FIX ME: use Matt's util function for the equal-to check later
const metadataTable = metadataSource.tables.find(t => {
return areTablesEqual(t.table, table);
});
/**
* If I can't find the table in the metadataSource, then something is inconsistent. Panic and throw error
*/
if (!metadataTable)
throw Error(
'useListAllRelationshipsFromMetadata: unable to find a metadata.source.table that matches table'
);
/**
* Collect all relationships - both local & remote
*/
let relationships: Relationship[] = [];
/**
* Remote relationships
*/
if (metadataTable.remote_relationships) {
relationships = [
...relationships,
...metadataTable.remote_relationships.map<Relationship>(
relationship => {
if (isRemoteDBRelationship(relationship))
return adaptRemoteDBRelationship(
dataSourceName,
table,
relationship
);
if (isRemoteSchemaRelationship(relationship))
return adaptRemoteSchemaRelationship(
dataSourceName,
table,
relationship
);
// If its neither of those two cases, then it must be a legacy DB-to-RS relationship
return adaptLegacyRemoteSchemaRelationship(
dataSourceName,
table,
relationship
);
}
),
];
}
/**
* Local Object relationships
*/
if (metadataTable.object_relationships) {
relationships = [
...relationships,
...metadataTable.object_relationships.map<Relationship>(
relationship => {
if (isManualObjectRelationship(relationship))
return adaptManualRelationship(
dataSourceName,
table,
relationship,
'Object'
);
/**
* To a local table via FK relationships
*/
if (isLocalTableObjectRelationship(relationship))
return adaptLocalTableRelationship(
dataSourceName,
table,
relationship,
fkRelationships,
'Object'
);
/**
* If its not both the above cases then its a object relationships between columns in the same table
*/
return adaptSameTableObjectRelationship(
dataSourceName,
table,
relationship,
fkRelationships
);
}
),
];
}
/**
* Local Array relationships
*/
if (metadataTable.array_relationships) {
relationships = [
...relationships,
...metadataTable.array_relationships.map<Relationship>(
relationship => {
if (isManualArrayRelationship(relationship))
return adaptManualRelationship(
dataSourceName,
table,
relationship,
'Array'
);
return adaptLocalTableRelationship(
dataSourceName,
table,
relationship,
fkRelationships,
'Array'
);
}
),
];
}
return relationships;
},
});
};

View File

@ -1,202 +0,0 @@
import {
TableFkRelationships,
isLegacyFkConstraint,
} from '@/features/DataSource';
import {
Legacy_SourceToRemoteSchemaRelationship,
LocalTableArrayRelationship,
LocalTableObjectRelationship,
ManualArrayRelationship,
ManualObjectRelationship,
SourceToSourceRelationship,
SourceToRemoteSchemaRelationship,
SameTableObjectRelationship,
Table,
} from '@/features/hasura-metadata-types';
import { getRemoteFieldPath } from '@/features/RelationshipsTable';
import { Relationship } from '../../types';
export const getTargetColumns = (
tableRlns: TableFkRelationships[],
fkConstraint: string[]
) => {
const destination = tableRlns.find(tr => {
return tr.from.column.find(c =>
fkConstraint.includes(c?.replace(/"/g, ''))
);
});
return destination?.to.column ?? [];
};
export const adaptRemoteSchemaRelationship = (
dataSourceName: string,
table: Table,
relationship: SourceToRemoteSchemaRelationship
): Relationship & { type: 'toRemoteSchema' } => {
return {
name: relationship.name,
type: 'toRemoteSchema',
toRemoteSchema: relationship.definition.to_remote_schema.remote_schema,
relationship_type: 'Remote Schema',
mapping: {
from: {
source: dataSourceName,
table,
columns: relationship.definition.to_remote_schema.lhs_fields,
},
to: {
remoteSchema: relationship.definition.to_remote_schema.remote_schema,
fields: getRemoteFieldPath(
relationship.definition.to_remote_schema.remote_field
),
},
},
};
};
export const adaptLegacyRemoteSchemaRelationship = (
dataSourceName: string,
table: Table,
relationship: Legacy_SourceToRemoteSchemaRelationship
): Relationship & { type: 'toRemoteSchema' } => {
return {
name: relationship.name,
type: 'toRemoteSchema',
toRemoteSchema: relationship.definition.remote_schema,
relationship_type: 'Remote Schema',
mapping: {
from: {
source: dataSourceName,
table,
columns: relationship.definition.hasura_fields,
},
to: {
remoteSchema: relationship.definition.remote_schema,
fields: getRemoteFieldPath(relationship.definition.remote_field),
},
},
};
};
export const adaptRemoteDBRelationship = (
dataSourceName: string,
table: Table,
relationship: SourceToSourceRelationship
): Relationship & { type: 'toSource' } => {
return {
name: relationship.name,
type: 'toSource',
toSource: relationship.definition.to_source.source,
relationship_type: relationship.definition.to_source.relationship_type,
mapping: {
from: {
source: dataSourceName,
table,
columns: Object.keys(relationship.definition.to_source.field_mapping),
},
to: {
source: relationship.definition.to_source.source,
table: relationship.definition.to_source.table,
columns: Object.values(relationship.definition.to_source.field_mapping),
},
},
};
};
export const adaptManualRelationship = (
dataSourceName: string,
table: Table,
relationship: ManualObjectRelationship | ManualArrayRelationship,
relationship_type: 'Object' | 'Array'
): Relationship & {
type: 'toLocalTableManual';
} => {
return {
name: relationship.name,
type: 'toLocalTableManual',
toLocalTable: table,
relationship_type,
mapping: {
from: {
source: dataSourceName,
table,
columns: Object.keys(
relationship.using.manual_configuration.column_mapping
),
},
to: {
source: dataSourceName,
table: relationship.using.manual_configuration.remote_table,
columns: Object.values(
relationship.using.manual_configuration.column_mapping
),
},
},
definition: relationship,
};
};
export const adaptLocalTableRelationship = (
dataSourceName: string,
table: Table,
relationship: LocalTableObjectRelationship | LocalTableArrayRelationship,
fkRelationships: TableFkRelationships[],
relationship_type: 'Array' | 'Object'
): Relationship & { type: 'toLocalTableFk' } => {
const columns = isLegacyFkConstraint(
relationship.using.foreign_key_constraint_on
)
? [relationship.using.foreign_key_constraint_on.column]
: relationship.using.foreign_key_constraint_on.columns;
return {
name: relationship.name,
type: 'toLocalTableFk',
toLocalTable: table,
relationship_type,
mapping: {
from: {
source: dataSourceName,
table,
columns,
},
to: {
source: dataSourceName,
table: relationship.using.foreign_key_constraint_on.table,
columns: getTargetColumns(fkRelationships, columns),
},
},
definition: relationship,
};
};
export const adaptSameTableObjectRelationship = (
dataSourceName: string,
table: Table,
relationship: SameTableObjectRelationship,
fkRelationships: TableFkRelationships[]
): Relationship & { type: 'toSameTableFk' } => {
const columns =
typeof relationship.using.foreign_key_constraint_on === 'string'
? [relationship.using.foreign_key_constraint_on]
: relationship.using.foreign_key_constraint_on;
return {
name: relationship.name,
type: 'toSameTableFk',
toLocalTable: table,
relationship_type: 'Object',
mapping: {
from: {
source: dataSourceName,
table,
columns,
},
to: {
source: dataSourceName,
table,
columns: getTargetColumns(fkRelationships, columns),
},
},
};
};

View File

@ -1,198 +0,0 @@
export const metadata = {
resource_version: 45,
metadata: {
version: 3,
sources: [
{
name: 'bikes',
kind: 'mssql',
tables: [
{
table: {
name: 'customers',
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,
max_connections: 50,
},
},
},
},
{
name: 'chinook',
kind: 'postgres',
tables: [
{
table: {
name: 'Album',
schema: 'public',
},
object_relationships: [
{
name: 'local_table_object_relationship',
using: {
foreign_key_constraint_on: 'ArtistId',
},
},
{
name: 'manual_object_relationship',
using: {
manual_configuration: {
column_mapping: {
AlbumId: 'ArtistId',
},
insertion_order: null,
remote_table: {
name: 'Artist',
schema: 'public',
},
},
},
},
],
array_relationships: [
{
name: 'array_manual_relationship',
using: {
manual_configuration: {
column_mapping: {
AlbumId: 'AlbumId',
},
insertion_order: null,
remote_table: {
name: 'Track',
schema: 'public',
},
},
},
},
{
name: 'local_table_array_relationship',
using: {
foreign_key_constraint_on: {
column: 'AlbumId',
table: {
name: 'Track',
schema: 'public',
},
},
},
},
],
remote_relationships: [
{
definition: {
hasura_fields: ['AlbumId'],
remote_field: {
country: {
arguments: {
code: '$AlbumId',
},
},
},
remote_schema: 'trevorblades',
},
name: 'legacy_remote_schema_relationship',
},
{
definition: {
to_source: {
field_mapping: {
AlbumId: 'customer_id',
},
relationship_type: 'array',
source: 'bikes',
table: {
name: 'customers',
schema: 'sales',
},
},
},
name: 'remote_db_array_relationship',
},
{
definition: {
to_source: {
field_mapping: {
AlbumId: 'customer_id',
},
relationship_type: 'object',
source: 'bikes',
table: {
name: 'customers',
schema: 'sales',
},
},
},
name: 'remote_db_object_relationship',
},
{
definition: {
to_remote_schema: {
lhs_fields: ['AlbumId'],
remote_field: {
country: {
arguments: {
code: '$AlbumId',
},
},
},
remote_schema: 'trevorblades',
},
},
name: 'source_to_remote_schema_relationship',
},
],
},
{
table: {
name: 'Artist',
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,
},
},
},
],
remote_schemas: [
{
name: 'trevorblades',
definition: {
url: 'https://countries.trevorblades.com/',
timeout_seconds: 60,
customization: {},
},
},
],
},
};
export const relationshipQueryResponse = {
result_type: 'TuplesOk',
result: [
['source_table', 'source_column', 'target_table', 'target_column'],
['"Album"', '"ArtistId"', '"Artist"', '"ArtistId"'],
['"Track"', '"AlbumId"', '"Album"', '"AlbumId"'],
['"Artist"', '"ArtistId"', '"Album"', '"Name"'],
],
};

View File

@ -1,70 +0,0 @@
import {
LocalTableArrayRelationship,
LocalTableObjectRelationship,
ManualArrayRelationship,
ManualObjectRelationship,
Table,
} from '@/features/hasura-metadata-types';
type SourceDef = {
source: string;
table: Table;
columns: string[];
};
type RemoteSchemaDef = {
remoteSchema: string;
fields: string[];
};
export type Relationship = {
name: string;
} & (
| {
type: 'toLocalTableManual';
toLocalTable: Table;
relationship_type: 'Array' | 'Object';
mapping: {
from: SourceDef;
to: SourceDef;
};
definition: ManualObjectRelationship | ManualArrayRelationship;
}
| {
type: 'toLocalTableFk';
toLocalTable: Table;
relationship_type: 'Array' | 'Object';
mapping: {
from: SourceDef;
to: SourceDef;
};
definition: LocalTableObjectRelationship | LocalTableArrayRelationship;
}
| {
type: 'toSameTableFk';
toLocalTable: Table;
relationship_type: 'Object';
mapping: {
from: SourceDef;
to: SourceDef;
};
}
| {
type: 'toSource';
toSource: string;
relationship_type: 'array' | 'object';
mapping: {
from: SourceDef;
to: SourceDef;
};
}
| {
type: 'toRemoteSchema';
toRemoteSchema: string;
relationship_type: 'Remote Schema';
mapping: {
from: SourceDef;
to: RemoteSchemaDef;
};
}
);

View File

@ -1,78 +0,0 @@
import React from 'react';
import { CardRadioGroup } from '@/new-components/CardRadioGroup';
import { Driver } from '@/dataSources';
import { DataTarget } from '@/features/Datasources';
import { DbToRsForm } from '@/features/RemoteRelationships';
import { LocalRelationshipWidget } from '../LocalDBRelationshipWidget';
import { RemoteDBRelationshipWidget } from '../RemoteDBRelationshipWidget';
import { formTabs, RelOption } from './utils';
interface CreateFormProps {
driver: Driver;
sourceTableInfo: DataTarget;
/**
* optional callback function, can be used to get the onComplete event, this could be a onSuccess, or onError event.
*
*/
onComplete: (callbackMessage: {
title?: string;
message?: string;
type: 'success' | 'error' | 'cancel';
}) => void;
}
interface Props extends CreateFormProps {
option: RelOption;
}
const RenderForm = ({ option, driver, sourceTableInfo, onComplete }: Props) => {
switch (option) {
case 'local':
return (
<LocalRelationshipWidget
driver={driver}
sourceTableInfo={sourceTableInfo}
onComplete={onComplete}
/>
);
case 'remoteDatabase':
return (
<RemoteDBRelationshipWidget
sourceTableInfo={sourceTableInfo}
onComplete={onComplete}
/>
);
case 'remoteSchema':
return (
<DbToRsForm sourceTableInfo={sourceTableInfo} onComplete={onComplete} />
);
default:
// This is a TS protection that forces the developer to properly manage all the cases.
// It throws when the developer adds new values to RelOption without adding a corresponding `case` here.
throw new Error(`Unknown RelOption: ${option}`);
}
};
export const CreateRelationshipForm = ({
sourceTableInfo,
driver,
onComplete,
}: CreateFormProps) => {
const [option, setOption] = React.useState<RelOption>('local');
return (
<>
<p className="mb-sm text-muted font-semibold">
Select a Relationship Method
</p>
<CardRadioGroup items={formTabs} onChange={setOption} value={option} />
<RenderForm
option={option}
driver={driver}
sourceTableInfo={sourceTableInfo}
onComplete={onComplete}
/>
</>
);
};

View File

@ -1,133 +0,0 @@
import React from 'react';
import { Driver } from '@/dataSources';
import { DataTarget } from '@/features/Datasources';
import { DbToRsForm } from '@/features/RemoteRelationships';
import {
useFindRelationship,
Relationship,
} from '@/features/RelationshipsTable';
import {
isLegacyRemoteSchemaRelationship,
isRemoteDBRelationship,
isRemoteSchemaRelationship,
} from '@/features/DataSource';
import { RemoteDBRelationshipWidget } from '../RemoteDBRelationshipWidget';
import { LocalRelationshipWidget } from '../LocalDBRelationshipWidget';
import { RenameRelationship } from '../RenameRelationship/RenameRelationship';
type EditRelationshipFormProps = {
driver: Driver;
sourceTableInfo: DataTarget;
existingRelationship: Relationship;
/**
* optional callback function, can be used to get the onComplete event, this could be a onSuccess, or onError event.
*
*/
onComplete: (callback: {
title?: string;
message?: string;
type: 'success' | 'error' | 'cancel';
}) => void;
onClose?: () => void;
};
export const EditRelationshipForm = ({
sourceTableInfo,
driver,
existingRelationship,
onComplete,
onClose,
}: EditRelationshipFormProps) => {
const { data: relationship, isLoading } = useFindRelationship({
dataSourceName: existingRelationship.mapping.from.source,
table: existingRelationship.mapping.from.table,
relationshipName: existingRelationship.name,
});
if (isLoading) return <>Loading...</>;
if (!relationship) return <>Relationship Not found in metadata</>;
if (
existingRelationship.type === 'toLocalTableFk' ||
existingRelationship.type === 'toLocalTableManual' ||
existingRelationship.type === 'toSameTableFk'
) {
return (
<RenameRelationship
relationship={existingRelationship}
onSuccess={onClose}
key={existingRelationship.name}
/>
);
}
if (isRemoteDBRelationship(relationship)) {
return (
<RemoteDBRelationshipWidget
key={relationship.name}
sourceTableInfo={sourceTableInfo}
existingRelationshipName={relationship.name}
onComplete={onComplete}
/>
);
}
if (isRemoteSchemaRelationship(relationship)) {
const { lhs_fields, remote_field, remote_schema } = {
...relationship.definition.to_remote_schema,
};
return (
<DbToRsForm
key={relationship.name}
sourceTableInfo={sourceTableInfo}
onComplete={onComplete}
selectedRelationship={{
name: relationship.name,
definition: {
to_remote_schema: {
lhs_fields,
remote_field,
remote_schema,
},
},
}}
/>
);
}
if (isLegacyRemoteSchemaRelationship(relationship)) {
const { lhs_fields, remote_field, remote_schema } = {
...relationship.definition,
lhs_fields: relationship.definition.hasura_fields,
};
return (
<DbToRsForm
key={relationship.name}
sourceTableInfo={sourceTableInfo}
onComplete={onComplete}
selectedRelationship={{
name: relationship.name,
definition: {
to_remote_schema: {
lhs_fields,
remote_field,
remote_schema,
},
},
}}
/>
);
}
return (
<LocalRelationshipWidget
key={relationship.name}
driver={driver}
sourceTableInfo={sourceTableInfo}
existingRelationshipName={relationship.name}
onComplete={onComplete}
/>
);
};

View File

@ -1,59 +0,0 @@
import React from 'react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { Form } from './Form';
export default {
title: 'Features/Data Relationships/Form',
decorators: [ReactQueryDecorator()],
component: Form,
} as ComponentMeta<typeof Form>;
export const Primary: ComponentStory<typeof Form> = args => <Form {...args} />;
Primary.args = {
sourceTableInfo: {
database: 'default',
schema: 'public',
table: 'resident',
},
driver: 'postgres',
};
export const WithExistingRelationship: ComponentStory<typeof Form> = args => (
<Form {...args} />
);
WithExistingRelationship.args = {
existingRelationship: {
name: 'Customers',
type: 'toSameTableFk',
toLocalTable: {
name: 'Customer',
schema: 'public',
},
relationship_type: 'Object',
mapping: {
from: {
source: 'chinook',
table: {
name: 'Employee',
schema: 'public',
},
columns: ['SupportRepId'],
},
to: {
source: 'chinook',
table: {
name: 'Customer',
schema: 'public',
},
columns: ['EmployeeId'],
},
},
},
sourceTableInfo: {
database: 'default',
schema: 'public',
table: 'resident',
},
driver: 'postgres',
};

View File

@ -1,56 +0,0 @@
import React from 'react';
import { DataTarget } from '@/features/Datasources';
import { Driver } from '@/dataSources';
// eslint-disable-next-line no-restricted-imports
import { Relationship } from '@/features/DataRelationships/components/DatabaseRelationshipsTable/types';
import { FormLayout } from './FormLayout';
import { CreateRelationshipForm } from './CreateRelationshipForm';
import { EditRelationshipForm } from './EditRelationshipForm';
interface Props {
existingRelationship?: Relationship;
sourceTableInfo: DataTarget;
driver: Driver;
/**
* optional callback function, can be used to get the onComplete event, this could be a onSuccess, or onError event.
*
*/
onComplete: (callback: {
title?: string;
message?: string;
type: 'success' | 'error' | 'cancel';
}) => void;
onClose?: () => void;
}
export const Form = ({
existingRelationship,
sourceTableInfo,
onComplete,
driver,
onClose,
}: Props) => {
if (existingRelationship) {
return (
<FormLayout existingRelationship onComplete={onComplete}>
<EditRelationshipForm
driver={driver}
sourceTableInfo={sourceTableInfo}
existingRelationship={existingRelationship}
onComplete={onComplete}
onClose={onClose}
/>
</FormLayout>
);
}
return (
<FormLayout existingRelationship={false} onComplete={onComplete}>
<CreateRelationshipForm
driver={driver}
sourceTableInfo={sourceTableInfo}
onComplete={onComplete}
/>
</FormLayout>
);
};

View File

@ -1,37 +0,0 @@
import React from 'react';
import { Button } from '@/new-components/Button';
interface Props extends React.ComponentProps<'div'> {
existingRelationship?: boolean;
/**
* optional callback function, can be used to get the onComplete event, this could be a onSuccess, or onError event.
*
*/
onComplete: (callback: {
title?: string;
message?: string;
type: 'success' | 'error' | 'cancel';
}) => void;
}
export const FormLayout = ({
existingRelationship,
onComplete,
children,
}: Props) => {
return (
<div className="w-full sm:w-9/12 bg-white shadow-sm rounded p-md border border-gray-300 show">
<div className="flex items-center mb-md">
<Button size="sm" onClick={() => onComplete({ type: 'cancel' })}>
Cancel
</Button>
<span className="font-semibold text-muted ml-1.5">
{existingRelationship ? 'Edit Relationship' : 'Add Relationship'}
</span>
</div>
<hr className="mb-md border-gray-300" />
<div>{children}</div>
</div>
);
};

View File

@ -1,19 +0,0 @@
export type RelOption = 'local' | 'remoteDatabase' | 'remoteSchema';
export const formTabs: { value: RelOption; title: string; body: string }[] = [
{
value: 'local',
title: 'Local Relationship',
body: 'Relationships from this table to a local database table.',
},
{
value: 'remoteDatabase',
title: 'Remote Database Relationship',
body: 'Relationship from this local table to a remote database table.',
},
{
value: 'remoteSchema',
title: 'Remote Schema Relationship',
body: 'Relationship from this local table to a remote schema.',
},
];

View File

@ -1,151 +0,0 @@
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { InputField, Select } from '@/new-components/Form';
import { ListMap } from '@/new-components/ListMap';
import { DatabaseSelector } from '@/features/Data';
import { useTableColumns } from '@/features/SqlQueries';
import {
LinkBlockHorizontal,
LinkBlockVertical,
} from '@/new-components/LinkBlock';
import { FaCircle } from 'react-icons/fa';
import { Schema } from './schema';
interface FormElementsProps {
existingRelationshipName: string;
}
export const FormElements = ({
existingRelationshipName,
}: FormElementsProps) => {
const { control, watch } = useFormContext<Schema>();
const [source, destination] = watch(['source', 'destination']);
const { data: sourceColumnData } = useTableColumns(source.database, {
name: source.table,
schema: source.schema ?? source.dataset ?? '',
});
const { data: referenceColumnData } = useTableColumns(destination.database, {
name: destination.table,
schema: destination.schema ?? destination.dataset ?? '',
});
return (
<>
<div className="w-full sm:w-6/12 mb-md">
<div className="mb-md">
<InputField
name="relationshipName"
label="Name"
placeholder="Relationship name"
dataTest="local-db-to-db-rel-name"
disabled={!!existingRelationshipName}
/>
</div>
<div className="mb-md">
<Select
name="relationshipType"
label="Type"
dataTest="local-db-to-db-select-rel-type"
placeholder="Select a relationship type..."
options={[
{
label: 'Object Relationship',
value: 'create_object_relationship',
},
{
label: 'Array Relationship',
value: 'create_array_relationship',
},
]}
/>
</div>
</div>
<>
<div className="grid grid-cols-12">
<div className="col-span-5">
<Controller
control={control}
name="source"
render={({
field: { onChange, value },
formState: { errors },
}) => (
<DatabaseSelector
value={value}
onChange={onChange}
name="source"
errors={errors}
className="border-l-4 border-l-green-600"
hiddenKeys={['database']}
disabledKeys={['schema', 'table', 'database']}
labels={{
database: 'Source Database',
schema: 'Source Schema',
dataset: 'Source Dataset',
table: 'Source Table',
}}
/>
)}
/>
</div>
<LinkBlockHorizontal />
<div className="col-span-5">
<Controller
control={control}
name="destination"
render={({
field: { onChange, value },
formState: { errors },
}) => (
<DatabaseSelector
value={value}
onChange={onChange}
name="destination"
errors={errors}
className="border-l-4 border-l-indigo-600"
hiddenKeys={['database']}
labels={{
database: 'Reference Database',
schema: 'Reference Schema',
dataset: 'Reference Dataset',
table: 'Reference Table',
}}
/>
)}
/>
</div>
</div>
<LinkBlockVertical title="Columns Mapped To" />
<ListMap
name="mapping"
existingRelationshipName={existingRelationshipName}
from={{
label: 'Source Column',
options: sourceColumnData
? sourceColumnData?.slice(1).map((x: string[]) => x[3])
: [],
placeholder: 'Select Source Column',
icon: <FaCircle className="text-green-600" />,
}}
to={{
type: 'array',
label: 'Reference Column',
options: referenceColumnData
? referenceColumnData?.slice(1).map((x: string[]) => x[3])
: [],
placeholder: 'Select Reference Column',
icon: <FaCircle className="text-indigo-600" />,
}}
/>
</>
</>
);
};

View File

@ -1,81 +0,0 @@
import React from 'react';
import { Story, Meta } from '@storybook/react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { within, userEvent } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import {
LocalRelationshipWidget,
LocalRelationshipWidgetProps,
} from './LocalRelationshipWidget';
import { handlers } from '../../../RemoteRelationships/RemoteSchemaRelationships/__mocks__/handlers.mock';
export default {
title:
'Features/Data Relationships/Local DB Relationships/Local DB Relationships Form',
component: LocalRelationshipWidget,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers(),
},
} as Meta;
export const Primary: Story<LocalRelationshipWidgetProps> = args => (
<LocalRelationshipWidget {...args} />
);
Primary.args = {
sourceTableInfo: {
database: 'chinook',
schema: 'public',
table: 'Album',
},
};
export const WithExistingObjectRelationship: Story<
LocalRelationshipWidgetProps
> = args => <LocalRelationshipWidget {...args} />;
WithExistingObjectRelationship.args = {
...Primary.args,
existingRelationshipName: 'relt1obj',
};
export const WithExistingArrayRelationship: Story<
LocalRelationshipWidgetProps
> = args => <LocalRelationshipWidget {...args} />;
WithExistingArrayRelationship.args = {
...Primary.args,
existingRelationshipName: 'relt1array',
};
export const PrimaryWithTest: Story<LocalRelationshipWidgetProps> = args => (
<LocalRelationshipWidget {...args} />
);
PrimaryWithTest.args = { ...Primary.args };
PrimaryWithTest.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const submitButton = await canvas.findByText('Save Relationship');
userEvent.click(submitButton);
const nameError = await canvas.findByText('Name is required!');
const tableError = await canvas.findByText('Reference Table is required!');
// expect error messages
expect(nameError).toBeInTheDocument();
expect(tableError).toBeInTheDocument();
// update fields
const nameInput = await canvas.findByLabelText('Name');
userEvent.type(nameInput, 'test');
const typeLabel = await canvas.findByLabelText('Type');
const schemaLabel = await canvas.findByLabelText('Reference Schema');
const tableLabel = await canvas.findByLabelText('Reference Table');
userEvent.selectOptions(typeLabel, 'Array Relationship');
userEvent.selectOptions(schemaLabel, 'user');
userEvent.selectOptions(tableLabel, 'userAddress');
userEvent.click(submitButton);
};

View File

@ -1,223 +0,0 @@
import React from 'react';
import {
allowedMetadataTypes,
useMetadataMigration,
useMetadataVersion,
} from '@/features/MetadataAPI';
import { useFireNotification } from '@/new-components/Notifications';
import { DataTarget } from '@/features/Datasources';
import { useConsoleForm } from '@/new-components/Form';
import { Button } from '@/new-components/Button';
import { IndicatorCard } from '@/new-components/IndicatorCard';
import { getMetadataQuery, MetadataQueryType } from '@/metadata/queryUtils';
import { Driver } from '@/dataSources';
import { schema, Schema } from './schema';
import { FormElements } from './FormElements';
import { useDefaultValues } from './hooks';
import { getSchemaKey } from '../RemoteDBRelationshipWidget/utils';
export type LocalRelationshipWidgetProps = {
sourceTableInfo: DataTarget;
existingRelationshipName?: string;
driver: Driver;
/**
* optional callback function, can be used to get the onComplete event, this could be a onSuccess, or onError event.
*
*/
onComplete?: (callback: {
title?: string;
message?: string;
type: 'success' | 'error' | 'cancel';
}) => void;
};
type MetadataPayloadType = {
type: allowedMetadataTypes;
args: { [key: string]: any };
version?: number;
};
export const LocalRelationshipWidget = ({
sourceTableInfo,
existingRelationshipName,
onComplete,
}: LocalRelationshipWidgetProps) => {
// hook to fetch data for existing relationship
const useValues = useDefaultValues({
sourceTableInfo,
existingRelationshipName,
});
const { data: defaultValues, isLoading, isError } = useValues;
const {
methods: { formState },
Form,
} = useConsoleForm({
schema,
options: {
defaultValues,
},
});
const { fireNotification } = useFireNotification();
const mutation = useMetadataMigration({
onSuccess: () => {
const status = {
title: 'Success!',
message: 'Relationship saved successfully',
type: 'success' as 'success' | 'error',
};
fireNotification(status);
if (onComplete) onComplete(status);
},
onError: (error: Error) => {
fireNotification({
title: 'Error',
message: error?.message ?? 'Error while creating the relationship',
type: 'error',
});
},
});
const { data: resourceVersion } = useMetadataVersion();
const updateRelationship = (values: Schema) => {
const remote_table: {
database?: string;
schema?: string;
dataset?: string;
table: string;
} = { ...values.destination };
delete remote_table.database;
const args = {
source: sourceTableInfo.database,
table: {
[getSchemaKey(sourceTableInfo)]:
(sourceTableInfo as any).dataset ?? (sourceTableInfo as any).schema,
name: sourceTableInfo.table,
},
name: values.relationshipName,
using: {
manual_configuration: {
remote_table: {
[getSchemaKey(remote_table as DataTarget)]:
(remote_table as any).dataset ?? (remote_table as any).schema,
name: remote_table.table,
},
column_mapping: values.mapping,
},
},
};
const requestBody = getMetadataQuery(
values.relationshipType as MetadataQueryType,
sourceTableInfo.database,
args
);
const body = {
type: 'bulk' as allowedMetadataTypes,
source: sourceTableInfo.database,
resource_version: resourceVersion,
args: [
{
type: 'pg_drop_relationship',
args: {
table: sourceTableInfo.table,
source: sourceTableInfo.database,
relationship: existingRelationshipName,
},
},
requestBody,
],
};
mutation.mutate({
query: body as MetadataPayloadType,
});
};
const createRelationship = (values: Schema) => {
const remote_table: {
database?: string;
schema?: string;
dataset?: string;
table: string;
} = { ...values.destination };
delete remote_table.database;
const args = {
source: sourceTableInfo.database,
table: {
[getSchemaKey(sourceTableInfo)]:
(sourceTableInfo as any).dataset ?? (sourceTableInfo as any).schema,
name: sourceTableInfo.table,
},
name: values.relationshipName,
using: {
manual_configuration: {
remote_table: {
[getSchemaKey(remote_table as DataTarget)]:
(remote_table as any).dataset ?? (remote_table as any).schema,
name: remote_table.table,
},
column_mapping: values.mapping,
},
},
};
const requestBody = getMetadataQuery(
values.relationshipType as MetadataQueryType,
sourceTableInfo.database,
args
);
mutation.mutate({
query: requestBody as MetadataPayloadType,
});
};
const submit = (values: Record<string, unknown>) => {
if (existingRelationshipName) {
return updateRelationship(values as Schema);
}
return createRelationship(values as Schema);
};
if (isLoading) {
return <div>Loading relationship data...</div>;
}
if (isError) {
return <div>Something went wrong while loading relationship data</div>;
}
return (
<Form onSubmit={submit} className="p-4">
<>
<div>
<FormElements
existingRelationshipName={existingRelationshipName || ''}
/>
<Button
mode="primary"
type="submit"
isLoading={mutation.isLoading}
loadingText="Saving relationship"
data-test="add-local-db-relationship"
>
Save Relationship
</Button>
</div>
{!!Object.keys(formState.errors).length && (
<IndicatorCard status="negative">
Error saving relationship
</IndicatorCard>
)}
</>
</Form>
);
};

View File

@ -1,76 +0,0 @@
import { MetadataSelector, useMetadata } from '@/features/MetadataAPI';
import { QualifiedTable } from '@/metadata/types';
import { DataTarget } from '@/features/Datasources';
import { Schema } from '../schema';
interface UseDefaultValuesProps {
sourceTableInfo: DataTarget;
existingRelationshipName?: string;
}
const getSchemaKey = (sourceTableInfo: DataTarget) => {
return 'dataset' in sourceTableInfo ? 'dataset' : 'schema';
};
type RelationshipType =
| 'create_object_relationship'
| 'create_array_relationship';
export const useDefaultValues = ({
sourceTableInfo,
existingRelationshipName,
}: UseDefaultValuesProps) => {
const {
data: metadataTable,
isLoading,
isError,
} = useMetadata(
MetadataSelector.getTable(sourceTableInfo.database, {
name: sourceTableInfo.table,
schema:
(sourceTableInfo as any).schema ?? (sourceTableInfo as any).dataset,
})
);
const manual_relationships = [
...(metadataTable?.object_relationships?.map(rel => ({
...rel,
type: 'create_object_relationship' as RelationshipType,
})) ?? []),
...(metadataTable?.array_relationships?.map(rel => ({
...rel,
type: 'create_array_relationship' as RelationshipType,
})) ?? []),
];
const relationship = manual_relationships?.find(
rel => rel.name === existingRelationshipName
);
const defaultValues: Schema = {
relationshipType: relationship?.type ?? 'create_object_relationship',
relationshipName: existingRelationshipName ?? '',
source: {
database: sourceTableInfo.database,
[getSchemaKey(sourceTableInfo)]:
(sourceTableInfo as any).dataset ?? (sourceTableInfo as any).schema,
table: sourceTableInfo.table,
},
destination: {
database: sourceTableInfo.database,
[getSchemaKey(sourceTableInfo)]:
(
relationship?.using?.manual_configuration
?.remote_table as QualifiedTable
)?.schema ?? '',
table:
(
relationship?.using?.manual_configuration
?.remote_table as QualifiedTable
)?.name ?? '',
},
mapping: relationship?.using.manual_configuration?.column_mapping ?? {},
};
return { data: defaultValues, isLoading, isError };
};

View File

@ -1,23 +0,0 @@
import { z } from 'zod';
export const schema = z.object({
relationshipType: z
.literal('create_array_relationship')
.or(z.literal('create_object_relationship')),
relationshipName: z.string().min(1, { message: 'Name is required!' }),
source: z.object({
database: z.string().min(1, 'Source Database is required!'),
schema: z.string().optional(),
dataset: z.string().optional(),
table: z.string().min(1, 'Source Table is required!'),
}),
destination: z.object({
database: z.string().min(1, 'Reference Database is required!'),
schema: z.string().optional(),
dataset: z.string().optional(),
table: z.string().min(1, 'Reference Table is required!'),
}),
mapping: z.record(z.string()),
});
export type Schema = z.infer<typeof schema>;

View File

@ -1,102 +0,0 @@
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { ListMap } from '@/new-components/ListMap';
import { DatabaseSelector } from '@/features/Data';
import { useTableColumns } from '@/features/SqlQueries';
import {
LinkBlockHorizontal,
LinkBlockVertical,
} from '@/new-components/LinkBlock';
import { FaCircle } from 'react-icons/fa';
import { Schema } from './schema';
export const FormElements = () => {
const { control, watch } = useFormContext<Schema>();
const [source, destination] = watch(['source', 'destination']);
const { data: sourceColumnData } = useTableColumns(source.database, {
name: source.table,
schema: source.schema ?? source.dataset ?? '',
});
const { data: referenceColumnData } = useTableColumns(destination.database, {
name: destination.table,
schema: destination.schema ?? destination.dataset ?? '',
});
return (
<>
<div className="grid grid-cols-12">
<div className="col-span-5">
<Controller
control={control}
name="source"
render={({ field: { onChange, value }, formState: { errors } }) => (
<DatabaseSelector
value={value}
onChange={onChange}
name="source"
errors={errors}
className="border-l-4 border-l-green-600"
disabledKeys={['schema', 'table', 'database']}
labels={{
database: 'Source Database',
schema: 'Source Schema',
dataset: 'Source Dataset',
table: 'Source Table',
}}
/>
)}
/>
</div>
<LinkBlockHorizontal />
<div className="col-span-5">
<Controller
control={control}
name="destination"
render={({ field: { onChange, value }, formState: { errors } }) => (
<DatabaseSelector
value={value}
onChange={onChange}
name="destination"
errors={errors}
className="border-l-4 border-l-indigo-600"
labels={{
database: 'Reference Database',
schema: 'Reference Schema',
dataset: 'Reference Dataset',
table: 'Reference Table',
}}
/>
)}
/>
</div>
</div>
<LinkBlockVertical title="Columns Mapped To" />
<ListMap
from={{
label: 'Source Column',
options: sourceColumnData
? sourceColumnData?.slice(1).map((x: string[]) => x[3])
: [],
placeholder: 'Select Source Column',
icon: <FaCircle className="text-green-600" />,
}}
to={{
type: 'array',
label: 'Reference Column',
options: referenceColumnData
? referenceColumnData?.slice(1).map((x: string[]) => x[3])
: [],
placeholder: 'Select Reference Column',
icon: <FaCircle className="text-indigo-600" />,
}}
name="mapping"
/>
</>
);
};

View File

@ -1,144 +0,0 @@
import React from 'react';
import { Story, Meta } from '@storybook/react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { within, userEvent, waitFor } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import {
RemoteDBRelationshipWidget,
RemoteDBRelationshipWidgetProps,
} from './RemoteDBRelationshipWidget';
import { handlers } from '../../../RemoteRelationships/RemoteSchemaRelationships/__mocks__/handlers.mock';
export default {
title:
'Features/Data Relationships/Remote DB Relationships/Remote DB Relationships Form',
component: RemoteDBRelationshipWidget,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers(),
},
} as Meta;
export const Primary: Story<RemoteDBRelationshipWidgetProps> = args => (
<RemoteDBRelationshipWidget {...args} />
);
Primary.args = {
sourceTableInfo: {
database: 'chinook',
schema: 'public',
table: 'Album',
},
};
export const WithExistingRelationship: Story<
RemoteDBRelationshipWidgetProps
> = args => <RemoteDBRelationshipWidget {...args} />;
WithExistingRelationship.args = {
...Primary.args,
existingRelationshipName: 'AlbumToResident',
};
let callbackResponsePrimaryWithTest = {};
export const PrimaryWithTest: Story<RemoteDBRelationshipWidgetProps> = args => (
<RemoteDBRelationshipWidget
{...args}
onComplete={d => {
callbackResponsePrimaryWithTest = d;
}}
/>
);
PrimaryWithTest.args = { ...Primary.args };
PrimaryWithTest.parameters = {
chromatic: { disableSnapshot: true },
};
PrimaryWithTest.play = async ({ canvasElement }) => {
callbackResponsePrimaryWithTest = {};
const canvas = within(canvasElement);
const submitButton = await canvas.findByText('Save Relationship');
userEvent.click(submitButton);
const nameError = await canvas.findByText('Name is required!');
const databaseError = await canvas.findByText(
'Reference Database is required!'
);
const tableError = await canvas.findByText('Reference Table is required!');
// expect error messages
expect(nameError).toBeInTheDocument();
expect(databaseError).toBeInTheDocument();
expect(tableError).toBeInTheDocument();
// update fields
const nameInput = await canvas.findByLabelText('Name');
userEvent.type(nameInput, 'test');
const typeLabel = await canvas.findByLabelText('Type');
const databaseLabel = await canvas.findByLabelText('Reference Database');
const schemaLabel = await canvas.findByLabelText('Reference Schema');
const tableLabel = await canvas.findByLabelText('Reference Table');
userEvent.selectOptions(typeLabel, 'Array Relationship');
userEvent.selectOptions(databaseLabel, 'default');
userEvent.selectOptions(schemaLabel, 'public');
userEvent.selectOptions(tableLabel, 'resident');
userEvent.click(submitButton);
await waitFor(() => {
expect(canvas.queryByText('Saving relationship')).toBeInTheDocument();
});
await waitFor(() => {
expect(JSON.stringify(callbackResponsePrimaryWithTest)).toContain(
'Success'
);
expect(JSON.stringify(callbackResponsePrimaryWithTest)).toContain(
'Relationship saved successfully'
);
});
};
let callbackResponseExistingRelationshipWithTest = {};
export const ExistingRelationshipWithTest: Story<
RemoteDBRelationshipWidgetProps
> = args => (
<RemoteDBRelationshipWidget
{...args}
onComplete={d => {
callbackResponseExistingRelationshipWithTest = d;
}}
/>
);
ExistingRelationshipWithTest.args = {
...Primary.args,
existingRelationshipName: 'AlbumToResident',
};
ExistingRelationshipWithTest.parameters = {
chromatic: { disableSnapshot: true },
};
ExistingRelationshipWithTest.play = async ({ canvasElement }) => {
callbackResponseExistingRelationshipWithTest = {};
const canvas = within(canvasElement);
const submitButton = await canvas.findByText('Save Relationship');
const relationshipType = await canvas.findByLabelText('Type');
userEvent.selectOptions(relationshipType, 'Array Relationship');
userEvent.click(submitButton);
await waitFor(() => {
expect(canvas.queryByText('Saving relationship')).toBeInTheDocument();
});
await waitFor(() => {
expect(
JSON.stringify(callbackResponseExistingRelationshipWithTest)
).toContain('Success');
expect(
JSON.stringify(callbackResponseExistingRelationshipWithTest)
).toContain('Relationship saved successfully');
});
};

View File

@ -1,211 +0,0 @@
import React from 'react';
import {
allowedMetadataTypes,
useMetadataMigration,
} from '@/features/MetadataAPI';
import { useFireNotification } from '@/new-components/Notifications';
import { DataTarget } from '@/features/Datasources';
import { InputField, Select, useConsoleForm } from '@/new-components/Form';
import { Button } from '@/new-components/Button';
import { IndicatorCard } from '@/new-components/IndicatorCard';
import { getMetadataQuery, MetadataQueryType } from '@/metadata/queryUtils';
import { schema, Schema } from './schema';
import { FormElements } from './FormElements';
import { useDefaultValues } from './hooks';
import { getSchemaKey } from './utils';
type StatusType = {
title: string;
message: string;
type: 'success' | 'error';
};
type MetadataPayloadType = {
type: allowedMetadataTypes;
args: { [key: string]: any };
version?: number;
};
export type RemoteDBRelationshipWidgetProps = {
/**
* the data source which the relationship originates from, this is the only mandator prop to render this component
*
* @type {DataTarget}
*/
sourceTableInfo: DataTarget;
/**
* Used to render the component in edit mode, passing the relationship name would prefill the form with default values required for the relationship
*
* @type {string}
*/
existingRelationshipName?: string;
/**
* optional callback function, can be used to get the onComplete event, this could be a onSuccess, or onError event.
*
*/
onComplete?: (v: StatusType) => void;
};
export const RemoteDBRelationshipWidget = ({
sourceTableInfo,
existingRelationshipName,
onComplete,
}: RemoteDBRelationshipWidgetProps) => {
// hook to fetch data for existing relationship
const {
data: defaultValues,
isLoading,
isError,
} = useDefaultValues({
sourceTableInfo,
existingRelationshipName,
});
const {
methods: { formState },
Form,
} = useConsoleForm({
schema,
options: {
defaultValues,
},
});
const { fireNotification } = useFireNotification();
const mutation = useMetadataMigration({
onSuccess: () => {
const status: StatusType = {
title: 'Success!',
message: 'Relationship saved successfully',
type: 'success',
};
fireNotification(status);
if (onComplete) {
onComplete(status);
}
},
onError: (error: Error) => {
const status: StatusType = {
title: 'Error',
message: error?.message ?? 'Error while creating the relationship',
type: 'error',
};
fireNotification(status);
if (onComplete) {
onComplete(status);
}
},
});
const submit = (values: Record<string, unknown>) => {
const remote_table: {
database?: string;
schema?: string;
dataset?: string;
table: string;
} = { ...(values as Schema).destination };
delete remote_table.database;
const args = {
source: sourceTableInfo.database,
table: {
[getSchemaKey(sourceTableInfo)]:
(sourceTableInfo as any).dataset ?? (sourceTableInfo as any).schema,
name: sourceTableInfo.table,
},
name: values.relationshipName,
definition: {
to_source: {
source: (values as Schema).destination.database,
table: {
[getSchemaKey(remote_table as DataTarget)]:
(remote_table as any).dataset ?? (remote_table as any).schema,
name: remote_table.table,
},
relationship_type: values.relationshipType,
field_mapping: values.mapping,
},
},
};
const requestBody = existingRelationshipName
? getMetadataQuery(
'update_remote_relationship' as MetadataQueryType,
sourceTableInfo.database,
args
)
: getMetadataQuery(
'create_remote_relationship' as MetadataQueryType,
sourceTableInfo.database,
args
);
mutation.mutate({
query: requestBody as MetadataPayloadType,
});
};
if (isLoading) {
return <div>Loading relationship data...</div>;
}
if (isError) {
return <div>Something went wrong while loading relationship data</div>;
}
return (
<Form onSubmit={submit}>
<>
<div>
<div className="w-full sm:w-6/12 mb-md">
<div className="mb-md">
<InputField
name="relationshipName"
label="Name"
placeholder="Relationship name"
dataTest="local-db-to-db-rel-name"
disabled={!!existingRelationshipName}
/>
</div>
<div className="mb-md">
<Select
name="relationshipType"
label="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',
},
]}
/>
</div>
</div>
<FormElements />
<Button
mode="primary"
type="submit"
isLoading={mutation.isLoading}
loadingText="Saving relationship"
data-test="add-local-db-relationship"
>
Save Relationship
</Button>
</div>
{!!Object.keys(formState.errors).length && (
<IndicatorCard status="negative">
Error saving relationship
</IndicatorCard>
)}
</>
</Form>
);
};

View File

@ -1,70 +0,0 @@
import { MetadataSelector, useMetadata } from '@/features/MetadataAPI';
import { QualifiedTable } from '@/metadata/types';
import { DataTarget } from '@/features/Datasources';
import { Schema } from '../schema';
import { getSchemaKey } from '../utils';
interface UseDefaultValuesProps {
sourceTableInfo: DataTarget;
existingRelationshipName?: string;
}
type MetadataTableType = {
dataset?: string;
schema?: string;
name: string;
};
type RelationshipType = 'object' | 'array';
export const useDefaultValues = ({
sourceTableInfo,
existingRelationshipName,
}: UseDefaultValuesProps) => {
const {
data: metadataTable,
isLoading,
isError,
} = useMetadata(
MetadataSelector.getTable(sourceTableInfo.database, {
name: sourceTableInfo.table,
[getSchemaKey(sourceTableInfo)]:
(sourceTableInfo as any).schema ?? (sourceTableInfo as any).dataset,
} as QualifiedTable)
);
const remote_db_relationships = metadataTable?.remote_relationships?.filter(
rel => 'to_source' in rel.definition
);
const relationship = remote_db_relationships?.find(
rel => rel.name === existingRelationshipName
);
const defaultValues: Schema = {
relationshipType:
(relationship?.definition?.to_source
?.relationship_type as RelationshipType) ?? 'object',
relationshipName: existingRelationshipName ?? '',
source: {
database: sourceTableInfo.database,
[getSchemaKey(sourceTableInfo)]:
(sourceTableInfo as any).dataset ?? (sourceTableInfo as any).schema,
table: sourceTableInfo.table,
},
destination: {
database: relationship?.definition?.to_source?.source ?? '',
[relationship?.definition?.to_source.source === 'bigquery'
? 'dataset'
: 'schema']:
(relationship?.definition?.to_source?.table as MetadataTableType)
?.dataset ??
(relationship?.definition?.to_source?.table as MetadataTableType)
?.schema ??
'',
table: relationship?.definition?.to_source?.table.name ?? '',
},
mapping: relationship?.definition?.to_source?.field_mapping ?? {},
};
return { data: defaultValues, isLoading, isError };
};

View File

@ -1,21 +0,0 @@
import { z } from 'zod';
export const schema = z.object({
relationshipType: z.literal('array').or(z.literal('object')),
relationshipName: z.string().min(1, { message: 'Name is required!' }),
source: z.object({
database: z.string().min(1, 'Source Database is required!'),
schema: z.string().optional(),
dataset: z.string().optional(),
table: z.string().min(1, 'Source Table is required!'),
}),
destination: z.object({
database: z.string().min(1, 'Reference Database is required!'),
schema: z.string().optional(),
dataset: z.string().optional(),
table: z.string().min(1, 'Reference Table is required!'),
}),
mapping: z.record(z.string()),
});
export type Schema = z.infer<typeof schema>;

View File

@ -1,5 +0,0 @@
import { DataTarget } from '@/features/Datasources';
export const getSchemaKey = (sourceTableInfo: DataTarget) => {
return 'dataset' in sourceTableInfo ? 'dataset' : 'schema';
};

View File

@ -1,52 +0,0 @@
import { Button } from '@/new-components/Button';
import { z } from 'zod';
import { InputField, SimpleForm } from '@/new-components/Form';
import React from 'react';
import { Relationship } from '../DatabaseRelationshipsTable/types';
import { useRenameRelationship } from './useRenameRelationship';
export const RenameRelationship = ({
relationship,
onError,
onSuccess,
}: {
relationship: Relationship;
onSuccess?: () => void;
onError?: (err: unknown) => void;
}) => {
const { renameRelationship, isLoading } = useRenameRelationship();
return (
<div>
<SimpleForm
schema={z.object({
name: z.string(),
})}
onSubmit={data => {
renameRelationship({
values: {
newName: data.name,
relationshipName: relationship.name,
fromTable: relationship.mapping.from.table,
fromSource: relationship.mapping.from.source,
},
onSuccess,
onError,
});
}}
options={{
defaultValues: {
name: relationship.name,
},
}}
>
<div>
<InputField name="name" type="text" label="Relationship Name" />
<Button type="submit" mode="primary" isLoading={isLoading}>
Rename
</Button>
</div>
</SimpleForm>
</div>
);
};

View File

@ -1,99 +0,0 @@
import { exportMetadata } from '@/features/DataSource';
import { Table } from '@/features/hasura-metadata-types';
import { useMetadataMigration } from '@/features/MetadataAPI';
import { useHttpClient } from '@/features/Network';
import { useFireNotification } from '@/new-components/Notifications';
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
type EditManualLocalRelationshipPayload = {
newName: string;
relationshipName: string;
fromTable: Table;
fromSource: string;
};
export const useRenameRelationship = () => {
const httpClient = useHttpClient();
const { fireNotification } = useFireNotification();
const { mutate, ...rest } = useMetadataMigration();
const queryClient = useQueryClient();
const renameRelationship = useCallback(
async ({
values,
onSuccess,
onError,
}: {
values: EditManualLocalRelationshipPayload;
onSuccess?: () => void;
onError?: (err: unknown) => void;
}) => {
try {
const { resource_version, metadata } = 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 = 'rename_relationship';
mutate(
{
query: {
resource_version,
type: `${driver}_${type}`,
args: {
table: values.fromTable,
source: values.fromSource,
name: values.relationshipName,
new_name: values.newName,
},
},
},
{
onSuccess: () => {
queryClient.refetchQueries([
values.fromSource,
'list_all_relationships',
]);
onSuccess?.();
fireNotification({
type: 'success',
title: 'Success!',
message: 'A relationship renamed succesfully!',
});
},
onError: err => {
onError?.(err);
fireNotification({
type: 'error',
title: 'Failed to rename relationship',
message: err?.message,
});
},
}
);
} catch (err) {
fireNotification({
title: 'Error',
type: 'error',
message: JSON.stringify(err),
});
}
},
[fireNotification, httpClient, mutate, queryClient]
);
return { renameRelationship, ...rest };
};

View File

@ -1,54 +0,0 @@
import React from 'react';
import { Story, Meta } from '@storybook/react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { within, userEvent } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import { DataTarget } from '@/features/Datasources';
import { handlers } from '../../hooks/mocks/handlers.mock';
import {
SuggestedRelationships,
SuggestedRelationshipProps,
} from './SuggestedRelationships';
export default {
title: 'Features/Data Relationships/Suggested Relationships',
component: SuggestedRelationships,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers(),
},
} as Meta;
const target: DataTarget = {
database: 'default',
schema: 'public',
table: 'user',
};
export const Primary: Story<SuggestedRelationshipProps> = args => (
<SuggestedRelationships {...args} />
);
Primary.args = {
target,
};
export const WithInteraction: Story<SuggestedRelationshipProps> = args => (
<SuggestedRelationships {...args} />
);
WithInteraction.args = Primary.args;
WithInteraction.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = await canvas.findByRole('button', { name: 'Add' });
userEvent.click(button);
const label = await canvas.findByLabelText('Relationship Name');
userEvent.type(label, 's');
expect(label).toBeInTheDocument();
const submitButton = await canvas.findByRole('button', {
name: 'Add Relationship',
});
userEvent.click(submitButton);
};

View File

@ -1,99 +0,0 @@
import React from 'react';
import { FaArrowRight, FaColumns, FaMagic, FaTable } from 'react-icons/fa';
import { CardedTable } from '@/new-components/CardedTable';
import { Button } from '@/new-components/Button';
import { DataTarget } from '@/features/Datasources';
import { SuggestedRelationshipForm } from './components';
import { useSuggestedRelationships } from './hooks';
export interface SuggestedRelationshipProps {
target: DataTarget;
}
export const SuggestedRelationships = ({
target,
}: SuggestedRelationshipProps) => {
const { data, isLoading, isError } = useSuggestedRelationships(target);
const [open, setOpen] = React.useState<string | null>(null);
if (isError) {
return (
<p className="text-red-500">Error loading suggested relationships</p>
);
}
if (isLoading) {
return <p>Loading...</p>;
}
if (!data?.length) {
return <p>No Suggested relationships</p>;
}
return (
<div>
<CardedTable.Table>
<CardedTable.TableHead>
<CardedTable.TableHeadRow>
<CardedTable.TableHeadCell>
<FaMagic className="text-sm text-gray-500 mr-2" />
SUGGESTED RELATIONSHIPS
</CardedTable.TableHeadCell>
<CardedTable.TableHeadCell>TYPE</CardedTable.TableHeadCell>
<CardedTable.TableHeadCell>RELATIONSHIP</CardedTable.TableHeadCell>
</CardedTable.TableHeadRow>
</CardedTable.TableHead>
<CardedTable.TableBody>
{data.map(relationship => {
// create a unique key
const key = `${relationship.to.table}-${relationship.to.column}-${relationship.from.table}-${relationship.from.column}`;
return (
<CardedTable.TableBodyRow key={key}>
<CardedTable.TableBodyCell>
{open === key ? (
<SuggestedRelationshipForm
key={key}
target={target}
relationship={relationship}
close={() => setOpen(null)}
/>
) : (
<Button size="sm" onClick={() => setOpen(key)}>
Add
</Button>
)}
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>
<FaTable className="text-sm text-muted mr-xs" />
Local Relation
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>
<span className="capitalize">{relationship.type}</span>
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>
<FaTable className="text-sm text-muted mr-xs" />
{relationship.from.table}&nbsp;/&nbsp;
<FaColumns className="text-sm text-muted mr-xs" />
{relationship.from.column}
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>
<FaArrowRight className="fill-current text-sm text-muted" />
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>
<FaTable className="text-sm text-muted mr-xs" />
{relationship.to.table}&nbsp;/&nbsp;
<FaColumns className="text-sm text-muted mr-xs" />
{relationship.to.column}
</CardedTable.TableBodyCell>
</CardedTable.TableBodyRow>
);
})}
</CardedTable.TableBody>
</CardedTable.Table>
</div>
);
};

View File

@ -1,88 +0,0 @@
import React from 'react';
import { useConsoleForm } from '@/new-components/Form';
import { Button } from '@/new-components/Button';
import { DataTarget } from '@/features/Datasources';
import { TableRelationship } from '@/features/MetadataAPI';
import { schema, useSubmit } from '../hooks';
export interface SuggestedRelationshipProps {
target: DataTarget;
}
interface SuggestedRelationshipFormProps {
target: DataTarget;
relationship: Omit<TableRelationship, 'name' | 'comment'>;
close: () => void;
}
const CloseIcon = () => (
<svg
className="fill-current cursor-pointer w-4 h-4 text-muted hover:text-gray-900"
stroke="currentColor"
fill="currentColor"
strokeWidth="0"
viewBox="0 0 512 512"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm121.6 313.1c4.7 4.7 4.7 12.3 0 17L338 377.6c-4.7 4.7-12.3 4.7-17 0L256 312l-65.1 65.6c-4.7 4.7-12.3 4.7-17 0L134.4 338c-4.7-4.7-4.7-12.3 0-17l65.6-65-65.6-65.1c-4.7-4.7-4.7-12.3 0-17l39.6-39.6c4.7-4.7 12.3-4.7 17 0l65 65.7 65.1-65.6c4.7-4.7 12.3-4.7 17 0l39.6 39.6c4.7 4.7 4.7 12.3 0 17L312 256l65.6 65.1z" />
</svg>
);
export const SuggestedRelationshipForm = ({
target,
relationship,
close,
}: SuggestedRelationshipFormProps) => {
const { submit, isLoading } = useSubmit();
const onSubmit = async ({ relationshipName }: Record<string, unknown>) => {
try {
await submit({
relationshipName: relationshipName as string,
target,
relationship,
});
close();
} catch (error) {
console.error('Error while adding the relationship', error);
}
};
const {
methods: { register },
Form,
} = useConsoleForm({
schema,
options: {
defaultValues: {
relationshipName: relationship.to.table,
},
},
});
return (
<Form onSubmit={onSubmit}>
<div className="flex items-center space-x-1.5 bg-white">
<label htmlFor="relationshipName" className="sr-only">
Relationship Name
</label>
<input
id="relationshipName"
type="text"
className="block w-full h-input shadow-sm rounded border border-gray-300 hover:border-gray-400 focus:outline-0 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
placeholder="Relationship Name..."
{...register('relationshipName')}
/>
<Button type="submit" mode="primary" isLoading={isLoading}>
Add Relationship
</Button>
<button onClick={close} aria-label="close">
<CloseIcon />
</button>
</div>
</Form>
);
};

View File

@ -1,143 +0,0 @@
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { store } from '../../../../../../store';
import {
ExistingRelationships,
ForeignKeyConstraints,
removeExistingRelationships,
useSuggestedRelationships,
} from '../useSuggestedRelationships';
import { metadata, query } from '../mocks/dataStubs';
const target = {
database: 'default',
schema: 'public',
table: 'user',
};
const fk: ForeignKeyConstraints = [
{
from: {
table: 'product',
column: ['fk_user_id'],
},
to: {
table: 'user',
column: ['id'],
},
},
];
const existing: ExistingRelationships = [
{
fromType: 'table',
toType: 'table',
name: 'user',
reference: 'default',
referenceTable: 'product',
target: 'default',
targetTable: 'user',
type: 'Object',
fieldsFrom: ['fk_user_id'],
fieldsTo: ['id'],
relationship: {
name: 'user',
using: {
manual_configuration: {
remote_table: {
schema: 'public',
name: 'user',
},
column_mapping: {
fk_user_id: 'id',
},
},
},
},
},
];
describe('remove existing relationships function', () => {
it('if no existing relationships returns original values', () => {
const res = removeExistingRelationships({
target,
foreignKeyConstraints: fk,
allExistingRelationships: [],
});
expect(res?.[0].from).toEqual({
column: ['id'],
table: 'user',
});
expect(res?.[0].to).toEqual({
column: ['fk_user_id'],
table: 'product',
});
expect(res?.[0].type).toEqual('array');
});
it('if existing relationships exist they are removed', () => {
const res = removeExistingRelationships({
target,
foreignKeyConstraints: fk,
allExistingRelationships: existing,
});
expect(res).toEqual([]);
});
});
const server = setupServer(
rest.post(`http://localhost/v1/metadata`, (_req, res, ctx) => {
return res(ctx.json(metadata));
}),
rest.post('http://localhost/v2/query', (_req, res, ctx) => {
return res(ctx.status(200), ctx.json(query));
})
);
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={store}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</Provider>
);
describe('useSuggestedRelationships hook', () => {
beforeAll(() => server.listen());
afterAll(() => server.close());
it('should return a list of existing relationships', async () => {
const { result, waitFor } = renderHook(
() => useSuggestedRelationships(target),
{
wrapper,
}
);
await waitFor(() => result.current.isSuccess);
const res = result.current.data;
expect(res?.[0].from).toEqual({
column: ['id'],
table: 'user',
});
expect(res?.[0].to).toEqual({
column: ['fk_user_id'],
table: 'product',
});
expect(res?.[0].type).toEqual('array');
});
});

View File

@ -1,2 +0,0 @@
export * from './useSuggestedRelationships';
export * from './useSubmitForm';

View File

@ -1,120 +0,0 @@
import { HasuraMetadataV3 } from '@/metadata/types';
export const metadata = {
resource_version: 1,
metadata: {
version: 3,
sources: [
{
name: 'default',
kind: 'postgres',
tables: [
{
table: {
schema: 'public',
name: 'user',
},
},
{
table: {
schema: 'public',
name: 'product',
},
},
],
configuration: {
connection_info: {
use_prepared_statements: true,
database_url: {
from_env: 'HASURA_GRAPHQL_DATABASE_URL',
},
isolation_level: 'read-committed',
pool_settings: {
connection_lifetime: 600,
retries: 1,
idle_timeout: 180,
max_connections: 50,
},
},
},
},
],
} as HasuraMetadataV3,
};
export const metadataWithExistingRelationships = {
resource_version: 1,
metadata: {
version: 3,
sources: [
{
name: 'default',
kind: 'postgres',
tables: [
{
table: {
schema: 'public',
name: 'product',
},
object_relationships: [
{
name: 'user',
using: {
manual_configuration: {
remote_table: {
schema: 'public',
name: 'user',
},
insertion_order: null,
column_mapping: {
fk_user_id: 'id',
},
},
},
},
],
},
{
table: {
schema: 'public',
name: 'user',
},
array_relationships: [
{
name: 'products',
using: {
manual_configuration: {
remote_table: {
schema: 'public',
name: 'product',
},
insertion_order: null,
column_mapping: {
id: 'fk_user_id',
},
},
},
},
],
},
],
configuration: {
connection_info: {
use_prepared_statements: false,
database_url:
'postgres://postgres:postgrespassword@postgres:5432/postgres',
isolation_level: 'read-committed',
},
},
},
],
},
};
export const query = {
result_type: 'TuplesOk',
result: [
['source_table', 'source_column', 'target_table', 'target_column'],
['product', 'fk_user_id', '"user"', 'id'],
],
};

View File

@ -1,27 +0,0 @@
import { rest } from 'msw';
import {
metadata,
metadataWithExistingRelationships,
query,
} from './dataStubs';
const baseUrl = 'http://localhost:8080';
interface HandlerProps {
url?: string;
withExisting?: boolean;
}
export const handlers = (args?: HandlerProps) => [
rest.post(`${args?.url || baseUrl}/v2/query`, (_req, res, ctx) =>
res(ctx.json(query))
),
rest.post(`${args?.url || baseUrl}/v1/metadata`, (_req, res, ctx) => {
if (args?.withExisting) {
return res(ctx.json(metadataWithExistingRelationships));
}
return res(ctx.json(metadata));
}),
];

View File

@ -1,87 +0,0 @@
import { z } from 'zod';
import { useFireNotification } from '@/new-components/Notifications';
import { DataTarget } from '@/features/Datasources';
import {
allowedMetadataTypes,
TableRelationship,
useMetadataMigration,
} from '@/features/MetadataAPI';
export const schema = z.object({
relationshipName: z
.string()
.min(3, 'Relationship name must be at least 3 characters long'),
});
export type Schema = z.infer<typeof schema>;
export interface SuggestedRelationshipProps {
target: DataTarget;
}
interface UseSubmitArgs {
relationshipName: string;
target: DataTarget;
relationship: Omit<TableRelationship, 'name' | 'comment'>;
}
export const useSubmit = () => {
const { fireNotification } = useFireNotification();
const mutation = useMetadataMigration({
onSuccess: () => {
fireNotification({
title: 'Success!',
message: 'Relationship added successfully',
type: 'success',
});
},
onError: () => {
fireNotification({
title: 'Error',
message: 'Error while adding the relationship',
type: 'error',
});
},
});
const submit = async ({
relationshipName,
target,
relationship,
}: UseSubmitArgs) => {
if (relationship.type === 'object') {
const query = {
type: 'pg_create_object_relationship' as allowedMetadataTypes,
args: {
table: target.table,
name: relationshipName,
source: target.database,
using: {
foreign_key_constraint_on: relationship.from.column,
},
},
};
return mutation.mutate({ query });
}
const query = {
type: 'pg_create_array_relationship' as allowedMetadataTypes,
args: {
table: target.table,
name: relationshipName,
source: target.database,
using: {
foreign_key_constraint_on: {
table: relationship.to.table,
columns: relationship.to.column,
},
},
},
};
return mutation.mutate({ query });
};
return { submit, ...mutation };
};

View File

@ -1,107 +0,0 @@
import React from 'react';
import ReactJson from 'react-json-view';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { Story, Meta } from '@storybook/react';
import { handlers } from '../hooks/mocks/handlers.mock';
import { useSuggestedRelationships } from './useSuggestedRelationships';
const target = {
database: 'default',
schema: 'public',
};
const UseSuggestedRelationshipsComponent = () => {
const { data: arrayRelationships } = useSuggestedRelationships({
...target,
table: 'user',
});
const { data: objectRelationships } = useSuggestedRelationships({
...target,
table: 'product',
});
if (!arrayRelationships || !objectRelationships) {
return <div>No relationships</div>;
}
return (
<>
<p>Suggested object relationships</p>
<ReactJson src={objectRelationships} />
<p>Suggested array relationships</p>
<ReactJson src={arrayRelationships} />
</>
);
};
const UseSuggestedObjectRelationshipsComponent = () => {
const tableName = 'product';
const { data: objectRelationships } = useSuggestedRelationships({
...target,
table: tableName,
});
if (!objectRelationships) {
return <div>No relationships</div>;
}
return (
<>
<h3 className="text-lg">
<strong>Table Name: {tableName}</strong>
</h3>
<p>Suggested object relationships</p>
<ReactJson src={objectRelationships} />
</>
);
};
const UseSuggestedArrayRelationshipsComponent = () => {
const tableName = 'user';
const { data: arrayRelationships } = useSuggestedRelationships({
...target,
table: tableName,
});
if (!arrayRelationships) {
return <div>No relationships</div>;
}
return (
<>
<h3 className="text-lg">
<strong>Table Name: {tableName}</strong>
</h3>
<p>Suggested array relationships</p>
<ReactJson src={arrayRelationships} />
</>
);
};
export default {
title: 'Features/Data Relationships/useSuggestedRelationships',
decorators: [ReactQueryDecorator()],
component: UseSuggestedRelationshipsComponent,
parameters: {
msw: handlers(),
},
} as Meta;
export const AllSuggested: Story = () => <UseSuggestedRelationshipsComponent />;
export const ObjectSuggested: Story = () => (
<UseSuggestedObjectRelationshipsComponent />
);
export const ArraySuggested: Story = () => (
<UseSuggestedArrayRelationshipsComponent />
);
export const WithExistingRelationships: Story = () => (
<>
<p>This should have no suggested relationships as they already exist</p>
<UseSuggestedRelationshipsComponent />
</>
);
WithExistingRelationships.parameters = {
msw: handlers({ withExisting: true }),
};

View File

@ -1,105 +0,0 @@
import { DataTarget, useTableRelationships } from '@/features/Datasources';
import { useLocalRelationships } from '@/features/MetadataAPI';
interface TableRelationshipCoreData {
type: 'object' | 'array';
from: {
table: string;
column: string[];
};
to: {
table: string;
column: string[];
};
}
export type ForeignKeyConstraints = ReturnType<
typeof useTableRelationships
>['data'];
export type ExistingRelationships = ReturnType<
typeof useLocalRelationships
>['data'];
interface RemoveExistingRelationshipsArgs {
target: DataTarget;
foreignKeyConstraints: ForeignKeyConstraints;
allExistingRelationships: ExistingRelationships;
}
export const removeExistingRelationships = ({
target,
foreignKeyConstraints,
allExistingRelationships,
}: RemoveExistingRelationshipsArgs) => {
return foreignKeyConstraints?.reduce<TableRelationshipCoreData[]>(
(acc, { from, to }) => {
const relationshipExists = allExistingRelationships.find(existing => {
const fromTableIsReference =
from?.table === existing?.referenceTable &&
to?.table === existing?.targetTable;
const toTableIsReference =
to?.table === existing?.referenceTable &&
from?.table === existing?.targetTable;
return fromTableIsReference || toTableIsReference;
});
if (!relationshipExists) {
// if the table passed into the function is the from table,
// this means it is on object relationship
const type: 'object' | 'array' =
from?.table === target.table ? 'object' : 'array';
// the `from` and `to` reference the foreign key constraint
// therefore if the relationship type is array they need to be swapped
// for example if the foreign key relationship is product.fk_user_id -> user.id
// it would be listed as from: product, to: user
// but the suggested relationship should be type: array, from: user, to: product
if (type === 'object') {
const suggestedRelationship = { type, from, to };
acc.push(suggestedRelationship);
} else {
const suggestedRelationship = { type, from: to, to: from };
acc.push(suggestedRelationship);
}
}
return acc;
},
[]
);
};
export const useSuggestedRelationships = (target: DataTarget) => {
const {
data: foreignKeyConstraints,
isLoading: foreignKeyConstraintsIsLoading,
isSuccess: foreignKeyConstraintsIsSuccess,
isError: foreignKeyConstraintsIsError,
} = useTableRelationships({
target,
});
const {
data: allExistingRelationships,
isLoading: allExistingRelationshipsIsLoading,
isSuccess: allExistingRelationshipsIsSuccess,
isError: allExistingRelationshipsIsError,
} = useLocalRelationships(target);
const suggestedRelationships = removeExistingRelationships({
target,
foreignKeyConstraints,
allExistingRelationships,
});
const isLoading =
foreignKeyConstraintsIsLoading || allExistingRelationshipsIsLoading;
const isSuccess =
foreignKeyConstraintsIsSuccess && allExistingRelationshipsIsSuccess;
const isError =
foreignKeyConstraintsIsError || allExistingRelationshipsIsError;
return { data: suggestedRelationships, isSuccess, isLoading, isError };
};

View File

@ -1 +0,0 @@
export * from './useMutations';

View File

@ -1,118 +0,0 @@
export const metadata = {
resource_version: 1,
metadata: {
version: 3,
sources: [
{
name: 'default',
kind: 'postgres',
tables: [
{
table: {
schema: 'public',
name: 'user',
},
},
{
table: {
schema: 'public',
name: 'product',
},
},
],
configuration: {
connection_info: {
use_prepared_statements: true,
database_url: {
from_env: 'HASURA_GRAPHQL_DATABASE_URL',
},
isolation_level: 'read-committed',
pool_settings: {
connection_lifetime: 600,
retries: 1,
idle_timeout: 180,
max_connections: 50,
},
},
},
},
],
},
};
export const metadataWithExistingRelationships = {
resource_version: 1,
metadata: {
version: 3,
sources: [
{
name: 'default',
kind: 'postgres',
tables: [
{
table: {
schema: 'public',
name: 'product',
},
object_relationships: [
{
name: 'user',
using: {
manual_configuration: {
remote_table: {
schema: 'public',
name: 'user',
},
insertion_order: null,
column_mapping: {
fk_user_id: 'id',
},
},
},
},
],
},
{
table: {
schema: 'public',
name: 'user',
},
array_relationships: [
{
name: 'products',
using: {
manual_configuration: {
remote_table: {
schema: 'public',
name: 'product',
},
insertion_order: null,
column_mapping: {
id: 'fk_user_id',
},
},
},
},
],
},
],
configuration: {
connection_info: {
use_prepared_statements: false,
database_url:
'postgres://postgres:postgrespassword@postgres:5432/postgres',
isolation_level: 'read-committed',
},
},
},
],
},
};
export const query = {
result_type: 'TuplesOk',
result: [
['source_table', 'source_column', 'target_table', 'target_column'],
['product', 'fk_user_id', '"user"', 'id'],
],
};

View File

@ -1,27 +0,0 @@
import { rest } from 'msw';
import {
metadata,
metadataWithExistingRelationships,
query,
} from './dataStubs';
const baseUrl = 'http://localhost:8080';
interface HandlerProps {
url?: string;
withExisting?: boolean;
}
export const handlers = (args?: HandlerProps) => [
rest.post(`${args?.url || baseUrl}/v2/query`, (_req, res, ctx) =>
res(ctx.json(query))
),
rest.post(`${args?.url || baseUrl}/v1/metadata`, (_req, res, ctx) => {
if (args?.withExisting) {
return res(ctx.json(metadataWithExistingRelationships));
}
return res(ctx.json(metadata));
}),
];

View File

@ -1,75 +0,0 @@
import { useMetadataVersion } from '@/features/MetadataAPI';
import { Button } from '@/new-components/Button';
import HookStatusWrapperWithMetadataVersion from '@/storybook/HookStatusWrapperWithMetadataVersion';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ReduxDecorator } from '@/storybook/decorators/redux-decorator';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import React from 'react';
import { useAddRemoteDatabaseRelationship } from '..';
function AddRemoteDatabaseRelationshipComponent({
rel,
}: {
rel: Record<string, unknown>;
}) {
const mutation = useAddRemoteDatabaseRelationship();
const { data: version, isSuccess } = useMetadataVersion();
return (
<div>
<Button onClick={() => mutation.mutate(rel)}>
Add Remote Database Relationship Mutation (Not compatible with CLI)
</Button>
<p className="mb-md text-muted mb-md pt-5">
Adds a new relationship from the source database table to the target
database table. The hook, on success, invalidates the existing metadata
and refetches it again.
</p>
<HookStatusWrapperWithMetadataVersion
status={{
isSuccess: mutation.isSuccess,
isError: mutation.isError,
isLoading: mutation.isLoading,
isIdle: mutation.isIdle,
}}
metadata={{ version, isSuccess }}
/>
</div>
);
}
export const AddRemoteDatabaseRelationship: ComponentStory<
typeof AddRemoteDatabaseRelationshipComponent
> = args => {
return <AddRemoteDatabaseRelationshipComponent {...args} />;
};
AddRemoteDatabaseRelationship.args = {
rel: {
source: 'default',
table: 'test',
name: 'name_of_the_remote_relationship',
definition: {
to_source: {
source: 'chinook',
table: 'Album',
relationship_type: 'object',
field_mapping: {
id: 'AlbumId',
},
},
},
},
};
AddRemoteDatabaseRelationship.parameters = {
// Disable storybook for Remote Relationship stories
chromatic: { disableSnapshot: true },
};
export default {
title: 'hooks/Remote Database Relationships/Create',
decorators: [
ReduxDecorator({ tables: { currentDataSource: 'default' } }),
ReactQueryDecorator(),
],
} as ComponentMeta<typeof AddRemoteDatabaseRelationshipComponent>;

View File

@ -1,65 +0,0 @@
import { useMetadataVersion } from '@/features/MetadataAPI';
import { Button } from '@/new-components/Button';
import HookStatusWrapperWithMetadataVersion from '@/storybook/HookStatusWrapperWithMetadataVersion';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ReduxDecorator } from '@/storybook/decorators/redux-decorator';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import React from 'react';
import { useDropRemoteDatabaseRelationship } from '..';
function DeleteRemoteDatabaseRelationshipComponent({
rel,
}: {
rel: Record<string, unknown>;
}) {
const mutation = useDropRemoteDatabaseRelationship();
const { data: version, isSuccess } = useMetadataVersion();
return (
<div>
<Button onClick={() => mutation.mutate(rel)}>
Delete Remote Database Relationship Mutation (Not compatible with CLI)
</Button>
<p className="mb-md text-muted mb-md pt-5">
Delets a remote relationship from the metadata using the name, source &
table as the input parameters. The hook, on success, invalidates the
existing metadata and refetches it again.
</p>
<HookStatusWrapperWithMetadataVersion
status={{
isSuccess: mutation.isSuccess,
isError: mutation.isError,
isLoading: mutation.isLoading,
isIdle: mutation.isIdle,
}}
metadata={{ version, isSuccess }}
/>
</div>
);
}
export const DeleteRemoteDatabaseRelationship: ComponentStory<
typeof DeleteRemoteDatabaseRelationshipComponent
> = args => {
return <DeleteRemoteDatabaseRelationshipComponent {...args} />;
};
DeleteRemoteDatabaseRelationship.args = {
rel: {
source: 'default',
table: 'test',
name: 'name_of_the_remote_relationship',
},
};
DeleteRemoteDatabaseRelationship.parameters = {
// Disable storybook for Remote Relationship stories
chromatic: { disableSnapshot: true },
};
export default {
title: 'hooks/Remote Database Relationships/Delete',
decorators: [
ReduxDecorator({ tables: { currentDataSource: 'default' } }),
ReactQueryDecorator(),
],
} as ComponentMeta<typeof DeleteRemoteDatabaseRelationshipComponent>;

View File

@ -1,75 +0,0 @@
import { useMetadataVersion } from '@/features/MetadataAPI';
import { Button } from '@/new-components/Button';
import HookStatusWrapperWithMetadataVersion from '@/storybook/HookStatusWrapperWithMetadataVersion';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ReduxDecorator } from '@/storybook/decorators/redux-decorator';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import React from 'react';
import { useUpdateRemoteDatabaseRelationship } from '..';
function UpdateRemoteDatabaseRelationshipComponent({
rel,
}: {
rel: Record<string, unknown>;
}) {
const mutation = useUpdateRemoteDatabaseRelationship();
const { data: version, isSuccess } = useMetadataVersion();
return (
<div>
<Button onClick={() => mutation.mutate(rel)}>
Update Remote Database Relationship Mutation (Not compatible with CLI)
</Button>
<p className="mb-md text-muted mb-md pt-5">
Updates an existing remote relationship from the metadata using the
name, source & table as the input parameters. The hook, on success,
invalidates the existing metadata and refetches it again.
</p>
<HookStatusWrapperWithMetadataVersion
status={{
isSuccess: mutation.isSuccess,
isError: mutation.isError,
isLoading: mutation.isLoading,
isIdle: mutation.isIdle,
}}
metadata={{ version, isSuccess }}
/>
</div>
);
}
export const UpdateRemoteDatabaseRelationship: ComponentStory<
typeof UpdateRemoteDatabaseRelationshipComponent
> = args => {
return <UpdateRemoteDatabaseRelationshipComponent {...args} />;
};
UpdateRemoteDatabaseRelationship.args = {
rel: {
source: 'default',
table: 'test',
name: 'name_of_the_remote_relationship',
definition: {
to_source: {
source: 'chinook',
table: 'Artist',
relationship_type: 'object',
field_mapping: {
id: 'ArtistId',
},
},
},
},
};
UpdateRemoteDatabaseRelationship.parameters = {
// Disable storybook for Remote Relationship stories
chromatic: { disableSnapshot: true },
};
export default {
title: 'hooks/Remote Database Relationships/Update',
decorators: [
ReduxDecorator({ tables: { currentDataSource: 'default' } }),
ReactQueryDecorator(),
],
} as ComponentMeta<typeof UpdateRemoteDatabaseRelationshipComponent>;

View File

@ -1,57 +0,0 @@
import { useRemoteDatabaseRelationships } from '@/features/MetadataAPI';
import { QualifiedTable } from '@/metadata/types';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ReduxDecorator } from '@/storybook/decorators/redux-decorator';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import React from 'react';
function GetRemoteDatabaseRelationshipsComponent({
database,
qualifiedTable,
}: {
database: string;
qualifiedTable: QualifiedTable;
}) {
const query = useRemoteDatabaseRelationships({
database,
table: qualifiedTable.name,
schema: qualifiedTable.schema,
});
return (
<div>
<b>Get Remote Database Relationship Query</b>
<p className="mb-md text-muted mb-md pt-5">
Gets the list of Remote Database Relationships available for a given
database & qualified table.
</p>
{JSON.stringify(query.data)}
</div>
);
}
export const GetRemoteDatabaseRelationships: ComponentStory<
typeof GetRemoteDatabaseRelationshipsComponent
> = args => {
return <GetRemoteDatabaseRelationshipsComponent {...args} />;
};
GetRemoteDatabaseRelationships.args = {
database: 'default',
qualifiedTable: {
schema: 'public',
name: 'test',
},
};
GetRemoteDatabaseRelationships.parameters = {
// Disable storybook for Remote Relationship stories
chromatic: { disableSnapshot: true },
};
export default {
title: 'hooks/Remote Database Relationships/Fetch',
decorators: [
ReduxDecorator({ tables: { currentDataSource: 'default' } }),
ReactQueryDecorator(),
],
} as ComponentMeta<typeof GetRemoteDatabaseRelationshipsComponent>;

View File

@ -1,79 +0,0 @@
import { exportMetadata } from '@/features/DataSource';
import { Table } from '@/features/hasura-metadata-types';
import { useMetadataMigration } from '@/features/MetadataAPI';
import { useHttpClient } from '@/features/Network';
import { useFireNotification } from '@/new-components/Notifications';
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { Relationship } from '../components/DatabaseRelationshipsTable/types';
export const useDeleteRelationship = (props?: { onSuccess?: () => void }) => {
const { mutate, ...rest } = useMetadataMigration();
const httpClient = useHttpClient();
const { fireNotification } = useFireNotification();
const queryClient = useQueryClient();
const deleteRelationship = useCallback(
async (dataSource: string, table: Table, relationship: Relationship) => {
const { metadata, resource_version } = await exportMetadata({
httpClient,
});
if (!metadata) throw Error('Unable to fetch metadata');
const metadataSource = metadata.sources.find(s => s.name === dataSource);
if (!metadataSource) throw Error('Unable to fetch metadata source');
const driver = metadataSource.kind;
const isRelationshipRemote =
relationship.type === 'toSource' ||
relationship.type === 'toRemoteSchema';
const type = isRelationshipRemote
? 'delete_remote_relationship'
: 'drop_relationship';
mutate(
{
query: {
resource_version,
type: `${driver}_${type}`,
args: {
table,
source: dataSource,
[isRelationshipRemote ? 'name' : 'relationship']:
relationship.name,
},
},
},
{
onSuccess: () => {
queryClient.refetchQueries([dataSource, 'list_all_relationships']);
props?.onSuccess?.();
fireNotification({
type: 'success',
title: 'Success!',
message: 'Relationship deleted successfully!',
});
},
onError: err => {
fireNotification({
type: 'error',
title: 'Failed to delete relationship',
message: err?.message,
});
},
}
);
},
[fireNotification, httpClient, mutate, props, queryClient]
);
return {
deleteRelationship,
...rest,
};
};

View File

@ -1,72 +0,0 @@
import Endpoints from '@/Endpoints';
import { Api } from '@/hooks/apiUtils';
import { useAppSelector } from '@/store';
import { useMutation, useQueryClient } from 'react-query';
export const useAddRemoteDatabaseRelationship = () => {
const queryClient = useQueryClient();
const headers = useAppSelector(state => state.tables.dataHeaders);
return useMutation(
(rel: any) =>
Api.post<Record<string, unknown>>({
headers,
body: {
type: 'pg_create_remote_relationship',
args: rel,
},
url: Endpoints.metadata,
}),
{
onSuccess: () => {
queryClient.refetchQueries(['metadata'], { active: true });
},
}
);
};
export const useDropRemoteDatabaseRelationship = () => {
const queryClient = useQueryClient();
const headers = useAppSelector(state => state.tables.dataHeaders);
return useMutation(
(rel: any) =>
Api.post<Record<string, string>>({
headers,
body: {
type: 'pg_delete_remote_relationship',
args: rel,
},
url: Endpoints.metadata,
}),
{
onSuccess: () => {
queryClient.refetchQueries(['metadata'], { active: true });
},
onError: () => {},
}
);
};
export const useUpdateRemoteDatabaseRelationship = () => {
const queryClient = useQueryClient();
const headers = useAppSelector(state => state.tables.dataHeaders);
return useMutation(
(rel: any) =>
Api.post<Record<string, string>>({
headers,
body: {
type: 'pg_update_remote_relationship',
args: rel,
},
url: Endpoints.metadata,
}),
{
onSuccess: () => {
queryClient.refetchQueries(['metadata'], { active: true });
},
onError: () => {},
}
);
};

View File

@ -1 +0,0 @@
export { DatabaseRelationshipsTab } from './DatabaseRelationshipsTab';

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState, useEffect } from 'react';
import { Table } from '@/features/hasura-metadata-types';
import { Button } from '@/new-components/Button';
import { useFireNotification } from '@/new-components/Notifications';
@ -9,6 +9,7 @@ import { MODE, Relationship } from './types';
import { AvailableRelationshipsList } from './components/AvailableRelationshipsList/AvailableRelationshipsList';
import { NOTIFICATIONS } from './components/constants';
import { RenderWidget } from './components/RenderWidget/RenderWidget';
import { useInvalidateMetadata } from '@/features/hasura-metadata-api';
export interface DatabaseRelationshipsProps {
dataSourceName: string;
@ -28,6 +29,8 @@ export const DatabaseRelationships = ({
});
const { fireNotification } = useFireNotification();
const invalidateMetadata = useInvalidateMetadata();
const onCancel = () => {
setTabState({
mode: undefined,
@ -35,11 +38,17 @@ export const DatabaseRelationships = ({
});
};
// just invalidate metadata when this screen loads for the first time
// why? because the user might be coming from a redux based paged and the resource_version might gone out of sync
useEffect(() => {
invalidateMetadata();
}, [invalidateMetadata]);
const onError = (err: Error) => {
if (mode)
fireNotification({
type: 'error',
title: NOTIFICATIONS.onSuccess[mode],
title: NOTIFICATIONS.onError[mode],
message: err?.message ?? '',
});
};

View File

@ -0,0 +1,43 @@
import { RightContainer } from '@/components/Common/Layout/RightContainer';
import { Driver } from '@/dataSources';
import { NormalizedTable } from '@/dataSources/types';
import { Table } from '@/features/hasura-metadata-types';
import TableHeader from '../../components/Services/Data/TableCommon/TableHeader';
import { FeatureFlagFloatingButton } from '../FeatureFlags/components/FeatureFlagFloatingButton';
import { DatabaseRelationships } from '.';
export const DatabaseRelationshipsTab = ({
table,
currentSource,
migrationMode,
driver,
metadataTable,
}: {
table: NormalizedTable;
currentSource: string;
migrationMode: boolean;
driver: Driver;
metadataTable: Table;
}) => {
return (
<RightContainer>
<TableHeader
dispatch={() => {}}
table={table}
source={currentSource}
tabName="relationships"
migrationMode={migrationMode}
readOnlyMode={false}
count={null}
isCountEstimated
/>
<DatabaseRelationships
dataSourceName={currentSource}
table={metadataTable}
/>
<FeatureFlagFloatingButton />
</RightContainer>
);
};

View File

@ -2,17 +2,12 @@ import React from 'react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ComponentMeta } from '@storybook/react';
import { NormalizedTable } from '@/dataSources/types';
import { DatabaseRelationshipsTab } from './DatabaseRelationshipsTab';
import { handlers } from './__mocks__';
import { DatabaseRelationshipsTab } from './DatabaseRelationshipsTab_Legacy';
export default {
title: 'Features/Data Relationships/Database Relationships Tab',
component: DatabaseRelationshipsTab,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers(),
},
} as ComponentMeta<typeof DatabaseRelationshipsTab>;
const table: NormalizedTable = {

View File

@ -0,0 +1,43 @@
import { RightContainer } from '@/components/Common/Layout/RightContainer';
import { Driver } from '@/dataSources';
import { NormalizedTable } from '@/dataSources/types';
import { Table } from '@/features/hasura-metadata-types';
import TableHeader from '../../components/Services/Data/TableCommon/TableHeader';
import { FeatureFlagFloatingButton } from '../FeatureFlags/components/FeatureFlagFloatingButton';
import { DatabaseRelationships } from '.';
export const DatabaseRelationshipsTab = ({
table,
currentSource,
migrationMode,
driver,
metadataTable,
}: {
table: NormalizedTable;
currentSource: string;
migrationMode: boolean;
driver: Driver;
metadataTable: Table;
}) => {
return (
<RightContainer>
<TableHeader
dispatch={() => {}}
table={table}
source={currentSource}
tabName="relationships"
migrationMode={migrationMode}
readOnlyMode={false}
count={null}
isCountEstimated
/>
<DatabaseRelationships
dataSourceName={currentSource}
table={metadataTable}
/>
<FeatureFlagFloatingButton />
</RightContainer>
);
};

View File

@ -1,134 +0,0 @@
import { Table } from '@/features/hasura-metadata-types';
import { Button } from '@/new-components/Button';
import { InputField, Select, SimpleForm } from '@/new-components/Form';
import React from 'react';
import { FaArrowRight } from 'react-icons/fa';
import { useManageLocalRelationship } from '../../hooks/useManageLocalRelationship';
import { LocalRelationship } from '../../types';
import { MapColumns } from '../common/ColumnMapping';
import { TablePicker } from '../common/TablePicker';
import { Schema, schema } from './schema';
interface WidgetProps {
dataSourceName: string;
table: Table;
onCancel: () => void;
onSuccess: () => void;
onError: (err: Error) => void;
}
export const Widget = (props: WidgetProps) => {
const { table, dataSourceName, onCancel, onSuccess, onError } = props;
const { createRelationship, isLoading } = useManageLocalRelationship({
dataSourceName,
onSuccess,
onError,
});
const handleFormSubmit = (data: Schema) => {
// Form the local relationship object from the FormInputs
const localRelationship: LocalRelationship = {
name: data.name,
type: 'localRelationship',
fromSource: data.fromSource.value.dataSourceName,
fromTable: data.fromSource.value.table,
relationshipType: data.relationship_type,
definition: {
toTable: data.toSource.value.table,
mapping: (data.columnMap ?? []).reduce(
(acc, entry) => ({ ...acc, [entry.from]: entry.to }),
{}
),
},
};
// Call the hook method to create the same on the metadata
createRelationship(localRelationship);
};
return (
<SimpleForm
schema={schema}
options={{
defaultValues: {
relationship_type: 'Object',
fromSource: {
value: {
dataSourceName,
table,
},
},
toSource: {
value: {
dataSourceName,
},
},
columnMap: [{ from: '', to: '' }],
},
}}
onSubmit={handleFormSubmit}
>
<div id="create-local-rel" className="mt-4 px-7">
<InputField
name="name"
label="Relationship Name"
placeholder="Name..."
dataTest="local-db-to-db-rel-name"
/>
<div>
<div className="grid grid-cols-12">
<div className="col-span-5">
<TablePicker type="fromSource" disabled isCurrentSource />
</div>
<div className="col-span-2 flex relative items-center justify-center w-full py-2 mt-3 text-muted">
<FaArrowRight />
</div>
<div className="col-span-5">
<TablePicker type="toSource" filterDataSource={dataSourceName} />
</div>
</div>
<div className="bg-white rounded-md shadow-sm border border-gray-300 mt-2 mb-4">
<div className="p-3 text-slate-900 font-semibold text-lg border-b border-gray-300">
Relationship Details
</div>
<div className="px-6 pt-4">
<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',
},
]}
/>
</div>
<MapColumns />
</div>
</div>
</div>
<div className="flex justify-end gap-2 sticky bottom-0 bg-slate-50 px-8 py-3 border-t border-gray-300 z-[100]">
<Button onClick={onCancel}>Close</Button>
<Button
type="submit"
mode="primary"
isLoading={isLoading}
loadingText="Creating"
>
Create Relationship
</Button>
</div>
</SimpleForm>
);
};

View File

@ -1,35 +0,0 @@
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')]),
fromSource: z.object({
value: z.object({
dataSourceName: z.string().min(1, 'From source is a required field'),
table: z.any(),
}),
}),
toSource: z.object({
value: z.object({
dataSourceName: z
.string()
.min(1, 'To Reference source is a required field'),
table: z.any(),
}),
}),
columnMap: z
.array(z.object({ from: z.string(), to: z.string() }))
.transform((columnMap, ctx) => {
return columnMap.map(map => {
if (!map.to || !map.from)
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Column Mapping cannot be empty`,
});
return map;
});
}),
});
export type Schema = z.infer<typeof schema>;

View File

@ -0,0 +1,19 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { Widget } from './Widget';
import { MODE, RemoteDatabaseRelationship } from '../../types';
export default {
component: Widget,
decorators: [ReactQueryDecorator()],
} as ComponentMeta<typeof Widget>;
export const CreateMode: ComponentStory<typeof Widget> = () => (
<Widget
dataSourceName="chinook"
table={{ name: 'Album', schema: 'public' }}
onSuccess={() => {}}
onError={() => {}}
onCancel={() => {}}
/>
);

View File

@ -0,0 +1,275 @@
import { Table } from '@/features/hasura-metadata-types';
import { Button } from '@/new-components/Button';
import { InputField, Select, useConsoleForm } from '@/new-components/Form';
import { useEffect } from 'react';
import { Controller } from 'react-hook-form';
import { FaArrowRight, FaLink } from 'react-icons/fa';
import { useRemoteSchemaIntrospection } from '../../hooks/useRemoteSchema';
import { useTableColumns } from '../../hooks/useTableColumns';
import { MODE, Relationship } from '../../types';
import { MapRemoteSchemaFields } from './parts/MapRemoteSchemaFields';
import { MapColumns } from './parts/MapColumns';
import { schema, Schema } from './schema';
import { SourceSelect } from './parts/SourceSelect';
import { useHandleSubmit } from './utils';
import { useSourceOptions } from '../../hooks/useSourceOptions';
import Skeleton from 'react-loading-skeleton';
interface WidgetProps {
dataSourceName: string;
table: Table;
onCancel: () => void;
onSuccess: () => void;
onError: (err: Error) => void;
defaultValue?: Relationship;
}
const getDefaultValue = ({
dataSourceName,
table,
relationship,
}: {
dataSourceName: string;
table: Table;
relationship: Relationship;
}): Schema => {
if (relationship.type === 'remoteSchemaRelationship')
return {
name: relationship.name,
fromSource: {
type: 'table',
dataSourceName,
table,
},
toSource: {
remoteSchema: relationship.definition.toRemoteSchema,
type: 'remoteSchema',
},
details: {
rsFieldMapping: relationship.definition.remote_field,
},
};
return {
name: relationship.name,
fromSource: {
type: 'table',
dataSourceName,
table,
},
toSource: {
dataSourceName:
relationship.type === 'remoteDatabaseRelationship'
? relationship.definition.toSource
: dataSourceName,
table: relationship.definition.toTable,
type: 'table',
},
details: {
columnMap: Object.entries(relationship.definition.mapping).map(
([key, value]) => ({ from: key, to: value })
),
relationshipType: relationship.relationshipType,
},
};
};
export const Widget = (props: WidgetProps) => {
const { dataSourceName, table, onCancel, onSuccess, onError, defaultValue } =
props;
const isEditMode = !!defaultValue;
const { data: sourceOptions } = useSourceOptions();
const {
Form,
methods: { watch, control, setValue },
} = useConsoleForm({
schema,
options: {
defaultValues: defaultValue
? getDefaultValue({
dataSourceName,
table,
relationship: defaultValue,
})
: {
fromSource: {
type: 'table',
dataSourceName,
table,
},
},
},
});
const { data: sourceTableColumns } = useTableColumns({
dataSourceName,
table,
});
const toSource = watch('toSource');
const { data: targetTableColumns, isLoading: isColumnDataLoading } =
useTableColumns({
dataSourceName: toSource?.type === 'table' ? toSource.dataSourceName : '',
table: toSource?.type === 'table' ? toSource.table : '',
});
const {
data: remoteSchemaGraphQLSchema,
isLoading: isRemoteSchemaIntrospectionLoading,
} = useRemoteSchemaIntrospection({
remoteSchemaName:
toSource?.type === 'remoteSchema' ? toSource.remoteSchema : '',
enabled: toSource?.type === 'remoteSchema',
});
const { handleSubmit, ...rest } = useHandleSubmit({
dataSourceName,
table,
mode: !defaultValue ? MODE.CREATE : MODE.EDIT,
onSuccess,
onError,
});
useEffect(() => {
if (!defaultValue) {
if (toSource?.type === 'table') {
setValue('details.relationshipType', 'Object');
setValue('details.columnMap', [{ from: '', to: '' }]);
} else setValue('details', {});
}
}, [defaultValue, setValue, toSource]);
return (
<Form onSubmit={handleSubmit}>
<div
id="create-local-rel"
className="mt-4 px-7"
style={{ minHeight: '450px' }}
>
<InputField
name="name"
label="Relationship Name"
placeholder="Name..."
dataTest="local-db-to-db-rel-name"
disabled={isEditMode}
/>
<div>
<div className="grid grid-cols-12">
<div className="col-span-5">
<SourceSelect
options={sourceOptions ?? []}
name="fromSource"
label="From Source"
disabled
/>
</div>
<div className="col-span-2 flex relative items-center justify-center w-full py-2 mt-3 text-muted">
<FaArrowRight />
</div>
<div className="col-span-5">
<SourceSelect
options={sourceOptions ?? []}
name="toSource"
label="To Reference"
/>
</div>
</div>
{toSource ? (
<div className="bg-white rounded-md shadow-sm border border-gray-300 mt-2 mb-4">
<div className="p-3 text-slate-900 font-semibold text-lg border-b border-gray-300">
Relationship Details
</div>
{isRemoteSchemaIntrospectionLoading || isColumnDataLoading ? (
<div className="px-sm m-sm">
<Skeleton height={30} count={5} className="my-2" />
</div>
) : (
<div className="px-6 pt-4">
{toSource?.type === 'table' && (
<div>
<div className="px-6 pt-4 w-1/3">
<Select
name="details.relationshipType"
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',
},
]}
/>
</div>
<MapColumns
name="details.columnMap"
targetTableColumns={targetTableColumns ?? []}
sourceTableColumns={sourceTableColumns ?? []}
/>
</div>
)}
{toSource?.type === 'remoteSchema' &&
remoteSchemaGraphQLSchema &&
sourceTableColumns && (
<div>
<Controller
control={control}
name="details.rsFieldMapping"
render={({ field: { onChange, value } }) => (
<MapRemoteSchemaFields
graphQLSchema={remoteSchemaGraphQLSchema}
onChange={onChange}
defaultValue={value}
tableColumns={sourceTableColumns.map(
col => col.name
)}
/>
)}
/>
</div>
)}
</div>
)}
</div>
) : (
<div
style={{ minHeight: '200px' }}
className="bg-gray-100 rounded-md shadow-sm border border-gray-300 mt-2 mb-4 h-20 flex items-center justify-center flex-col"
>
<div>
<FaLink />
</div>
<div>
Please select a source and a reference to create a relationship
</div>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-2 sticky bottom-0 bg-slate-50 px-8 py-3 border-t border-gray-300">
<Button onClick={onCancel}>Close</Button>
<Button
type="submit"
mode="primary"
isLoading={rest.isLoading}
loadingText="Creating"
>
{isEditMode ? 'Edit Relationship' : 'Create Relationship'}
</Button>
</div>
</Form>
);
};

View File

@ -1,5 +1,5 @@
import { Widget } from './Widget';
export const ManualLocalRelationship = {
export const RelationshipForm = {
Widget,
};

View File

@ -0,0 +1,130 @@
import React, { useEffect } from 'react';
import Skeleton from 'react-loading-skeleton';
import { FieldError, useFieldArray, useFormContext } from 'react-hook-form';
import { Select } from '@/new-components/Form';
import {
FaArrowAltCircleLeft,
FaArrowAltCircleRight,
FaArrowRight,
FaExclamationCircle,
FaTimesCircle,
} from 'react-icons/fa';
import { Button } from '@/new-components/Button';
import { IndicatorCard } from '@/new-components/IndicatorCard';
import { useTableColumns } from '../../../hooks/useTableColumns';
import { TableColumn } from '@/features/DataSource';
import { get } from 'lodash';
type Schema = Record<
string,
{
from: string;
to: string;
}[]
>;
export const MapColumns = ({
name,
sourceTableColumns,
targetTableColumns,
}: {
name: string;
sourceTableColumns: TableColumn[];
targetTableColumns: TableColumn[];
}) => {
const { fields, append } = useFieldArray<Schema>({ name });
const {
watch,
setValue,
formState: { errors },
} = useFormContext<Schema>();
const maybeError = get(errors, name) as unknown as FieldError;
const columnMappings: {
from: string;
to: string;
}[] = watch(name);
return (
<div className="px-md pb-md mb-md mt-0 h">
<div className="grid grid-cols-12 mb-1 items-center font-semibold text-muted">
<div className="col-span-6 flex items-center">
Source Column{' '}
<FaArrowAltCircleRight className="fill-emerald-700 ml-1.5" />
</div>
<div className="col-span-6 flex items-center">
Reference Column{' '}
<FaArrowAltCircleLeft className="fill-violet-700 ml-1.5" />
</div>
</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">
<Select
options={sourceTableColumns.map(column => ({
label: column.name,
value: column.name,
}))}
name={`${name}.${index}.from`}
disabled={!sourceTableColumns?.length}
placeholder="Select source column"
noErrorPlaceholder
/>
</div>
<div className="flex justify-around">
<FaArrowRight className="fill-muted" />
</div>
<div className="col-span-5">
<Select
options={(targetTableColumns ?? []).map(column => ({
label: column.name,
value: column.name,
}))}
name={`${name}.${index}.to`}
disabled={!targetTableColumns?.length}
placeholder="Select reference column"
noErrorPlaceholder
/>
</div>
<div className="flex justify-around">
<Button
type="button"
size="sm"
className="h-10"
icon={<FaTimesCircle />}
onClick={() => {
setValue(
name,
columnMappings.filter((_, i) => index !== i)
);
}}
/>
</div>
</div>
);
})}
{maybeError && (
<div className="text-red-600 mt-1 flex items-center text-sm">
<FaExclamationCircle className="fill-current h-4 w-4 mr-xs shrink-0" />{' '}
{maybeError.message}
</div>
)}
<div className="my-4">
<Button
type="button"
onClick={() => append({})}
disabled={!targetTableColumns?.length || !sourceTableColumns?.length}
>
Add New Mapping
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,85 @@
import { useConsoleForm } from '@/new-components/Form';
import { z } from 'zod';
import { Controller } from 'react-hook-form';
import { Button } from '@/new-components/Button';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { MapRemoteSchemaFields } from './MapRemoteSchemaFields';
import { useRemoteSchemaIntrospection } from '../../../../hooks/useRemoteSchema';
import { handlers } from '../../../../handler.mock';
export default {
component: MapRemoteSchemaFields,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers(),
},
} as ComponentMeta<typeof MapRemoteSchemaFields>;
export const StandaloneComponent: ComponentStory<
typeof MapRemoteSchemaFields
> = () => {
const { data: remoteSchemaGraphQLSchema, isLoading } =
useRemoteSchemaIntrospection({
remoteSchemaName: 'trevorBladeCountriesAPI',
});
if (isLoading) return <>Loading...</>;
if (!remoteSchemaGraphQLSchema) return <>no data</>;
return (
<MapRemoteSchemaFields
graphQLSchema={remoteSchemaGraphQLSchema}
onChange={result => {
console.log(result);
}}
/>
);
};
export const WithReactHookForm: ComponentStory<
typeof MapRemoteSchemaFields
> = () => {
const { data: remoteSchemaGraphQLSchema, isLoading } =
useRemoteSchemaIntrospection({
remoteSchemaName: 'trevorBladeCountriesAPI',
});
const {
methods: { control },
Form,
} = useConsoleForm({
schema: z.object({
remote_schema_field_mapping: z.any(),
}),
options: {
defaultValues: {
remote_schema_field_mapping: {
country: { arguments: { code: '$AlbumId' } },
},
},
},
});
if (isLoading) return <>Loading...</>;
if (!remoteSchemaGraphQLSchema) return <>no data</>;
return (
<Form onSubmit={console.log}>
<Controller
control={control}
name="remote_schema_field_mapping"
render={({ field: { onChange, value } }) => (
<MapRemoteSchemaFields
graphQLSchema={remoteSchemaGraphQLSchema}
onChange={onChange}
defaultValue={value}
/>
)}
/>
<Button type="submit">Submit</Button>
</Form>
);
};

View File

@ -0,0 +1,60 @@
import {
buildServerRemoteFieldObject,
RemoteSchemaTree,
} from './parts/RemoteSchemaTree';
import { RelationshipFields } from './types';
import { GraphQLSchema } from 'graphql';
import React, { useCallback, useEffect, useState } from 'react';
import { RemoteSchemaRelationship } from '../../../../types';
import { parseServerRelationship } from './utils';
interface MapRemoteSchemaFieldsProps {
graphQLSchema: GraphQLSchema;
defaultValue?: RemoteSchemaRelationship['definition']['remote_field'];
tableColumns: string[];
onChange?: (
value: RemoteSchemaRelationship['definition']['remote_field']
) => void;
}
export const MapRemoteSchemaFields = (props: MapRemoteSchemaFieldsProps) => {
const { defaultValue, onChange, tableColumns, graphQLSchema } = props;
// Why is this eslint rule disabled? => unless graphQL schema changes there is no change on the parent onChange handler reference.
// eslint-disable-next-line react-hooks/exhaustive-deps
const memoizedCallback = useCallback(v => onChange?.(v), [graphQLSchema]);
const [relationshipFields, setRelationshipFields] = useState<
RelationshipFields[]
>(defaultValue ? parseServerRelationship(defaultValue) : []);
useEffect(() => {
memoizedCallback?.(buildServerRemoteFieldObject(relationshipFields));
}, [memoizedCallback, relationshipFields]);
return (
<div>
<div className="mb-sm">
<input
className="pl-sm mt-xs block h-input w-full shadow-sm rounded cursor-not-allowed bg-gray-100 border border-gray-300"
disabled
value={JSON.stringify(
buildServerRemoteFieldObject(relationshipFields)
)}
/>
</div>
<RemoteSchemaTree
schema={graphQLSchema}
relationshipFields={relationshipFields}
setRelationshipFields={setRelationshipFields}
fields={tableColumns}
rootFields={['query']}
/>
</div>
);
};
MapRemoteSchemaFields.defaultProps = {
defaultValue: undefined,
onChange: () => {},
};

View File

@ -0,0 +1 @@
export { MapRemoteSchemaFields } from './MapRemoteSchemaFields';

View File

@ -0,0 +1,204 @@
import React, { useMemo, useState } from 'react';
import { Tree as AntTree } from 'antd';
import { GraphQLSchema } from 'graphql';
import { EventDataNode } from 'antd/lib/tree';
import './index.css';
import {
AllowedRootFields,
AntdTreeNode,
HasuraRsFields,
RelationshipFields,
TreeNode,
} from '../../types';
import {
buildTree,
findRemoteField,
getFieldData,
getExpandedKeys,
getCheckedKeys,
} from './utils';
import { SearchBar } from '../SearchBar/SearchBar';
export interface RemoteSchemaTreeProps
extends React.ComponentProps<typeof AntTree> {
/**
* Graphql schema for setting new permissions.
*/
schema: GraphQLSchema;
relationshipFields: RelationshipFields[];
rootFields: AllowedRootFields;
setRelationshipFields: React.Dispatch<
React.SetStateAction<RelationshipFields[]>
>;
fields: HasuraRsFields;
}
export const RemoteSchemaTree = ({
schema,
relationshipFields,
rootFields,
setRelationshipFields,
fields,
...rest
}: RemoteSchemaTreeProps) => {
const defaultSearchValue = relationshipFields[1]
? relationshipFields[1].key.split('.')[
relationshipFields[1].key.split('.').length - 1
]
: '';
const [searchText, setSearchText] = useState(defaultSearchValue);
const tree: TreeNode[] = useMemo(
() =>
buildTree({
schema,
relationshipFields,
setRelationshipFields,
fields,
rootFields,
}),
[relationshipFields, schema, rootFields, fields]
);
const expandedKeys = useMemo(
() => getExpandedKeys(relationshipFields),
[relationshipFields]
);
const checkedKeys = useMemo(
() => getCheckedKeys(relationshipFields),
[relationshipFields]
);
const onCheck = (
// onCheck props expects checked param
checked:
| React.Key[]
| {
checked: React.Key[];
halfChecked: React.Key[];
},
// CheckInfo is not exported by the library
// https://github.com/react-component/tree/issues/411
checkedNodeInfo: Record<string, any>
) => {
const nodeInfo = checkedNodeInfo.node as AntdTreeNode;
const selectedField = findRemoteField(relationshipFields, nodeInfo);
const fieldData = getFieldData(nodeInfo);
if (selectedField) {
setRelationshipFields(
relationshipFields.filter(field => !(field.key === nodeInfo.key))
);
} else {
setRelationshipFields([
...relationshipFields.filter(field => !(field.key === nodeInfo.key)),
fieldData,
]);
}
};
const onExpand = (
expanded: React.Key[],
expandedNodeInfo: {
node: EventDataNode;
expanded: boolean;
nativeEvent: MouseEvent;
}
) => {
const nodeInfo = expandedNodeInfo.node as AntdTreeNode;
const selectedField = findRemoteField(relationshipFields, nodeInfo);
const fieldData = getFieldData(nodeInfo);
if (selectedField) {
// if the node is already expanded, collapse the node,
// and remove all its children
setRelationshipFields(
relationshipFields.filter(
field =>
!(
field.key === nodeInfo.key ||
field.key.includes(`${nodeInfo.key}.`)
)
)
);
} else {
// `fields` at same or higher depth, if the current node is `argument` we skip this
const levelDepthFields =
nodeInfo.type === 'field'
? relationshipFields
.filter(
field => field.type === 'field' && field.depth >= nodeInfo.depth
)
.map(field => field.key)
: [];
// remove all the fields and their children which are on same/higher depth, and add the current field
// as one parent can have only one field at a certain depth
setRelationshipFields([
...relationshipFields.filter(
field =>
!(
field.key === nodeInfo.key ||
// remove all current or higher depth fields and their children
(nodeInfo.type === 'field' &&
levelDepthFields.some(
refFieldKey =>
field.key === refFieldKey ||
field.key.includes(`${refFieldKey}.`)
))
)
),
fieldData,
]);
}
};
const expandedParentTrees: string[] = [];
const filteredTree = tree.map(subTree => {
return {
...subTree,
children: (subTree.children ?? []).filter(subTreeItem => {
if (searchText.length) {
if (subTreeItem.key.includes(searchText))
expandedParentTrees.push(subTreeItem.key.split('.')[0]);
return subTreeItem.key.includes(searchText);
}
return true;
}),
};
});
// If nothing can be opened via search, at least open the query root fields
const uniqueExpandedRoots = !expandedParentTrees.length
? ['__query']
: Array.from(new Set([...expandedParentTrees]));
return (
<div>
<div className="mb-sm">
<SearchBar
value={searchText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSearchText(e.target.value)
}
/>
</div>
<AntTree
checkable
checkStrictly
blockNode
selectable={false}
onCheck={onCheck}
onExpand={onExpand}
treeData={filteredTree}
expandedKeys={[...expandedKeys, ...uniqueExpandedRoots]}
checkedKeys={checkedKeys}
// disable animation onExpand to improve performance
motion={null}
{...rest}
/>
</div>
);
};

View File

@ -0,0 +1,47 @@
import React from 'react';
import { GraphQLType } from 'graphql';
import { ArgValueForm } from './ArgValueForm';
import { RelationshipFields, ArgValue, HasuraRsFields } from '../../../types';
type ArgFieldTitleProps = {
title: string;
argKey: string;
relationshipFields: RelationshipFields[];
setRelationshipFields: React.Dispatch<
React.SetStateAction<RelationshipFields[]>
>;
showForm: boolean;
argValue: ArgValue;
fields: HasuraRsFields;
argType: GraphQLType;
};
const titleStyles =
'flex items-center cursor-pointer text-purple-600 whitespace-nowrap hover:text-purple-900';
export const ArgFieldTitle = ({
title,
argKey,
relationshipFields,
setRelationshipFields,
showForm,
argValue,
fields,
argType,
}: ArgFieldTitleProps) => {
return showForm ? (
<>
<div className={titleStyles}>{title}</div>
<ArgValueForm
argKey={argKey}
relationshipFields={relationshipFields}
setRelationshipFields={setRelationshipFields}
argValue={argValue}
fields={fields}
argType={argType}
/>
</>
) : (
<div className={titleStyles}>{title}</div>
);
};

View File

@ -0,0 +1,171 @@
import React, { useState, useEffect } from 'react';
import { FaCircle } from 'react-icons/fa';
import { GraphQLType } from 'graphql';
import { useDebouncedEffect } from '@/hooks/useDebounceEffect';
import {
ArgValue,
ArgValueKind,
HasuraRsFields,
RelationshipFields,
} from '../../../types';
import { defaultArgValue } from '../utils';
import StaticArgValue from './StaticArgValue';
const fieldStyle =
'block w-full h-input shadow-sm rounded border border-gray-300 hover:border-gray-400 focus:outline-0 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400';
export interface ArgValueFormProps {
argKey: string;
relationshipFields: RelationshipFields[];
setRelationshipFields: React.Dispatch<
React.SetStateAction<RelationshipFields[]>
>;
argValue: ArgValue;
fields: HasuraRsFields;
argType: GraphQLType;
}
const argValueTypeOptions = [
{ key: 'field', content: 'Source Field' },
{ key: 'static', content: 'Static Value' },
];
export const ArgValueForm = ({
argKey,
relationshipFields,
setRelationshipFields,
argValue,
fields,
argType,
}: ArgValueFormProps) => {
const [localArgValue, setLocalArgValue] = useState(argValue);
useEffect(() => {
setLocalArgValue(argValue);
}, [argValue]);
useDebouncedEffect(
() => {
setRelationshipFields(
relationshipFields.map(f => {
if (f.key === argKey) {
return {
...f,
argValue: {
...(f.argValue ?? defaultArgValue),
value: localArgValue.value,
},
};
}
return f;
})
);
},
400,
[localArgValue.value]
);
const changeInputType = (e: React.ChangeEvent<HTMLSelectElement>) => {
setRelationshipFields(
relationshipFields.map(f => {
if (f.key === argKey) {
return {
...f,
argValue: {
...(f.argValue ?? defaultArgValue),
value: '',
kind: e.target.value as ArgValueKind,
},
};
}
return f;
})
);
};
const changeInputColumnValue = (e: React.ChangeEvent<HTMLSelectElement>) => {
setRelationshipFields(
relationshipFields.map(f => {
if (f.key === argKey) {
return {
...f,
argValue: {
...(f.argValue ?? defaultArgValue),
value: e.target.value,
},
};
}
return f;
})
);
};
const onValueChangeHandler = (value: string | number | boolean) => {
setLocalArgValue({ ...localArgValue, value });
};
return (
<div
onClick={e => e.stopPropagation()}
className="rounded bg-white shadow pt-xs pb-sm px-sm my-sm -ml-8 border-l-2 border-yellow-400 w-full"
>
<div className="grid grid-cols-2 gap-2">
<div>
<label htmlFor="fillFrom text-muted font-semibold">Fill From</label>
<select
id="fillFrom"
className={fieldStyle}
value={localArgValue.kind}
onChange={changeInputType}
data-test="select-argument"
>
<option disabled>Select an arugment...</option>
{argValueTypeOptions.map(option => (
<option key={option.key} value={option.key}>
{option.content}
</option>
))}
</select>
</div>
<div>
{localArgValue.kind === 'field' ? (
<>
<label htmlFor="fromField" className="text-muted font-semibold">
<FaCircle className="text-green-600 mr-2 mb-1" />
From Field
</label>
<select
className={fieldStyle}
value={localArgValue.value as string | number}
onChange={changeInputColumnValue}
data-test="select-source-field"
id="fromField"
>
<option value="" disabled>
Select Field...
</option>
{(fields ?? []).map(option => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</>
) : (
<>
<label htmlFor="argValue text-muted font-semibold">
Static Value
</label>
<StaticArgValue
data-test="select-static-value"
argType={argType}
localArgValue={localArgValue}
onValueChangeHandler={onValueChangeHandler}
/>
</>
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,13 @@
import React from 'react';
type FieldLabelProps = {
title: string;
};
export const FieldLabel = ({ title }: FieldLabelProps) => {
return (
<div className="text-xs pt-2 uppercase text-gray-400 tracking-wide font-semibold">
{title}
</div>
);
};

View File

@ -0,0 +1,14 @@
import React from 'react';
import { FaProjectDiagram } from 'react-icons/fa';
type RootFieldTitleProps = {
title: string;
};
export const RootFieldTitle = ({ title }: RootFieldTitleProps) => {
return (
<div className="flex font-semibold items-center cursor-pointer text-gray-900 w-max whitespace-nowrap hover:text-gray-900">
<FaProjectDiagram className="mr-xs h-4 w-5" /> {title}
</div>
);
};

View File

@ -0,0 +1,111 @@
import { GraphQLType, isScalarType } from 'graphql';
import React, { ReactText } from 'react';
import { isJsonString } from '@/components/Common/utils/jsUtils';
import { ArgValue } from '../../../types';
const fieldStyle =
'block w-full h-input shadow-sm rounded border border-gray-300 hover:border-gray-400 focus:outline-0 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400';
type StaticArgValueProps = {
localArgValue: ArgValue;
onValueChangeHandler: (value: React.ReactText) => void;
argType: GraphQLType;
};
const SCALAR_PREFIX = '__SCALAR__';
const StaticArgValue = ({
argType,
localArgValue,
onValueChangeHandler,
}: StaticArgValueProps) => {
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (
isScalarType(argType) &&
(argType?.name === 'Int' || argType?.name === 'Float')
)
onValueChangeHandler(`${SCALAR_PREFIX}${e.target.value}`);
else onValueChangeHandler(e.target.value);
};
if (isScalarType(argType) && argType?.name === 'Int') {
let value: string | boolean | number = localArgValue.value;
if (typeof value === 'string' && value?.startsWith(SCALAR_PREFIX))
value = Number.parseInt(value?.substring(10), 10);
return (
<input
type="number"
name="argValue"
id="argValue"
className={fieldStyle}
value={value as number}
onChange={onChange}
data-test="select-static-value"
/>
);
}
if (isScalarType(argType) && argType?.name === 'Float') {
let value: string | boolean | number = localArgValue.value;
if (typeof value === 'string' && value?.startsWith(SCALAR_PREFIX))
value = Number.parseFloat(value?.substring(10));
return (
<input
type="number"
name="argValue"
id="argValue"
className={fieldStyle}
value={value as number}
onChange={onChange}
data-test="select-static-value"
/>
);
}
if (isScalarType(argType) && argType?.name === 'Boolean') {
let value: boolean | undefined;
if (
typeof localArgValue.value === 'string' &&
localArgValue.value?.startsWith(SCALAR_PREFIX)
)
value = isJsonString(localArgValue.value.substring(10))
? JSON.parse(localArgValue.value.substring(10))
: false;
return (
<div className="flex">
{[true, false].map(bool => (
<p className="flex items-center font-semibold text-muted">
<input
id={`radio-select-${bool}`}
type="radio"
x-model="relationType"
className="cursor-pointer rounded-full border shadow-sm "
onChange={() => onValueChangeHandler(`${SCALAR_PREFIX}${bool}`)}
checked={value === bool}
data-test={`radio-select-${bool}`}
/>
<label
htmlFor={`radio-select-${bool}`}
className="cursor-pointer ml-sm mr-md font-semibold"
>
{bool ? 'true' : 'false'}
</label>
</p>
))}
</div>
);
}
return (
<input
type="text"
name="argValue"
id="argValue"
className={fieldStyle}
value={localArgValue.value as ReactText}
onChange={e => onValueChangeHandler(e.target.value)}
data-test="select-static-value"
/>
);
};
export default StaticArgValue;

View File

@ -0,0 +1,34 @@
import { IconTooltip } from '@/new-components/Tooltip';
import React from 'react';
type SubFieldTitleProps = {
title: string;
enabled?: boolean;
isSubfield?: boolean;
};
export const SubFieldTitle = ({
title,
enabled,
isSubfield,
}: SubFieldTitleProps) => {
return (
<>
<div className="flex items-center cursor-pointer w-max whitespace-nowrap">
{!enabled ? (
<>
<IconTooltip
className="mr-sm text-gray-400"
message="Only fields with arguments or subfields can be toggled"
/>
<span className="text-gray-400">{title}</span>
</>
) : isSubfield ? (
<span className="text-blue-600 hover:text-blue-900">{title}</span>
) : (
<span className="text-gray-900">{title}</span>
)}
</div>
</>
);
};

View File

@ -0,0 +1,564 @@
/* stylelint-disable at-rule-empty-line-before,at-rule-name-space-after,at-rule-no-unknown */
/* stylelint-disable no-duplicate-selectors */
/* stylelint-disable */
/* stylelint-disable declaration-bang-space-before,no-duplicate-selectors,string-no-newline */
@-webkit-keyframes antCheckboxEffect {
0% {
transform: scale(1);
opacity: 0.5;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
@keyframes antCheckboxEffect {
0% {
transform: scale(1);
opacity: 0.5;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
@-webkit-keyframes ant-tree-node-fx-do-not-use {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes ant-tree-node-fx-do-not-use {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.ant-tree.ant-tree-directory .ant-tree-treenode {
position: relative;
}
.ant-tree.ant-tree-directory .ant-tree-treenode::before {
position: absolute;
top: 0;
right: 0;
bottom: 4px;
left: 0;
transition: background-color 0.3s;
content: '';
pointer-events: none;
}
.ant-tree.ant-tree-directory .ant-tree-treenode:hover::before {
background: #f5f5f5;
}
.ant-tree.ant-tree-directory .ant-tree-treenode > * {
z-index: 1;
}
.ant-tree.ant-tree-directory .ant-tree-treenode .ant-tree-switcher {
transition: color 0.3s;
}
.ant-tree.ant-tree-directory .ant-tree-treenode .ant-tree-node-content-wrapper {
border-radius: 0;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.ant-tree.ant-tree-directory
.ant-tree-treenode
.ant-tree-node-content-wrapper:hover {
background: transparent;
}
.ant-tree.ant-tree-directory
.ant-tree-treenode
.ant-tree-node-content-wrapper.ant-tree-node-selected {
color: #fff;
background: transparent;
}
.ant-tree.ant-tree-directory .ant-tree-treenode-selected:hover::before,
.ant-tree.ant-tree-directory .ant-tree-treenode-selected::before {
background: #1890ff;
}
.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-switcher {
color: #fff;
}
.ant-tree.ant-tree-directory
.ant-tree-treenode-selected
.ant-tree-node-content-wrapper {
color: #fff;
background: transparent;
}
.ant-tree-checkbox {
box-sizing: border-box;
margin: 0;
padding: 0;
color: rgba(0, 0, 0, 0.85);
font-size: 14px;
font-variant: tabular-nums;
line-height: 1.5715;
list-style: none;
font-feature-settings: 'tnum';
position: relative;
top: 0.2em;
line-height: 1;
white-space: nowrap;
outline: none;
cursor: pointer;
}
.ant-tree-checkbox-wrapper:hover .ant-tree-checkbox-inner,
.ant-tree-checkbox:hover .ant-tree-checkbox-inner,
.ant-tree-checkbox-input:focus + .ant-tree-checkbox-inner {
border-color: #1890ff;
}
.ant-tree-checkbox-checked::after {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 1px solid #1890ff;
border-radius: 2px;
visibility: hidden;
-webkit-animation: antCheckboxEffect 0.36s ease-in-out;
animation: antCheckboxEffect 0.36s ease-in-out;
-webkit-animation-fill-mode: backwards;
animation-fill-mode: backwards;
content: '';
}
.ant-tree-checkbox:hover::after,
.ant-tree-checkbox-wrapper:hover .ant-tree-checkbox::after {
visibility: visible;
}
.ant-tree-checkbox-inner {
position: relative;
top: 0;
left: 0;
display: block;
width: 16px;
height: 16px;
direction: ltr;
background-color: #fff;
border: 1px solid #d9d9d9;
border-radius: 2px;
border-collapse: separate;
transition: all 0.3s;
}
.ant-tree-checkbox-inner::after {
position: absolute;
top: 50%;
left: 21.5%;
display: table;
width: 5.71428571px;
height: 9.14285714px;
border: 2px solid #fff;
border-top: 0;
border-left: 0;
transform: rotate(45deg) scale(0) translate(-50%, -50%);
opacity: 0;
transition: all 0.1s cubic-bezier(0.71, -0.46, 0.88, 0.6), opacity 0.1s;
content: ' ';
}
.ant-tree-checkbox-input {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
width: 100%;
height: 100%;
cursor: pointer;
opacity: 0;
}
.ant-tree-checkbox-checked .ant-tree-checkbox-inner::after {
position: absolute;
display: table;
border: 2px solid #fff;
border-top: 0;
border-left: 0;
transform: rotate(45deg) scale(1) translate(-50%, -50%);
opacity: 1;
transition: all 0.2s cubic-bezier(0.12, 0.4, 0.29, 1.46) 0.1s;
content: ' ';
}
.ant-tree-checkbox-checked .ant-tree-checkbox-inner {
background-color: #1890ff;
border-color: #1890ff;
}
.ant-tree-checkbox-disabled {
cursor: not-allowed;
}
.ant-tree-checkbox-disabled.ant-tree-checkbox-checked
.ant-tree-checkbox-inner::after {
border-color: rgba(0, 0, 0, 0.25);
-webkit-animation-name: none;
animation-name: none;
}
.ant-tree-checkbox-disabled .ant-tree-checkbox-input {
cursor: not-allowed;
pointer-events: none;
}
.ant-tree-checkbox-disabled .ant-tree-checkbox-inner {
background-color: #f5f5f5;
border-color: #d9d9d9 !important;
}
.ant-tree-checkbox-disabled .ant-tree-checkbox-inner::after {
border-color: #f5f5f5;
border-collapse: separate;
-webkit-animation-name: none;
animation-name: none;
}
.ant-tree-checkbox-disabled + span {
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
}
.ant-tree-checkbox-disabled:hover::after,
.ant-tree-checkbox-wrapper:hover .ant-tree-checkbox-disabled::after {
visibility: hidden;
}
.ant-tree-checkbox-wrapper {
box-sizing: border-box;
margin: 0;
padding: 0;
color: rgba(0, 0, 0, 0.85);
font-size: 14px;
font-variant: tabular-nums;
line-height: 1.5715;
list-style: none;
font-feature-settings: 'tnum';
display: inline-flex;
align-items: baseline;
line-height: unset;
cursor: pointer;
}
.ant-tree-checkbox-wrapper::after {
display: inline-block;
width: 0;
overflow: hidden;
content: '\a0';
}
.ant-tree-checkbox-wrapper.ant-tree-checkbox-wrapper-disabled {
cursor: not-allowed;
}
.ant-tree-checkbox-wrapper + .ant-tree-checkbox-wrapper {
margin-left: 8px;
}
.ant-tree-checkbox + span {
padding-right: 8px;
padding-left: 8px;
}
.ant-tree-checkbox-group {
box-sizing: border-box;
margin: 0;
padding: 0;
color: rgba(0, 0, 0, 0.85);
font-size: 14px;
font-variant: tabular-nums;
line-height: 1.5715;
list-style: none;
font-feature-settings: 'tnum';
display: inline-block;
}
.ant-tree-checkbox-group-item {
margin-right: 8px;
}
.ant-tree-checkbox-group-item:last-child {
margin-right: 0;
}
.ant-tree-checkbox-group-item + .ant-tree-checkbox-group-item {
margin-left: 0;
}
.ant-tree-checkbox-indeterminate .ant-tree-checkbox-inner {
background-color: #fff;
border-color: #d9d9d9;
}
.ant-tree-checkbox-indeterminate .ant-tree-checkbox-inner::after {
top: 50%;
left: 50%;
width: 8px;
height: 8px;
background-color: #1890ff;
border: 0;
transform: translate(-50%, -50%) scale(1);
opacity: 1;
content: ' ';
}
.ant-tree-checkbox-indeterminate.ant-tree-checkbox-disabled
.ant-tree-checkbox-inner::after {
background-color: rgba(0, 0, 0, 0.25);
border-color: rgba(0, 0, 0, 0.25);
}
.ant-tree {
box-sizing: border-box;
margin: 0;
padding: 0;
color: rgba(0, 0, 0, 0.85);
font-size: 14px;
font-variant: tabular-nums;
line-height: 1.5715;
list-style: none;
font-feature-settings: 'tnum';
background: transparent;
border-radius: 2px;
transition: background-color 0.3s;
}
.ant-tree-focused:not(:hover):not(.ant-tree-active-focused) {
background: #e6f7ff;
}
.ant-tree-list-holder-inner {
align-items: flex-start;
}
.ant-tree.ant-tree-block-node .ant-tree-list-holder-inner {
align-items: stretch;
}
.ant-tree.ant-tree-block-node
.ant-tree-list-holder-inner
.ant-tree-node-content-wrapper {
flex: auto;
}
.ant-tree.ant-tree-block-node
.ant-tree-list-holder-inner
.ant-tree-treenode.dragging {
position: relative;
}
.ant-tree.ant-tree-block-node
.ant-tree-list-holder-inner
.ant-tree-treenode.dragging::after {
position: absolute;
top: 0;
right: 0;
bottom: 4px;
left: 0;
border: 1px solid #1890ff;
opacity: 0;
-webkit-animation: ant-tree-node-fx-do-not-use 0.3s;
animation: ant-tree-node-fx-do-not-use 0.3s;
-webkit-animation-play-state: running;
animation-play-state: running;
-webkit-animation-fill-mode: forwards;
animation-fill-mode: forwards;
content: '';
pointer-events: none;
}
.ant-tree .ant-tree-treenode {
display: flex;
align-items: flex-start;
padding: 0 0 4px 0;
outline: none;
}
.ant-tree .ant-tree-treenode-disabled .ant-tree-node-content-wrapper {
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
}
.ant-tree .ant-tree-treenode-disabled .ant-tree-node-content-wrapper:hover {
background: transparent;
}
.ant-tree .ant-tree-treenode-active .ant-tree-node-content-wrapper {
background: #f5f5f5;
}
.ant-tree
.ant-tree-treenode:not(.ant-tree .ant-tree-treenode-disabled).filter-node
.ant-tree-title {
color: inherit;
font-weight: 500;
}
.ant-tree-indent {
align-self: stretch;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.ant-tree-indent-unit {
display: inline-block;
width: 24px;
}
.ant-tree-draggable-icon {
width: 24px;
line-height: 24px;
text-align: center;
opacity: 0.2;
transition: opacity 0.3s;
}
.ant-tree-treenode:hover .ant-tree-draggable-icon {
opacity: 0.45;
}
.ant-tree-switcher {
position: relative;
flex: none;
align-self: stretch;
width: 24px;
margin: 0;
line-height: 24px;
text-align: center;
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.ant-tree-switcher .ant-tree-switcher-icon,
.ant-tree-switcher .ant-select-tree-switcher-icon {
display: inline-block;
font-size: 10px;
vertical-align: baseline;
}
.ant-tree-switcher .ant-tree-switcher-icon svg,
.ant-tree-switcher .ant-select-tree-switcher-icon svg {
transition: transform 0.3s;
}
.ant-tree-switcher-noop {
cursor: default;
}
.ant-tree-switcher_close .ant-tree-switcher-icon svg {
transform: rotate(-90deg);
}
.ant-tree-switcher-loading-icon {
color: #1890ff;
}
.ant-tree-switcher-leaf-line {
position: relative;
z-index: 1;
display: inline-block;
width: 100%;
height: 100%;
}
.ant-tree-switcher-leaf-line::before {
position: absolute;
top: 0;
right: 12px;
bottom: -4px;
margin-left: -1px;
border-right: 1px solid #d9d9d9;
content: ' ';
}
.ant-tree-switcher-leaf-line::after {
position: absolute;
width: 10px;
height: 14px;
border-bottom: 1px solid #d9d9d9;
content: ' ';
}
.ant-tree-checkbox {
top: initial;
margin: 4px 8px 0 0;
}
.ant-tree .ant-tree-node-content-wrapper {
position: relative;
z-index: auto;
min-height: 24px;
margin: 0;
padding: 0 4px;
color: inherit;
line-height: 24px;
background: transparent;
border-radius: 2px;
cursor: pointer;
transition: all 0.3s, border 0s, line-height 0s, box-shadow 0s;
}
.ant-tree .ant-tree-node-content-wrapper:hover {
background-color: #f5f5f5;
}
.ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected {
background-color: #bae7ff;
}
.ant-tree .ant-tree-node-content-wrapper .ant-tree-iconEle {
display: inline-block;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
vertical-align: top;
}
.ant-tree .ant-tree-node-content-wrapper .ant-tree-iconEle:empty {
display: none;
}
.ant-tree-unselectable .ant-tree-node-content-wrapper:hover {
background-color: transparent;
}
.ant-tree-node-content-wrapper {
line-height: 24px;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.ant-tree-node-content-wrapper .ant-tree-drop-indicator {
position: absolute;
z-index: 1;
height: 2px;
background-color: #1890ff;
border-radius: 1px;
pointer-events: none;
}
.ant-tree-node-content-wrapper .ant-tree-drop-indicator::after {
position: absolute;
top: -3px;
left: -6px;
width: 8px;
height: 8px;
background-color: transparent;
border: 2px solid #1890ff;
border-radius: 50%;
content: '';
}
.ant-tree .ant-tree-treenode.drop-container > [draggable] {
box-shadow: 0 0 0 2px #1890ff;
}
.ant-tree-show-line .ant-tree-indent-unit {
position: relative;
height: 100%;
}
.ant-tree-show-line .ant-tree-indent-unit::before {
position: absolute;
top: 0;
right: 12px;
bottom: -4px;
border-right: 1px solid #d9d9d9;
content: '';
}
.ant-tree-show-line .ant-tree-indent-unit-end::before {
display: none;
}
.ant-tree-show-line .ant-tree-switcher {
background: #fff;
}
.ant-tree-show-line .ant-tree-switcher-line-icon {
vertical-align: -0.15em;
}
.ant-tree .ant-tree-treenode-leaf-last .ant-tree-switcher-leaf-line::before {
top: auto !important;
bottom: auto !important;
height: 14px !important;
}
.ant-tree-rtl {
direction: rtl;
}
.ant-tree-rtl
.ant-tree-node-content-wrapper[draggable='true']
.ant-tree-drop-indicator::after {
right: -6px;
left: unset;
}
.ant-tree .ant-tree-treenode-rtl {
direction: rtl;
}
.ant-tree-rtl .ant-tree-switcher_close .ant-tree-switcher-icon svg {
transform: rotate(90deg);
}
.ant-tree-rtl.ant-tree-show-line .ant-tree-indent-unit::before {
right: auto;
left: -13px;
border-right: none;
border-left: 1px solid #d9d9d9;
}
.ant-tree-rtl.ant-tree-checkbox {
margin: 4px 0 0 8px;
}
.ant-tree-select-dropdown-rtl .ant-select-tree-checkbox {
margin: 4px 0 0 8px;
}

View File

@ -0,0 +1,2 @@
export * from './RemoteSchemaTree';
export { buildServerRemoteFieldObject, parseServerRelationship } from './utils';

View File

@ -0,0 +1,585 @@
import React from 'react';
import {
isInputObjectType,
isInterfaceType,
isObjectType,
GraphQLSchema,
GraphQLField,
GraphQLType,
GraphQLArgument,
GraphQLInputField,
isWrappingType,
isListType,
isNonNullType,
} from 'graphql';
import {
isEmpty,
isFloat,
isJsonString,
isNumber,
} from '@/components/Common/utils/jsUtils';
import {
AllowedRootFields,
ArgValue,
HasuraRsFields,
RelationshipFields,
TreeNode,
RemoteRelationship,
RemoteField,
InputArgumentsType,
InputArgumentValueType,
AntdTreeNode,
} from '../../types';
import { SubFieldTitle } from './components/SubFieldTitle';
import { RootFieldTitle } from './components/RootFieldTitle';
import { FieldLabel } from './components/FieldLabel';
import { ArgFieldTitle } from './components/ArgFieldTitle';
export const getFieldData = (nodeData: AntdTreeNode): RelationshipFields => ({
key: nodeData.key,
depth: nodeData.depth,
checkable: nodeData.checkable,
argValue: nodeData.argValue ?? null,
type: nodeData.type,
});
export const defaultArgValue: ArgValue = {
kind: 'field',
value: '',
type: 'String',
};
export const findRemoteField = (
fields: RelationshipFields[],
field: AntdTreeNode
) => {
return fields.find(f => f.key === field.key);
};
const isElementActive = (
relationshipFields: RelationshipFields[],
fieldKey: string
) => {
return relationshipFields.some(f => f.key === fieldKey);
};
export const getUnderlyingType = (_type: Record<string, any>) => {
let type = Object.assign(Object.create(_type), _type);
const wraps = [];
while (isWrappingType(type)) {
if (isListType(type)) wraps.push('l');
if (isNonNullType(type)) wraps.push('n');
type = type.ofType;
}
return {
wraps,
type,
};
};
/* returns checked value if arg is checked
* returns null if arg isn't involved in the relationship
*/
export const getCheckedArgValue = (
relationshipFields: RelationshipFields[],
key: string
): ArgValue | null => {
const field = relationshipFields.find(r => r.key === key);
if (field) {
return field.argValue;
}
return null;
};
const getPlaceholderChild = (parentKey: string, depth: number): TreeNode => ({
title: '',
key: `${parentKey}.__placeholder.${depth}`,
depth: depth + 1,
checkable: false,
type: 'field',
disabled: true,
});
const buildArgElement = ({
arg,
parentKey,
relationshipFields,
setRelationshipFields,
fieldOptions,
depth,
}: {
arg: GraphQLArgument | GraphQLInputField;
parentKey: string;
relationshipFields: RelationshipFields[];
setRelationshipFields: React.Dispatch<
React.SetStateAction<RelationshipFields[]>
>;
fieldOptions: HasuraRsFields;
depth: number;
}): TreeNode => {
const { type: argType }: { type: GraphQLType } = getUnderlyingType(arg.type);
let children: TreeNode[] = [];
let checkable = true;
const argKey = `${parentKey}.${arg.name}`;
const isActive = isElementActive(relationshipFields, argKey);
const argValue = getCheckedArgValue(relationshipFields, argKey);
if (isInputObjectType(argType) || isInterfaceType(argType)) {
const argFields = argType.getFields();
if (!isEmpty(argFields)) {
checkable = false;
children = [getPlaceholderChild(argKey, depth + 1)];
if (isActive) {
children = [
...Object.values(argFields).map(argField =>
buildArgElement({
arg: argField,
parentKey: argKey,
relationshipFields,
setRelationshipFields,
fieldOptions,
depth: depth + 1,
})
),
];
}
}
}
return {
title: (
<ArgFieldTitle
title={arg.name}
argKey={argKey}
relationshipFields={relationshipFields}
setRelationshipFields={setRelationshipFields}
fields={fieldOptions}
showForm={isActive && checkable}
argValue={argValue || defaultArgValue}
argType={argType}
/>
),
key: argKey,
depth,
type: 'arg',
checkable,
argValue: checkable ? argValue || defaultArgValue : null,
children,
};
};
interface BuildFieldElementArgs {
field: GraphQLField<any, any, Record<string, any>>;
parentKey: string;
relationshipFields: RelationshipFields[];
setRelationshipFields: React.Dispatch<
React.SetStateAction<RelationshipFields[]>
>;
depth: number;
isSubfield: boolean;
fieldOptions: HasuraRsFields;
}
const buildFieldElement = ({
field,
parentKey,
relationshipFields,
setRelationshipFields,
fieldOptions,
depth,
isSubfield,
}: BuildFieldElementArgs): TreeNode => {
const { type: fieldType }: { type: GraphQLType } = getUnderlyingType(
field.type
);
const fieldKey = `${parentKey}.${field.name}`;
const enabled =
(field.args && !!field.args.length) || isObjectType(fieldType);
let children: TreeNode[] = [];
if (enabled) {
children = [getPlaceholderChild(fieldKey, depth + 1)];
}
const isActive = isElementActive(relationshipFields, fieldKey);
if (isActive) {
children = [];
if (field.args && !!field.args.length) {
children = [
{
title: <FieldLabel title="Arguments" />,
key: `${fieldKey}.__placeholder.args.${depth}`,
checkable: false,
type: 'field',
depth,
},
...field.args.map(arg =>
buildArgElement({
arg,
parentKey: `${fieldKey}.arguments`,
relationshipFields,
setRelationshipFields,
fieldOptions,
depth: depth + 1,
})
),
];
}
if (isObjectType(fieldType) || isInterfaceType(fieldType)) {
const subFields = fieldType.getFields();
if (!isEmpty(subFields)) {
children = [
...children,
{
title: <FieldLabel title="Sub-Fields" />,
key: `${fieldKey}.__placeholder.sub_fields.${depth}`,
checkable: false,
type: 'field',
depth,
},
...Object.values(subFields).map(subField =>
buildFieldElement({
field: subField,
parentKey: `${fieldKey}.field`,
relationshipFields,
setRelationshipFields,
fieldOptions,
depth: depth + 1,
isSubfield: true,
})
),
];
}
}
}
return {
title: (
<SubFieldTitle
title={field.name}
enabled={enabled}
isSubfield={isSubfield}
/>
),
key: fieldKey,
depth: depth + 1,
checkable: false,
type: 'field',
disabled: !enabled,
children,
};
};
interface BuildTreeArgs {
schema: GraphQLSchema;
relationshipFields: RelationshipFields[];
setRelationshipFields: React.Dispatch<
React.SetStateAction<RelationshipFields[]>
>;
rootFields: AllowedRootFields;
fields: HasuraRsFields;
}
export const buildTree = ({
schema,
relationshipFields,
setRelationshipFields,
fields: fieldOptions,
rootFields,
}: BuildTreeArgs): TreeNode[] => {
const treeData: TreeNode[] = [];
if (rootFields.includes('query')) {
const queryType = schema.getQueryType();
const fields = queryType?.getFields();
const fieldKey = '__query';
if (fields) {
treeData.push({
title: <RootFieldTitle title="Query" />,
key: fieldKey,
checkable: false,
depth: 0,
type: 'field',
children: Object.values(fields).map(field =>
buildFieldElement({
field,
parentKey: `${fieldKey}.field`,
relationshipFields,
setRelationshipFields,
fieldOptions,
depth: 0,
isSubfield: false,
})
),
});
}
}
if (rootFields.includes('mutation')) {
const mutationType = schema.getMutationType();
const fields = mutationType?.getFields();
const fieldKey = '__mutation';
if (fields) {
treeData.push({
title: <RootFieldTitle title="Mutation" />,
key: fieldKey,
checkable: false,
depth: 0,
type: 'field',
children: Object.values(fields).map(field =>
buildFieldElement({
field,
parentKey: `${fieldKey}.field`,
relationshipFields,
setRelationshipFields,
fieldOptions,
depth: 0,
isSubfield: false,
})
),
});
}
}
if (rootFields.includes('subscription')) {
const subscriptionType = schema.getSubscriptionType();
const fields = subscriptionType?.getFields();
const fieldKey = '__subscription';
if (fields) {
treeData.push({
title: <RootFieldTitle title="Subscription" />,
key: fieldKey,
checkable: false,
depth: 0,
type: 'field',
children: Object.values(fields).map(field =>
buildFieldElement({
field,
parentKey: `${fieldKey}.field`,
relationshipFields,
setRelationshipFields,
fieldOptions,
depth: 0,
isSubfield: false,
})
),
});
}
}
return treeData;
};
const getRemoteFieldObject = (
ukSplit: string[][],
depth: number,
maxDepth: number
): RemoteField | InputArgumentsType | undefined => {
if (depth > maxDepth) {
return;
}
const obj: RemoteField | InputArgumentsType = {};
const depthRemoteFields: string[] = ukSplit.map(uk => uk[depth] ?? '');
// get unique depth remote fields at current depth
const uniqueDepthRemoteFields = Array.from(new Set(depthRemoteFields));
uniqueDepthRemoteFields.forEach((uniqueField, i) => {
if (uniqueField.length > 0) {
const newUkSplit: string[][] = [];
depthRemoteFields.forEach((depthField, index) => {
if (depthField === uniqueField) {
newUkSplit.push(ukSplit[index]);
}
});
// if leaf, push arg value
if (ukSplit[i][depth + 1] === '__argVal') {
const value = ukSplit[i][depth + 2];
if (value.startsWith('__SCALAR__'))
obj[uniqueField] = isJsonString(value.substring(10))
? JSON.parse(value.substring(10))
: '';
else obj[uniqueField] = value;
} else {
obj[uniqueField] = {
...getRemoteFieldObject(newUkSplit, depth + 1, maxDepth),
};
if (ukSplit[i][depth - 1] === 'field') {
obj[uniqueField] = {
...(obj[uniqueField] as RemoteField),
arguments: {
...(obj[uniqueField] as RemoteField).arguments,
},
};
}
}
}
});
return Object.keys(obj).length ? obj : undefined;
};
const getUniqueRelFields = (relationshipFields: RelationshipFields[]) => {
return relationshipFields?.filter(
f =>
!relationshipFields.some(
refF => refF.key !== f.key && refF.key.includes(f.key)
)
);
};
const getKeysWithArgValues = (relationshipFields: RelationshipFields[]) =>
relationshipFields?.map(field => {
if (field.type === 'arg') {
if (field.argValue && field.argValue?.kind === 'static')
return `${field.key}.__argVal.${field.argValue?.value}`;
else if (field.argValue && field.argValue?.kind === 'field')
return `${field.key}.__argVal.$${field.argValue?.value}`;
return `${field.key}.__argVal.$`;
}
return field.key;
});
export const buildServerRemoteFieldObject = (
relationshipFields: RelationshipFields[]
) => {
const uniqueRelFields = getUniqueRelFields(relationshipFields);
const uniqueKeys = getKeysWithArgValues(uniqueRelFields);
const ukSplit: string[][] = [];
uniqueKeys.forEach(uk => ukSplit.push(uk.split('.')));
let maxDepth = 0;
ukSplit.forEach(ar => {
if (ar.length > maxDepth) maxDepth = ar.length;
});
const relationshipObject = getRemoteFieldObject(ukSplit, 2, maxDepth);
return (relationshipObject as RemoteField) || {};
};
export const getExpandedKeys = (relationshipFields: RelationshipFields[]) =>
relationshipFields.filter(rf => !rf.argValue).map(rf => rf.key);
export const getCheckedKeys = (relationshipFields: RelationshipFields[]) =>
relationshipFields.filter(rf => rf.argValue).map(rf => rf.key);
export const parseArgValue = (
argValue: InputArgumentValueType
): ArgValue | null => {
if (typeof argValue === 'object' && argValue !== null) {
return null;
}
if (typeof argValue === 'string') {
const isStatic = !argValue.startsWith('$');
return {
value: isStatic ? argValue.toString() : argValue.substr(1),
kind: isStatic ? 'static' : 'field',
type: 'String',
};
}
if (typeof argValue === 'boolean') {
return {
kind: 'static',
value: argValue.toString(),
type: 'Boolean',
};
}
return {
kind: 'static',
value: argValue.toString(),
type:
(isNumber(argValue) && (isFloat(argValue) ? 'Float' : 'Int')) || 'String',
};
};
const serialiseArguments = (
args: InputArgumentValueType,
key: string,
depth: number,
callback: (f: RelationshipFields) => void
): void => {
if (typeof args === 'object') {
Object.keys(args).forEach(argName => {
const argValue = args[argName];
const argValueMetadata = parseArgValue(argValue);
if (argValueMetadata) {
callback({
key: `${key}.${argName}`,
depth,
checkable: true,
argValue: argValueMetadata,
type: 'arg',
});
} else {
callback({
key: `${key}.${argName}`,
depth,
checkable: false,
argValue: null,
type: 'arg',
});
serialiseArguments(argValue, `${key}.${argName}`, depth + 1, callback);
}
});
}
};
const serialiseRemoteField = (
field: {
arguments: InputArgumentsType | never;
field?: RemoteField | undefined;
},
key: string,
depth: number,
callback: (f: RelationshipFields) => void
): void => {
callback({
key,
depth,
checkable: false,
argValue: null,
type: 'field',
});
if (field.field) {
const subFieldName = field.field ? Object.keys(field.field)[0] : '';
const subField = field.field?.[subFieldName];
serialiseRemoteField(
subField,
`${key}.field.${subFieldName}`,
depth + 1,
callback
);
}
if (field.arguments) {
serialiseArguments(
field.arguments,
`${key}.arguments`,
depth + 1,
callback
);
}
};
// TODO: this only parses the remote relationship in old format, and the type `RemoteRelationship` is old format
// we should extend this for both old & new format once the server work is done, and remove this comment
export const parseServerRelationship = (
serverRelationship: RemoteRelationship
): RelationshipFields[] => {
const remoteFields =
serverRelationship?.definition?.to_remote_schema.remote_field;
if (!remoteFields || isEmpty(remoteFields)) {
return [];
}
// only query root is supported by server relationships, so we only expect fields under query root
// this could be extended to use mutation and subscription if server support exists in future
const key = '__query';
const depth = 0;
const relationshipFields: RelationshipFields[] = [
{ key, depth, checkable: false, argValue: null, type: 'field' },
];
Object.keys(remoteFields).forEach(rf => {
serialiseRemoteField(
remoteFields[rf],
`${key}.field.${rf}`,
depth + 1,
(field: RelationshipFields) => relationshipFields.push(field)
);
});
return relationshipFields;
};

View File

@ -0,0 +1,20 @@
import clsx from 'clsx';
import React from 'react';
import { FaSearch } from 'react-icons/fa';
export const SearchBar = (props: React.HTMLProps<HTMLInputElement>) => {
return (
<div className={clsx('flex relative w-full')}>
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<FaSearch className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
className={clsx(
'pl-10 block w-full h-input shadow-sm rounded border border-gray-300 hover:border-gray-400 focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-yellow-200 focus-visible:border-yellow-400 placeholder-gray-500'
)}
{...props}
/>
</div>
);
};

View File

@ -0,0 +1,73 @@
import { EventDataNode } from 'antd/lib/tree';
export type HasuraRsFields = string[];
export type AllowedRootFields = ('query' | 'mutation' | 'subscription')[];
export type ArgValueKind = 'field' | 'static';
export type ArgValue = {
kind: ArgValueKind;
value: string | number | boolean;
type: string;
};
export type RelationshipFields = {
key: string;
argValue: ArgValue | null;
checkable: boolean;
depth: number;
type: 'field' | 'arg';
};
export type TreeNode = {
title: JSX.Element | string;
key: string;
checkable: boolean;
depth: number;
type: 'field' | 'arg';
disabled?: boolean;
argValue?: ArgValue | null;
children?: TreeNode[];
};
export type RemoteField = {
[FieldName: string]: {
arguments: InputArgumentsType | never;
field?: RemoteField;
};
};
export type InputArgumentValueType =
| string
| boolean
| number
| InputArgumentsType;
export type InputArgumentsType = {
[key: string]: InputArgumentValueType;
};
// we should extend RemoteRelationship type from `metadata/types.ts` once we have
// the correct types in place, for both old and new format
export type RemoteRelationship = {
name: string;
definition: {
to_remote_schema: {
lhs_fields: string[];
remote_field: RemoteField;
remote_schema: string;
};
};
};
export interface AntdTreeNode extends EventDataNode {
title: JSX.Element | string;
key: string;
checkable: boolean;
depth: number;
type: 'field' | 'arg';
disabled?: boolean;
argValue?: ArgValue | null;
children?: TreeNode[];
}

View File

@ -0,0 +1,127 @@
import {
InputArgumentsType,
InputArgumentValueType,
RemoteField,
} from '@/features/hasura-metadata-types';
import { isEmpty } from '@/components/Common/utils/jsUtils';
import { RemoteSchemaRelationship } from '../../../../types';
import { parseArgValue } from './parts/RemoteSchemaTree/utils';
import { RelationshipFields } from './types';
const serialiseArguments = (
args: InputArgumentValueType,
key: string,
depth: number,
callback: (f: RelationshipFields) => void
): void => {
if (typeof args === 'object') {
Object.keys(args).forEach(argName => {
const argValue = args[argName];
const argValueMetadata = parseArgValue(argValue);
if (argValueMetadata) {
callback({
key: `${key}.${argName}`,
depth,
checkable: true,
argValue: argValueMetadata,
type: 'arg',
});
} else {
callback({
key: `${key}.${argName}`,
depth,
checkable: false,
argValue: null,
type: 'arg',
});
serialiseArguments(argValue, `${key}.${argName}`, depth + 1, callback);
}
});
}
};
const serialiseRemoteField = (
field: {
arguments: InputArgumentsType | never;
field?: RemoteField | undefined;
},
key: string,
depth: number,
callback: (f: RelationshipFields) => void
): void => {
callback({
key,
depth,
checkable: false,
argValue: null,
type: 'field',
});
if (field.field) {
const subFieldName = field.field ? Object.keys(field.field)[0] : '';
const subField = field.field?.[subFieldName];
serialiseRemoteField(
subField,
`${key}.field.${subFieldName}`,
depth + 1,
callback
);
}
if (field.arguments) {
serialiseArguments(
field.arguments,
`${key}.arguments`,
depth + 1,
callback
);
}
};
// TODO: this only parses the remote relationship in old format, and the type `RemoteRelationship` is old format
// we should extend this for both old & new format once the server work is done, and remove this comment
export const parseServerRelationship = (
remoteField: RemoteSchemaRelationship['definition']['remote_field']
): RelationshipFields[] => {
const remoteFields = remoteField;
if (!remoteFields || isEmpty(remoteFields)) {
return [];
}
// only query root is supported by server relationships, so we only expect fields under query root
// this could be extended to use mutation and subscription if server support exists in future
const key = '__query';
const depth = 0;
const relationshipFields: RelationshipFields[] = [
{ key, depth, checkable: false, argValue: null, type: 'field' },
];
Object.keys(remoteFields).forEach(rf => {
serialiseRemoteField(
remoteFields[rf],
`${key}.field.${rf}`,
depth + 1,
(field: RelationshipFields) => relationshipFields.push(field)
);
});
return relationshipFields;
};
const matchAll = (re: RegExp, str: string) => {
let match;
const matches = [];
// eslint-disable-next-line no-cond-assign
while ((match = re.exec(str)) !== null) {
// ref : https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec
matches.push(match[0]);
}
return matches;
};
export const generateLhsFields = (resultSet: Record<string, unknown>) => {
const regexp = /([$])\w+/g;
const str = JSON.stringify(resultSet, null, 2);
const lhs_fieldSet = new Set<string>();
const results = matchAll(regexp, str);
results.forEach(i => lhs_fieldSet.add(i.substring(1))); // remove $ symbol from the string to pass as lhs_fields
return Array.from(lhs_fieldSet);
};

View File

@ -0,0 +1,69 @@
import React from 'react';
import { SimpleForm } from '@/new-components/Form';
import { z } from 'zod';
import { Button } from '@/new-components/Button';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { SourceOption, SourceSelect } from './SourceSelect';
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
component: SourceSelect,
} as ComponentMeta<typeof SourceSelect>;
const options: SourceOption[] = [
{
value: {
type: 'table',
dataSourceName: 'chinook',
table: { name: 'Album', schema: 'public' },
},
label: 'chinook / public / Album',
},
{
value: {
type: 'table',
dataSourceName: 'chinook',
table: { name: 'Artist', schema: 'public' },
},
label: 'chinook / public / Artist',
},
{
value: {
type: 'remoteSchema',
remoteSchema: 'TrevorBlades',
},
label: 'TrevorBlades',
},
];
export const Primary: ComponentStory<typeof SourceSelect> = () => (
<SimpleForm
schema={z.object({
test: z.any(),
})}
onSubmit={data => {
console.log(data);
}}
options={{
defaultValues: {
test: {
type: 'table',
dataSourceName: 'chinook',
table: {
name: 'Artist',
schema: 'public',
},
},
},
}}
>
<div>
<SourceSelect options={options} name="test" label="Select a source" />
<Button type="submit">Submit</Button>
</div>
</SimpleForm>
);

View File

@ -0,0 +1,115 @@
import { RemoteSchema, Table } from '@/features/hasura-metadata-types';
import {
FieldWrapper,
FieldWrapperPassThroughProps,
} from '@/new-components/Form';
import isEqual from 'lodash.isequal';
import get from 'lodash.get';
import React from 'react';
import { Controller, FieldError, useFormContext } from 'react-hook-form';
import { FaDatabase, FaPlug, FaTable } from 'react-icons/fa';
import Select, {
components,
ControlProps,
OptionProps,
SingleValueProps,
} from 'react-select';
import './index.css';
export interface SourceOption {
value:
| { type: 'table'; dataSourceName: string; table: Table }
| { type: 'remoteSchema'; remoteSchema: RemoteSchema['name'] };
label: string;
}
type SearchableSelectProps = FieldWrapperPassThroughProps & {
options: SourceOption[];
name: string;
disabled?: boolean;
};
const Option = (props: OptionProps<SourceOption>) => {
return (
<components.Option {...props}>
<span className="flex items-center gap-2">
<span className="text-gray-500">
{props.data.value.type === 'table' ? <FaTable /> : <FaPlug />}
</span>
<span className="text-gray-800">{props.label}</span>
</span>
</components.Option>
);
};
const SingleValue = (props: SingleValueProps<SourceOption>) => {
// console.log('!!', props.data.value);
return (
<components.SingleValue {...props}>
<span className="flex items-center gap-2">
<span className="text-gray-500">
{props.data.value.type === 'table' ? <FaTable /> : <FaPlug />}
</span>
<span className="text-gray-800">{props.data.label}</span>
</span>
</components.SingleValue>
);
};
const selectStyles = {
option: (base: any, state: OptionProps<SourceOption>) => {
return {
...base,
backgroundColor: state.isFocused ? '#ededed' : null,
color: '#333333',
};
},
control: (base: any, state: ControlProps<SourceOption>) => {
return {
...base,
border: state.isFocused ? '1px solid #FACC14' : null,
boxShadow: 'none',
};
},
// menuPortal: (base: any) => ({ ...base, zIndex: 9999 }),
// menuPortal: (provided: any) => ({ ...provided, zIndex: 9999 }),
// menu: (base: any) => ({ ...base, zIndex: 9999 }),
};
export const SourceSelect = ({
options,
name,
disabled,
...wrapperProps
}: SearchableSelectProps) => {
const {
formState: { errors },
control,
} = useFormContext();
const maybeError = get(errors, name) as FieldError | undefined;
return (
<FieldWrapper id={name} {...wrapperProps} error={maybeError}>
<Controller
control={control}
name={name}
render={({ field: { onChange, onBlur, value, ref } }) => (
<Select
classNamePrefix="my-select"
onBlur={onBlur}
value={options.find(c => isEqual(c.value, value))}
onChange={val => onChange((val as SourceOption).value)}
inputRef={ref}
components={{ Option, SingleValue }}
options={options}
styles={selectStyles}
isDisabled={disabled}
// menuPortalTarget={document.body}
// menuPosition={'fixed'}
// menuContainerStyle={{ zIndex: 5 }}
/>
)}
/>
</FieldWrapper>
);
};

View File

@ -0,0 +1,3 @@
.my-select__menu {
z-index: 200 !important;
}

View File

@ -0,0 +1,51 @@
import { z } from 'zod';
const dbTodbSchema = z.object({
name: z.string().min(1, 'Name is a required field'),
fromSource: z.object({
type: z.literal('table'),
dataSourceName: z.string().min(1, 'Origin source is a required field'),
table: z.any(),
}),
toSource: z.object({
type: z.literal('table'),
dataSourceName: z.string().min(1, 'Reference source is a required field'),
table: z.any(),
}),
details: z.object({
relationshipType: z.union([z.literal('Object'), z.literal('Array')]),
columnMap: z
.array(z.object({ from: z.string(), to: z.string() }))
.transform((columnMap, ctx) => {
return columnMap.map(map => {
if (!map.to || !map.from)
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Column Mapping cannot be empty`,
});
return map;
});
}),
}),
});
export const dbToRsSchema = z.object({
name: z.string().min(1, 'Name is a required field'),
fromSource: z.object({
type: z.literal('table'),
dataSourceName: z.string().min(1, 'Origin source is a required field'),
table: z.any(),
}),
toSource: z.object({
type: z.literal('remoteSchema'),
remoteSchema: z.string().min(1, 'Remote Schema is a required field'),
}),
details: z.object({
rsFieldMapping: z.any(),
}),
});
export const schema = z.union([dbTodbSchema, dbToRsSchema]);
export type Schema = z.infer<typeof schema>;

View File

@ -0,0 +1,183 @@
import { GDCTable } from '@/features/DataSource';
import { Table } from '@/features/hasura-metadata-types';
import { isArray, isObject } from 'lodash';
import { useCallback } from 'react';
import { useManageLocalRelationship } from '../../hooks/useManageLocalRelationship';
import { useManageRemoteDatabaseRelationship } from '../../hooks/useManageRemoteDatabaseRelationship';
import { useManageRemoteSchemaRelationship } from '../../hooks/useManageRemoteSchemaRelationship';
import {
LocalRelationship,
MODE,
RemoteDatabaseRelationship,
RemoteSchemaRelationship,
} from '../../types';
import {
DatasetTable,
SchemaTable,
} from '../common/SourcePicker/SourcePicker.utils';
import { generateLhsFields } from './parts/MapRemoteSchemaFields/utils';
import { Schema } from './schema';
export const isSchemaTable = (table: Table): table is SchemaTable =>
isObject(table) && 'schema' in table && 'name' in table;
export const isDatasetTable = (table: Table): table is DatasetTable =>
isObject(table) && 'dataset' in table && 'name' in table;
export const isGDCTable = (table: Table): table is GDCTable => isArray(table);
export const getTableLabel = ({
dataSourceName,
table,
}: {
dataSourceName: string;
table: Table;
}) => {
if (isSchemaTable(table)) {
return `${dataSourceName} / ${table.schema} / ${table.name}`;
}
if (isDatasetTable(table)) {
return `${dataSourceName} / ${table.dataset} / ${table.name}`;
}
if (isGDCTable(table)) {
return `${dataSourceName} / ${table.join(' /')}`;
}
return '';
};
export const useHandleSubmit = ({
dataSourceName,
table,
mode,
onSuccess,
onError,
}: {
dataSourceName: string;
table: Table;
mode: MODE;
onSuccess: () => void;
onError: (err: Error) => void;
}) => {
const { createRelationship: createLocalRelationship, isLoading } =
useManageLocalRelationship({
dataSourceName,
onSuccess,
onError,
});
const {
createRelationship: createRemoteDatabaseRelationship,
editRelationship: editRemoteDatabaseRelationship,
isLoading: remoteDatabaseRelationshipLoading,
} = useManageRemoteDatabaseRelationship({
dataSourceName,
onSuccess,
onError,
});
const {
createRelationship: createRemoteSchemaRelationship,
editRelationship: editRemoteSchemaRelationship,
isLoading: remoteSchemaRelationshipLoading,
} = useManageRemoteSchemaRelationship({
dataSourceName,
onSuccess,
onError,
});
const handleSubmit = useCallback(
(formData: Schema) => {
console.log('this is the submitted data', formData);
const { fromSource, toSource, details } = formData;
// Local relationship
if (
toSource.type === 'table' &&
toSource.dataSourceName === dataSourceName &&
'columnMap' in details
) {
const localRelationship: LocalRelationship = {
name: formData.name,
type: 'localRelationship',
fromSource: fromSource.dataSourceName,
fromTable: fromSource.table,
relationshipType: details.relationshipType,
definition: {
toTable: toSource.table,
mapping: (details.columnMap ?? []).reduce(
(acc, entry) => ({ ...acc, [entry.from]: entry.to }),
{}
),
},
};
createLocalRelationship(localRelationship);
}
// remote database relationship
if (
toSource.type === 'table' &&
toSource.dataSourceName !== dataSourceName &&
'columnMap' in details
) {
const remoteDatabaseRelationship: RemoteDatabaseRelationship = {
name: formData.name,
type: 'remoteDatabaseRelationship',
fromSource: fromSource.dataSourceName,
fromTable: fromSource.table,
relationshipType: details.relationshipType,
definition: {
toSource: toSource.dataSourceName,
toTable: toSource.table,
mapping: (details.columnMap ?? []).reduce(
(acc, entry) => ({ ...acc, [entry.from]: entry.to }),
{}
),
},
};
if (mode === MODE.CREATE)
createRemoteDatabaseRelationship(remoteDatabaseRelationship);
else editRemoteDatabaseRelationship(remoteDatabaseRelationship);
}
// remote schema relationship
if (toSource.type === 'remoteSchema' && 'rsFieldMapping' in details) {
const remoteSchemaRelationship: RemoteSchemaRelationship = {
name: formData.name,
type: 'remoteSchemaRelationship',
relationshipType: 'Remote',
fromSource: fromSource.dataSourceName,
fromTable: fromSource.table,
definition: {
toRemoteSchema: toSource.remoteSchema,
lhs_fields: generateLhsFields(details.rsFieldMapping),
remote_field: details.rsFieldMapping,
},
};
if (mode === MODE.CREATE)
createRemoteSchemaRelationship(remoteSchemaRelationship);
else editRemoteSchemaRelationship(remoteSchemaRelationship);
}
},
[
createLocalRelationship,
createRemoteDatabaseRelationship,
createRemoteSchemaRelationship,
dataSourceName,
editRemoteDatabaseRelationship,
editRemoteSchemaRelationship,
mode,
]
);
return {
handleSubmit,
isLoading:
isLoading ||
remoteDatabaseRelationshipLoading ||
remoteSchemaRelationshipLoading,
};
};

View File

@ -1,9 +1,9 @@
import { Table } from '@/features/hasura-metadata-types';
import React from 'react';
import { Dialog } from '@/new-components/Dialog';
import { MODE, Relationship } from '../../types';
import { RelationshipForm } from '../RelationshipForm';
import { RenameRelationship } from '../RenameRelationship/RenameRelationship';
import { ConfirmDeleteRelationshipPopup } from './parts/ConfirmDeleteRelationshipPopup';
import { CreateRelationship } from './parts/CreateRelationship';
interface RenderWidgetProps {
dataSourceName: string;
@ -26,11 +26,10 @@ export const RenderWidget = (props: RenderWidgetProps) => {
onError,
} = props;
if (mode === MODE.CREATE)
if (mode === MODE.DELETE && relationship)
return (
<CreateRelationship
dataSourceName={dataSourceName}
table={table}
<ConfirmDeleteRelationshipPopup
relationship={relationship}
onSuccess={onSuccess}
onCancel={onCancel}
onError={onError}
@ -47,16 +46,30 @@ export const RenderWidget = (props: RenderWidgetProps) => {
/>
);
if (mode === MODE.DELETE && relationship)
return (
<ConfirmDeleteRelationshipPopup
relationship={relationship}
onSuccess={onSuccess}
onCancel={onCancel}
onError={onError}
/>
);
// Since we support only local relationships for GDC, there is no edit mode for it. Edit only exists for remote relationships
return null;
return (
<Dialog
hasBackdrop
title={mode === MODE.EDIT ? 'Edit Relationship' : 'Create Relationship'}
description={
mode === MODE.EDIT
? 'Edit your existing relationship.'
: 'Create and track a new relationship to view it in your GraphQL schema.'
}
onClose={onCancel}
size="xxl"
>
<div>
<RelationshipForm.Widget
dataSourceName={dataSourceName}
table={table}
onSuccess={onSuccess}
onCancel={onCancel}
onError={onError}
defaultValue={
mode === MODE.EDIT && relationship ? relationship : undefined
}
/>
</div>
</Dialog>
);
};

View File

@ -2,6 +2,8 @@ import { Dialog } from '@/new-components/Dialog';
import React from 'react';
import { Relationship } from '../../../types';
import { useManageLocalRelationship } from '../../../hooks/useManageLocalRelationship';
import { useManageRemoteDatabaseRelationship } from '@/features/DatabaseRelationships/hooks/useManageRemoteDatabaseRelationship';
import { useManageRemoteSchemaRelationship } from '@/features/DatabaseRelationships/hooks/useManageRemoteSchemaRelationship';
interface ConfirmDeleteRelationshipPopupProps {
relationship: Relationship;
@ -15,7 +17,28 @@ export const ConfirmDeleteRelationshipPopup = (
) => {
const { relationship, onCancel, onSuccess, onError } = props;
const { deleteRelationship } = useManageLocalRelationship({
const {
deleteRelationship: deleteLocalRelationship,
isLoading: isDeleteLocalRelationshipLoading,
} = useManageLocalRelationship({
dataSourceName: relationship.fromSource,
onSuccess,
onError,
});
const {
deleteRelationship: deleteRemoteDatabaseRelationship,
isLoading: isDeleteRemoteDatabaseRelationshipLoading,
} = useManageRemoteDatabaseRelationship({
dataSourceName: relationship.fromSource,
onSuccess,
onError,
});
const {
deleteRelationship: deleteRemoteSchemaRelationship,
isLoading: isDeleteRemoteSchemaRelationshipLoading,
} = useManageRemoteSchemaRelationship({
dataSourceName: relationship.fromSource,
onSuccess,
onError,
@ -31,14 +54,21 @@ export const ConfirmDeleteRelationshipPopup = (
footer={
<Dialog.Footer
onSubmit={() => {
// Right now there is only support for local gdc relationship. We will add others as we release more features on the server
if (relationship.type === 'localRelationship') {
deleteRelationship(relationship);
}
deleteLocalRelationship(relationship);
} else if (relationship.type === 'remoteDatabaseRelationship') {
deleteRemoteDatabaseRelationship(relationship);
} else if (relationship.type === 'remoteSchemaRelationship')
deleteRemoteSchemaRelationship(relationship);
}}
onClose={onCancel}
callToDeny="Cancel"
callToAction="Drop Relationship"
isLoading={
isDeleteLocalRelationshipLoading ||
isDeleteRemoteDatabaseRelationshipLoading ||
isDeleteRemoteSchemaRelationshipLoading
}
/>
}
>

View File

@ -1,77 +0,0 @@
import { Table } from '@/features/hasura-metadata-types';
import { CardRadioGroup } from '@/new-components/CardRadioGroup';
import { Dialog } from '@/new-components/Dialog';
import { IndicatorCard } from '@/new-components/IndicatorCard';
import React, { useState } from 'react';
import { ManualLocalRelationship } from '../../ManualLocalRelationship';
interface CreateRelationshipProps {
dataSourceName: string;
table: Table;
onCancel: () => void;
onSuccess: () => void;
onError: (err: Error) => void;
}
const formTabs = [
{
value: 'local',
title: 'Local Relationship',
body: 'Relationships from this table to a local database table.',
},
{
value: 'remoteDatabase',
title: 'Remote Database Relationship',
body: 'Relationship from this local table to a remote database table.',
},
{
value: 'remoteSchema',
title: 'Remote Schema Relationship',
body: 'Relationship from this local table to a remote schema.',
},
];
export const CreateRelationship: React.VFC<CreateRelationshipProps> = ({
dataSourceName,
table,
onCancel,
onSuccess,
onError,
}) => {
const [relationshipType, setRelationshipType] = useState('local');
return (
<Dialog
hasBackdrop
title="Add Relationship"
description="Create and track a new relationship to view it in your GraphQL schema."
onClose={onCancel}
size="xxl"
>
<div>
<div className="px-7 pt-2">
<CardRadioGroup
items={formTabs}
onChange={setRelationshipType}
value={relationshipType}
/>
</div>
{relationshipType === 'local' && (
<ManualLocalRelationship.Widget
dataSourceName={dataSourceName}
table={table}
onSuccess={onSuccess}
onError={onError}
onCancel={onCancel}
/>
)}
{(relationshipType === 'remoteDatabase' ||
relationshipType === 'remoteSchema') && (
<div className="mt-sm mx-7">
<IndicatorCard status="info" headline="Feature coming soon" />
</div>
)}
</div>
</Dialog>
);
};

Some files were not shown because too many files have changed in this diff Show More