console: UX improvement for postgres tracking UI

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9578
Co-authored-by: Matthew Goodwin <49927862+m4ttheweric@users.noreply.github.com>
GitOrigin-RevId: 334a08a89a22196e8e5d11b469279690d279ed46
This commit is contained in:
Vijay Prasanna 2023-06-24 00:34:51 +05:30 committed by hasura-bot
parent 324e02b43e
commit ef79d6be9f
30 changed files with 643 additions and 232 deletions

View File

@ -1,3 +1,4 @@
/* eslint-disable no-unused-vars */
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Helmet from 'react-helmet';
@ -56,6 +57,7 @@ import { createNewSchema, deleteCurrentSchema } from './Actions';
import { EmptyState } from './components/EmptyState/EmptyState';
import { TrackableFunctionsList } from './FunctionsList';
import { getTrackableFunctions } from './utils';
import { FeatureFlagContainer } from './TrackTablesContainer';
const DeleteSchemaButton = ({ dispatch, migrationMode, currentDataSource }) => {
const successCb = () => {
@ -737,9 +739,18 @@ class Schema extends Component {
<hr className="my-md" />
{getCurrentSchemaSection()}
<hr className="my-md" />
{getUntrackedTablesSection()}
<FeatureFlagContainer
dataSourceName={currentDataSource}
schema={currentSchema}
dispatch={dispatch}
>
{getUntrackedTablesSection()}
</FeatureFlagContainer>
{isFeatureSupported('tables.relationships.track') &&
getUntrackedRelationsSection()}
{getUntrackedFunctionsSection(
isFeatureSupported('functions.track.enabled')
)}

View File

@ -0,0 +1,148 @@
import React from 'react';
import { TrackableResourceTabs } from '../../../../features/Data/ManageDatabase/components';
import { CollapsibleResource } from '../../../../features/Data/ManageDatabase/parts';
import { TableList } from '../../../../features/Data/ManageTable/parts/TableList';
import { PostgresTable } from '../../../../features/DataSource';
import {
availableFeatureFlagIds,
useIsFeatureFlagEnabled,
} from '../../../../features/FeatureFlags';
import { exportMetadata } from '../../../../metadata/actions';
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
import {
getSchemaBaseRoute,
getTableModifyRoute,
} from '../../../Common/utils/routesUtils';
import { updateSchemaInfo } from '../DataActions';
import _push from '../push';
import { ExperimentalFeatureBanner } from '../../../../features/components';
import { useTrackTablesState } from './useTrackTablesState';
export const TrackTablesContainer = ({
dataSourceName,
schema,
dispatch,
}: {
dataSourceName: string;
schema: string;
dispatch: any;
}) => {
const {
trackedTables,
untrackedTables,
tab,
setTab,
introspectionError,
isIntroLoading,
isIntrospectionError,
isMetaLoading,
isMetadataError,
metadataError,
} = useTrackTablesState(dataSourceName, schema);
const onMultiple = React.useCallback(() => {
dispatch(exportMetadata()).then(
dispatch(updateSchemaInfo()).then(() => {
dispatch(_push(getSchemaBaseRoute(schema, dataSourceName)));
})
);
}, [dataSourceName, dispatch, schema]);
if (isMetadataError || isIntrospectionError)
return (
<IndicatorCard
status="negative"
headline="Error while fetching data"
showIcon
>
<div>
{metadataError?.message ??
(introspectionError as any)?.message ??
'Something went wrong'}
</div>
</IndicatorCard>
);
return (
<CollapsibleResource
title={<div>Untracked tables or views</div>}
tooltip="Tables or views that are not exposed over the GraphQL API"
defaultOpen
key={`${dataSourceName}-${schema}`}
>
<ExperimentalFeatureBanner
githubIssueLink={
'https://github.com/hasura/graphql-engine/discussions/9727'
}
/>
<div className="my-2 text-muted">
Tracking tables adds them to your GraphQL API. All objects will be
admin-only until permissions have been set.
</div>
<TrackableResourceTabs
value={tab}
onValueChange={value => {
setTab(value);
}}
isLoading={isMetaLoading || isIntroLoading}
items={{
untracked: {
amount: untrackedTables.length,
content: (
<TableList
viewingTablesThatAre={'untracked'}
dataSourceName={dataSourceName}
tables={untrackedTables}
onMultipleTablesTrack={onMultiple}
onSingleTableTrack={table => {
dispatch(
_push(
getTableModifyRoute(
schema,
dataSourceName,
(table.table as PostgresTable).name,
table.type === 'BASE TABLE'
)
)
);
}}
/>
),
},
tracked: {
amount: trackedTables.length,
content: (
<TableList
viewingTablesThatAre={'tracked'}
dataSourceName={dataSourceName}
tables={trackedTables}
onMultipleTablesTrack={onMultiple}
onSingleTableTrack={table => {
onMultiple();
}}
/>
),
},
}}
/>
</CollapsibleResource>
);
};
export const FeatureFlagContainer = ({
children,
...props
}: {
dataSourceName: string;
schema: string;
dispatch: any;
children: React.ReactNode;
}) => {
const { enabled } = useIsFeatureFlagEnabled(
availableFeatureFlagIds.trackingSectionUI
);
if (enabled) return <TrackTablesContainer {...props} />;
return children;
};

View File

@ -0,0 +1,74 @@
import React from 'react';
import {
adaptTrackedTables,
adaptUntrackedTables,
selectTrackedTables,
} from '../../../../features/Data/ManageTable/selectors';
import { useIntrospectedTables } from '../../../../features/Data/hooks/useIntrospectedTables';
import { Feature, IntrospectedTable } from '../../../../features/DataSource';
import { useMetadata } from '../../../../features/hasura-metadata-api';
export function useTrackTablesState(dataSourceName: string, schema: string) {
const [tab, setTab] = React.useState<'tracked' | 'untracked'>('untracked');
const {
data: metadataTables = [],
isFetched,
isLoading: isMetaLoading,
isError: isMetadataError,
error: metadataError,
} = useMetadata(m => selectTrackedTables(m)(dataSourceName));
// if this is not memoized it re-runs each render
const select = React.useCallback(
(introspectedTables: Feature | IntrospectedTable[]) => {
const untracked =
adaptUntrackedTables(metadataTables)(introspectedTables);
const untrackedBySchema = untracked.filter(
t => t.name.split('.')[0] === schema
);
const tracked = adaptTrackedTables(metadataTables)(introspectedTables);
const trackedBySchema = tracked.filter(
t => t.name.split('.')[0] === schema
);
return {
untrackedTables: untrackedBySchema,
trackedTables: trackedBySchema,
};
},
[metadataTables, schema]
);
const {
data: { untrackedTables = [], trackedTables = [] } = {},
isLoading: isIntroLoading,
isError: isIntrospectionError,
error: introspectionError,
} = useIntrospectedTables({
dataSourceName,
options: {
select,
enabled: isFetched,
refetchOnWindowFocus: false,
onSuccess: data => {
if (data?.untrackedTables.length === 0) {
// if user has no tracked tables, switch to the untracked list
setTab('tracked');
}
},
},
});
return {
untrackedTables,
trackedTables,
isMetaLoading,
isMetadataError,
metadataError,
isIntroLoading,
isIntrospectionError,
introspectionError,
tab,
setTab,
};
}

View File

@ -22,12 +22,14 @@ export type CustomFieldNamesFormProps = {
callToAction?: string;
callToActionLoadingText?: string;
callToDeny?: string;
isLoading: boolean;
};
export const CustomFieldNamesForm: React.VFC<
CustomFieldNamesFormProps
> = props => {
const {
isLoading,
onClose,
callToAction = 'Save',
callToActionLoadingText = 'Saving...',
@ -158,6 +160,7 @@ export const CustomFieldNamesForm: React.VFC<
*/}
<Dialog.Footer
callToAction={callToAction}
isLoading={isLoading}
callToActionLoadingText={callToActionLoadingText}
callToDeny={callToDeny}
onClose={onClose}

View File

@ -7,23 +7,21 @@ import { IconTooltip } from '../../../../new-components/Tooltip';
export const CollapsibleResource: React.FC<
{
title: string;
title: React.ReactNode;
tooltip: string;
} & Omit<CollapsibleProps, 'triggerChildren'>
> = ({ title, tooltip, children, ...rest }) => (
<Collapsible
triggerChildren={
<div>
<div className="flex mb-1 items-center">
<div className="font-semibold inline-flex items-center text-lg">
{title}
</div>
<IconTooltip
icon={<RiInformationFill />}
message={tooltip}
side="right"
/>
<div className="flex mb-1 items-center">
<div className="font-semibold inline-flex items-center text-lg">
{title}
</div>
<IconTooltip
icon={<RiInformationFill />}
message={tooltip}
side="right"
/>
</div>
}
{...rest}

View File

@ -80,7 +80,7 @@ export const ManageTrackedTables = ({
amount: untrackedTables.length,
content: (
<TableList
mode={'untrack'}
viewingTablesThatAre={'untracked'}
dataSourceName={dataSourceName}
tables={untrackedTables}
/>
@ -90,7 +90,7 @@ export const ManageTrackedTables = ({
amount: trackedTables.length,
content: (
<TableList
mode={'track'}
viewingTablesThatAre={'tracked'}
dataSourceName={dataSourceName}
tables={trackedTables}
/>

View File

@ -4,11 +4,12 @@ import { Input } from '../../../../new-components/Form';
type SearchBarProps = {
onSearch: (searchText: string) => void;
defaultValue?: string;
};
export const SearchBar = ({ onSearch }: SearchBarProps) => {
export const SearchBar = ({ onSearch, defaultValue }: SearchBarProps) => {
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const [value, setValue] = React.useState('');
const [value, setValue] = React.useState(defaultValue ?? '');
return (
<div className="flex gap-2">
<Input

View File

@ -1,102 +1,144 @@
import React, { useState } from 'react';
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';
import { Badge } from '../../../../new-components/Badge';
import { FaFilter } from 'react-icons/fa';
import { DropDown } from '../../../../new-components/AdvancedDropDown';
import { Button } from '../../../../new-components/Button';
import { CardedTable } from '../../../../new-components/CardedTable';
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
import {
DEFAULT_PAGE_NUMBER,
DEFAULT_PAGE_SIZE,
DEFAULT_PAGE_SIZES,
} from '../../TrackResources/constants';
import { useCheckRows } from '../hooks/useCheckRows';
import { TrackableTable } from '../types';
import { paginate, search } from '../utils';
import { SearchBar } from './SearchBar';
import { TableRow } from './TableRow';
import { usePushRoute } from '../../../ConnectDBRedesign/hooks';
import { useTrackTables } from '../../hooks/useTrackTables';
import { hasuraToast } from '../../../../new-components/Toasts';
import { usePushRoute } from '../../../ConnectDBRedesign/hooks';
import { PostgresTable } from '../../../DataSource';
import { TrackableListMenu } from '../../TrackResources/components/TrackableListMenu';
import { usePaginatedSearchableList } from '../../TrackResources/hooks';
import { DisplayToastErrorMessage } from '../../components/DisplayErrorMessage';
import { useTrackTables } from '../../hooks/useTrackTables';
import { TrackableTable } from '../types';
import { filterByTableType, filterByText } from '../utils';
import { TableRow } from './TableRow';
interface TableListProps {
dataSourceName: string;
tables: TrackableTable[];
mode: 'track' | 'untrack';
viewingTablesThatAre: 'tracked' | 'untracked';
onTrackedTable?: () => void;
onMultipleTablesTrack?: () => void;
defaultFilter?: string;
onSingleTableTrack?: (table: TrackableTable) => void;
}
// const getDefaultSelectedTableType = (availableTableTypes: string[]) => {
// return availableTableTypes.includes('BASE TABLE')
// ? ['BASE TABLE']
// : availableTableTypes;
// };
const countByType = (tables: TrackableTable[]) =>
tables.reduce<Record<string, number>>((prev, current) => {
if (prev[current.type]) {
prev[current.type]++;
} else {
prev[current.type] = 1;
}
return prev;
}, {});
export const TableList = (props: TableListProps) => {
const { mode, dataSourceName, tables, onTrackedTable } = props;
const {
viewingTablesThatAre,
dataSourceName,
tables,
onTrackedTable,
defaultFilter,
onMultipleTablesTrack,
} = props;
const [pageNumber, setPageNumber] = useState(DEFAULT_PAGE_NUMBER);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [searchText, setSearchText] = useState('');
const filteredTables = search(tables, searchText);
//const availableTableTypes = getUniqueTableTypes(tables);
const checkboxRef = React.useRef<HTMLInputElement>(null);
const typeCounts = React.useMemo(() => countByType(tables), [tables]);
const { checkedIds, onCheck, allChecked, toggleAll, reset, inputStatus } =
useCheckRows(filteredTables || []);
const availableTableTypes = React.useMemo(
() => Object.keys(typeCounts),
[typeCounts]
);
React.useEffect(() => {
if (!checkboxRef.current) return;
checkboxRef.current.indeterminate = inputStatus === 'indeterminate';
}, [inputStatus]);
const [selectedTableTypes, setSelectedTableTypes] = useState<string[]>([]);
const searchFn = React.useCallback(
(searchText: string, table: TrackableTable) => {
const parentText = table.name.toLowerCase().split('.').join(' / ');
return (
filterByText(parentText, searchText) &&
filterByTableType(table.type, selectedTableTypes)
);
},
[selectedTableTypes]
);
const listProps = usePaginatedSearchableList<TrackableTable>({
data: tables,
filterFn: searchFn,
defaultQuery: defaultFilter,
});
const {
checkData: { onCheck, checkedIds, reset: resetCheckboxes, checkAllElement },
paginatedData: paginatedTables,
checkedItems: checkedTables,
} = listProps;
const { trackTables, isLoading, untrackTables } = useTrackTables({
dataSourceName,
});
const onClick = async () => {
const tables = filteredTables.filter(({ name }) =>
checkedIds.includes(name)
);
const verb = viewingTablesThatAre === 'untracked' ? 'tracked' : 'untracked';
const action =
viewingTablesThatAre === 'untracked' ? trackTables : untrackTables;
if (mode === 'untrack') {
trackTables({
tablesToBeTracked: tables,
onSuccess: () => {
hasuraToast({
type: 'success',
title: 'Successfully tracked',
message: `${tables.length} ${
tables.length <= 1 ? 'table' : 'tables'
} tracked!`,
});
},
onError: err => {
hasuraToast({
type: 'error',
title: err.name,
children: <DisplayToastErrorMessage message={err.message} />,
});
},
});
} else {
untrackTables({
tablesToBeUntracked: tables,
onSuccess: () => {
hasuraToast({
type: 'success',
title: 'Successfully untracked',
message: `${tables.length} ${
tables.length <= 1 ? 'table' : 'tables'
} untracked`,
});
},
onError: err => {
hasuraToast({
type: 'error',
title: err.name,
children: <DisplayToastErrorMessage message={err.message} />,
});
},
});
}
const handleCheckAction = async () => {
//make a copy of the current counts to have an accurate copy of what it was prior to the track/untrack
const currentCounts = { ...typeCounts };
// count the items by type in the payload
const actionCounts = countByType(checkedTables);
onTrackedTable?.();
reset();
action({
tables: checkedTables,
onSuccess: () => {
// create an array of item types where the number tracked/untracked is the same as the total (user tracked/untracked ALL of that type)
const toRemove = Object.entries(actionCounts).reduce<string[]>(
(prev, [key, value]) => {
if (value === currentCounts[key]) {
prev = [...prev, key];
}
return prev;
},
[]
);
// if we found any, filter them out of the selectedTableTypes
if (toRemove.length > 0) {
setSelectedTableTypes(prev =>
prev.filter(t => !toRemove.includes(t))
);
}
onTrackedTable?.();
resetCheckboxes();
hasuraToast({
type: 'success',
title: `Successfully ${verb}`,
message: `${checkedTables.length} ${
checkedTables.length <= 1 ? 'table' : 'tables'
} ${verb}!`,
});
onMultipleTablesTrack?.();
},
onError: err => {
hasuraToast({
type: 'error',
title: err.name,
children: <DisplayToastErrorMessage message={err.message} />,
});
},
});
};
const pushRoute = usePushRoute();
@ -105,104 +147,118 @@ export const TableList = (props: TableListProps) => {
return (
<div className="space-y-4">
<IndicatorCard>{`No ${
mode === 'track' ? 'tracked' : 'untracked'
viewingTablesThatAre === 'tracked' ? 'tracked' : 'untracked'
} tables 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={onClick}
isLoading={isLoading}
loadingText={'Please Wait'}
<TrackableListMenu
checkActionText={`${
viewingTablesThatAre === 'tracked' ? 'Untrack' : 'Track'
} Selected (${checkedTables.length})`}
handleTrackButton={() => {
handleCheckAction();
}}
showButton
isLoading={isLoading}
searchChildren={
<DropDown.Root
trigger={
<Button icon={<FaFilter />}>
{selectedTableTypes.length ? (
<>Type ({selectedTableTypes.length} selected)</>
) : (
<>No Filters applied</>
)}
</Button>
}
>
{`${mode === 'track' ? 'Untrack' : '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>{filteredTables.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} tables
</option>
))}
</select>
<Button
icon={<FaAngleRight />}
onClick={() => setPageNumber(pageNumber + 1)}
disabled={pageNumber >= filteredTables.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>Object</CardedTable.TableHeadCell>
<CardedTable.TableHeadCell>Type</CardedTable.TableHeadCell>
<CardedTable.TableHeadCell>Actions</CardedTable.TableHeadCell>
</CardedTable.TableHeadRow>
</CardedTable.TableHead>
<CardedTable.TableBody>
{paginate(filteredTables, pageSize, pageNumber).map(table => (
<TableRow
key={table.id}
table={table}
dataSourceName={dataSourceName}
checked={checkedIds.includes(table.id)}
reset={reset}
onChange={() => onCheck(table.id)}
onTableNameClick={
mode === 'track'
? () => {
pushRoute(
`data/v2/manage/table/browse?database=${dataSourceName}&table=${encodeURIComponent(
JSON.stringify(table.table)
)}`
<DropDown.Label>Table Types:</DropDown.Label>
<>
{availableTableTypes.map(tableType => (
<DropDown.CheckItem
key={tableType}
onCheckChange={() => {
if (selectedTableTypes.includes(tableType))
setSelectedTableTypes(t =>
t.filter(x => x !== tableType)
);
}
: undefined
}
/>
))}
</CardedTable.TableBody>
</CardedTable.Table>
else setSelectedTableTypes(t => [...t, tableType]);
}}
checked={selectedTableTypes.includes(tableType)}
>
<div>
{tableType} ({typeCounts[tableType]})
</div>
</DropDown.CheckItem>
))}
</>
</DropDown.Root>
}
{...listProps}
/>
{!paginatedTables.length ? (
<div className="space-y-4">
<IndicatorCard>{`No ${
viewingTablesThatAre === 'tracked' ? 'tracked' : 'untracked'
} tables found found for the applied filter`}</IndicatorCard>
</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">
{checkAllElement()}
</th>
<CardedTable.TableHeadCell>Table</CardedTable.TableHeadCell>
<CardedTable.TableHeadCell>Type</CardedTable.TableHeadCell>
<CardedTable.TableHeadCell>Actions</CardedTable.TableHeadCell>
</CardedTable.TableHeadRow>
</CardedTable.TableHead>
<CardedTable.TableBody>
{paginatedTables.map(table => (
<TableRow
key={table.id}
table={table}
dataSourceName={dataSourceName}
checked={checkedIds.includes(table.id)}
reset={resetCheckboxes}
onChange={() => onCheck(table.id)}
onTableTrack={props.onSingleTableTrack}
onTableNameClick={
viewingTablesThatAre === 'tracked'
? () => {
if ('schema' in (table.table as any)) {
const { name, schema } = table.table as PostgresTable;
pushRoute(
`/data/${dataSourceName}/schema/${schema}/tables/${name}/modify`
);
} else
pushRoute(
`data/v2/manage/table/browse?database=${dataSourceName}&table=${encodeURIComponent(
JSON.stringify(table.table)
)}`
);
}
: undefined
}
/>
))}
</CardedTable.TableBody>
</CardedTable.Table>
)}
<style
// fixes double scroll bar issue on page:
dangerouslySetInnerHTML={{
__html: `div[class^="RightContainer_main"] { overflow: unset; }`,
}}
/>
</div>
);
};

View File

@ -17,6 +17,7 @@ interface TableRowProps {
reset: () => void;
onChange: () => void;
onTableNameClick?: () => void;
onTableTrack?: (table: TrackableTable) => void;
}
export const TableRow = React.memo(
@ -27,6 +28,7 @@ export const TableRow = React.memo(
reset,
onChange,
onTableNameClick,
onTableTrack,
}: TableRowProps) => {
const [showCustomModal, setShowCustomModal] = React.useState(false);
const { trackTables, untrackTables, isLoading } = useTrackTables({
@ -40,7 +42,7 @@ export const TableRow = React.memo(
}
trackTables({
tablesToBeTracked: [t],
tables: [t],
onSuccess: () => {
hasuraToast({
type: 'success',
@ -49,9 +51,9 @@ export const TableRow = React.memo(
});
reset();
setShowCustomModal(false);
onTableTrack?.(table);
},
onError: err => {
console.log('!!!', err);
hasuraToast({
type: 'error',
title: 'Unable to perform operation',
@ -63,7 +65,7 @@ export const TableRow = React.memo(
const untrack = () => {
untrackTables({
tablesToBeUntracked: [table],
tables: [table],
onSuccess: () => {
hasuraToast({
type: 'success',
@ -71,9 +73,9 @@ export const TableRow = React.memo(
message: 'Object untracked successfully.',
});
reset();
onTableTrack?.(table);
},
onError: err => {
console.log('log!!');
hasuraToast({
type: 'error',
title: 'Unable to perform operation',

View File

@ -1,5 +1,4 @@
import { Table } from '../../hasura-metadata-types';
import { TrackableTable } from './types';
export const getQualifiedTable = (table: Table): string[] => {
if (Array.isArray(table)) return table;
@ -32,10 +31,14 @@ export const paginate = <T>(
return array.slice((page_number - 1) * page_size, page_number * page_size);
};
export const search = (tables: TrackableTable[], searchText: string) => {
if (!searchText.length) return tables;
export const filterByText = (parentText: string, searchText: string) => {
if (!searchText.length) return true;
return tables.filter(table =>
table.name.toLowerCase().includes(searchText.toLowerCase())
);
return parentText.includes(searchText.toLowerCase());
};
export const filterByTableType = (type: string, selectedTypes?: string[]) => {
if (!selectedTypes?.length) return true;
return selectedTypes.includes(type);
};

View File

@ -62,7 +62,7 @@ export const TrackedFunctions = (props: TrackedFunctionsProps) => {
const listProps = usePaginatedSearchableList({
data: functionsWithId,
searchFn: searchFn,
filterFn: searchFn,
});
const {

View File

@ -86,7 +86,7 @@ export const UntrackedFunctions = (props: UntrackedFunctionsProps) => {
const listProps = usePaginatedSearchableList({
data: functionsWithId,
searchFn: (query, item) => {
filterFn: (query, item) => {
return item.name.toLowerCase().includes(query.toLowerCase());
},
});

View File

@ -11,6 +11,8 @@ export const TrackableListMenu = (
handleTrackButton?: () => void;
checkActionText: string;
showButton?: boolean;
searchChildren?: React.ReactChild;
actionChildren?: React.ReactChild;
}
) => (
<div className="flex justify-between space-x-4">
@ -27,6 +29,7 @@ export const TrackableListMenu = (
>
{props.checkActionText}
</Button>
{props.actionChildren && props.actionChildren}
<span className="border-r border-slate-300" />
</>
)}
@ -34,6 +37,7 @@ export const TrackableListMenu = (
{/* Search Input */}
<div className="flex gap-2">
<SearchBar onSearch={props.handleSearch} />
{props.searchChildren && props.searchChildren}
{props.searchIsActive ? (
<Badge>{props.filteredData.length} results found</Badge>
) : null}

View File

@ -3,9 +3,12 @@ import produce from 'immer';
import React from 'react';
import { AiFillCaretDown } from 'react-icons/ai';
import { DropdownMenu } from '../../../../new-components/DropdownMenu';
import { FaFilter } from 'react-icons/fa';
import { BsCheck2All } from 'react-icons/bs';
export const useCheckRows = <T,>(
data: (T & { id: string })[],
filteredData: (T & { id: string })[],
allData: (T & { id: string })[]
) => {
const [checkedIds, setCheckedIds] = useState<string[]>([]);
@ -15,7 +18,8 @@ export const useCheckRows = <T,>(
// Derived statuses
const allChecked =
(data.length > 0 && checkedIds.length === data.length) ||
(allData.length > 0 && checkedIds.length === allData.length);
(allData.length > 0 && checkedIds.length === allData.length) ||
(filteredData.length > 0 && checkedIds.length === filteredData.length);
// Input field determinate status
const partialSelection =
@ -38,12 +42,22 @@ export const useCheckRows = <T,>(
);
};
const toggleAll = (useOriginalList?: boolean) => {
const toggleAll = (props?: {
useOriginalList?: boolean;
useAllFilteredList?: boolean;
}) => {
const { useOriginalList, useAllFilteredList } = props ?? {};
if (useOriginalList) {
setCheckedIds(allData.map(item => item.id));
return;
}
if (useAllFilteredList) {
setCheckedIds(filteredData.map(item => item.id));
return;
}
if (allChecked) {
setCheckedIds([]);
} else {
@ -75,9 +89,20 @@ export const useCheckRows = <T,>(
<DropdownMenu
items={[
[
<span className="py-1.5" onClick={() => toggleAll(true)}>
All {allData.length} items
</span>,
<div
className="py-1.5 gap-2 flex items-center"
onClick={() => toggleAll({ useAllFilteredList: true })}
>
<FaFilter /> All {filteredData.length} results (filtered)
</div>,
],
[
<div
className="py-1.5 gap-2 flex items-center"
onClick={() => toggleAll({ useOriginalList: true })}
>
<BsCheck2All /> All {allData.length} items
</div>,
],
]}
>

View File

@ -5,20 +5,22 @@ import { paginate, search } from '../utils';
export function usePaginatedSearchableList<TData extends { id: string }>({
data,
searchFn,
filterFn,
defaultQuery,
}: {
data: TData[];
searchFn: (searchText: string, item: TData) => boolean;
filterFn: (searchText: string, item: TData) => boolean;
defaultQuery?: string;
}) {
const [pageNumber, setPageNumber] = useState(DEFAULT_PAGE_NUMBER);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [searchText, setSearchText] = useState('');
const [searchText, setSearchText] = useState(defaultQuery ?? '');
const searchIsActive = !!searchText.length;
const filteredData = React.useMemo(
() => search<TData>({ data, searchText, searchFn }),
[data, searchFn, searchText]
() => search<TData>({ data, searchText, filterFn }),
[data, filterFn, searchText]
);
const { data: paginatedData, totalPages } = React.useMemo(
@ -28,7 +30,7 @@ export function usePaginatedSearchableList<TData extends { id: string }>({
const rowsToBeChecked = searchIsActive ? filteredData : paginatedData;
const checkData = useCheckRows(rowsToBeChecked, data);
const checkData = useCheckRows(rowsToBeChecked, filteredData, data);
const checkedItems = React.useMemo(
() => data.filter(d => checkData.checkedIds.includes(d.id)),

View File

@ -48,13 +48,11 @@ export const paginate = <T>({
export function search<T>({
data,
searchText,
searchFn,
filterFn,
}: {
data: T[];
searchText: string;
searchFn: (searchText: string, item: T) => boolean;
filterFn: (searchText: string, item: T) => boolean;
}) {
if (!searchText.length) return data;
return data.filter(item => searchFn(searchText, item));
return data.filter(item => filterFn(searchText, item));
}

View File

@ -32,10 +32,10 @@ export const useTrackTables = ({
const trackTables = useCallback(
({
tablesToBeTracked,
tables,
...mutateOptions
}: {
tablesToBeTracked: TrackableTable[];
tables: TrackableTable[];
} & MetadataMigrationOptions<BulkKeepGoingResponse>) => {
mutate(
{
@ -44,7 +44,7 @@ export const useTrackTables = ({
resource_version,
args: {
allow_warnings: true,
tables: tablesToBeTracked.map(trackableTable => ({
tables: tables.map(trackableTable => ({
table: trackableTable.table,
source: dataSourceName,
configuration: trackableTable.configuration,
@ -60,10 +60,10 @@ export const useTrackTables = ({
const untrackTables = useCallback(
({
tablesToBeUntracked,
tables,
...mutateOptions
}: {
tablesToBeUntracked: TrackableTable[];
tables: TrackableTable[];
} & MetadataMigrationOptions) => {
mutate(
{
@ -72,7 +72,7 @@ export const useTrackTables = ({
resource_version,
args: {
allow_warnings: true,
tables: tablesToBeUntracked.map(untrackableTable => ({
tables: tables.map(untrackableTable => ({
table: untrackableTable.table,
source: dataSourceName,
configuration: untrackableTable.configuration,

View File

@ -5,6 +5,7 @@ import globals from '../../Globals';
const relationshipTabTablesId = 'f6c57c31-abd3-46d9-aae9-b97435793273';
const importActionFromOpenApiId = '12e5aaf4-c794-4b8f-b762-5fda0bff946a';
const permissionsNewUI = '5f7b1673-b2ef-4c98-89f7-f30cb64f0136';
const trackingSectionUI = 'c2536b28-0ea3-11ee-be56-0242ac120002';
const importActionFromOpenApi: FeatureFlagDefinition = {
id: importActionFromOpenApiId,
@ -21,6 +22,7 @@ export const availableFeatureFlagIds = {
relationshipTabTablesId,
importActionFromOpenApiId,
permissionsNewUI,
trackingSectionUI,
};
export const availableFeatureFlags: FeatureFlagDefinition[] = [
@ -43,6 +45,15 @@ export const availableFeatureFlags: FeatureFlagDefinition[] = [
defaultValue: false,
discussionUrl: '',
},
{
id: trackingSectionUI,
title: 'Enable new Table Tracking UI for Postgres & SQL Server',
description: 'Try out the new UI experience for tracking tables',
section: 'data',
status: 'experimental',
defaultValue: false,
discussionUrl: 'https://github.com/hasura/graphql-engine/discussions/9727',
},
// eslint-disable-next-line no-underscore-dangle
...(isProConsole(globals) ? [importActionFromOpenApi] : []),
];

View File

@ -0,0 +1,14 @@
import { Meta, StoryObj } from '@storybook/react';
import { ExperimentalFeatureBanner } from './ExperimentalFeatureBanner';
export default {
title: 'features/components/Experimental Feature Banner 🧬',
component: ExperimentalFeatureBanner,
args: {
githubIssueLink: 'https://github.com',
},
} as Meta<typeof ExperimentalFeatureBanner>;
export const Basic: StoryObj<typeof ExperimentalFeatureBanner> = {
render: args => <ExperimentalFeatureBanner {...args} />,
};

View File

@ -0,0 +1,41 @@
import clsx from 'clsx';
import React from 'react';
import { FaGithub } from 'react-icons/fa';
import { Button } from '../../new-components/Button';
import { IndicatorCard } from '../../new-components/IndicatorCard';
const twButtonExperimental = `from-purple-50 to-purple-50 border-purple-300 hover:border-purple-500 focus-visible:from-purple-200 focus-visible:to-purple-200 disabled:border-purple-300 !text-purple-800`;
export const ExperimentalFeatureBanner: React.VFC<{
githubIssueLink: string;
feedbackIcon?: JSX.Element;
}> = ({ githubIssueLink, feedbackIcon }) => {
return (
<IndicatorCard
status="experimental"
className="py-4 px-md"
showIcon
contentFullWidth
>
<div className='flex items-center justify-between mx-4"'>
<div>
<h1 className="text-purple-800 font-bold text-lg">
This is an experimental feature
</h1>
<div className="text-muted">
Join the discussion on GitHub to talk about this feature or report
bugs
</div>
</div>
<a href={githubIssueLink} target="_blank" rel="noreferrer">
<Button
className={clsx(twButtonExperimental)}
icon={feedbackIcon ?? <FaGithub />}
>
Share Feedback
</Button>
</a>
</div>
</IndicatorCard>
);
};

View File

@ -0,0 +1,3 @@
# Why is this direcotory here?
This is a place for components that are not atomic and therefore do not belong in `new-components` but are common across many features.

View File

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

View File

@ -200,13 +200,13 @@ export const CheckItem: StoryObj<typeof DropDown.Root> = {
<DropDown.Root trigger={<Trigger />}>
<DropDown.CheckItem
checked={bookmarksChecked}
onCheckChange={checked => setBookmarksChecked(checked)}
onCheckChange={setBookmarksChecked}
>
Show Bookmarks
</DropDown.CheckItem>
<DropDown.CheckItem
checked={urlsChecked}
onCheckChange={checked => setUrlsChecked(checked)}
onCheckChange={setUrlsChecked}
>
Show Full URLs
</DropDown.CheckItem>

View File

@ -15,10 +15,10 @@ export const BasicItem: React.FC<{
return (
<DropdownMenu.Item
disabled={disabled}
className="group/item outline-none cursor-pointer"
className="group/item outline-none"
onClick={onClick}
>
<StyleWrappers.Item dangerous={dangerous} link={link}>
<StyleWrappers.Item dangerous={dangerous} link={link} disabled={disabled}>
{children}
</StyleWrappers.Item>
</DropdownMenu.Item>

View File

@ -1,6 +1,6 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import React from 'react';
import { BsCheck } from 'react-icons/bs';
import { FaCheckSquare, FaRegSquare } from 'react-icons/fa';
import * as StyleWrappers from './style-wrappers';
export const CheckItem: React.FC<{
@ -10,12 +10,19 @@ export const CheckItem: React.FC<{
<DropdownMenu.CheckboxItem
className="group/item outline-none"
checked={checked}
onCheckedChange={onCheckChange}
onClick={e => {
e.preventDefault();
onCheckChange?.(!checked);
}}
>
<StyleWrappers.Item selectable>
<DropdownMenu.ItemIndicator className="absolute left-0 w-7 inline-flex items-center justify-center">
<BsCheck size={15} />
</DropdownMenu.ItemIndicator>
<div className="absolute left-0 w-7 inline-flex items-center justify-center">
{!checked ? (
<FaRegSquare className="text-blue-600" />
) : (
<FaCheckSquare className="text-blue-500" />
)}
</div>
{children}
</StyleWrappers.Item>
</DropdownMenu.CheckboxItem>

View File

@ -5,9 +5,10 @@ import * as StyleWrappers from './style-wrappers';
export const RadioItem: React.FC<{ value: string }> = ({ value, children }) => (
<DropdownMenu.RadioItem className="group/item outline-none" value={value}>
{console.log(value)}
<StyleWrappers.Item selectable>
<DropdownMenu.ItemIndicator className="absolute left-0 w-7 inline-flex items-center justify-center">
<BsDot size={20} />
<BsDot size={24} className="text-blue-600" />
</DropdownMenu.ItemIndicator>
{children}
</StyleWrappers.Item>

View File

@ -6,14 +6,16 @@ export const Item: React.FC<{
dangerous?: boolean;
link?: boolean;
selectable?: boolean;
}> = ({ children, dangerous, link, selectable = false }) => (
disabled?: boolean;
}> = ({ children, dangerous, link, selectable = false, disabled }) => (
<div
className={clsx(
styles.twBaseStyle,
selectable && styles.twSelectableItem,
styles.twDefault,
dangerous && styles.twDangerous,
link && styles.twLink
link && styles.twLink,
disabled && 'cursor-not-allowed'
)}
>
{children}

View File

@ -9,10 +9,11 @@ export const twBaseStyle = `
pl-3
mx-2
select-none
cursor-pointer
group-data-[disabled]/item:!pointer-events-none
group-data-[disabled]/item:!text-neutral-200`;
export const twSelectableItem = `pl-6`;
export const twSelectableItem = `pl-7`;
export const twDefault = `
text-gray-900

View File

@ -7,11 +7,13 @@ import { DropdownMenu, DropdownMenuProps } from '../DropdownMenu';
interface DropdownButtonProps extends React.ComponentProps<typeof Button> {
items: React.ReactNode[][];
options?: DropdownMenuProps['options'];
size?: 'sm' | 'md' | 'lg';
}
export const DropdownButton: React.FC<DropdownButtonProps> = ({
items,
options = {},
size,
...rest
}) => {
const dropdownMenuOptions = produce(options, draft => {
@ -30,7 +32,7 @@ export const DropdownButton: React.FC<DropdownButtonProps> = ({
icon={
<FaChevronDown className="transition-transform group-radix-state-open:rotate-180 w-3 h-3" />
}
size="sm"
size={size ?? 'sm'}
{...rest}
/>
</DropdownMenu>

View File

@ -1,8 +1,8 @@
import React from 'react';
import clsx from 'clsx';
import { FaCheck, FaQuestion, FaTimes } from 'react-icons/fa';
import { FaCheck, FaFlask, FaQuestion, FaTimes } from 'react-icons/fa';
type indicatorCardStatus = 'info' | 'positive' | 'negative';
type indicatorCardStatus = 'info' | 'positive' | 'negative' | 'experimental';
export type IndicatorCardProps = {
status?: indicatorCardStatus;
@ -15,10 +15,11 @@ export type IndicatorCardProps = {
id?: string;
};
const cardColors: Record<indicatorCardStatus, string> = {
const twCardColors: Record<indicatorCardStatus, string> = {
info: 'border-l-secondary',
negative: 'border-l-red-600',
positive: 'border-l-green-600',
experimental: `border-l-purple-600`,
};
const IconPerStatus: Record<
@ -28,12 +29,14 @@ const IconPerStatus: Record<
info: FaQuestion,
negative: FaTimes,
positive: FaCheck,
experimental: FaFlask,
};
const iconColorsPerStatus: Record<indicatorCardStatus, string> = {
info: 'text-blue-800 bg-indigo-100',
positive: 'text-green-800 bg-green-100',
negative: 'text-red-800 bg-red-100',
experimental: 'text-purple-800 bg-purple-100',
};
export const IndicatorCard = ({
@ -52,7 +55,7 @@ export const IndicatorCard = ({
<div
className={clsx(
'flex items-center bg-white rounded p-md border border-gray-300 border-l-4 mb-sm',
cardColors[status],
twCardColors[status],
className
)}
>