mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
Allow users to track relationships from the manage database view
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/8568 Co-authored-by: Julian@Hasura <118911427+julian-mayorga@users.noreply.github.com> Co-authored-by: Vijay Prasanna <11921040+vijayprasanna13@users.noreply.github.com> GitOrigin-RevId: 6741cd59cd5432b18f72ffa965ccb84f2503fb4e
This commit is contained in:
parent
4b2344f646
commit
c3451bd622
@ -1,9 +1,7 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Tooltip } from '../../../new-components/Tooltip';
|
|
||||||
import { Analytics, REDACT_EVERYTHING } from '../../Analytics';
|
import { Analytics, REDACT_EVERYTHING } from '../../Analytics';
|
||||||
import { FaAngleRight, FaDatabase } from 'react-icons/fa';
|
import { FaAngleRight, FaDatabase } from 'react-icons/fa';
|
||||||
import { RiInformationFill } from 'react-icons/ri';
|
import { ManageTrackedTables } from '../TrackResources/components/ManageTrackedTables';
|
||||||
import { TrackTables } from '../TrackTables/TrackTables';
|
import { ManageTrackedRelationshipsContainer } from '../TrackResources/components/ManageTrackedRelationshipsContainer';
|
||||||
|
|
||||||
interface ManageDatabaseProps {
|
interface ManageDatabaseProps {
|
||||||
dataSourceName: string;
|
dataSourceName: string;
|
||||||
@ -37,25 +35,11 @@ export const ManageDatabase = (props: ManageDatabaseProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="px-md group relative">
|
<div className="px-md group relative">
|
||||||
<div className="flex mb-1 items-center">
|
<ManageTrackedTables dataSourceName={props.dataSourceName} />
|
||||||
<h4 className="inline-flex items-center font-semibold">
|
<ManageTrackedRelationshipsContainer
|
||||||
Track Tables
|
dataSourceName={props.dataSourceName}
|
||||||
</h4>
|
/>
|
||||||
<Tooltip
|
|
||||||
tooltipContentChildren="Expose the tables available in your database via the GraphQL API"
|
|
||||||
side="right"
|
|
||||||
>
|
|
||||||
<RiInformationFill />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted">
|
|
||||||
Manage your database Tracking objects adds them to your GraphQL
|
|
||||||
API. All objects will be admin-only until permissions have been
|
|
||||||
set.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<TrackTables dataSourceName={props.dataSourceName} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Analytics>
|
</Analytics>
|
||||||
|
@ -0,0 +1,40 @@
|
|||||||
|
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||||
|
import { userEvent, within } from '@storybook/testing-library';
|
||||||
|
import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query';
|
||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
|
||||||
|
import { ManageTrackedRelationships } from '../components/ManageTrackedRelationships';
|
||||||
|
import { SuggestedRelationshipWithName } from '../../../DatabaseRelationships/components/SuggestedRelationships/hooks/useSuggestedRelationships';
|
||||||
|
import { Relationship } from '../../../DatabaseRelationships';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Data/Components/ManageTrackedRelationships',
|
||||||
|
component: ManageTrackedRelationships,
|
||||||
|
decorators: [ReactQueryDecorator()],
|
||||||
|
} as ComponentMeta<typeof ManageTrackedRelationships>;
|
||||||
|
|
||||||
|
const suggestedRelationships: SuggestedRelationshipWithName[] = [];
|
||||||
|
|
||||||
|
const trackedFKRelationships: Relationship[] = [];
|
||||||
|
|
||||||
|
export const Base: ComponentStory<typeof ManageTrackedRelationships> = () => (
|
||||||
|
<ManageTrackedRelationships
|
||||||
|
dataSourceName="chinook"
|
||||||
|
suggestedRelationships={suggestedRelationships}
|
||||||
|
trackedFKRelationships={trackedFKRelationships}
|
||||||
|
isLoading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
Base.play = async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
// Reset initial metadata to make sure tests start from a clean slate everytime
|
||||||
|
|
||||||
|
userEvent.click(
|
||||||
|
await canvas.findByText('Untracked Foreign Key Relationships')
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
canvas.getByText('No untracked relationships found')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
};
|
@ -0,0 +1,116 @@
|
|||||||
|
import { RiInformationFill } from 'react-icons/ri';
|
||||||
|
import { Collapsible } from '../../../../new-components/Collapsible';
|
||||||
|
import * as Tabs from '@radix-ui/react-tabs';
|
||||||
|
import { Tooltip } from '../../../../new-components/Tooltip';
|
||||||
|
import React from 'react';
|
||||||
|
import Skeleton from 'react-loading-skeleton';
|
||||||
|
import { TrackedRelationshipsContainer } from './TrackedRelationshipsContainer';
|
||||||
|
import { UntrackedRelationships } from './UntrackedRelationships';
|
||||||
|
import { SuggestedRelationshipWithName } from '../../../DatabaseRelationships/components/SuggestedRelationships/hooks/useSuggestedRelationships';
|
||||||
|
import { Relationship } from '../../../DatabaseRelationships/types';
|
||||||
|
|
||||||
|
const classNames = {
|
||||||
|
selected:
|
||||||
|
'border-yellow-500 text-yellow-500 whitespace-nowrap p-xs border-b-2 font-semibold -mb-0.5',
|
||||||
|
unselected:
|
||||||
|
'border-transparent text-muted whitespace-nowrap p-xs border-b-2 font-semibold -mb-0.5 hover:border-gray-300 hover:text-gray-900',
|
||||||
|
};
|
||||||
|
|
||||||
|
type ManageTrackedRelationshipsProps = {
|
||||||
|
dataSourceName: string;
|
||||||
|
suggestedRelationships: SuggestedRelationshipWithName[];
|
||||||
|
trackedFKRelationships: Relationship[];
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ManageTrackedRelationships: React.VFC<
|
||||||
|
ManageTrackedRelationshipsProps
|
||||||
|
> = ({
|
||||||
|
dataSourceName,
|
||||||
|
isLoading,
|
||||||
|
suggestedRelationships,
|
||||||
|
trackedFKRelationships,
|
||||||
|
}) => {
|
||||||
|
const [tab, setTab] = React.useState<'tracked' | 'untracked'>('untracked');
|
||||||
|
|
||||||
|
if (!suggestedRelationships)
|
||||||
|
return <div className="px-md">Something went wrong</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible
|
||||||
|
triggerChildren={
|
||||||
|
<div>
|
||||||
|
<div className="flex mb-1 items-center">
|
||||||
|
<div className="font-semibold inline-flex items-center text-lg">
|
||||||
|
Untracked Foreign Key Relationships
|
||||||
|
</div>
|
||||||
|
<Tooltip
|
||||||
|
tooltipContentChildren="Expose the tables available in your database via the GraphQL API"
|
||||||
|
side="right"
|
||||||
|
>
|
||||||
|
<RiInformationFill />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
// defaultOpen
|
||||||
|
>
|
||||||
|
<Tabs.Root
|
||||||
|
defaultValue="untracked"
|
||||||
|
data-testid="track-relationships"
|
||||||
|
className="space-y-4"
|
||||||
|
onValueChange={value =>
|
||||||
|
setTab(value === 'tracked' ? 'tracked' : 'untracked')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-muted">
|
||||||
|
Tracking tables adds them to your GraphQL API. All objects will be
|
||||||
|
admin-only until permissions have been set.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Tabs.List
|
||||||
|
className="border-b border-gray-300 px-4 flex space-x-4"
|
||||||
|
aria-label="Tabs"
|
||||||
|
>
|
||||||
|
<Tabs.Trigger
|
||||||
|
value="untracked"
|
||||||
|
className={
|
||||||
|
tab === 'untracked' ? classNames.selected : classNames.unselected
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Untracked
|
||||||
|
<span className="bg-gray-300 ml-1 px-1.5 py-0.5 rounded text-xs">
|
||||||
|
{suggestedRelationships.length}
|
||||||
|
</span>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger
|
||||||
|
value="tracked"
|
||||||
|
className={
|
||||||
|
tab === 'tracked' ? classNames.selected : classNames.unselected
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Tracked
|
||||||
|
<span className="bg-gray-300 ml-1 px-1.5 py-0.5 rounded text-xs">
|
||||||
|
{trackedFKRelationships.length}
|
||||||
|
</span>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="px-md">
|
||||||
|
<Skeleton count={8} height={25} className="mb-2" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Tabs.Content value="tracked" className="px-md">
|
||||||
|
<TrackedRelationshipsContainer dataSourceName={dataSourceName} />
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="untracked" className="px-md">
|
||||||
|
<UntrackedRelationships dataSourceName={dataSourceName} />
|
||||||
|
</Tabs.Content>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Tabs.Root>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,35 @@
|
|||||||
|
import { useSuggestedRelationships } from '../../../DatabaseRelationships/components/SuggestedRelationships/hooks/useSuggestedRelationships';
|
||||||
|
import { useTrackedRelationships } from './hooks/useTrackedRelationships';
|
||||||
|
import { ManageTrackedRelationships } from './ManageTrackedRelationships';
|
||||||
|
|
||||||
|
export const ManageTrackedRelationshipsContainer = ({
|
||||||
|
dataSourceName,
|
||||||
|
}: {
|
||||||
|
dataSourceName: string;
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
data: trackedFKRelationships,
|
||||||
|
isLoading: isLoadingTrackedRelationships,
|
||||||
|
} = useTrackedRelationships(dataSourceName);
|
||||||
|
|
||||||
|
const { suggestedRelationships, isLoadingSuggestedRelationships } =
|
||||||
|
useSuggestedRelationships({
|
||||||
|
dataSourceName,
|
||||||
|
existingRelationships: [],
|
||||||
|
isEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!suggestedRelationships)
|
||||||
|
return <div className="px-md">Something went wrong</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ManageTrackedRelationships
|
||||||
|
dataSourceName={dataSourceName}
|
||||||
|
suggestedRelationships={suggestedRelationships}
|
||||||
|
trackedFKRelationships={trackedFKRelationships}
|
||||||
|
isLoading={
|
||||||
|
isLoadingSuggestedRelationships || isLoadingTrackedRelationships
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,123 @@
|
|||||||
|
import { RiInformationFill } from 'react-icons/ri';
|
||||||
|
import { Collapsible } from '../../../../new-components/Collapsible';
|
||||||
|
import * as Tabs from '@radix-ui/react-tabs';
|
||||||
|
import { Tooltip } from '../../../../new-components/Tooltip';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useTables } from '../hooks/useTables';
|
||||||
|
import Skeleton from 'react-loading-skeleton';
|
||||||
|
import { TableList } from './TableList';
|
||||||
|
|
||||||
|
const classNames = {
|
||||||
|
selected:
|
||||||
|
'border-yellow-500 text-yellow-500 whitespace-nowrap p-xs border-b-2 font-semibold -mb-0.5',
|
||||||
|
unselected:
|
||||||
|
'border-transparent text-muted whitespace-nowrap p-xs border-b-2 font-semibold -mb-0.5 hover:border-gray-300 hover:text-gray-900',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ManageTrackedTables = ({
|
||||||
|
dataSourceName,
|
||||||
|
}: {
|
||||||
|
dataSourceName: string;
|
||||||
|
}) => {
|
||||||
|
const [tab, setTab] = React.useState<'tracked' | 'untracked'>('untracked');
|
||||||
|
|
||||||
|
const { data, isLoading } = useTables({
|
||||||
|
dataSourceName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const trackedTables = useMemo(
|
||||||
|
() => (data ?? []).filter(table => table.is_tracked),
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
|
const untrackedTables = useMemo(
|
||||||
|
() => (data ?? []).filter(table => !table.is_tracked),
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<div className="px-md">
|
||||||
|
<Skeleton count={8} height={25} className="mb-2" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data) return <div className="px-md">Something went wrong</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible
|
||||||
|
triggerChildren={
|
||||||
|
<div>
|
||||||
|
<div className="flex mb-1 items-center">
|
||||||
|
<div className="font-semibold inline-flex items-center text-lg">
|
||||||
|
Untracked Tables/Views
|
||||||
|
</div>
|
||||||
|
<Tooltip
|
||||||
|
tooltipContentChildren="Expose the tables available in your database via the GraphQL API"
|
||||||
|
side="right"
|
||||||
|
>
|
||||||
|
<RiInformationFill />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
defaultOpen
|
||||||
|
>
|
||||||
|
<Tabs.Root
|
||||||
|
defaultValue="untracked"
|
||||||
|
data-testid="track-tables"
|
||||||
|
className="space-y-4"
|
||||||
|
onValueChange={value =>
|
||||||
|
setTab(value === 'tracked' ? 'tracked' : 'untracked')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-muted">
|
||||||
|
Tracking tables adds them to your GraphQL API. All objects will be
|
||||||
|
admin-only until permissions have been set.
|
||||||
|
</p>
|
||||||
|
<Tabs.List
|
||||||
|
className="border-b border-gray-300 px-4 flex space-x-4"
|
||||||
|
aria-label="Tabs"
|
||||||
|
>
|
||||||
|
<Tabs.Trigger
|
||||||
|
value="untracked"
|
||||||
|
className={
|
||||||
|
tab === 'untracked' ? classNames.selected : classNames.unselected
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Untracked
|
||||||
|
<span className="bg-gray-300 ml-1 px-1.5 py-0.5 rounded text-xs">
|
||||||
|
{untrackedTables.length}
|
||||||
|
</span>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger
|
||||||
|
value="tracked"
|
||||||
|
className={
|
||||||
|
tab === 'tracked' ? classNames.selected : classNames.unselected
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Tracked
|
||||||
|
<span className="bg-gray-300 ml-1 px-1.5 py-0.5 rounded text-xs">
|
||||||
|
{trackedTables.length}
|
||||||
|
</span>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Content value="tracked" className="px-md">
|
||||||
|
<TableList
|
||||||
|
mode={'track'}
|
||||||
|
dataSourceName={dataSourceName}
|
||||||
|
tables={trackedTables}
|
||||||
|
/>
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="untracked" className="px-md">
|
||||||
|
<TableList
|
||||||
|
mode={'untrack'}
|
||||||
|
dataSourceName={dataSourceName}
|
||||||
|
tables={untrackedTables}
|
||||||
|
/>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,41 @@
|
|||||||
|
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||||
|
import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query';
|
||||||
|
import { SuggestedRelationshipWithName } from '../../../DatabaseRelationships/components/SuggestedRelationships/hooks/useSuggestedRelationships';
|
||||||
|
import { RelationshipRow, RelationshipRowProps } from './RelationshipRow';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: RelationshipRow,
|
||||||
|
decorators: [ReactQueryDecorator()],
|
||||||
|
} as ComponentMeta<typeof RelationshipRow>;
|
||||||
|
|
||||||
|
const relationship: SuggestedRelationshipWithName = {
|
||||||
|
constraintName: 'Album_Artist',
|
||||||
|
type: 'object',
|
||||||
|
from: {
|
||||||
|
table: 'Album',
|
||||||
|
columns: ['id'],
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
table: 'Artist',
|
||||||
|
columns: ['albumId'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseProps: RelationshipRowProps = {
|
||||||
|
relationship: relationship,
|
||||||
|
dataSourceName: 'Chinook',
|
||||||
|
isChecked: false,
|
||||||
|
isLoading: false,
|
||||||
|
onCustomize: () => action('onCustomize')(),
|
||||||
|
onToggle: () => action('onToggle')(),
|
||||||
|
onTrack: async () => action('onTrack')(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Base: ComponentStory<typeof RelationshipRow> = () => (
|
||||||
|
<RelationshipRow {...baseProps} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Checked: ComponentStory<typeof RelationshipRow> = () => (
|
||||||
|
<RelationshipRow {...baseProps} isChecked />
|
||||||
|
);
|
@ -0,0 +1,102 @@
|
|||||||
|
import { ChangeEvent, useState } from 'react';
|
||||||
|
import { Button } from '../../../../new-components/Button';
|
||||||
|
import { CardedTable } from '../../../../new-components/CardedTable';
|
||||||
|
import { FaArrowRight, FaColumns, FaDatabase, FaTable } from 'react-icons/fa';
|
||||||
|
import { capitaliseFirstLetter } from '../../../../components/Common/ConfigureTransformation/utils';
|
||||||
|
import { getTableDisplayName } from '../../../DatabaseRelationships';
|
||||||
|
import { SuggestedRelationshipWithName } from '../../../DatabaseRelationships/components/SuggestedRelationships/hooks/useSuggestedRelationships';
|
||||||
|
import { hasuraToast } from '../../../../new-components/Toasts';
|
||||||
|
|
||||||
|
export type RelationshipRowProps = {
|
||||||
|
dataSourceName: string;
|
||||||
|
isChecked: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
onCustomize: () => void;
|
||||||
|
onToggle: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onTrack: () => Promise<void>;
|
||||||
|
relationship: SuggestedRelationshipWithName;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RelationshipRow: React.VFC<RelationshipRowProps> = ({
|
||||||
|
dataSourceName,
|
||||||
|
isChecked,
|
||||||
|
isLoading,
|
||||||
|
onCustomize,
|
||||||
|
onToggle,
|
||||||
|
onTrack,
|
||||||
|
relationship,
|
||||||
|
}) => {
|
||||||
|
const [isLoadingState, setLoading] = useState(false);
|
||||||
|
const onTrackHandler = () => {
|
||||||
|
setLoading(true);
|
||||||
|
onTrack()
|
||||||
|
.then(() => {
|
||||||
|
hasuraToast({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Relationship tracked',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<CardedTable.TableBodyRow
|
||||||
|
className={isChecked ? 'bg-blue-50' : 'bg-transparent'}
|
||||||
|
>
|
||||||
|
<td className="w-0 px-sm text-sm font-semibold text-muted uppercase tracking-wider">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="cursor-pointer rounded border shadow-sm border-gray-400 hover:border-gray-500 focus:ring-yellow-400"
|
||||||
|
value={relationship.constraintName}
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={onToggle}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<CardedTable.TableBodyCell>
|
||||||
|
{relationship.constraintName}
|
||||||
|
</CardedTable.TableBodyCell>
|
||||||
|
<CardedTable.TableBodyCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FaDatabase /> <span>{dataSourceName}</span>
|
||||||
|
</div>
|
||||||
|
</CardedTable.TableBodyCell>
|
||||||
|
<CardedTable.TableBodyCell>
|
||||||
|
{capitaliseFirstLetter(relationship.type)}
|
||||||
|
</CardedTable.TableBodyCell>
|
||||||
|
<CardedTable.TableBodyCell>
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<FaTable />
|
||||||
|
<span>{getTableDisplayName(relationship.from.table)}</span>
|
||||||
|
/
|
||||||
|
<FaColumns />
|
||||||
|
{relationship.from.columns.join(' ')}
|
||||||
|
<FaArrowRight />
|
||||||
|
<FaTable />
|
||||||
|
<span>{getTableDisplayName(relationship.to.table)}</span>
|
||||||
|
/
|
||||||
|
<FaColumns />
|
||||||
|
{relationship.to.columns.join(' ')}
|
||||||
|
</div>
|
||||||
|
</CardedTable.TableBodyCell>
|
||||||
|
<CardedTable.TableBodyCell>
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onTrackHandler()}
|
||||||
|
isLoading={isLoading || isLoadingState}
|
||||||
|
>
|
||||||
|
Track
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="ml-1"
|
||||||
|
onClick={() => onCustomize()}
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
Customize
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardedTable.TableBodyCell>
|
||||||
|
</CardedTable.TableBodyRow>
|
||||||
|
);
|
||||||
|
};
|
@ -9,7 +9,6 @@ type SearchBarProps = {
|
|||||||
export const SearchBar = ({ onSearch }: SearchBarProps) => {
|
export const SearchBar = ({ onSearch }: SearchBarProps) => {
|
||||||
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [value, setValue] = React.useState('');
|
const [value, setValue] = React.useState('');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
@ -20,10 +20,11 @@ interface TableListProps {
|
|||||||
dataSourceName: string;
|
dataSourceName: string;
|
||||||
tables: TrackableTable[];
|
tables: TrackableTable[];
|
||||||
mode: 'track' | 'untrack';
|
mode: 'track' | 'untrack';
|
||||||
|
onTrackedTable?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TableList = (props: TableListProps) => {
|
export const TableList = (props: TableListProps) => {
|
||||||
const { mode, dataSourceName, tables } = props;
|
const { mode, dataSourceName, tables, onTrackedTable } = props;
|
||||||
|
|
||||||
const [pageNumber, setPageNumber] = useState(DEFAULT_PAGE_NUMBER);
|
const [pageNumber, setPageNumber] = useState(DEFAULT_PAGE_NUMBER);
|
||||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
@ -42,16 +43,17 @@ export const TableList = (props: TableListProps) => {
|
|||||||
|
|
||||||
const { untrackTables, trackTables, loading } = useTrackTable(dataSourceName);
|
const { untrackTables, trackTables, loading } = useTrackTable(dataSourceName);
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = async () => {
|
||||||
const tables = filteredTables.filter(({ name }) =>
|
const tables = filteredTables.filter(({ name }) =>
|
||||||
checkedIds.includes(name)
|
checkedIds.includes(name)
|
||||||
);
|
);
|
||||||
if (mode === 'track') {
|
if (mode === 'track') {
|
||||||
untrackTables(tables);
|
await untrackTables(tables);
|
||||||
} else {
|
} else {
|
||||||
trackTables(tables);
|
await trackTables(tables);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onTrackedTable?.();
|
||||||
reset();
|
reset();
|
||||||
};
|
};
|
||||||
|
|
@ -0,0 +1,85 @@
|
|||||||
|
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||||
|
import { within } from '@storybook/testing-library';
|
||||||
|
import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query';
|
||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
import { TrackedRelationships } from './TrackedRelationships';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { Relationship } from '../../../DatabaseRelationships';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Data/Components/TrackedRelationships',
|
||||||
|
component: TrackedRelationships,
|
||||||
|
decorators: [ReactQueryDecorator()],
|
||||||
|
} as ComponentMeta<typeof TrackedRelationships>;
|
||||||
|
|
||||||
|
const relationships: Relationship[] = [
|
||||||
|
{
|
||||||
|
name: 'CUSTOMER_INVOICEs',
|
||||||
|
fromSource: 'Snow',
|
||||||
|
fromTable: ['CUSTOMER'],
|
||||||
|
relationshipType: 'Array',
|
||||||
|
type: 'localRelationship',
|
||||||
|
definition: { mapping: {}, toTable: 'INVOICE' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'INVOICE_CUSTOMER',
|
||||||
|
fromSource: 'Snow',
|
||||||
|
fromTable: ['INVOICE'],
|
||||||
|
relationshipType: 'Object',
|
||||||
|
type: 'localRelationship',
|
||||||
|
definition: {
|
||||||
|
toTable: ['CUSTOMER'],
|
||||||
|
mapping: { CUSTOMERID: 'CUSTOMERID' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Base: ComponentStory<typeof TrackedRelationships> = () => (
|
||||||
|
<TrackedRelationships
|
||||||
|
dataSourceName="chinook"
|
||||||
|
isLoading={false}
|
||||||
|
onRefetchMetadata={() => action('onRefetchMetadata')()}
|
||||||
|
relationships={relationships}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Loading: ComponentStory<typeof TrackedRelationships> = () => (
|
||||||
|
<TrackedRelationships
|
||||||
|
dataSourceName="chinook"
|
||||||
|
isLoading={true}
|
||||||
|
onRefetchMetadata={() => action('onRefetchMetadata')()}
|
||||||
|
relationships={relationships}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const NoRelationships: ComponentStory<
|
||||||
|
typeof TrackedRelationships
|
||||||
|
> = () => (
|
||||||
|
<TrackedRelationships
|
||||||
|
dataSourceName="chinook"
|
||||||
|
isLoading={false}
|
||||||
|
onRefetchMetadata={() => action('onRefetchMetadata')()}
|
||||||
|
relationships={[]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
Base.play = async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
await expect(canvas.getByText('Untrack Selected (0)')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText('Show 10 relationships')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText('RELATIONSHIP NAME')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText('SOURCE')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText('TYPE')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText('RELATIONSHIP')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await expect(canvas.getByText('CUSTOMER_INVOICEs')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText('INVOICE_CUSTOMER')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await expect(canvas.getByText('Array')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText('Object')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await expect(canvas.getAllByText('Snow')).toHaveLength(2);
|
||||||
|
await expect(canvas.getAllByText('Rename')).toHaveLength(2);
|
||||||
|
await expect(canvas.getAllByText('Remove')).toHaveLength(2);
|
||||||
|
};
|
@ -0,0 +1,368 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Button } from '../../../../new-components/Button';
|
||||||
|
import { CardedTable } from '../../../../new-components/CardedTable';
|
||||||
|
import { useCheckRows } from '../hooks/useCheckRows';
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_NUMBER,
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
DEFAULT_PAGE_SIZES,
|
||||||
|
} from '../constants';
|
||||||
|
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';
|
||||||
|
import { paginate } from '../utils';
|
||||||
|
import { SearchBar } from './SearchBar';
|
||||||
|
import { Badge } from '../../../../new-components/Badge';
|
||||||
|
import { hasuraToast } from '../../../../new-components/Toasts';
|
||||||
|
import { TargetName } from '../../../DatabaseRelationships/components/AvailableRelationshipsList/parts/TargetName';
|
||||||
|
import { RelationshipMapping } from '../../../DatabaseRelationships/components/AvailableRelationshipsList/parts/RelationshipMapping';
|
||||||
|
import { RowActions } from '../../../DatabaseRelationships/components/AvailableRelationshipsList/parts/RowActions';
|
||||||
|
import { MODE, Relationship } from '../../../DatabaseRelationships/types';
|
||||||
|
import { RenderWidget } from '../../../DatabaseRelationships/components/RenderWidget/RenderWidget';
|
||||||
|
import { NOTIFICATIONS } from '../../../DatabaseRelationships/components/constants';
|
||||||
|
import { useFireNotification } from '../../../../new-components/Notifications';
|
||||||
|
import { useMetadataMigration } from '../../../MetadataAPI';
|
||||||
|
import {
|
||||||
|
generateDeleteLocalRelationshipRequest,
|
||||||
|
generateRemoteRelationshipDeleteRequest,
|
||||||
|
} from '../../../DatabaseRelationships/utils/generateRequest';
|
||||||
|
import { generateQueryKeys } from '../../../DatabaseRelationships/utils/queryClientUtils';
|
||||||
|
import { useQueryClient } from 'react-query';
|
||||||
|
import { exportMetadata } from '../../../DataSource';
|
||||||
|
import { useHttpClient } from '../../../Network';
|
||||||
|
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
|
||||||
|
import { MetadataDataSource } from '../../../../metadata/types';
|
||||||
|
import Skeleton from 'react-loading-skeleton';
|
||||||
|
import { getSuggestedRelationshipsCacheQuery } from '../../../DatabaseRelationships/components/SuggestedRelationships/hooks/useSuggestedRelationships';
|
||||||
|
|
||||||
|
const getQueryFunction = (relationship: Relationship) => {
|
||||||
|
if (relationship.type === 'localRelationship') {
|
||||||
|
return generateDeleteLocalRelationshipRequest;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
relationship.type === 'remoteDatabaseRelationship' ||
|
||||||
|
relationship.type === 'remoteSchemaRelationship'
|
||||||
|
) {
|
||||||
|
return generateRemoteRelationshipDeleteRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSerializedRelationshipNames = (relationships: Relationship[]) =>
|
||||||
|
relationships.map(rel => rel.name).join('-');
|
||||||
|
|
||||||
|
type RelationshipAction = {
|
||||||
|
mode?: MODE;
|
||||||
|
relationship?: Relationship;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TrackedRelationshipsProps {
|
||||||
|
dataSourceName: string;
|
||||||
|
driver?: MetadataDataSource['kind'];
|
||||||
|
isLoading: boolean;
|
||||||
|
onRefetchMetadata: () => void;
|
||||||
|
relationships: Relationship[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrackedRelationships: React.VFC<TrackedRelationshipsProps> = ({
|
||||||
|
dataSourceName,
|
||||||
|
driver,
|
||||||
|
isLoading,
|
||||||
|
onRefetchMetadata,
|
||||||
|
relationships,
|
||||||
|
}) => {
|
||||||
|
const httpClient = useHttpClient();
|
||||||
|
const { mutateAsync } = useMetadataMigration();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isTrackingSelectedRelationships, setTrackingSelectedRelationships] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
const [pageNumber, setPageNumber] = useState(DEFAULT_PAGE_NUMBER);
|
||||||
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
|
||||||
|
const checkboxRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const { checkedIds, onCheck, allChecked, toggleAll, reset, inputStatus } =
|
||||||
|
useCheckRows(relationships.map(rel => ({ id: rel.name })));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!checkboxRef.current) return;
|
||||||
|
checkboxRef.current.indeterminate = inputStatus === 'indeterminate';
|
||||||
|
}, [inputStatus]);
|
||||||
|
|
||||||
|
const [filteredRelationships, setFilteredRelationships] =
|
||||||
|
useState<Relationship[]>(relationships);
|
||||||
|
|
||||||
|
const serializedRelationshipNames =
|
||||||
|
getSerializedRelationshipNames(relationships);
|
||||||
|
// apply the search text to the relationships
|
||||||
|
useEffect(() => {
|
||||||
|
reset();
|
||||||
|
|
||||||
|
if (!searchText) {
|
||||||
|
setFilteredRelationships(relationships);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredRelationships(
|
||||||
|
relationships.filter(rel =>
|
||||||
|
rel.name.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [serializedRelationshipNames, searchText]);
|
||||||
|
|
||||||
|
const onUntrackSelected = async () => {
|
||||||
|
setTrackingSelectedRelationships(true);
|
||||||
|
try {
|
||||||
|
const selectedRelationships = relationships.filter(rel =>
|
||||||
|
checkedIds.includes(rel.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (driver) {
|
||||||
|
for (let i = 0; i < selectedRelationships.length; i++) {
|
||||||
|
const selectedRelationship = selectedRelationships[i];
|
||||||
|
const mutationOptions = {
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(generateQueryKeys.metadata());
|
||||||
|
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
generateQueryKeys.suggestedRelationships({
|
||||||
|
dataSourceName,
|
||||||
|
table: selectedRelationship.fromTable,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryFunction = getQueryFunction(selectedRelationship);
|
||||||
|
if (queryFunction) {
|
||||||
|
const recentMetadata = await exportMetadata({ httpClient });
|
||||||
|
await mutateAsync(
|
||||||
|
{
|
||||||
|
query: queryFunction({
|
||||||
|
driver,
|
||||||
|
resource_version: recentMetadata.resource_version,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
relationship: selectedRelationship,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
mutationOptions
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
getSuggestedRelationshipsCacheQuery(
|
||||||
|
dataSourceName,
|
||||||
|
selectedRelationship.fromTable
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onRefetchMetadata();
|
||||||
|
|
||||||
|
const relationshipLabel =
|
||||||
|
selectedRelationships.length > 1 ? 'Relationships' : 'Relationship';
|
||||||
|
const toastMessage = `${selectedRelationships.length} ${relationshipLabel} untracked`;
|
||||||
|
|
||||||
|
hasuraToast({
|
||||||
|
title: 'Success',
|
||||||
|
message: toastMessage,
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setTrackingSelectedRelationships(false);
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
setTrackingSelectedRelationships(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { fireNotification } = useFireNotification();
|
||||||
|
const [{ mode, relationship }, setRelationshipAction] =
|
||||||
|
useState<RelationshipAction>({
|
||||||
|
mode: undefined,
|
||||||
|
relationship: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onRelationshipActionCancel = () => {
|
||||||
|
setRelationshipAction({
|
||||||
|
mode: undefined,
|
||||||
|
relationship: undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRelationshipActionError = (err: Error) => {
|
||||||
|
if (mode)
|
||||||
|
fireNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: NOTIFICATIONS.onError[mode],
|
||||||
|
message: err?.message ?? '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRelationshipActionSuccess = () => {
|
||||||
|
if (mode)
|
||||||
|
fireNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Success!',
|
||||||
|
message: NOTIFICATIONS.onSuccess[mode],
|
||||||
|
});
|
||||||
|
|
||||||
|
setRelationshipAction({
|
||||||
|
mode: undefined,
|
||||||
|
relationship: undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="px-md">
|
||||||
|
<Skeleton count={4} height={25} className="mb-2" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoading && relationships.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<IndicatorCard>No tracked relationships found</IndicatorCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between space-x-4">
|
||||||
|
<div className="flex gap-5">
|
||||||
|
<Button
|
||||||
|
mode="primary"
|
||||||
|
disabled={!checkedIds.length}
|
||||||
|
onClick={onUntrackSelected}
|
||||||
|
isLoading={isTrackingSelectedRelationships}
|
||||||
|
loadingText="Please Wait"
|
||||||
|
>
|
||||||
|
Untrack Selected ({checkedIds.length})
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="border-r border-slate-300"></span>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<SearchBar onSearch={data => setSearchText(data)} />
|
||||||
|
{searchText.length ? (
|
||||||
|
<Badge>{filteredRelationships.length} results found</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
icon={<FaAngleLeft />}
|
||||||
|
onClick={() => setPageNumber(pageNumber - 1)}
|
||||||
|
disabled={pageNumber === 1}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={e => {
|
||||||
|
setPageSize(Number(e.target.value));
|
||||||
|
}}
|
||||||
|
className="block w-full max-w-xl h-8 min-h-full shadow-sm rounded pl-3 pr-6 py-0.5 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"
|
||||||
|
>
|
||||||
|
{DEFAULT_PAGE_SIZES.map(_pageSize => (
|
||||||
|
<option key={_pageSize} value={_pageSize}>
|
||||||
|
Show {_pageSize} relationships
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
icon={<FaAngleRight />}
|
||||||
|
onClick={() => setPageNumber(pageNumber + 1)}
|
||||||
|
disabled={pageNumber >= relationships.length / pageSize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardedTable.Table>
|
||||||
|
<CardedTable.TableHead>
|
||||||
|
<CardedTable.TableHeadRow>
|
||||||
|
<th className="w-0 bg-gray-50 px-sm text-sm font-semibold text-muted uppercase tracking-wider border-r">
|
||||||
|
<input
|
||||||
|
ref={checkboxRef}
|
||||||
|
type="checkbox"
|
||||||
|
className="cursor-pointer
|
||||||
|
rounded border shadow-sm border-gray-400 hover:border-gray-500 focus:ring-yellow-400"
|
||||||
|
checked={allChecked}
|
||||||
|
onChange={toggleAll}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<CardedTable.TableHeadCell>
|
||||||
|
RELATIONSHIP NAME
|
||||||
|
</CardedTable.TableHeadCell>
|
||||||
|
<CardedTable.TableHeadCell>SOURCE</CardedTable.TableHeadCell>
|
||||||
|
<CardedTable.TableHeadCell>TYPE</CardedTable.TableHeadCell>
|
||||||
|
<CardedTable.TableHeadCell>RELATIONSHIP</CardedTable.TableHeadCell>
|
||||||
|
<CardedTable.TableHeadCell></CardedTable.TableHeadCell>
|
||||||
|
</CardedTable.TableHeadRow>
|
||||||
|
</CardedTable.TableHead>
|
||||||
|
|
||||||
|
<CardedTable.TableBody>
|
||||||
|
{paginate(filteredRelationships, pageSize, pageNumber).map(
|
||||||
|
relationship => {
|
||||||
|
return (
|
||||||
|
<CardedTable.TableBodyRow key={relationship.name}>
|
||||||
|
<td className="w-0 px-sm text-sm font-semibold text-muted uppercase tracking-wider">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="cursor-pointer rounded border shadow-sm border-gray-400 hover:border-gray-500 focus:ring-yellow-400"
|
||||||
|
value={relationship.name}
|
||||||
|
checked={checkedIds.includes(relationship.name)}
|
||||||
|
onChange={() => onCheck(relationship.name)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<CardedTable.TableBodyCell>
|
||||||
|
{relationship.name}
|
||||||
|
</CardedTable.TableBodyCell>
|
||||||
|
<CardedTable.TableBodyCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TargetName relationship={relationship} />
|
||||||
|
</div>
|
||||||
|
</CardedTable.TableBodyCell>
|
||||||
|
<CardedTable.TableBodyCell>
|
||||||
|
{relationship.relationshipType}
|
||||||
|
</CardedTable.TableBodyCell>
|
||||||
|
|
||||||
|
<CardedTable.TableBodyCell>
|
||||||
|
<RelationshipMapping relationship={relationship} />
|
||||||
|
</CardedTable.TableBodyCell>
|
||||||
|
|
||||||
|
<CardedTable.TableBodyActionCell>
|
||||||
|
<RowActions
|
||||||
|
relationship={relationship}
|
||||||
|
onActionClick={(_relationship, _mode) => {
|
||||||
|
setRelationshipAction({
|
||||||
|
mode: _mode,
|
||||||
|
relationship: _relationship,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardedTable.TableBodyActionCell>
|
||||||
|
</CardedTable.TableBodyRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</CardedTable.TableBody>
|
||||||
|
</CardedTable.Table>
|
||||||
|
<div>
|
||||||
|
{mode && (
|
||||||
|
<RenderWidget
|
||||||
|
dataSourceName={dataSourceName}
|
||||||
|
table={relationship?.fromTable}
|
||||||
|
mode={mode}
|
||||||
|
relationship={relationship}
|
||||||
|
onSuccess={onRelationshipActionSuccess}
|
||||||
|
onCancel={onRelationshipActionCancel}
|
||||||
|
onError={onRelationshipActionError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,58 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useTrackedRelationships } from './hooks/useTrackedRelationships';
|
||||||
|
import { useInvalidateMetadata } from '../../../hasura-metadata-api';
|
||||||
|
import { useMetadata } from '../../../MetadataAPI';
|
||||||
|
import { TrackedRelationships } from './TrackedRelationships';
|
||||||
|
|
||||||
|
const useInvalidateMetadataOnLoad = () => {
|
||||||
|
const invalidateMetadata = useInvalidateMetadata();
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TrackedRelationshipsContainerProps {
|
||||||
|
dataSourceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrackedRelationshipsContainer: React.VFC<
|
||||||
|
TrackedRelationshipsContainerProps
|
||||||
|
> = ({ dataSourceName }) => {
|
||||||
|
useInvalidateMetadataOnLoad();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: relationships,
|
||||||
|
isLoading: isLoadingRelationships,
|
||||||
|
refetchRelationships,
|
||||||
|
} = useTrackedRelationships(dataSourceName);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: metadataDataSource,
|
||||||
|
refetch: refetchMetadata,
|
||||||
|
isLoading: isLoadingMetadata,
|
||||||
|
} = useMetadata(m => {
|
||||||
|
return {
|
||||||
|
resource_version: m.resource_version,
|
||||||
|
source: m.metadata.sources.find(s => s.name === dataSourceName),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const metadataSource = metadataDataSource?.source;
|
||||||
|
const driver = metadataSource?.kind;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TrackedRelationships
|
||||||
|
dataSourceName={dataSourceName}
|
||||||
|
isLoading={isLoadingRelationships || isLoadingMetadata}
|
||||||
|
relationships={relationships}
|
||||||
|
onRefetchMetadata={() => {
|
||||||
|
refetchMetadata().then(() => {
|
||||||
|
refetchRelationships();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
driver={driver}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,251 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '../../../../new-components/Button';
|
||||||
|
import { CardedTable } from '../../../../new-components/CardedTable';
|
||||||
|
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
|
||||||
|
import { useCheckRows } from '../hooks/useCheckRows';
|
||||||
|
import { DEFAULT_PAGE_SIZES } from '../constants';
|
||||||
|
import { FaAngleLeft, FaAngleRight, FaMagic } from 'react-icons/fa';
|
||||||
|
import { DEFAULT_PAGE_NUMBER, DEFAULT_PAGE_SIZE } from '../constants';
|
||||||
|
import { paginate } from '../utils';
|
||||||
|
import { SearchBar } from './SearchBar';
|
||||||
|
import { Badge } from '../../../../new-components/Badge';
|
||||||
|
import {
|
||||||
|
SuggestedRelationshipWithName,
|
||||||
|
useSuggestedRelationships,
|
||||||
|
} from '../../../DatabaseRelationships/components/SuggestedRelationships/hooks/useSuggestedRelationships';
|
||||||
|
import { RelationshipRow } from './RelationshipRow';
|
||||||
|
import { SuggestedRelationshipTrackModal } from '../../../DatabaseRelationships/components/SuggestedRelationshipTrackModal/SuggestedRelationshipTrackModal';
|
||||||
|
import { hasuraToast } from '../../../../new-components/Toasts';
|
||||||
|
import Skeleton from 'react-loading-skeleton';
|
||||||
|
import { useQueryClient } from 'react-query';
|
||||||
|
import { getTrackedRelationshipsCacheKey } from './hooks/useTrackedRelationships';
|
||||||
|
|
||||||
|
interface UntrackedRelationshipsProps {
|
||||||
|
dataSourceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UntrackedRelationships: React.VFC<UntrackedRelationshipsProps> = ({
|
||||||
|
dataSourceName,
|
||||||
|
}) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [pageNumber, setPageNumber] = useState(DEFAULT_PAGE_NUMBER);
|
||||||
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
|
||||||
|
const [isModalVisible, setModalVisible] = useState(false);
|
||||||
|
const [selectedRelationship, setSelectedRelationship] =
|
||||||
|
useState<SuggestedRelationshipWithName | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
suggestedRelationships,
|
||||||
|
isLoadingSuggestedRelationships,
|
||||||
|
onAddSuggestedRelationship,
|
||||||
|
refetchSuggestedRelationships,
|
||||||
|
} = useSuggestedRelationships({
|
||||||
|
dataSourceName,
|
||||||
|
existingRelationships: [],
|
||||||
|
isEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkboxRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const { checkedIds, onCheck, allChecked, toggleAll, reset, inputStatus } =
|
||||||
|
useCheckRows(
|
||||||
|
suggestedRelationships.map(rel => ({ id: rel.constraintName }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const [filteredRelationships, setFilteredRelationships] = useState<
|
||||||
|
SuggestedRelationshipWithName[]
|
||||||
|
>(suggestedRelationships);
|
||||||
|
|
||||||
|
const serializedRelationshipNames = suggestedRelationships
|
||||||
|
.map(rel => rel.constraintName)
|
||||||
|
.join('-');
|
||||||
|
useEffect(() => {
|
||||||
|
reset();
|
||||||
|
if (!searchText) {
|
||||||
|
setFilteredRelationships(suggestedRelationships);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredRelationships(
|
||||||
|
suggestedRelationships.filter(rel =>
|
||||||
|
rel.constraintName.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [serializedRelationshipNames, searchText]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!checkboxRef.current) return;
|
||||||
|
checkboxRef.current.indeterminate = inputStatus === 'indeterminate';
|
||||||
|
}, [inputStatus]);
|
||||||
|
|
||||||
|
const onTrackRelationship = (relationship: SuggestedRelationshipWithName) => {
|
||||||
|
const isObjectRelationship = !!relationship.from?.constraint_name;
|
||||||
|
|
||||||
|
return onAddSuggestedRelationship({
|
||||||
|
name: relationship.constraintName,
|
||||||
|
columnNames: isObjectRelationship
|
||||||
|
? relationship.from.columns
|
||||||
|
: relationship.to.columns,
|
||||||
|
relationshipType: isObjectRelationship ? 'object' : 'array',
|
||||||
|
toTable: isObjectRelationship ? undefined : relationship.to.table,
|
||||||
|
fromTable: relationship.from.table,
|
||||||
|
}).then(() => {
|
||||||
|
refetchSuggestedRelationships();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const [isTrackingSelectedRelationships, setTrackingSelectedRelationships] =
|
||||||
|
useState(false);
|
||||||
|
const onTrackSelected = async () => {
|
||||||
|
setTrackingSelectedRelationships(true);
|
||||||
|
try {
|
||||||
|
const selectedRelationships = suggestedRelationships.filter(rel =>
|
||||||
|
checkedIds.includes(rel.constraintName)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const selectedRelationship of selectedRelationships) {
|
||||||
|
await onTrackRelationship(selectedRelationship);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: getTrackedRelationshipsCacheKey(dataSourceName),
|
||||||
|
});
|
||||||
|
|
||||||
|
hasuraToast({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Relationships tracked',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setTrackingSelectedRelationships(false);
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
refetchSuggestedRelationships();
|
||||||
|
setTrackingSelectedRelationships(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingSuggestedRelationships) {
|
||||||
|
return (
|
||||||
|
<div className="px-sm -mt-2 mb-xs">
|
||||||
|
<Skeleton width={200} height={20} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingSuggestedRelationships && suggestedRelationships.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<IndicatorCard>No untracked relationships found</IndicatorCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between space-x-4">
|
||||||
|
<div className="flex gap-5">
|
||||||
|
<Button
|
||||||
|
mode="primary"
|
||||||
|
disabled={!checkedIds.length}
|
||||||
|
onClick={onTrackSelected}
|
||||||
|
isLoading={isTrackingSelectedRelationships}
|
||||||
|
loadingText="Please Wait"
|
||||||
|
>
|
||||||
|
Track Selected ({checkedIds.length})
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="border-r border-slate-300"></span>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<SearchBar onSearch={data => setSearchText(data)} />
|
||||||
|
{searchText.length ? (
|
||||||
|
<Badge>{filteredRelationships.length} results found</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
icon={<FaAngleLeft />}
|
||||||
|
onClick={() => setPageNumber(pageNumber - 1)}
|
||||||
|
disabled={pageNumber === 1}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={e => {
|
||||||
|
setPageSize(Number(e.target.value));
|
||||||
|
}}
|
||||||
|
className="block w-full max-w-xl h-8 min-h-full shadow-sm rounded pl-3 pr-6 py-0.5 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"
|
||||||
|
>
|
||||||
|
{DEFAULT_PAGE_SIZES.map(_pageSize => (
|
||||||
|
<option key={_pageSize} value={_pageSize}>
|
||||||
|
Show {_pageSize} relationships
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
icon={<FaAngleRight />}
|
||||||
|
onClick={() => setPageNumber(pageNumber + 1)}
|
||||||
|
disabled={pageNumber >= suggestedRelationships.length / pageSize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardedTable.Table>
|
||||||
|
<CardedTable.TableHead>
|
||||||
|
<CardedTable.TableHeadRow>
|
||||||
|
<th className="w-0 bg-gray-50 px-sm text-sm font-semibold text-muted uppercase tracking-wider border-r">
|
||||||
|
<input
|
||||||
|
ref={checkboxRef}
|
||||||
|
type="checkbox"
|
||||||
|
className="cursor-pointer
|
||||||
|
rounded border shadow-sm border-gray-400 hover:border-gray-500 focus:ring-yellow-400"
|
||||||
|
checked={allChecked}
|
||||||
|
onChange={toggleAll}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<CardedTable.TableHeadCell>
|
||||||
|
<div>
|
||||||
|
<FaMagic className="fill-muted" /> SUGGESTED RELATIONSHIPS
|
||||||
|
</div>
|
||||||
|
</CardedTable.TableHeadCell>
|
||||||
|
<CardedTable.TableHeadCell>SOURCE</CardedTable.TableHeadCell>
|
||||||
|
<CardedTable.TableHeadCell>TYPE</CardedTable.TableHeadCell>
|
||||||
|
<CardedTable.TableHeadCell>RELATIONSHIP</CardedTable.TableHeadCell>
|
||||||
|
<CardedTable.TableHeadCell>ACTIONS</CardedTable.TableHeadCell>
|
||||||
|
</CardedTable.TableHeadRow>
|
||||||
|
</CardedTable.TableHead>
|
||||||
|
|
||||||
|
<CardedTable.TableBody>
|
||||||
|
{paginate(filteredRelationships, pageSize, pageNumber).map(
|
||||||
|
relationship => (
|
||||||
|
<RelationshipRow
|
||||||
|
key={relationship.constraintName}
|
||||||
|
isChecked={checkedIds.includes(relationship.constraintName)}
|
||||||
|
isLoading={false}
|
||||||
|
relationship={relationship}
|
||||||
|
onToggle={() => onCheck(relationship.constraintName)}
|
||||||
|
onTrack={() => onTrackRelationship(relationship)}
|
||||||
|
onCustomize={() => {
|
||||||
|
setSelectedRelationship(relationship);
|
||||||
|
setModalVisible(true);
|
||||||
|
}}
|
||||||
|
dataSourceName={dataSourceName}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</CardedTable.TableBody>
|
||||||
|
</CardedTable.Table>
|
||||||
|
{isModalVisible && selectedRelationship && (
|
||||||
|
<SuggestedRelationshipTrackModal
|
||||||
|
relationship={selectedRelationship}
|
||||||
|
dataSourceName={dataSourceName}
|
||||||
|
onClose={() => setModalVisible(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,67 @@
|
|||||||
|
import { useQuery } from 'react-query';
|
||||||
|
import { tableRelationships as getTableRelationships } from '../../../../DatabaseRelationships/utils/tableRelationships';
|
||||||
|
import { DataSource } from '../../../../DataSource';
|
||||||
|
import {
|
||||||
|
useMetadata,
|
||||||
|
MetadataSelectors,
|
||||||
|
} from '../../../../hasura-metadata-api';
|
||||||
|
import { useHttpClient } from '../../../../Network';
|
||||||
|
|
||||||
|
export const getTrackedRelationshipsCacheKey = (dataSourceName: string) => [
|
||||||
|
'tracked_relationships',
|
||||||
|
dataSourceName,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const useTrackedRelationships = (dataSourceName: string) => {
|
||||||
|
const httpClient = useHttpClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: metadataTables,
|
||||||
|
isFetching: isMetadataPending,
|
||||||
|
isLoading: isMetadataLoading,
|
||||||
|
error: metadataError,
|
||||||
|
refetch: refetchMetadata,
|
||||||
|
} = useMetadata(MetadataSelectors.getTables(dataSourceName));
|
||||||
|
|
||||||
|
const fetchRelationships = async () => {
|
||||||
|
const _tableRelationships = [];
|
||||||
|
if (metadataTables && !isMetadataLoading) {
|
||||||
|
for (const metadataTable of metadataTables) {
|
||||||
|
const fkConstraints = await DataSource(
|
||||||
|
httpClient
|
||||||
|
).getTableFkRelationships({
|
||||||
|
dataSourceName,
|
||||||
|
table: metadataTable.table,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableRelationships = getTableRelationships(
|
||||||
|
metadataTable,
|
||||||
|
dataSourceName,
|
||||||
|
fkConstraints
|
||||||
|
);
|
||||||
|
_tableRelationships.push(...tableRelationships);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _tableRelationships;
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: relationships,
|
||||||
|
isLoading: isLoadingRelationships,
|
||||||
|
isFetching: isFetchingRelationships,
|
||||||
|
refetch: refetchRelationships,
|
||||||
|
} = useQuery({
|
||||||
|
queryFn: fetchRelationships,
|
||||||
|
queryKey: getTrackedRelationshipsCacheKey(dataSourceName),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: relationships || [],
|
||||||
|
isFetching: isMetadataPending || isFetchingRelationships,
|
||||||
|
isLoading: isMetadataLoading || isLoadingRelationships,
|
||||||
|
error: [metadataError],
|
||||||
|
refetchRelationships,
|
||||||
|
refetchMetadata,
|
||||||
|
};
|
||||||
|
};
|
@ -1,9 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import produce from 'immer';
|
import produce from 'immer';
|
||||||
|
|
||||||
import type { TrackableTable } from '../types';
|
export const useCheckRows = (data: { id: string }[]) => {
|
||||||
|
|
||||||
export const useCheckRows = (data: TrackableTable[]) => {
|
|
||||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
// Derived statuses
|
// Derived statuses
|
@ -11,23 +11,23 @@ import { ReactQueryDecorator } from '../../../../storybook/decorators/react-quer
|
|||||||
import { expect } from '@storybook/jest';
|
import { expect } from '@storybook/jest';
|
||||||
import { dangerouslyDelay } from '../../../../storybook/utils/dangerouslyDelay';
|
import { dangerouslyDelay } from '../../../../storybook/utils/dangerouslyDelay';
|
||||||
|
|
||||||
import { TrackTables } from '../TrackTables';
|
|
||||||
import { handlers, resetMetadata } from './handlers.mock';
|
import { handlers, resetMetadata } from './handlers.mock';
|
||||||
|
import { ManageTrackedTables } from '../components/ManageTrackedTables';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Data/Components/TrackTables',
|
title: 'Data/Components/ManageTrackedTables',
|
||||||
component: TrackTables,
|
component: ManageTrackedTables,
|
||||||
decorators: [ReactQueryDecorator()],
|
decorators: [ReactQueryDecorator()],
|
||||||
parameters: {
|
parameters: {
|
||||||
msw: handlers(),
|
msw: handlers(),
|
||||||
},
|
},
|
||||||
} as ComponentMeta<typeof TrackTables>;
|
} as ComponentMeta<typeof ManageTrackedTables>;
|
||||||
|
|
||||||
export const TrackedTables: ComponentStory<typeof TrackTables> = () => (
|
export const UntrackedTables: ComponentStory<
|
||||||
<TrackTables dataSourceName="chinook" />
|
typeof ManageTrackedTables
|
||||||
);
|
> = () => <ManageTrackedTables dataSourceName="chinook" />;
|
||||||
|
|
||||||
TrackedTables.play = async ({ canvasElement }) => {
|
UntrackedTables.play = async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
// Reset initial metadata to make sure tests start from a clean slate everytime
|
// Reset initial metadata to make sure tests start from a clean slate everytime
|
||||||
resetMetadata();
|
resetMetadata();
|
||||||
@ -46,64 +46,8 @@ TrackedTables.play = async ({ canvasElement }) => {
|
|||||||
await expect(canvas.getByText('public.MediaType')).toBeInTheDocument();
|
await expect(canvas.getByText('public.MediaType')).toBeInTheDocument();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Track: ComponentStory<typeof TrackTables> = () => (
|
export const Untrack: ComponentStory<typeof ManageTrackedTables> = () => (
|
||||||
<TrackTables dataSourceName="chinook" />
|
<ManageTrackedTables dataSourceName="chinook" />
|
||||||
);
|
|
||||||
|
|
||||||
Track.play = async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
// Reset initial metadata to make sure tests start from a clean slate everytime
|
|
||||||
resetMetadata();
|
|
||||||
|
|
||||||
// Wait until it finishes loading
|
|
||||||
await waitFor(() => canvas.findByTestId('track-tables'), {
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for the button to appear on the screen using findBy. Store it in a variable to click it afterwards.
|
|
||||||
const button = await canvas.findByTestId(`track-public.Invoice`);
|
|
||||||
|
|
||||||
// Wait an additional second, otherwise clicking does not fire the request
|
|
||||||
// Tried to figure out how to avoid using delay and favor waitFor and await findBy,
|
|
||||||
// but could not find a visual cue that indicates that clicking the button will work
|
|
||||||
// This might indicate that the button must wait for some asynchronous operation before it's ready
|
|
||||||
await dangerouslyDelay(1000);
|
|
||||||
|
|
||||||
// Track public.Invoice
|
|
||||||
userEvent.click(button);
|
|
||||||
|
|
||||||
// It should not be in the Untracked tab anymore
|
|
||||||
await waitForElementToBeRemoved(() => canvas.queryByText('public.Invoice'), {
|
|
||||||
timeout: 2000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UntrackedTables: ComponentStory<typeof TrackTables> = () => (
|
|
||||||
<TrackTables dataSourceName="chinook" />
|
|
||||||
);
|
|
||||||
|
|
||||||
UntrackedTables.play = async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
// Reset initial metadata to make sure tests start from a clean slate everytime
|
|
||||||
resetMetadata();
|
|
||||||
|
|
||||||
// Wait until it finishes loading
|
|
||||||
await waitFor(() => canvas.findByTestId('track-tables'), {
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(await canvas.findByText('Tracked'));
|
|
||||||
|
|
||||||
// Verify it correctly displays tracked tables
|
|
||||||
await expect(canvas.getByText('public.Artist')).toBeInTheDocument();
|
|
||||||
await expect(canvas.getByText('public.Album')).toBeInTheDocument();
|
|
||||||
await expect(canvas.getByText('public.Employee')).toBeInTheDocument();
|
|
||||||
await expect(canvas.getByText('public.Customer')).toBeInTheDocument();
|
|
||||||
await expect(canvas.getByText('public.Genre')).toBeInTheDocument();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Untrack: ComponentStory<typeof TrackTables> = () => (
|
|
||||||
<TrackTables dataSourceName="chinook" />
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Untrack.play = async ({ canvasElement }) => {
|
Untrack.play = async ({ canvasElement }) => {
|
||||||
@ -136,7 +80,63 @@ Untrack.play = async ({ canvasElement }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MassiveTableAmount = TrackedTables.bind({});
|
export const Track: ComponentStory<typeof ManageTrackedTables> = () => (
|
||||||
|
<ManageTrackedTables dataSourceName="chinook" />
|
||||||
|
);
|
||||||
|
|
||||||
|
Track.play = async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
// Reset initial metadata to make sure tests start from a clean slate everytime
|
||||||
|
resetMetadata();
|
||||||
|
|
||||||
|
// Wait until it finishes loading
|
||||||
|
await waitFor(() => canvas.findByTestId('track-tables'), {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the button to appear on the screen using findBy. Store it in a variable to click it afterwards.
|
||||||
|
const button = await canvas.findByTestId(`track-public.Invoice`);
|
||||||
|
|
||||||
|
// Wait an additional second, otherwise clicking does not fire the request
|
||||||
|
// Tried to figure out how to avoid using delay and favor waitFor and await findBy,
|
||||||
|
// but could not find a visual cue that indicates that clicking the button will work
|
||||||
|
// This might indicate that the button must wait for some asynchronous operation before it's ready
|
||||||
|
await dangerouslyDelay(1000);
|
||||||
|
|
||||||
|
// Track public.Invoice
|
||||||
|
userEvent.click(button);
|
||||||
|
|
||||||
|
// It should not be in the Untracked tab anymore
|
||||||
|
await waitForElementToBeRemoved(() => canvas.queryByText('public.Invoice'), {
|
||||||
|
timeout: 2000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TrackedTables: ComponentStory<typeof ManageTrackedTables> = () => (
|
||||||
|
<ManageTrackedTables dataSourceName="chinook" />
|
||||||
|
);
|
||||||
|
|
||||||
|
TrackedTables.play = async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
// Reset initial metadata to make sure tests start from a clean slate everytime
|
||||||
|
resetMetadata();
|
||||||
|
|
||||||
|
// Wait until it finishes loading
|
||||||
|
await waitFor(() => canvas.findByTestId('track-tables'), {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(await canvas.findByText('Tracked'));
|
||||||
|
|
||||||
|
// Verify it correctly displays tracked tables
|
||||||
|
await expect(canvas.getByText('public.Artist')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText('public.Album')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText('public.Employee')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText('public.Customer')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText('public.Genre')).toBeInTheDocument();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MassiveTableAmount = UntrackedTables.bind({});
|
||||||
|
|
||||||
MassiveTableAmount.parameters = {
|
MassiveTableAmount.parameters = {
|
||||||
msw: handlers(1000000),
|
msw: handlers(1000000),
|
@ -1,112 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import * as Tabs from '@radix-ui/react-tabs';
|
|
||||||
import { useTables } from './hooks/useTables';
|
|
||||||
|
|
||||||
import { TableList } from './components/TableList';
|
|
||||||
import Skeleton from 'react-loading-skeleton';
|
|
||||||
import { TrackableTable } from './types';
|
|
||||||
|
|
||||||
const classNames = {
|
|
||||||
selected:
|
|
||||||
'border-yellow-500 text-yellow-500 whitespace-nowrap p-xs border-b-2 font-semibold -mb-0.5',
|
|
||||||
unselected:
|
|
||||||
'border-transparent text-muted whitespace-nowrap p-xs border-b-2 font-semibold -mb-0.5 hover:border-gray-300 hover:text-gray-900',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
dataSourceName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupTables = (tables: TrackableTable[]) => {
|
|
||||||
const trackedTables: TrackableTable[] = [];
|
|
||||||
const untrackedTables: TrackableTable[] = [];
|
|
||||||
|
|
||||||
if (tables) {
|
|
||||||
//doing this in one loop to reduce the overhead for large data sets
|
|
||||||
tables.forEach(t => {
|
|
||||||
if (t.is_tracked) {
|
|
||||||
trackedTables.push(t);
|
|
||||||
} else {
|
|
||||||
untrackedTables.push(t);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { trackedTables, untrackedTables };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TrackTables = ({ dataSourceName }: Props) => {
|
|
||||||
const [tab, setTab] = React.useState<'tracked' | 'untracked'>('untracked');
|
|
||||||
|
|
||||||
const { data, isLoading } = useTables({
|
|
||||||
dataSourceName,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { trackedTables, untrackedTables } = React.useMemo(
|
|
||||||
() => groupTables(data ?? []),
|
|
||||||
[data]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading)
|
|
||||||
return (
|
|
||||||
<div className="px-md">
|
|
||||||
<Skeleton count={8} height={25} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!data) return <div className="px-md">Something went wrong</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs.Root
|
|
||||||
defaultValue="untracked"
|
|
||||||
data-testid="track-tables"
|
|
||||||
className="space-y-4"
|
|
||||||
onValueChange={value =>
|
|
||||||
setTab(value === 'tracked' ? 'tracked' : 'untracked')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tabs.List
|
|
||||||
className="border-b border-gray-300 px-4 flex space-x-4"
|
|
||||||
aria-label="Tabs"
|
|
||||||
>
|
|
||||||
<Tabs.Trigger
|
|
||||||
value="untracked"
|
|
||||||
className={
|
|
||||||
tab === 'untracked' ? classNames.selected : classNames.unselected
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Untracked
|
|
||||||
<span className="bg-gray-300 ml-1 px-1.5 py-0.5 rounded text-xs">
|
|
||||||
{untrackedTables.length}
|
|
||||||
</span>
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger
|
|
||||||
value="tracked"
|
|
||||||
className={
|
|
||||||
tab === 'tracked' ? classNames.selected : classNames.unselected
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Tracked
|
|
||||||
<span className="bg-gray-300 ml-1 px-1.5 py-0.5 rounded text-xs">
|
|
||||||
{trackedTables.length}
|
|
||||||
</span>
|
|
||||||
</Tabs.Trigger>
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<Tabs.Content value="tracked" className="px-md">
|
|
||||||
<TableList
|
|
||||||
mode={'track'}
|
|
||||||
dataSourceName={dataSourceName}
|
|
||||||
tables={trackedTables}
|
|
||||||
/>
|
|
||||||
</Tabs.Content>
|
|
||||||
<Tabs.Content value="untracked" className="px-md">
|
|
||||||
<TableList
|
|
||||||
mode={'untrack'}
|
|
||||||
dataSourceName={dataSourceName}
|
|
||||||
tables={untrackedTables}
|
|
||||||
/>
|
|
||||||
</Tabs.Content>
|
|
||||||
</Tabs.Root>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,9 +1,9 @@
|
|||||||
export * from './ManageDatabaseContainer';
|
export * from './ManageDatabaseContainer';
|
||||||
export * from './components';
|
export * from './components';
|
||||||
export * from './hooks';
|
export * from './hooks';
|
||||||
export { tablesQueryKey } from './TrackTables/hooks/useTables';
|
export { tablesQueryKey } from './TrackResources/hooks/useTables';
|
||||||
export { useTrackTable } from './TrackTables/hooks/useTrackTable';
|
export { useTrackTable } from './TrackResources/hooks/useTrackTable';
|
||||||
export { useMetadataSource } from './TrackTables/hooks/useMetadataSource';
|
export { useMetadataSource } from './TrackResources/hooks/useMetadataSource';
|
||||||
export * from './CustomFieldNames';
|
export * from './CustomFieldNames';
|
||||||
export * from '../../utils/getDataRoute';
|
export * from '../../utils/getDataRoute';
|
||||||
export * from './mocks/metadata.mocks';
|
export * from './mocks/metadata.mocks';
|
||||||
|
@ -18,8 +18,8 @@ import { useMetadataMigration } from '../../../../MetadataAPI';
|
|||||||
|
|
||||||
type UseSuggestedRelationshipsArgs = {
|
type UseSuggestedRelationshipsArgs = {
|
||||||
dataSourceName: string;
|
dataSourceName: string;
|
||||||
table: Table;
|
table?: Table;
|
||||||
existingRelationships: LocalRelationship[];
|
existingRelationships?: LocalRelationship[];
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -145,13 +145,18 @@ export const removeExistingRelationships = ({
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getSuggestedRelationshipsCacheQuery = (
|
||||||
|
dataSourceName: string,
|
||||||
|
table: Table
|
||||||
|
) => ['suggested_relationships', dataSourceName, table];
|
||||||
|
|
||||||
export const useSuggestedRelationships = ({
|
export const useSuggestedRelationships = ({
|
||||||
dataSourceName,
|
dataSourceName,
|
||||||
table,
|
table,
|
||||||
existingRelationships,
|
existingRelationships = [],
|
||||||
isEnabled,
|
isEnabled,
|
||||||
}: UseSuggestedRelationshipsArgs) => {
|
}: UseSuggestedRelationshipsArgs) => {
|
||||||
const { data: metadataSource } = useMetadata(
|
const { data: metadataSource, isFetching } = useMetadata(
|
||||||
MetadataSelectors.findSource(dataSourceName)
|
MetadataSelectors.findSource(dataSourceName)
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -173,14 +178,14 @@ export const useSuggestedRelationships = ({
|
|||||||
refetch: refetchSuggestedRelationships,
|
refetch: refetchSuggestedRelationships,
|
||||||
isLoading: isLoadingSuggestedRelationships,
|
isLoading: isLoadingSuggestedRelationships,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['suggested_relationships', dataSourceName, table],
|
queryKey: getSuggestedRelationshipsCacheQuery(dataSourceName, table),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const body = {
|
const body = {
|
||||||
type: `${dataSourcePrefix}_suggest_relationships`,
|
type: `${dataSourcePrefix}_suggest_relationships`,
|
||||||
args: {
|
args: {
|
||||||
omit_tracked: true,
|
omit_tracked: true,
|
||||||
tables: [table],
|
|
||||||
source: dataSourceName,
|
source: dataSourceName,
|
||||||
|
...(table ? { tables: [table] } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const result = await runMetadataQuery<SuggestedRelationshipsResponse>({
|
const result = await runMetadataQuery<SuggestedRelationshipsResponse>({
|
||||||
@ -190,7 +195,7 @@ export const useSuggestedRelationships = ({
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
enabled: isEnabled,
|
enabled: isEnabled && !isFetching,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -202,11 +207,13 @@ export const useSuggestedRelationships = ({
|
|||||||
columnNames,
|
columnNames,
|
||||||
relationshipType,
|
relationshipType,
|
||||||
toTable,
|
toTable,
|
||||||
|
fromTable,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
columnNames: string[];
|
columnNames: string[];
|
||||||
relationshipType: 'object' | 'array';
|
relationshipType: 'object' | 'array';
|
||||||
toTable?: Table;
|
toTable?: Table;
|
||||||
|
fromTable?: Table;
|
||||||
}) => {
|
}) => {
|
||||||
setAddingSuggestedRelationship(true);
|
setAddingSuggestedRelationship(true);
|
||||||
|
|
||||||
@ -214,7 +221,7 @@ export const useSuggestedRelationships = ({
|
|||||||
query: {
|
query: {
|
||||||
type: `${dataSourcePrefix}_create_${relationshipType}_relationship`,
|
type: `${dataSourcePrefix}_create_${relationshipType}_relationship`,
|
||||||
args: {
|
args: {
|
||||||
table,
|
table: fromTable || table,
|
||||||
name,
|
name,
|
||||||
source: dataSourceName,
|
source: dataSourceName,
|
||||||
using: {
|
using: {
|
||||||
@ -229,11 +236,16 @@ export const useSuggestedRelationships = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setAddingSuggestedRelationship(false);
|
setAddingSuggestedRelationship(false);
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: generateQueryKeys.metadata(),
|
queryKey: generateQueryKeys.metadata(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: getSuggestedRelationshipsCacheQuery(dataSourceName, table),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -244,10 +256,12 @@ export const useSuggestedRelationships = ({
|
|||||||
|
|
||||||
const suggestedRelationships = data?.relationships || [];
|
const suggestedRelationships = data?.relationships || [];
|
||||||
|
|
||||||
const tableFilteredRelationships = filterTableRelationships({
|
const tableFilteredRelationships = table
|
||||||
table,
|
? filterTableRelationships({
|
||||||
relationships: suggestedRelationships,
|
table,
|
||||||
});
|
relationships: suggestedRelationships,
|
||||||
|
})
|
||||||
|
: suggestedRelationships;
|
||||||
|
|
||||||
// TODO: remove when the metadata request will correctly omit already tracked relationships
|
// TODO: remove when the metadata request will correctly omit already tracked relationships
|
||||||
const notExistingRelationships = removeExistingRelationships({
|
const notExistingRelationships = removeExistingRelationships({
|
||||||
|
Loading…
Reference in New Issue
Block a user