mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-09-11 10:46:25 +03:00
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:
parent
ac985c2235
commit
c663cb9879
@ -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 {
|
||||
|
@ -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';
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
},
|
||||
];
|
File diff suppressed because it is too large
Load Diff
@ -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));
|
||||
}),
|
||||
];
|
@ -1,3 +0,0 @@
|
||||
export { handlers } from './handlers.mock';
|
||||
export * from './constants';
|
||||
export * from './schema';
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
File diff suppressed because it is too large
Load Diff
@ -1,4 +0,0 @@
|
||||
export const schemaList = {
|
||||
result_type: 'TuplesOk',
|
||||
result: [['schema_name'], ['public'], ['schema2']],
|
||||
};
|
@ -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'],
|
||||
],
|
||||
};
|
@ -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',
|
||||
},
|
||||
};
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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'],
|
||||
},
|
||||
},
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
||||
},
|
||||
});
|
||||
};
|
@ -1 +0,0 @@
|
||||
export { useListAllRelationshipsFromMetadata } from './useListAllRelationshipsFromMetadata';
|
@ -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;
|
||||
},
|
||||
});
|
||||
};
|
@ -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),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
@ -1 +0,0 @@
|
||||
export * from './DatabaseRelationshipTable';
|
@ -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"'],
|
||||
],
|
||||
};
|
@ -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;
|
||||
};
|
||||
}
|
||||
);
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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',
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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.',
|
||||
},
|
||||
];
|
@ -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" />,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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 };
|
||||
};
|
@ -1 +0,0 @@
|
||||
export * from './LocalRelationshipWidget';
|
@ -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>;
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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');
|
||||
});
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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 };
|
||||
};
|
@ -1 +0,0 @@
|
||||
export * from './RemoteDBRelationshipWidget';
|
@ -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>;
|
@ -1,5 +0,0 @@
|
||||
import { DataTarget } from '@/features/Datasources';
|
||||
|
||||
export const getSchemaKey = (sourceTableInfo: DataTarget) => {
|
||||
return 'dataset' in sourceTableInfo ? 'dataset' : 'schema';
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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 };
|
||||
};
|
@ -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);
|
||||
};
|
@ -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} /
|
||||
<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} /
|
||||
<FaColumns className="text-sm text-muted mr-xs" />
|
||||
{relationship.to.column}
|
||||
</CardedTable.TableBodyCell>
|
||||
</CardedTable.TableBodyRow>
|
||||
);
|
||||
})}
|
||||
</CardedTable.TableBody>
|
||||
</CardedTable.Table>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -1 +0,0 @@
|
||||
export * from './AddSuggestedRelationshipForm';
|
@ -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');
|
||||
});
|
||||
});
|
@ -1,2 +0,0 @@
|
||||
export * from './useSuggestedRelationships';
|
||||
export * from './useSubmitForm';
|
@ -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'],
|
||||
],
|
||||
};
|
@ -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));
|
||||
}),
|
||||
];
|
@ -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 };
|
||||
};
|
@ -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 }),
|
||||
};
|
@ -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 };
|
||||
};
|
@ -1 +0,0 @@
|
||||
export * from './SuggestedRelationships';
|
@ -1 +0,0 @@
|
||||
export * from './useMutations';
|
@ -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'],
|
||||
],
|
||||
};
|
@ -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));
|
||||
}),
|
||||
];
|
@ -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>;
|
@ -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>;
|
@ -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>;
|
@ -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>;
|
@ -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,
|
||||
};
|
||||
};
|
@ -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: () => {},
|
||||
}
|
||||
);
|
||||
};
|
@ -1 +0,0 @@
|
||||
export { DatabaseRelationshipsTab } from './DatabaseRelationshipsTab';
|
@ -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 ?? '',
|
||||
});
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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 = {
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>;
|
@ -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={() => {}}
|
||||
/>
|
||||
);
|
@ -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>
|
||||
);
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
import { Widget } from './Widget';
|
||||
|
||||
export const ManualLocalRelationship = {
|
||||
export const RelationshipForm = {
|
||||
Widget,
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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: () => {},
|
||||
};
|
@ -0,0 +1 @@
|
||||
export { MapRemoteSchemaFields } from './MapRemoteSchemaFields';
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export * from './RemoteSchemaTree';
|
||||
export { buildServerRemoteFieldObject, parseServerRelationship } from './utils';
|
@ -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;
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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[];
|
||||
}
|
@ -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);
|
||||
};
|
@ -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>
|
||||
);
|
@ -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>
|
||||
);
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
.my-select__menu {
|
||||
z-index: 200 !important;
|
||||
}
|
@ -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>;
|
@ -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,
|
||||
};
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user