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:
Luca Restagno 2023-04-03 17:34:27 +02:00 committed by hasura-bot
parent 4b2344f646
commit c3451bd622
29 changed files with 1395 additions and 224 deletions

View File

@ -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>

View File

@ -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();
};

View File

@ -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>
);
};

View File

@ -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
}
/>
);
};

View File

@ -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>
);
};

View File

@ -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 />
);

View File

@ -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>
);
};

View File

@ -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

View File

@ -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();
}; };

View File

@ -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);
};

View File

@ -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>
);
};

View File

@ -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}
/>
);
};

View File

@ -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>
);
};

View File

@ -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,
};
};

View File

@ -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

View File

@ -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),

View File

@ -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>
);
};

View File

@ -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';

View File

@ -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({