Reusable tables (#1703)

* Table and TableColumn components, TableState and DataSource

* Table: Migrate topics table to new Table component

* fix module paths

* test for propertyLookup

* improve useTableState code

* fix folder name

* improve table ordering

* fix selected count for table

Co-authored-by: Roman Zabaluev <rzabaluev@provectus.com>
This commit is contained in:
Sasha Stepanyan 2022-03-17 12:35:29 +04:00 committed by GitHub
parent 5ce24cb8fd
commit 97b6e2593a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 621 additions and 94 deletions

View file

@ -10,23 +10,35 @@ import { clusterTopicNewPath } from 'lib/paths';
import usePagination from 'lib/hooks/usePagination'; import usePagination from 'lib/hooks/usePagination';
import ClusterContext from 'components/contexts/ClusterContext'; import ClusterContext from 'components/contexts/ClusterContext';
import PageLoader from 'components/common/PageLoader/PageLoader'; import PageLoader from 'components/common/PageLoader/PageLoader';
import Pagination from 'components/common/Pagination/Pagination';
import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal'; import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
import { import {
CleanUpPolicy,
GetTopicsRequest, GetTopicsRequest,
SortOrder, SortOrder,
TopicColumnsToSort, TopicColumnsToSort,
} from 'generated-sources'; } from 'generated-sources';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import Search from 'components/common/Search/Search'; import Search from 'components/common/Search/Search';
import { PER_PAGE } from 'lib/constants'; import { PER_PAGE } from 'lib/constants';
import { Table } from 'components/common/table/Table/Table.styled';
import { Button } from 'components/common/Button/Button'; import { Button } from 'components/common/Button/Button';
import PageHeading from 'components/common/PageHeading/PageHeading'; import PageHeading from 'components/common/PageHeading/PageHeading';
import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled'; import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
import Switch from 'components/common/Switch/Switch'; import Switch from 'components/common/Switch/Switch';
import { SmartTable } from 'components/common/SmartTable/SmartTable';
import {
TableCellProps,
TableColumn,
} from 'components/common/SmartTable/TableColumn';
import { useTableState } from 'lib/hooks/useTableState';
import Dropdown from 'components/common/Dropdown/Dropdown';
import VerticalElipsisIcon from 'components/common/Icons/VerticalElipsisIcon';
import DropdownItem from 'components/common/Dropdown/DropdownItem';
import ListItem from './ListItem'; import {
MessagesCell,
OutOfSyncReplicasCell,
TitleCell,
TopicSizeCell,
} from './TopicsTableCells';
export interface TopicsListProps { export interface TopicsListProps {
areTopicsFetching: boolean; areTopicsFetching: boolean;
@ -63,7 +75,8 @@ const List: React.FC<TopicsListProps> = ({
setTopicsSearch, setTopicsSearch,
setTopicsOrderBy, setTopicsOrderBy,
}) => { }) => {
const { isReadOnly } = React.useContext(ClusterContext); const { isReadOnly, isTopicDeletionAllowed } =
React.useContext(ClusterContext);
const { clusterName } = useParams<{ clusterName: ClusterName }>(); const { clusterName } = useParams<{ clusterName: ClusterName }>();
const { page, perPage, pathname } = usePagination(); const { page, perPage, pathname } = usePagination();
const [showInternal, setShowInternal] = React.useState<boolean>(true); const [showInternal, setShowInternal] = React.useState<boolean>(true);
@ -90,6 +103,24 @@ const List: React.FC<TopicsListProps> = ({
showInternal, showInternal,
]); ]);
const tableState = useTableState<
TopicWithDetailedInfo,
string,
TopicColumnsToSort
>(
topics,
{
idSelector: (topic) => topic.name,
totalPages,
isRowSelectable: (topic) => !topic.internal,
},
{
handleOrderBy: setTopicsOrderBy,
orderBy,
sortOrder,
}
);
const handleSwitch = React.useCallback(() => { const handleSwitch = React.useCallback(() => {
setShowInternal(!showInternal); setShowInternal(!showInternal);
history.push(`${pathname}?page=1&perPage=${perPage || PER_PAGE}`); history.push(`${pathname}?page=1&perPage=${perPage || PER_PAGE}`);
@ -103,36 +134,26 @@ const List: React.FC<TopicsListProps> = ({
setConfirmationModal(''); setConfirmationModal('');
}; };
const [selectedTopics, setSelectedTopics] = React.useState<Set<string>>( const clearSelectedTopics = React.useCallback(() => {
new Set() tableState.toggleSelection(false);
); }, [tableState]);
const clearSelectedTopics = () => {
setSelectedTopics(new Set());
};
const toggleTopicSelected = (topicName: string) => {
setSelectedTopics((prevState) => {
const newState = new Set(prevState);
if (newState.has(topicName)) {
newState.delete(topicName);
} else {
newState.add(topicName);
}
return newState;
});
};
const deleteTopicsHandler = React.useCallback(() => { const deleteTopicsHandler = React.useCallback(() => {
deleteTopics(clusterName, Array.from(selectedTopics)); deleteTopics(clusterName, Array.from(tableState.selectedIds));
closeConfirmationModal(); closeConfirmationModal();
clearSelectedTopics(); clearSelectedTopics();
}, [clusterName, deleteTopics, selectedTopics]); }, [clearSelectedTopics, clusterName, deleteTopics, tableState.selectedIds]);
const purgeMessagesHandler = React.useCallback(() => { const purgeMessagesHandler = React.useCallback(() => {
clearTopicsMessages(clusterName, Array.from(selectedTopics)); clearTopicsMessages(clusterName, Array.from(tableState.selectedIds));
closeConfirmationModal(); closeConfirmationModal();
clearSelectedTopics(); clearSelectedTopics();
}, [clearTopicsMessages, clusterName, selectedTopics]); }, [
clearSelectedTopics,
clearTopicsMessages,
clusterName,
tableState.selectedIds,
]);
const searchHandler = React.useCallback( const searchHandler = React.useCallback(
(searchString: string) => { (searchString: string) => {
setTopicsSearch(searchString); setTopicsSearch(searchString);
@ -141,6 +162,53 @@ const List: React.FC<TopicsListProps> = ({
[setTopicsSearch, history, pathname, perPage] [setTopicsSearch, history, pathname, perPage]
); );
const ActionsCell = React.memo<TableCellProps<TopicWithDetailedInfo, string>>(
({ hovered, dataItem: { internal, cleanUpPolicy, name } }) => {
const [
isDeleteTopicConfirmationVisible,
setDeleteTopicConfirmationVisible,
] = React.useState(false);
const deleteTopicHandler = React.useCallback(() => {
deleteTopic(clusterName, name);
}, [name]);
const clearTopicMessagesHandler = React.useCallback(() => {
clearTopicMessages(clusterName, name);
}, [name]);
return (
<>
{!internal && !isReadOnly && hovered ? (
<div className="has-text-right">
<Dropdown label={<VerticalElipsisIcon />} right>
{cleanUpPolicy === CleanUpPolicy.DELETE && (
<DropdownItem onClick={clearTopicMessagesHandler} danger>
Clear Messages
</DropdownItem>
)}
{isTopicDeletionAllowed && (
<DropdownItem
onClick={() => setDeleteTopicConfirmationVisible(true)}
danger
>
Remove Topic
</DropdownItem>
)}
</Dropdown>
</div>
) : null}
<ConfirmationModal
isOpen={isDeleteTopicConfirmationVisible}
onCancel={() => setDeleteTopicConfirmationVisible(false)}
onConfirm={deleteTopicHandler}
>
Are you sure want to remove <b>{name}</b> topic?
</ConfirmationModal>
</>
);
}
);
return ( return (
<div> <div>
<div> <div>
@ -178,7 +246,7 @@ const List: React.FC<TopicsListProps> = ({
<PageLoader /> <PageLoader />
) : ( ) : (
<div> <div>
{selectedTopics.size > 0 && ( {tableState.selectedCount > 0 && (
<> <>
<ControlPanelWrapper data-testid="delete-buttons"> <ControlPanelWrapper data-testid="delete-buttons">
<Button <Button
@ -215,62 +283,43 @@ const List: React.FC<TopicsListProps> = ({
</ConfirmationModal> </ConfirmationModal>
</> </>
)} )}
<Table isFullwidth> <SmartTable
<thead> selectable={!isReadOnly}
<tr> tableState={tableState}
{!isReadOnly && <TableHeaderCell />} placeholder="No topics found"
<TableHeaderCell isFullwidth
title="Topic Name" paginated
orderValue={TopicColumnsToSort.NAME} hoverable
orderBy={orderBy} >
sortOrder={sortOrder} <TableColumn
handleOrderBy={setTopicsOrderBy} width="44%"
/> title="Topic Name"
<TableHeaderCell cell={TitleCell}
title="Total Partitions" orderValue={TopicColumnsToSort.NAME}
orderValue={TopicColumnsToSort.TOTAL_PARTITIONS} />
orderBy={orderBy} <TableColumn
sortOrder={sortOrder} title="Total Partitions"
handleOrderBy={setTopicsOrderBy} field="partitions.length"
/> orderValue={TopicColumnsToSort.TOTAL_PARTITIONS}
<TableHeaderCell />
title="Out of sync replicas" <TableColumn
orderValue={TopicColumnsToSort.OUT_OF_SYNC_REPLICAS} title="Out of sync replicas"
orderBy={orderBy} cell={OutOfSyncReplicasCell}
sortOrder={sortOrder} orderValue={TopicColumnsToSort.OUT_OF_SYNC_REPLICAS}
handleOrderBy={setTopicsOrderBy} />
/> <TableColumn title="Replication Factor" field="replicationFactor" />
<TableHeaderCell title="Replication Factor" /> <TableColumn title="Number of messages" cell={MessagesCell} />
<TableHeaderCell title="Number of messages" /> <TableColumn
<TableHeaderCell title="Size"
title="Size" cell={TopicSizeCell}
orderValue={TopicColumnsToSort.SIZE} orderValue={TopicColumnsToSort.SIZE}
orderBy={orderBy} />
sortOrder={sortOrder} <TableColumn
handleOrderBy={setTopicsOrderBy} width="4%"
/> className="topic-action-block"
</tr> cell={ActionsCell}
</thead> />
<tbody> </SmartTable>
{topics.map((topic) => (
<ListItem
clusterName={clusterName}
key={topic.name}
topic={topic}
selected={selectedTopics.has(topic.name)}
toggleTopicSelected={toggleTopicSelected}
deleteTopic={deleteTopic}
clearTopicMessages={clearTopicMessages}
/>
))}
{topics.length === 0 && (
<tr>
<td colSpan={10}>No topics found</td>
</tr>
)}
</tbody>
</Table>
<Pagination totalPages={totalPages} />
</div> </div>
)} )}
</div> </div>

View file

@ -0,0 +1,59 @@
import React from 'react';
import { TopicWithDetailedInfo } from 'redux/interfaces';
import { TableCellProps } from 'components/common/SmartTable/TableColumn';
import { Tag } from 'components/common/Tag/Tag.styled';
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
import * as S from './List.styled';
export const TitleCell: React.FC<
TableCellProps<TopicWithDetailedInfo, string>
> = ({ dataItem: { internal, name } }) => {
return (
<>
{internal && <Tag color="gray">IN</Tag>}
<S.Link exact to={`topics/${name}`} $isInternal={internal}>
{name}
</S.Link>
</>
);
};
export const TopicSizeCell: React.FC<
TableCellProps<TopicWithDetailedInfo, string>
> = ({ dataItem: { segmentSize } }) => {
return <BytesFormatted value={segmentSize} />;
};
export const OutOfSyncReplicasCell: React.FC<
TableCellProps<TopicWithDetailedInfo, string>
> = ({ dataItem: { partitions } }) => {
const data = React.useMemo(() => {
if (partitions === undefined || partitions.length === 0) {
return 0;
}
return partitions.reduce((memo, { replicas }) => {
const outOfSync = replicas?.filter(({ inSync }) => !inSync);
return memo + (outOfSync?.length || 0);
}, 0);
}, [partitions]);
return <span>{data}</span>;
};
export const MessagesCell: React.FC<
TableCellProps<TopicWithDetailedInfo, string>
> = ({ dataItem: { partitions } }) => {
const data = React.useMemo(() => {
if (partitions === undefined || partitions.length === 0) {
return 0;
}
return partitions.reduce((memo, { offsetMax, offsetMin }) => {
return memo + (offsetMax - offsetMin);
}, 0);
}, [partitions]);
return <span>{data}</span>;
};

View file

@ -155,7 +155,7 @@ describe('List', () => {
</StaticRouter> </StaticRouter>
); );
const getCheckboxInput = (at: number) => const getCheckboxInput = (at: number) =>
component.find('ListItem').at(at).find('input[type="checkbox"]').at(0); component.find('TableRow').at(at).find('input[type="checkbox"]').at(0);
const getConfirmationModal = () => const getConfirmationModal = () =>
component.find('mock-ConfirmationModal').at(0); component.find('mock-ConfirmationModal').at(0);
@ -166,12 +166,12 @@ describe('List', () => {
expect(component.find('.buttons').length).toEqual(0); expect(component.find('.buttons').length).toEqual(0);
// check first item // check first item
getCheckboxInput(0).simulate('change'); getCheckboxInput(0).simulate('change', { target: { checked: true } });
expect(getCheckboxInput(0).props().checked).toBeTruthy(); expect(getCheckboxInput(0).props().checked).toBeTruthy();
expect(getCheckboxInput(1).props().checked).toBeFalsy(); expect(getCheckboxInput(1).props().checked).toBeFalsy();
// check second item // check second item
getCheckboxInput(1).simulate('change'); getCheckboxInput(1).simulate('change', { target: { checked: true } });
expect(getCheckboxInput(0).props().checked).toBeTruthy(); expect(getCheckboxInput(0).props().checked).toBeTruthy();
expect(getCheckboxInput(1).props().checked).toBeTruthy(); expect(getCheckboxInput(1).props().checked).toBeTruthy();
expect( expect(
@ -179,7 +179,7 @@ describe('List', () => {
).toEqual(1); ).toEqual(1);
// uncheck second item // uncheck second item
getCheckboxInput(1).simulate('change'); getCheckboxInput(1).simulate('change', { target: { checked: false } });
expect(getCheckboxInput(0).props().checked).toBeTruthy(); expect(getCheckboxInput(0).props().checked).toBeTruthy();
expect(getCheckboxInput(1).props().checked).toBeFalsy(); expect(getCheckboxInput(1).props().checked).toBeFalsy();
expect( expect(
@ -187,7 +187,7 @@ describe('List', () => {
).toEqual(1); ).toEqual(1);
// uncheck first item // uncheck first item
getCheckboxInput(0).simulate('change'); getCheckboxInput(0).simulate('change', { target: { checked: false } });
expect(getCheckboxInput(0).props().checked).toBeFalsy(); expect(getCheckboxInput(0).props().checked).toBeFalsy();
expect(getCheckboxInput(1).props().checked).toBeFalsy(); expect(getCheckboxInput(1).props().checked).toBeFalsy();
expect( expect(
@ -203,8 +203,8 @@ describe('List', () => {
: 'Are you sure you want to purge messages of selected topics?'; : 'Are you sure you want to purge messages of selected topics?';
const mockFn = const mockFn =
action === 'deleteTopics' ? mockDeleteTopics : mockClearTopicsMessages; action === 'deleteTopics' ? mockDeleteTopics : mockClearTopicsMessages;
getCheckboxInput(0).simulate('change'); getCheckboxInput(0).simulate('change', { target: { checked: true } });
getCheckboxInput(1).simulate('change'); getCheckboxInput(1).simulate('change', { target: { checked: true } });
let modal = getConfirmationModal(); let modal = getConfirmationModal();
expect(modal.prop('isOpen')).toBeFalsy(); expect(modal.prop('isOpen')).toBeFalsy();
component component
@ -240,8 +240,8 @@ describe('List', () => {
}); });
it('closes ConfirmationModal when clicked on the cancel button', async () => { it('closes ConfirmationModal when clicked on the cancel button', async () => {
getCheckboxInput(0).simulate('change'); getCheckboxInput(0).simulate('change', { target: { checked: true } });
getCheckboxInput(1).simulate('change'); getCheckboxInput(1).simulate('change', { target: { checked: true } });
let modal = getConfirmationModal(); let modal = getConfirmationModal();
expect(modal.prop('isOpen')).toBeFalsy(); expect(modal.prop('isOpen')).toBeFalsy();
component component

View file

@ -0,0 +1,134 @@
import React from 'react';
import Pagination from 'components/common/Pagination/Pagination';
import { Table } from 'components/common/table/Table/Table.styled';
import * as S from 'components/common/table/TableHeaderCell/TableHeaderCell.styled';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import { TableState } from 'lib/hooks/useTableState';
import {
isColumnElement,
SelectCell,
TableHeaderCellProps,
} from './TableColumn';
import { TableRow } from './TableRow';
interface SmartTableProps<T, TId extends IdType, OT = never> {
tableState: TableState<T, TId, OT>;
allSelectable?: boolean;
selectable?: boolean;
className?: string;
placeholder?: string;
isFullwidth?: boolean;
paginated?: boolean;
hoverable?: boolean;
}
export const SmartTable = <T, TId extends IdType, OT = never>({
children,
tableState,
selectable = false,
allSelectable = false,
placeholder = 'No Data Found',
isFullwidth = false,
paginated = false,
hoverable = false,
}: React.PropsWithChildren<SmartTableProps<T, TId, OT>>) => {
const handleRowSelection = React.useCallback(
(row: T, checked: boolean) => {
tableState.setRowsSelection([row], checked);
},
[tableState]
);
const headerRow = React.useMemo(() => {
const headerCells = React.Children.map(children, (child) => {
if (!isColumnElement<T, TId, OT>(child)) {
return child;
}
const { headerCell, title, orderValue } = child.props;
const HeaderCell = headerCell as
| React.FC<TableHeaderCellProps<T, TId, OT>>
| undefined;
return HeaderCell ? (
<S.TableHeaderCell>
<HeaderCell
orderValue={orderValue}
orderable={tableState.orderable}
tableState={tableState}
/>
</S.TableHeaderCell>
) : (
// TODO types will be changed after fixing TableHeaderCell
<TableHeaderCell
{...(tableState.orderable as never)}
orderValue={orderValue as never}
title={title}
/>
);
});
return (
<tr>
{allSelectable ? (
<SelectCell
rowIndex={-1}
el="th"
selectable
selected={tableState.selectedCount === tableState.data.length}
onChange={tableState.toggleSelection}
/>
) : (
<S.TableHeaderCell />
)}
{headerCells}
</tr>
);
}, [children, allSelectable, tableState]);
const bodyRows = React.useMemo(() => {
if (tableState.data.length === 0) {
const colspan = React.Children.count(children) + +selectable;
return (
<tr>
<td colSpan={colspan}>{placeholder}</td>
</tr>
);
}
return tableState.data.map((dataItem, index) => {
return (
<TableRow
key={tableState.idSelector(dataItem)}
index={index}
hoverable={hoverable}
dataItem={dataItem}
tableState={tableState}
selectable={selectable}
onSelectChange={handleRowSelection}
>
{children}
</TableRow>
);
});
}, [
children,
handleRowSelection,
hoverable,
placeholder,
selectable,
tableState,
]);
return (
<>
<Table isFullwidth={isFullwidth}>
<thead>{headerRow}</thead>
<tbody>{bodyRows}</tbody>
</Table>
{paginated && tableState.totalPages !== undefined && (
<Pagination totalPages={tableState.totalPages} />
)}
</>
);
};

View file

@ -0,0 +1,94 @@
import React from 'react';
import { TableState } from 'lib/hooks/useTableState';
import { SortOrder } from 'generated-sources';
export interface OrderableProps<OT> {
orderBy: OT | null;
sortOrder: SortOrder;
handleOrderBy: (orderBy: OT | null) => void;
}
interface TableCellPropsBase<T, TId extends IdType, OT = never> {
tableState: TableState<T, TId, OT>;
}
export interface TableHeaderCellProps<T, TId extends IdType, OT = never>
extends TableCellPropsBase<T, TId, OT> {
orderable?: OrderableProps<OT>;
orderValue?: OT;
}
export interface TableCellProps<T, TId extends IdType, OT = never>
extends TableCellPropsBase<T, TId, OT> {
rowIndex: number;
dataItem: T;
hovered?: boolean;
}
interface TableColumnProps<T, TId extends IdType, OT = never> {
cell?: React.FC<TableCellProps<T, TId>>;
children?: React.ReactElement;
headerCell?: React.FC<TableHeaderCellProps<T, TId, OT>>;
field?: string;
title?: string;
width?: string;
className?: string;
orderValue?: OT;
}
export const TableColumn = <T, TId extends IdType, OT = never>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
props: React.PropsWithChildren<TableColumnProps<T, TId, OT>>
): React.ReactElement => {
return <td />;
};
export function isColumnElement<T, TId extends IdType, OT = never>(
element: React.ReactNode
): element is React.ReactElement<TableColumnProps<T, TId, OT>> {
if (!React.isValidElement(element)) {
return false;
}
const elementType = (element as React.ReactElement).type;
return (
elementType === TableColumn ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(elementType as any).originalType === TableColumn
);
}
interface SelectCellProps {
selected: boolean;
selectable: boolean;
el: 'td' | 'th';
rowIndex: number;
onChange: (checked: boolean) => void;
}
export const SelectCell: React.FC<SelectCellProps> = ({
selected,
selectable,
rowIndex,
onChange,
el,
}) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.checked);
};
const El = el;
return (
<El>
{selectable && (
<input
data-row={rowIndex}
onChange={handleChange}
type="checkbox"
checked={selected}
/>
)}
</El>
);
};

View file

@ -0,0 +1,85 @@
import React from 'react';
import { propertyLookup } from 'lib/propertyLookup';
import { TableState } from 'lib/hooks/useTableState';
import { isColumnElement, SelectCell, TableCellProps } from './TableColumn';
interface TableRowProps<T, TId extends IdType = never, OT = never> {
index: number;
id?: TId;
hoverable?: boolean;
tableState: TableState<T, TId, OT>;
dataItem: T;
selectable: boolean;
onSelectChange?: (row: T, checked: boolean) => void;
}
export const TableRow = <T, TId extends IdType, OT = never>({
children,
hoverable = false,
id,
index,
dataItem,
selectable,
tableState,
onSelectChange,
}: React.PropsWithChildren<TableRowProps<T, TId, OT>>): React.ReactElement => {
const [hovered, setHovered] = React.useState(false);
const handleMouseEnter = React.useCallback(() => {
setHovered(true);
}, []);
const handleMouseLeave = React.useCallback(() => {
setHovered(false);
}, []);
const handleSelectChange = React.useCallback(
(checked: boolean) => {
onSelectChange?.(dataItem, checked);
},
[dataItem, onSelectChange]
);
return (
<tr
tabIndex={index}
id={id as string}
onMouseEnter={hoverable ? handleMouseEnter : undefined}
onMouseLeave={hoverable ? handleMouseLeave : undefined}
>
{selectable && (
<SelectCell
rowIndex={index}
el="td"
selectable={tableState.isRowSelectable(dataItem)}
selected={tableState.selectedIds.has(tableState.idSelector(dataItem))}
onChange={handleSelectChange}
/>
)}
{React.Children.map(children, (child) => {
if (!isColumnElement<T, TId>(child)) {
return child;
}
const { cell, field, width, className } = child.props;
const Cell = cell as React.FC<TableCellProps<T, TId, OT>> | undefined;
return Cell ? (
<td className={className} style={{ width }}>
<Cell
tableState={tableState}
hovered={hovered}
rowIndex={index}
dataItem={dataItem}
/>
</td>
) : (
<td className={className} style={{ width }}>
{field && propertyLookup(field, dataItem)}
</td>
);
})}
</tr>
);
};

View file

@ -1 +1,2 @@
type Dictionary<T> = Record<string, T>; type Dictionary<T> = Record<string, T>;
type IdType = string | number;

View file

@ -0,0 +1,18 @@
import { propertyLookup } from 'lib/propertyLookup';
describe('Property Lookup', () => {
const entityObject = {
prop: {
nestedProp: 1,
},
};
it('returns undefined if property not found', () => {
expect(
propertyLookup('prop.nonExistingProp', entityObject)
).toBeUndefined();
});
it('returns value of nested property if it exists', () => {
expect(propertyLookup('prop.nestedProp', entityObject)).toBe(1);
});
});

View file

@ -0,0 +1,78 @@
import React, { useCallback } from 'react';
import { OrderableProps } from 'components/common/SmartTable/TableColumn';
export interface TableState<T, TId extends IdType, OT = never> {
data: T[];
selectedIds: Set<TId>;
totalPages?: number;
idSelector: (row: T) => TId;
isRowSelectable: (row: T) => boolean;
selectedCount: number;
setRowsSelection: (rows: T[], selected: boolean) => void;
toggleSelection: (selected: boolean) => void;
orderable?: OrderableProps<OT>;
}
export const useTableState = <T, TId extends IdType, OT = never>(
data: T[],
options: {
totalPages: number;
isRowSelectable?: (row: T) => boolean;
idSelector: (row: T) => TId;
},
orderable?: OrderableProps<OT>
): TableState<T, TId, OT> => {
const [selectedIds, setSelectedIds] = React.useState(new Set<TId>());
const { idSelector, totalPages, isRowSelectable = () => true } = options;
const selectedCount = selectedIds.size;
const setRowsSelection = useCallback(
(rows: T[], selected: boolean) => {
rows.forEach((row) => {
const id = idSelector(row);
const newSet = new Set(selectedIds);
if (selected) {
newSet.add(id);
} else {
newSet.delete(id);
}
setSelectedIds(newSet);
});
},
[idSelector, selectedIds]
);
const toggleSelection = useCallback(
(selected: boolean) => {
const newSet = new Set(selected ? data.map((r) => idSelector(r)) : []);
setSelectedIds(newSet);
},
[data, idSelector]
);
return React.useMemo<TableState<T, TId, OT>>(() => {
return {
data,
totalPages,
selectedIds,
orderable,
selectedCount,
idSelector,
isRowSelectable,
setRowsSelection,
toggleSelection,
};
}, [
data,
orderable,
selectedIds,
totalPages,
selectedCount,
idSelector,
isRowSelectable,
setRowsSelection,
toggleSelection,
]);
};

View file

@ -0,0 +1,9 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function propertyLookup<T extends { [key: string]: any }>(
path: string,
obj: T
) {
return path.split('.').reduce((prev, curr) => {
return prev ? prev[curr] : null;
}, obj);
}