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:
parent
5ce24cb8fd
commit
97b6e2593a
10 changed files with 621 additions and 94 deletions
|
@ -10,23 +10,35 @@ import { clusterTopicNewPath } from 'lib/paths';
|
|||
import usePagination from 'lib/hooks/usePagination';
|
||||
import ClusterContext from 'components/contexts/ClusterContext';
|
||||
import PageLoader from 'components/common/PageLoader/PageLoader';
|
||||
import Pagination from 'components/common/Pagination/Pagination';
|
||||
import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
|
||||
import {
|
||||
CleanUpPolicy,
|
||||
GetTopicsRequest,
|
||||
SortOrder,
|
||||
TopicColumnsToSort,
|
||||
} from 'generated-sources';
|
||||
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
|
||||
import Search from 'components/common/Search/Search';
|
||||
import { PER_PAGE } from 'lib/constants';
|
||||
import { Table } from 'components/common/table/Table/Table.styled';
|
||||
import { Button } from 'components/common/Button/Button';
|
||||
import PageHeading from 'components/common/PageHeading/PageHeading';
|
||||
import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
|
||||
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 {
|
||||
areTopicsFetching: boolean;
|
||||
|
@ -63,7 +75,8 @@ const List: React.FC<TopicsListProps> = ({
|
|||
setTopicsSearch,
|
||||
setTopicsOrderBy,
|
||||
}) => {
|
||||
const { isReadOnly } = React.useContext(ClusterContext);
|
||||
const { isReadOnly, isTopicDeletionAllowed } =
|
||||
React.useContext(ClusterContext);
|
||||
const { clusterName } = useParams<{ clusterName: ClusterName }>();
|
||||
const { page, perPage, pathname } = usePagination();
|
||||
const [showInternal, setShowInternal] = React.useState<boolean>(true);
|
||||
|
@ -90,6 +103,24 @@ const List: React.FC<TopicsListProps> = ({
|
|||
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(() => {
|
||||
setShowInternal(!showInternal);
|
||||
history.push(`${pathname}?page=1&perPage=${perPage || PER_PAGE}`);
|
||||
|
@ -103,36 +134,26 @@ const List: React.FC<TopicsListProps> = ({
|
|||
setConfirmationModal('');
|
||||
};
|
||||
|
||||
const [selectedTopics, setSelectedTopics] = React.useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
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 clearSelectedTopics = React.useCallback(() => {
|
||||
tableState.toggleSelection(false);
|
||||
}, [tableState]);
|
||||
|
||||
const deleteTopicsHandler = React.useCallback(() => {
|
||||
deleteTopics(clusterName, Array.from(selectedTopics));
|
||||
deleteTopics(clusterName, Array.from(tableState.selectedIds));
|
||||
closeConfirmationModal();
|
||||
clearSelectedTopics();
|
||||
}, [clusterName, deleteTopics, selectedTopics]);
|
||||
}, [clearSelectedTopics, clusterName, deleteTopics, tableState.selectedIds]);
|
||||
const purgeMessagesHandler = React.useCallback(() => {
|
||||
clearTopicsMessages(clusterName, Array.from(selectedTopics));
|
||||
clearTopicsMessages(clusterName, Array.from(tableState.selectedIds));
|
||||
closeConfirmationModal();
|
||||
clearSelectedTopics();
|
||||
}, [clearTopicsMessages, clusterName, selectedTopics]);
|
||||
}, [
|
||||
clearSelectedTopics,
|
||||
clearTopicsMessages,
|
||||
clusterName,
|
||||
tableState.selectedIds,
|
||||
]);
|
||||
|
||||
const searchHandler = React.useCallback(
|
||||
(searchString: string) => {
|
||||
setTopicsSearch(searchString);
|
||||
|
@ -141,6 +162,53 @@ const List: React.FC<TopicsListProps> = ({
|
|||
[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 (
|
||||
<div>
|
||||
<div>
|
||||
|
@ -178,7 +246,7 @@ const List: React.FC<TopicsListProps> = ({
|
|||
<PageLoader />
|
||||
) : (
|
||||
<div>
|
||||
{selectedTopics.size > 0 && (
|
||||
{tableState.selectedCount > 0 && (
|
||||
<>
|
||||
<ControlPanelWrapper data-testid="delete-buttons">
|
||||
<Button
|
||||
|
@ -215,62 +283,43 @@ const List: React.FC<TopicsListProps> = ({
|
|||
</ConfirmationModal>
|
||||
</>
|
||||
)}
|
||||
<Table isFullwidth>
|
||||
<thead>
|
||||
<tr>
|
||||
{!isReadOnly && <TableHeaderCell />}
|
||||
<TableHeaderCell
|
||||
title="Topic Name"
|
||||
orderValue={TopicColumnsToSort.NAME}
|
||||
orderBy={orderBy}
|
||||
sortOrder={sortOrder}
|
||||
handleOrderBy={setTopicsOrderBy}
|
||||
/>
|
||||
<TableHeaderCell
|
||||
title="Total Partitions"
|
||||
orderValue={TopicColumnsToSort.TOTAL_PARTITIONS}
|
||||
orderBy={orderBy}
|
||||
sortOrder={sortOrder}
|
||||
handleOrderBy={setTopicsOrderBy}
|
||||
/>
|
||||
<TableHeaderCell
|
||||
title="Out of sync replicas"
|
||||
orderValue={TopicColumnsToSort.OUT_OF_SYNC_REPLICAS}
|
||||
orderBy={orderBy}
|
||||
sortOrder={sortOrder}
|
||||
handleOrderBy={setTopicsOrderBy}
|
||||
/>
|
||||
<TableHeaderCell title="Replication Factor" />
|
||||
<TableHeaderCell title="Number of messages" />
|
||||
<TableHeaderCell
|
||||
title="Size"
|
||||
orderValue={TopicColumnsToSort.SIZE}
|
||||
orderBy={orderBy}
|
||||
sortOrder={sortOrder}
|
||||
handleOrderBy={setTopicsOrderBy}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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} />
|
||||
<SmartTable
|
||||
selectable={!isReadOnly}
|
||||
tableState={tableState}
|
||||
placeholder="No topics found"
|
||||
isFullwidth
|
||||
paginated
|
||||
hoverable
|
||||
>
|
||||
<TableColumn
|
||||
width="44%"
|
||||
title="Topic Name"
|
||||
cell={TitleCell}
|
||||
orderValue={TopicColumnsToSort.NAME}
|
||||
/>
|
||||
<TableColumn
|
||||
title="Total Partitions"
|
||||
field="partitions.length"
|
||||
orderValue={TopicColumnsToSort.TOTAL_PARTITIONS}
|
||||
/>
|
||||
<TableColumn
|
||||
title="Out of sync replicas"
|
||||
cell={OutOfSyncReplicasCell}
|
||||
orderValue={TopicColumnsToSort.OUT_OF_SYNC_REPLICAS}
|
||||
/>
|
||||
<TableColumn title="Replication Factor" field="replicationFactor" />
|
||||
<TableColumn title="Number of messages" cell={MessagesCell} />
|
||||
<TableColumn
|
||||
title="Size"
|
||||
cell={TopicSizeCell}
|
||||
orderValue={TopicColumnsToSort.SIZE}
|
||||
/>
|
||||
<TableColumn
|
||||
width="4%"
|
||||
className="topic-action-block"
|
||||
cell={ActionsCell}
|
||||
/>
|
||||
</SmartTable>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -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>;
|
||||
};
|
|
@ -155,7 +155,7 @@ describe('List', () => {
|
|||
</StaticRouter>
|
||||
);
|
||||
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 = () =>
|
||||
component.find('mock-ConfirmationModal').at(0);
|
||||
|
@ -166,12 +166,12 @@ describe('List', () => {
|
|||
expect(component.find('.buttons').length).toEqual(0);
|
||||
|
||||
// check first item
|
||||
getCheckboxInput(0).simulate('change');
|
||||
getCheckboxInput(0).simulate('change', { target: { checked: true } });
|
||||
expect(getCheckboxInput(0).props().checked).toBeTruthy();
|
||||
expect(getCheckboxInput(1).props().checked).toBeFalsy();
|
||||
|
||||
// check second item
|
||||
getCheckboxInput(1).simulate('change');
|
||||
getCheckboxInput(1).simulate('change', { target: { checked: true } });
|
||||
expect(getCheckboxInput(0).props().checked).toBeTruthy();
|
||||
expect(getCheckboxInput(1).props().checked).toBeTruthy();
|
||||
expect(
|
||||
|
@ -179,7 +179,7 @@ describe('List', () => {
|
|||
).toEqual(1);
|
||||
|
||||
// uncheck second item
|
||||
getCheckboxInput(1).simulate('change');
|
||||
getCheckboxInput(1).simulate('change', { target: { checked: false } });
|
||||
expect(getCheckboxInput(0).props().checked).toBeTruthy();
|
||||
expect(getCheckboxInput(1).props().checked).toBeFalsy();
|
||||
expect(
|
||||
|
@ -187,7 +187,7 @@ describe('List', () => {
|
|||
).toEqual(1);
|
||||
|
||||
// uncheck first item
|
||||
getCheckboxInput(0).simulate('change');
|
||||
getCheckboxInput(0).simulate('change', { target: { checked: false } });
|
||||
expect(getCheckboxInput(0).props().checked).toBeFalsy();
|
||||
expect(getCheckboxInput(1).props().checked).toBeFalsy();
|
||||
expect(
|
||||
|
@ -203,8 +203,8 @@ describe('List', () => {
|
|||
: 'Are you sure you want to purge messages of selected topics?';
|
||||
const mockFn =
|
||||
action === 'deleteTopics' ? mockDeleteTopics : mockClearTopicsMessages;
|
||||
getCheckboxInput(0).simulate('change');
|
||||
getCheckboxInput(1).simulate('change');
|
||||
getCheckboxInput(0).simulate('change', { target: { checked: true } });
|
||||
getCheckboxInput(1).simulate('change', { target: { checked: true } });
|
||||
let modal = getConfirmationModal();
|
||||
expect(modal.prop('isOpen')).toBeFalsy();
|
||||
component
|
||||
|
@ -240,8 +240,8 @@ describe('List', () => {
|
|||
});
|
||||
|
||||
it('closes ConfirmationModal when clicked on the cancel button', async () => {
|
||||
getCheckboxInput(0).simulate('change');
|
||||
getCheckboxInput(1).simulate('change');
|
||||
getCheckboxInput(0).simulate('change', { target: { checked: true } });
|
||||
getCheckboxInput(1).simulate('change', { target: { checked: true } });
|
||||
let modal = getConfirmationModal();
|
||||
expect(modal.prop('isOpen')).toBeFalsy();
|
||||
component
|
||||
|
|
|
@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
1
kafka-ui-react-app/src/custom.d.ts
vendored
1
kafka-ui-react-app/src/custom.d.ts
vendored
|
@ -1 +1,2 @@
|
|||
type Dictionary<T> = Record<string, T>;
|
||||
type IdType = string | number;
|
||||
|
|
18
kafka-ui-react-app/src/lib/__test__/propertyLookup.spec.ts
Normal file
18
kafka-ui-react-app/src/lib/__test__/propertyLookup.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
78
kafka-ui-react-app/src/lib/hooks/useTableState.ts
Normal file
78
kafka-ui-react-app/src/lib/hooks/useTableState.ts
Normal 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,
|
||||
]);
|
||||
};
|
9
kafka-ui-react-app/src/lib/propertyLookup.ts
Normal file
9
kafka-ui-react-app/src/lib/propertyLookup.ts
Normal 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);
|
||||
}
|
Loading…
Add table
Reference in a new issue