From 97b6e2593a306ddfa8f3cb9260850a444fe8a596 Mon Sep 17 00:00:00 2001 From: Sasha Stepanyan <100123785+sasunprov@users.noreply.github.com> Date: Thu, 17 Mar 2022 12:35:29 +0400 Subject: [PATCH] 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 --- .../src/components/Topics/List/List.tsx | 219 +++++++++++------- .../Topics/List/TopicsTableCells.tsx | 59 +++++ .../Topics/List/__tests__/List.spec.tsx | 18 +- .../common/SmartTable/SmartTable.tsx | 134 +++++++++++ .../common/SmartTable/TableColumn.tsx | 94 ++++++++ .../components/common/SmartTable/TableRow.tsx | 85 +++++++ kafka-ui-react-app/src/custom.d.ts | 1 + .../src/lib/__test__/propertyLookup.spec.ts | 18 ++ .../src/lib/hooks/useTableState.ts | 78 +++++++ kafka-ui-react-app/src/lib/propertyLookup.ts | 9 + 10 files changed, 621 insertions(+), 94 deletions(-) create mode 100644 kafka-ui-react-app/src/components/Topics/List/TopicsTableCells.tsx create mode 100644 kafka-ui-react-app/src/components/common/SmartTable/SmartTable.tsx create mode 100644 kafka-ui-react-app/src/components/common/SmartTable/TableColumn.tsx create mode 100644 kafka-ui-react-app/src/components/common/SmartTable/TableRow.tsx create mode 100644 kafka-ui-react-app/src/lib/__test__/propertyLookup.spec.ts create mode 100644 kafka-ui-react-app/src/lib/hooks/useTableState.ts create mode 100644 kafka-ui-react-app/src/lib/propertyLookup.ts diff --git a/kafka-ui-react-app/src/components/Topics/List/List.tsx b/kafka-ui-react-app/src/components/Topics/List/List.tsx index fd75c87a91..4458761f7e 100644 --- a/kafka-ui-react-app/src/components/Topics/List/List.tsx +++ b/kafka-ui-react-app/src/components/Topics/List/List.tsx @@ -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 = ({ 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(true); @@ -90,6 +103,24 @@ const List: React.FC = ({ 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 = ({ setConfirmationModal(''); }; - const [selectedTopics, setSelectedTopics] = React.useState>( - 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 = ({ [setTopicsSearch, history, pathname, perPage] ); + const ActionsCell = React.memo>( + ({ 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 ? ( +
+ } right> + {cleanUpPolicy === CleanUpPolicy.DELETE && ( + + Clear Messages + + )} + {isTopicDeletionAllowed && ( + setDeleteTopicConfirmationVisible(true)} + danger + > + Remove Topic + + )} + +
+ ) : null} + setDeleteTopicConfirmationVisible(false)} + onConfirm={deleteTopicHandler} + > + Are you sure want to remove {name} topic? + + + ); + } + ); + return (
@@ -178,7 +246,7 @@ const List: React.FC = ({ ) : (
- {selectedTopics.size > 0 && ( + {tableState.selectedCount > 0 && ( <>
)}
diff --git a/kafka-ui-react-app/src/components/Topics/List/TopicsTableCells.tsx b/kafka-ui-react-app/src/components/Topics/List/TopicsTableCells.tsx new file mode 100644 index 0000000000..024bf45d73 --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/List/TopicsTableCells.tsx @@ -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 +> = ({ dataItem: { internal, name } }) => { + return ( + <> + {internal && IN} + + {name} + + + ); +}; + +export const TopicSizeCell: React.FC< + TableCellProps +> = ({ dataItem: { segmentSize } }) => { + return ; +}; + +export const OutOfSyncReplicasCell: React.FC< + TableCellProps +> = ({ 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 {data}; +}; + +export const MessagesCell: React.FC< + TableCellProps +> = ({ 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 {data}; +}; diff --git a/kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx b/kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx index d11b08fa26..5aacb2543e 100644 --- a/kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx +++ b/kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx @@ -155,7 +155,7 @@ describe('List', () => { ); 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 diff --git a/kafka-ui-react-app/src/components/common/SmartTable/SmartTable.tsx b/kafka-ui-react-app/src/components/common/SmartTable/SmartTable.tsx new file mode 100644 index 0000000000..301ee671a3 --- /dev/null +++ b/kafka-ui-react-app/src/components/common/SmartTable/SmartTable.tsx @@ -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 { + tableState: TableState; + allSelectable?: boolean; + selectable?: boolean; + className?: string; + placeholder?: string; + isFullwidth?: boolean; + paginated?: boolean; + hoverable?: boolean; +} + +export const SmartTable = ({ + children, + tableState, + selectable = false, + allSelectable = false, + placeholder = 'No Data Found', + isFullwidth = false, + paginated = false, + hoverable = false, +}: React.PropsWithChildren>) => { + 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(child)) { + return child; + } + + const { headerCell, title, orderValue } = child.props; + + const HeaderCell = headerCell as + | React.FC> + | undefined; + + return HeaderCell ? ( + + + + ) : ( + // TODO types will be changed after fixing TableHeaderCell + + ); + }); + return ( + + {allSelectable ? ( + + ) : ( + + )} + {headerCells} + + ); + }, [children, allSelectable, tableState]); + + const bodyRows = React.useMemo(() => { + if (tableState.data.length === 0) { + const colspan = React.Children.count(children) + +selectable; + return ( + + {placeholder} + + ); + } + return tableState.data.map((dataItem, index) => { + return ( + + {children} + + ); + }); + }, [ + children, + handleRowSelection, + hoverable, + placeholder, + selectable, + tableState, + ]); + + return ( + <> + + {headerRow} + {bodyRows} +
+ {paginated && tableState.totalPages !== undefined && ( + + )} + + ); +}; diff --git a/kafka-ui-react-app/src/components/common/SmartTable/TableColumn.tsx b/kafka-ui-react-app/src/components/common/SmartTable/TableColumn.tsx new file mode 100644 index 0000000000..971aacf7bd --- /dev/null +++ b/kafka-ui-react-app/src/components/common/SmartTable/TableColumn.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { TableState } from 'lib/hooks/useTableState'; +import { SortOrder } from 'generated-sources'; + +export interface OrderableProps { + orderBy: OT | null; + sortOrder: SortOrder; + handleOrderBy: (orderBy: OT | null) => void; +} + +interface TableCellPropsBase { + tableState: TableState; +} + +export interface TableHeaderCellProps + extends TableCellPropsBase { + orderable?: OrderableProps; + orderValue?: OT; +} + +export interface TableCellProps + extends TableCellPropsBase { + rowIndex: number; + dataItem: T; + hovered?: boolean; +} + +interface TableColumnProps { + cell?: React.FC>; + children?: React.ReactElement; + headerCell?: React.FC>; + field?: string; + title?: string; + width?: string; + className?: string; + orderValue?: OT; +} + +export const TableColumn = ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + props: React.PropsWithChildren> +): React.ReactElement => { + return ; +}; + +export function isColumnElement( + element: React.ReactNode +): element is React.ReactElement> { + 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 = ({ + selected, + selectable, + rowIndex, + onChange, + el, +}) => { + const handleChange = (e: React.ChangeEvent) => { + onChange(e.target.checked); + }; + + const El = el; + + return ( + + {selectable && ( + + )} + + ); +}; diff --git a/kafka-ui-react-app/src/components/common/SmartTable/TableRow.tsx b/kafka-ui-react-app/src/components/common/SmartTable/TableRow.tsx new file mode 100644 index 0000000000..14a8979727 --- /dev/null +++ b/kafka-ui-react-app/src/components/common/SmartTable/TableRow.tsx @@ -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 { + index: number; + id?: TId; + hoverable?: boolean; + tableState: TableState; + dataItem: T; + selectable: boolean; + onSelectChange?: (row: T, checked: boolean) => void; +} + +export const TableRow = ({ + children, + hoverable = false, + id, + index, + dataItem, + selectable, + tableState, + onSelectChange, +}: React.PropsWithChildren>): 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 ( + + {selectable && ( + + )} + {React.Children.map(children, (child) => { + if (!isColumnElement(child)) { + return child; + } + const { cell, field, width, className } = child.props; + + const Cell = cell as React.FC> | undefined; + + return Cell ? ( + + + + ) : ( + + {field && propertyLookup(field, dataItem)} + + ); + })} + + ); +}; diff --git a/kafka-ui-react-app/src/custom.d.ts b/kafka-ui-react-app/src/custom.d.ts index 990f04c63a..9c2d0f2d6e 100644 --- a/kafka-ui-react-app/src/custom.d.ts +++ b/kafka-ui-react-app/src/custom.d.ts @@ -1 +1,2 @@ type Dictionary = Record; +type IdType = string | number; diff --git a/kafka-ui-react-app/src/lib/__test__/propertyLookup.spec.ts b/kafka-ui-react-app/src/lib/__test__/propertyLookup.spec.ts new file mode 100644 index 0000000000..45f89b0a08 --- /dev/null +++ b/kafka-ui-react-app/src/lib/__test__/propertyLookup.spec.ts @@ -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); + }); +}); diff --git a/kafka-ui-react-app/src/lib/hooks/useTableState.ts b/kafka-ui-react-app/src/lib/hooks/useTableState.ts new file mode 100644 index 0000000000..b5fa6ed79c --- /dev/null +++ b/kafka-ui-react-app/src/lib/hooks/useTableState.ts @@ -0,0 +1,78 @@ +import React, { useCallback } from 'react'; +import { OrderableProps } from 'components/common/SmartTable/TableColumn'; + +export interface TableState { + data: T[]; + selectedIds: Set; + totalPages?: number; + idSelector: (row: T) => TId; + isRowSelectable: (row: T) => boolean; + selectedCount: number; + setRowsSelection: (rows: T[], selected: boolean) => void; + toggleSelection: (selected: boolean) => void; + orderable?: OrderableProps; +} + +export const useTableState = ( + data: T[], + options: { + totalPages: number; + isRowSelectable?: (row: T) => boolean; + idSelector: (row: T) => TId; + }, + orderable?: OrderableProps +): TableState => { + const [selectedIds, setSelectedIds] = React.useState(new Set()); + + 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>(() => { + return { + data, + totalPages, + selectedIds, + orderable, + selectedCount, + idSelector, + isRowSelectable, + setRowsSelection, + toggleSelection, + }; + }, [ + data, + orderable, + selectedIds, + totalPages, + selectedCount, + idSelector, + isRowSelectable, + setRowsSelection, + toggleSelection, + ]); +}; diff --git a/kafka-ui-react-app/src/lib/propertyLookup.ts b/kafka-ui-react-app/src/lib/propertyLookup.ts new file mode 100644 index 0000000000..6a563ca623 --- /dev/null +++ b/kafka-ui-react-app/src/lib/propertyLookup.ts @@ -0,0 +1,9 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function propertyLookup( + path: string, + obj: T +) { + return path.split('.').reduce((prev, curr) => { + return prev ? prev[curr] : null; + }, obj); +}