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