diff --git a/kafka-ui-react-app/src/components/Brokers/Broker/Broker.tsx b/kafka-ui-react-app/src/components/Brokers/Broker/Broker.tsx index 780fdc72d1..ba3d3fcb4c 100644 --- a/kafka-ui-react-app/src/components/Brokers/Broker/Broker.tsx +++ b/kafka-ui-react-app/src/components/Brokers/Broker/Broker.tsx @@ -17,13 +17,6 @@ import BrokerMetrics from 'components/Brokers/Broker/BrokerMetrics/BrokerMetrics import Navbar from 'components/common/Navigation/Navbar.styled'; import PageLoader from 'components/common/PageLoader/PageLoader'; -export interface BrokerLogdirState { - name: string; - error: string; - topics: number; - partitions: number; -} - const Broker: React.FC = () => { const { clusterName, brokerId } = useAppParams(); diff --git a/kafka-ui-react-app/src/components/Brokers/Broker/BrokerLogdir/BrokerLogdir.tsx b/kafka-ui-react-app/src/components/Brokers/Broker/BrokerLogdir/BrokerLogdir.tsx index 37e1bce49d..b38f2b190a 100644 --- a/kafka-ui-react-app/src/components/Brokers/Broker/BrokerLogdir/BrokerLogdir.tsx +++ b/kafka-ui-react-app/src/components/Brokers/Broker/BrokerLogdir/BrokerLogdir.tsx @@ -1,40 +1,53 @@ import React from 'react'; import useAppParams from 'lib/hooks/useAppParams'; -import { translateLogdirs } from 'components/Brokers/utils/translateLogdirs'; -import { SmartTable } from 'components/common/SmartTable/SmartTable'; -import { TableColumn } from 'components/common/SmartTable/TableColumn'; -import { useTableState } from 'lib/hooks/useTableState'; import { ClusterBrokerParam } from 'lib/paths'; import { useBrokerLogDirs } from 'lib/hooks/api/brokers'; - -interface BrokerLogdirState { - name: string; - error: string; - topics: number; - partitions: number; -} +import Table from 'components/common/NewTable'; +import { ColumnDef } from '@tanstack/react-table'; +import { BrokersLogdirs } from 'generated-sources'; const BrokerLogdir: React.FC = () => { const { clusterName, brokerId } = useAppParams(); - const { data: logDirs } = useBrokerLogDirs(clusterName, Number(brokerId)); + const { data } = useBrokerLogDirs(clusterName, Number(brokerId)); - const preparedRows = translateLogdirs(logDirs); - const tableState = useTableState(preparedRows, { - idSelector: ({ name }) => name, - totalPages: 0, - }); + const columns = React.useMemo[]>( + () => [ + { header: 'Name', accessorKey: 'name' }, + { header: 'Error', accessorKey: 'error' }, + { + header: 'Topics', + accessorKey: 'topics', + cell: ({ getValue }) => + getValue()?.length || 0, + enableSorting: false, + }, + { + id: 'partitions', + header: 'Partitions', + accessorKey: 'topics', + cell: ({ getValue }) => { + const topics = getValue(); + if (!topics) { + return 0; + } + return topics.reduce( + (acc, topic) => acc + (topic.partitions?.length || 0), + 0 + ); + }, + enableSorting: false, + }, + ], + [] + ); return ( - - - - - - + ); }; diff --git a/kafka-ui-react-app/src/components/Brokers/Broker/BrokerLogdir/__test__/BrokerLogdir.spec.tsx b/kafka-ui-react-app/src/components/Brokers/Broker/BrokerLogdir/__test__/BrokerLogdir.spec.tsx index 47209c66ba..bb48fde32a 100644 --- a/kafka-ui-react-app/src/components/Brokers/Broker/BrokerLogdir/__test__/BrokerLogdir.spec.tsx +++ b/kafka-ui-react-app/src/components/Brokers/Broker/BrokerLogdir/__test__/BrokerLogdir.spec.tsx @@ -16,7 +16,7 @@ const clusterName = 'local'; const brokerId = 1; describe('BrokerLogdir Component', () => { - const renderComponent = async (payload: BrokerLogdirs[] = []) => { + const renderComponent = async (payload?: BrokerLogdirs[]) => { (useBrokerLogDirs as jest.Mock).mockImplementation(() => ({ data: payload, })); @@ -32,13 +32,35 @@ describe('BrokerLogdir Component', () => { }); }; - it('shows warning when server returns empty logDirs response', async () => { + it('shows warning when server returns undefined logDirs response', async () => { await renderComponent(); - expect(screen.getByText('Log dir data not available')).toBeInTheDocument(); + expect( + screen.getByRole('row', { name: 'Log dir data not available' }) + ).toBeInTheDocument(); }); - it('shows broker', async () => { + it('shows warning when server returns empty logDirs response', async () => { + await renderComponent([]); + expect( + screen.getByRole('row', { name: 'Log dir data not available' }) + ).toBeInTheDocument(); + }); + + it('shows brokers', async () => { await renderComponent(brokerLogDirsPayload); - expect(screen.getByText('/opt/kafka/data-0/logs')).toBeInTheDocument(); + expect( + screen.queryByRole('row', { name: 'Log dir data not available' }) + ).not.toBeInTheDocument(); + + expect( + screen.getByRole('row', { + name: '/opt/kafka/data-0/logs NONE 3 4', + }) + ).toBeInTheDocument(); + expect( + screen.getByRole('row', { + name: '/opt/kafka/data-1/logs NONE 0 0', + }) + ).toBeInTheDocument(); }); }); diff --git a/kafka-ui-react-app/src/components/Brokers/utils/__test__/fixtures.ts b/kafka-ui-react-app/src/components/Brokers/utils/__test__/fixtures.ts index 076fb06a4e..9ab3e4b662 100644 --- a/kafka-ui-react-app/src/components/Brokers/utils/__test__/fixtures.ts +++ b/kafka-ui-react-app/src/components/Brokers/utils/__test__/fixtures.ts @@ -1,21 +1,5 @@ -import { BrokerLogdirState } from 'components/Brokers/Broker/Broker'; import { BrokerMetrics } from 'generated-sources'; -export const transformedBrokerLogDirsPayload: BrokerLogdirState[] = [ - { - error: 'NONE', - name: '/opt/kafka/data-0/logs', - topics: 3, - partitions: 4, - }, -]; -export const defaultTransformedBrokerLogDirsPayload: BrokerLogdirState = { - error: '-', - name: '-', - topics: 0, - partitions: 0, -}; - export const brokerMetricsPayload: BrokerMetrics = { segmentSize: 23, segmentCount: 23, diff --git a/kafka-ui-react-app/src/components/Brokers/utils/__test__/translateLogdirs.spec.tsx b/kafka-ui-react-app/src/components/Brokers/utils/__test__/translateLogdirs.spec.tsx deleted file mode 100644 index 85fe55eb24..0000000000 --- a/kafka-ui-react-app/src/components/Brokers/utils/__test__/translateLogdirs.spec.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { - translateLogdir, - translateLogdirs, -} from 'components/Brokers/utils/translateLogdirs'; -import { brokerLogDirsPayload } from 'lib/fixtures/brokers'; - -import { - defaultTransformedBrokerLogDirsPayload, - transformedBrokerLogDirsPayload, -} from './fixtures'; - -describe('translateLogdir and translateLogdirs', () => { - describe('translateLogdirs', () => { - it('returns empty array when broker logdirs is not defined', () => { - expect(translateLogdirs(undefined)).toEqual([]); - }); - it('returns transformed LogDirs array when broker logdirs defined', () => { - expect(translateLogdirs(brokerLogDirsPayload)).toEqual( - transformedBrokerLogDirsPayload - ); - }); - }); - describe('translateLogdir', () => { - it('returns default data when broker logdir is empty', () => { - expect(translateLogdir({})).toEqual( - defaultTransformedBrokerLogDirsPayload - ); - }); - it('returns transformed LogDir when broker logdir defined', () => { - expect(translateLogdir(brokerLogDirsPayload[0])).toEqual( - transformedBrokerLogDirsPayload[0] - ); - }); - }); -}); diff --git a/kafka-ui-react-app/src/components/Brokers/utils/translateLogdirs.ts b/kafka-ui-react-app/src/components/Brokers/utils/translateLogdirs.ts deleted file mode 100644 index 6dd619cddb..0000000000 --- a/kafka-ui-react-app/src/components/Brokers/utils/translateLogdirs.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { BrokersLogdirs } from 'generated-sources'; -import { BrokerLogdirState } from 'components/Brokers/Broker/Broker'; - -export const translateLogdir = (data: BrokersLogdirs): BrokerLogdirState => { - const partitionsCount = - data.topics?.reduce( - (prevValue, value) => prevValue + (value.partitions?.length || 0), - 0 - ) || 0; - - return { - name: data.name || '-', - error: data.error || '-', - topics: data.topics?.length || 0, - partitions: partitionsCount, - }; -}; - -export const translateLogdirs = ( - data: BrokersLogdirs[] | undefined -): BrokerLogdirState[] => { - return data?.map(translateLogdir) || []; -}; diff --git a/kafka-ui-react-app/src/components/Topics/List/ActionsCell.tsx b/kafka-ui-react-app/src/components/Topics/List/ActionsCell.tsx index 9aa14276d2..3ab51be310 100644 --- a/kafka-ui-react-app/src/components/Topics/List/ActionsCell.tsx +++ b/kafka-ui-react-app/src/components/Topics/List/ActionsCell.tsx @@ -1,9 +1,8 @@ import React from 'react'; import { CleanUpPolicy, Topic } from 'generated-sources'; +import { CellContext } from '@tanstack/react-table'; import { useAppDispatch } from 'lib/hooks/redux'; -import { TableCellProps } from 'components/common/SmartTable/TableColumn'; import ClusterContext from 'components/contexts/ClusterContext'; -import * as S from 'components/Topics/List/List.styled'; import { ClusterNameRoute } from 'lib/paths'; import useAppParams from 'lib/hooks/useAppParams'; import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice'; @@ -15,10 +14,9 @@ import { useRecreateTopic, } from 'lib/hooks/api/topics'; -const ActionsCell: React.FC> = ({ - hovered, - dataItem: { internal, cleanUpPolicy, name }, -}) => { +const ActionsCell: React.FC> = ({ row }) => { + const { name, internal, cleanUpPolicy } = row.original; + const { isReadOnly, isTopicDeletionAllowed } = React.useContext(ClusterContext); const dispatch = useAppDispatch(); @@ -28,7 +26,7 @@ const ActionsCell: React.FC> = ({ const deleteTopic = useDeleteTopic(clusterName); const recreateTopic = useRecreateTopic({ clusterName, topicName: name }); - const isHidden = internal || isReadOnly || !hovered; + const isHidden = internal || isReadOnly; const clearTopicMessagesHandler = async () => { await dispatch( @@ -37,47 +35,49 @@ const ActionsCell: React.FC> = ({ queryClient.invalidateQueries(topicKeys.all(clusterName)); }; + const isCleanupDisabled = cleanUpPolicy !== CleanUpPolicy.DELETE; + return ( - - {!isHidden && ( - - {cleanUpPolicy === CleanUpPolicy.DELETE && ( - - Clear Messages - - )} - - Are you sure to recreate {name} topic? - - } - danger - > - Recreate Topic - - {isTopicDeletionAllowed && ( - deleteTopic.mutateAsync(name)} - confirm={ - <> - Are you sure want to remove {name} topic? - - } - danger - > - Remove Topic - - )} - - )} - + + + Clear Messages + + + Are you sure to recreate {name} topic? + + } + danger + > + Recreate Topic + + deleteTopic.mutateAsync(name)} + confirm={ + <> + Are you sure want to remove {name} topic? + + } + title={ + isTopicDeletionAllowed + ? 'The topic deletion is restricted by app configuration' + : '' + } + danger + > + Remove Topic + + ); }; -export default React.memo(ActionsCell); +export default ActionsCell; diff --git a/kafka-ui-react-app/src/components/Topics/List/BatchActionsBar.tsx b/kafka-ui-react-app/src/components/Topics/List/BatchActionsBar.tsx new file mode 100644 index 0000000000..a5acec28f5 --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/List/BatchActionsBar.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Row } from '@tanstack/react-table'; +import { Topic } from 'generated-sources'; +import useAppParams from 'lib/hooks/useAppParams'; +import { ClusterName } from 'redux/interfaces'; +import { topicKeys, useDeleteTopic } from 'lib/hooks/api/topics'; +import { useConfirm } from 'lib/hooks/useConfirm'; +import { Button } from 'components/common/Button/Button'; +import { useAppDispatch } from 'lib/hooks/redux'; +import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice'; +import { clusterTopicCopyRelativePath } from 'lib/paths'; +import { useQueryClient } from '@tanstack/react-query'; + +interface BatchActionsbarProps { + rows: Row[]; + resetRowSelection(): void; +} + +const BatchActionsbar: React.FC = ({ + rows, + resetRowSelection, +}) => { + const { clusterName } = useAppParams<{ clusterName: ClusterName }>(); + const confirm = useConfirm(); + const dispatch = useAppDispatch(); + const deleteTopic = useDeleteTopic(clusterName); + const selectedTopics = rows.map(({ original }) => original.name); + const client = useQueryClient(); + + const deleteTopicsHandler = () => { + confirm('Are you sure you want to remove selected topics?', async () => { + try { + await Promise.all( + selectedTopics.map((topicName) => deleteTopic.mutateAsync(topicName)) + ); + resetRowSelection(); + } catch (e) { + // do nothing; + } + }); + }; + + const purgeTopicsHandler = () => { + confirm( + 'Are you sure you want to purge messages of selected topics?', + async () => { + try { + await Promise.all( + selectedTopics.map((topicName) => + dispatch(clearTopicMessages({ clusterName, topicName })).unwrap() + ) + ); + resetRowSelection(); + } catch (e) { + // do nothing; + } finally { + client.invalidateQueries(topicKeys.all(clusterName)); + } + } + ); + }; + + type Tuple = [string, string]; + + const getCopyTopicPath = () => { + const topic = rows[0].original; + + const search = Object.keys(topic).reduce((acc: Tuple[], key) => { + const value = topic[key as keyof typeof topic]; + if (!value || key === 'partitions' || key === 'internal') { + return acc; + } + const tuple: Tuple = [key, value.toString()]; + return [...acc, tuple]; + }, []); + + return { + pathname: clusterTopicCopyRelativePath, + search: new URLSearchParams(search).toString(), + }; + }; + + return ( + <> + + + + + ); +}; + +export default BatchActionsbar; diff --git a/kafka-ui-react-app/src/components/Topics/List/List.styled.ts b/kafka-ui-react-app/src/components/Topics/List/List.styled.ts deleted file mode 100644 index c1324deb7d..0000000000 --- a/kafka-ui-react-app/src/components/Topics/List/List.styled.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Td } from 'components/common/table/TableHeaderCell/TableHeaderCell.styled'; -import { NavLink } from 'react-router-dom'; -import styled, { css } from 'styled-components'; - -export const Link = styled(NavLink)<{ - $isInternal?: boolean; -}>( - ({ theme, $isInternal }) => css` - color: ${theme.topicsList.color.normal}; - font-weight: 500; - padding-left: ${$isInternal ? '5px' : 0}; - - &:hover { - background-color: ${theme.topicsList.backgroundColor.hover}; - color: ${theme.topicsList.color.hover}; - } - - &.active { - background-color: ${theme.topicsList.backgroundColor.active}; - color: ${theme.topicsList.color.active}; - } - ` -); - -export const ActionsTd = styled(Td)` - overflow: visible; - width: 50px; -`; - -export const ActionsContainer = styled.div` - text-align: right !important; -`; diff --git a/kafka-ui-react-app/src/components/Topics/List/ListPage.tsx b/kafka-ui-react-app/src/components/Topics/List/ListPage.tsx index bdd44a60a5..d8031c92a9 100644 --- a/kafka-ui-react-app/src/components/Topics/List/ListPage.tsx +++ b/kafka-ui-react-app/src/components/Topics/List/ListPage.tsx @@ -11,7 +11,7 @@ import Switch from 'components/common/Switch/Switch'; import PlusIcon from 'components/common/Icons/PlusIcon'; import useSearch from 'lib/hooks/useSearch'; import PageLoader from 'components/common/PageLoader/PageLoader'; -import TopicsTable from 'components/Topics/List/TopicsTable'; +import TopicTable from 'components/Topics/List/TopicTable'; const ListPage: React.FC = () => { const { isReadOnly } = React.useContext(ClusterContext); @@ -29,7 +29,7 @@ const ListPage: React.FC = () => { ) { searchParams.set('hideInternal', 'true'); } - setSearchParams(searchParams, { replace: true }); + setSearchParams(searchParams); }, []); const handleSwitch = () => { @@ -41,8 +41,8 @@ const ListPage: React.FC = () => { searchParams.set('hideInternal', 'true'); } // Page must be reset when the switch is toggled - searchParams.delete('page'); - setSearchParams(searchParams.toString(), { replace: true }); + searchParams.set('page', '1'); + setSearchParams(searchParams); }; return ( @@ -59,26 +59,22 @@ const ListPage: React.FC = () => { )} -
- +
-
- -
+ Show Internal Topics +
}> - + ); diff --git a/kafka-ui-react-app/src/components/Topics/List/TopicTable.tsx b/kafka-ui-react-app/src/components/Topics/List/TopicTable.tsx new file mode 100644 index 0000000000..a855435c87 --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/List/TopicTable.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { SortOrder, Topic, TopicColumnsToSort } from 'generated-sources'; +import { ColumnDef } from '@tanstack/react-table'; +import Table, { SizeCell } from 'components/common/NewTable'; +import useAppParams from 'lib/hooks/useAppParams'; +import { ClusterName } from 'redux/interfaces'; +import { useSearchParams } from 'react-router-dom'; +import ClusterContext from 'components/contexts/ClusterContext'; +import { useTopics } from 'lib/hooks/api/topics'; +import { PER_PAGE } from 'lib/constants'; + +import { TopicTitleCell } from './TopicTitleCell'; +import ActionsCell from './ActionsCell'; +import BatchActionsbar from './BatchActionsBar'; + +const TopicTable: React.FC = () => { + const { clusterName } = useAppParams<{ clusterName: ClusterName }>(); + const [searchParams] = useSearchParams(); + const { isReadOnly } = React.useContext(ClusterContext); + const { data } = useTopics({ + clusterName, + page: Number(searchParams.get('page') || 1), + perPage: Number(searchParams.get('perPage') || PER_PAGE), + search: searchParams.get('q') || undefined, + showInternal: !searchParams.has('hideInternal'), + orderBy: (searchParams.get('sortBy') as TopicColumnsToSort) || undefined, + sortOrder: + (searchParams.get('sortDirection')?.toUpperCase() as SortOrder) || + undefined, + }); + + const topics = data?.topics || []; + const pageCount = data?.pageCount || 0; + + const columns = React.useMemo[]>( + () => [ + { + id: TopicColumnsToSort.NAME, + header: 'Topic Name', + accessorKey: 'name', + cell: TopicTitleCell, + }, + { + id: TopicColumnsToSort.TOTAL_PARTITIONS, + header: 'Total Partitions', + accessorKey: 'partitionCount', + }, + { + id: TopicColumnsToSort.OUT_OF_SYNC_REPLICAS, + header: 'Out of sync replicas', + accessorKey: 'partitions', + cell: ({ getValue }) => { + const partitions = getValue(); + 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); + }, + }, + { + header: 'Replication Factor', + accessorKey: 'replicationFactor', + enableSorting: false, + }, + { + header: 'Number of messages', + accessorKey: 'partitions', + enableSorting: false, + cell: ({ getValue }) => { + const partitions = getValue(); + if (partitions === undefined || partitions.length === 0) { + return 0; + } + return partitions.reduce((memo, { offsetMax, offsetMin }) => { + return memo + (offsetMax - offsetMin); + }, 0); + }, + }, + { + id: TopicColumnsToSort.SIZE, + header: 'Size', + accessorKey: 'segmentSize', + cell: SizeCell, + }, + { + id: 'actions', + header: '', + cell: ActionsCell, + }, + ], + [] + ); + + return ( +
!row.original.internal : undefined + } + emptyMessage="No topics found" + /> + ); +}; + +export default TopicTable; diff --git a/kafka-ui-react-app/src/components/Topics/List/TopicTitleCell.tsx b/kafka-ui-react-app/src/components/Topics/List/TopicTitleCell.tsx new file mode 100644 index 0000000000..fd32304bee --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/List/TopicTitleCell.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { CellContext } from '@tanstack/react-table'; +import { Tag } from 'components/common/Tag/Tag.styled'; +import { Topic } from 'generated-sources'; +import { NavLink } from 'react-router-dom'; + +export const TopicTitleCell: React.FC> = ({ + row: { original }, +}) => { + const { internal, name } = original; + return ( + + {internal && ( + <> + IN +   + + )} + {name} + + ); +}; diff --git a/kafka-ui-react-app/src/components/Topics/List/TopicsTable.tsx b/kafka-ui-react-app/src/components/Topics/List/TopicsTable.tsx deleted file mode 100644 index 6250d0c4da..0000000000 --- a/kafka-ui-react-app/src/components/Topics/List/TopicsTable.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import React from 'react'; -import { SortOrder, Topic, TopicColumnsToSort } from 'generated-sources'; -import { useSearchParams } from 'react-router-dom'; -import { useDeleteTopic, useTopics } from 'lib/hooks/api/topics'; -import useAppParams from 'lib/hooks/useAppParams'; -import { ClusterName } from 'redux/interfaces'; -import { PER_PAGE } from 'lib/constants'; -import { useTableState } from 'lib/hooks/useTableState'; -import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled'; -import { Button } from 'components/common/Button/Button'; -import { clusterTopicCopyRelativePath } from 'lib/paths'; -import { useConfirm } from 'lib/hooks/useConfirm'; -import { SmartTable } from 'components/common/SmartTable/SmartTable'; -import { TableColumn } from 'components/common/SmartTable/TableColumn'; -import ClusterContext from 'components/contexts/ClusterContext'; -import { useAppDispatch } from 'lib/hooks/redux'; -import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice'; - -import { - MessagesCell, - OutOfSyncReplicasCell, - TitleCell, - TopicSizeCell, -} from './TopicsTableCells'; -import ActionsCell from './ActionsCell'; -import { ActionsTd } from './List.styled'; - -const TopicsTable: React.FC = () => { - const { clusterName } = useAppParams<{ clusterName: ClusterName }>(); - const [searchParams, setSearchParams] = useSearchParams(); - const { isReadOnly } = React.useContext(ClusterContext); - const dispatch = useAppDispatch(); - const confirm = useConfirm(); - const deleteTopic = useDeleteTopic(clusterName); - const { data, refetch } = useTopics({ - clusterName, - page: Number(searchParams.get('page') || 1), - perPage: Number(searchParams.get('perPage') || PER_PAGE), - search: searchParams.get('q') || undefined, - showInternal: !searchParams.has('hideInternal'), - orderBy: (searchParams.get('orderBy') as TopicColumnsToSort) || undefined, - sortOrder: (searchParams.get('sortOrder') as SortOrder) || undefined, - }); - - const handleOrderBy = (orderBy: string | null) => { - const currentOrderBy = searchParams.get('orderBy'); - const currentSortOrder = searchParams.get('sortOrder'); - - if (orderBy) { - if (orderBy === currentOrderBy) { - searchParams.set( - 'sortOrder', - currentSortOrder === SortOrder.DESC ? SortOrder.ASC : SortOrder.DESC - ); - } - - searchParams.set('orderBy', orderBy); - } else { - searchParams.delete('orderBy'); - searchParams.delete('sortOrder'); - } - setSearchParams(searchParams, { replace: true }); - }; - - const tableState = useTableState( - data?.topics || [], - { - idSelector: (topic) => topic.name, - totalPages: data?.pageCount || 0, - isRowSelectable: (topic) => !topic.internal, - }, - { - handleOrderBy, - orderBy: searchParams.get('orderBy'), - sortOrder: (searchParams.get('sortOrder') as SortOrder) || SortOrder.ASC, - } - ); - - const getSelectedTopic = (): string => { - const name = Array.from(tableState.selectedIds)[0]; - const selectedTopic = - tableState.data.find((topic: Topic) => topic.name === name) || {}; - - return Object.keys(selectedTopic) - .map((x: string) => { - const value = selectedTopic[x as keyof typeof selectedTopic]; - return value && x !== 'partitions' ? `${x}=${value}` : null; - }) - .join('&'); - }; - - const clearSelectedTopics = () => tableState.toggleSelection(false); - - const deleteTopicsHandler = () => { - const selectedTopics = Array.from(tableState.selectedIds); - confirm('Are you sure you want to remove selected topics?', async () => { - try { - await Promise.all( - selectedTopics.map((topicName) => deleteTopic.mutateAsync(topicName)) - ); - clearSelectedTopics(); - } catch (e) { - // do nothing; - } finally { - refetch(); - } - }); - }; - - const purgeTopicsHandler = () => { - const selectedTopics = Array.from(tableState.selectedIds); - confirm( - 'Are you sure you want to purge messages of selected topics?', - async () => { - try { - await Promise.all( - selectedTopics.map((topicName) => - dispatch(clearTopicMessages({ clusterName, topicName })).unwrap() - ) - ); - clearSelectedTopics(); - } catch (e) { - // do nothing; - } finally { - refetch(); - } - } - ); - }; - - return ( - <> - {tableState.selectedCount > 0 && ( - - - {tableState.selectedCount === 1 && ( - - )} - - - - )} - - - - - - - - - - - ); -}; - -export default TopicsTable; diff --git a/kafka-ui-react-app/src/components/Topics/List/TopicsTableCells.tsx b/kafka-ui-react-app/src/components/Topics/List/TopicsTableCells.tsx deleted file mode 100644 index d79dd2eb67..0000000000 --- a/kafka-ui-react-app/src/components/Topics/List/TopicsTableCells.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; -import { TableCellProps } from 'components/common/SmartTable/TableColumn'; -import { Tag } from 'components/common/Tag/Tag.styled'; -import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted'; -import { Topic } from 'generated-sources'; - -import * as S from './List.styled'; - -export const TitleCell: React.FC> = ({ - dataItem: { internal, name }, -}) => { - return ( - <> - {internal && IN} - - {name} - - - ); -}; - -export const TopicSizeCell: React.FC> = ({ - dataItem: { segmentSize }, -}) => { - return ; -}; - -export const OutOfSyncReplicasCell: React.FC> = ({ - 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> = ({ - 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__/ActionCell.spec.tsx b/kafka-ui-react-app/src/components/Topics/List/__tests__/ActionCell.spec.tsx deleted file mode 100644 index 80f45efc54..0000000000 --- a/kafka-ui-react-app/src/components/Topics/List/__tests__/ActionCell.spec.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import React from 'react'; -import { render } from 'lib/testHelpers'; -import { TableState } from 'lib/hooks/useTableState'; -import { act, screen, waitFor } from '@testing-library/react'; -import { CleanUpPolicy, Topic } from 'generated-sources'; -import { topicsPayload } from 'lib/fixtures/topics'; -import ActionsCell from 'components/Topics/List/ActionsCell'; -import ClusterContext from 'components/contexts/ClusterContext'; -import userEvent from '@testing-library/user-event'; -import { useDeleteTopic, useRecreateTopic } from 'lib/hooks/api/topics'; - -const mockUnwrap = jest.fn(); -const useDispatchMock = () => jest.fn(() => ({ unwrap: mockUnwrap })); - -jest.mock('lib/hooks/redux', () => ({ - ...jest.requireActual('lib/hooks/redux'), - useAppDispatch: useDispatchMock, -})); - -jest.mock('lib/hooks/api/topics', () => ({ - ...jest.requireActual('lib/hooks/api/topics'), - useDeleteTopic: jest.fn(), - useRecreateTopic: jest.fn(), -})); - -const deleteTopicMock = jest.fn(); -const recreateTopicMock = jest.fn(); - -describe('ActionCell Components', () => { - beforeEach(() => { - (useDeleteTopic as jest.Mock).mockImplementation(() => ({ - mutateAsync: deleteTopicMock, - })); - (useRecreateTopic as jest.Mock).mockImplementation(() => ({ - mutateAsync: recreateTopicMock, - })); - }); - - const mockTableState: TableState = { - data: topicsPayload, - selectedIds: new Set([]), - idSelector: jest.fn(), - isRowSelectable: jest.fn(), - selectedCount: 0, - setRowsSelection: jest.fn(), - toggleSelection: jest.fn(), - }; - - const renderComponent = ( - currentData: Topic, - isReadOnly = false, - hovered = true, - isTopicDeletionAllowed = true - ) => { - return render( - - - - ); - }; - - const expectCellIsEmpty = () => { - expect( - screen.queryByRole('button', { name: 'Dropdown Toggle' }) - ).not.toBeInTheDocument(); - }; - - const expectDropdownExists = () => { - const btn = screen.getByRole('button', { name: 'Dropdown Toggle' }); - expect(btn).toBeInTheDocument(); - userEvent.click(btn); - expect(screen.getByRole('menu')).toBeInTheDocument(); - }; - - describe('is empty', () => { - it('for internal topic', () => { - renderComponent(topicsPayload[0]); - expectCellIsEmpty(); - }); - it('for readonly cluster', () => { - renderComponent(topicsPayload[1], true); - expectCellIsEmpty(); - }); - it('for non-hovered row', () => { - renderComponent(topicsPayload[1], false, false); - expectCellIsEmpty(); - }); - }); - - describe('is not empty', () => { - it('for external topic', async () => { - renderComponent(topicsPayload[1]); - expectDropdownExists(); - }); - describe('and clear messages action', () => { - it('is visible for topic with CleanUpPolicy.DELETE', async () => { - renderComponent({ - ...topicsPayload[1], - cleanUpPolicy: CleanUpPolicy.DELETE, - }); - expectDropdownExists(); - expect(screen.getByText('Clear Messages')).toBeInTheDocument(); - }); - it('is hidden for topic without CleanUpPolicy.DELETE', async () => { - renderComponent({ - ...topicsPayload[1], - cleanUpPolicy: CleanUpPolicy.COMPACT, - }); - expectDropdownExists(); - expect(screen.queryByText('Clear Messages')).not.toBeInTheDocument(); - }); - it('works as expected', async () => { - renderComponent({ - ...topicsPayload[1], - cleanUpPolicy: CleanUpPolicy.DELETE, - }); - expectDropdownExists(); - userEvent.click(screen.getByText('Clear Messages')); - expect( - screen.getByText('Are you sure want to clear topic messages?') - ).toBeInTheDocument(); - await act(() => - userEvent.click(screen.getByRole('button', { name: 'Confirm' })) - ); - expect(mockUnwrap).toHaveBeenCalled(); - }); - }); - - describe('and remove topic action', () => { - it('is visible only when topic deletion allowed for cluster', async () => { - renderComponent(topicsPayload[1]); - expectDropdownExists(); - expect(screen.getByText('Remove Topic')).toBeInTheDocument(); - }); - it('is hidden when topic deletion is not allowed for cluster', async () => { - renderComponent(topicsPayload[1], false, true, false); - expectDropdownExists(); - expect(screen.queryByText('Remove Topic')).not.toBeInTheDocument(); - }); - it('works as expected', async () => { - renderComponent(topicsPayload[1]); - expectDropdownExists(); - userEvent.click(screen.getByText('Remove Topic')); - expect(screen.getByText('Confirm the action')).toBeInTheDocument(); - expect(screen.getByText('external.topic')).toBeInTheDocument(); - await waitFor(() => - userEvent.click(screen.getByRole('button', { name: 'Confirm' })) - ); - await waitFor(() => expect(deleteTopicMock).toHaveBeenCalled()); - }); - }); - - describe('and recreate topic action', () => { - it('works as expected', async () => { - renderComponent(topicsPayload[1]); - expectDropdownExists(); - userEvent.click(screen.getByText('Recreate Topic')); - expect(screen.getByText('Confirm the action')).toBeInTheDocument(); - expect(screen.getByText('external.topic')).toBeInTheDocument(); - await waitFor(() => - userEvent.click(screen.getByRole('button', { name: 'Confirm' })) - ); - await waitFor(() => expect(recreateTopicMock).toHaveBeenCalled()); - }); - }); - }); -}); diff --git a/kafka-ui-react-app/src/components/Topics/List/__tests__/ListPage.spec.tsx b/kafka-ui-react-app/src/components/Topics/List/__tests__/ListPage.spec.tsx index 301001d382..5c5c3da778 100644 --- a/kafka-ui-react-app/src/components/Topics/List/__tests__/ListPage.spec.tsx +++ b/kafka-ui-react-app/src/components/Topics/List/__tests__/ListPage.spec.tsx @@ -8,9 +8,7 @@ import ListPage from 'components/Topics/List/ListPage'; const clusterName = 'test-cluster'; -jest.mock('components/Topics/List/TopicsTable', () => () => ( - <>TopicsTableMock -)); +jest.mock('components/Topics/List/TopicTable', () => () => <>TopicTableMock); describe('ListPage Component', () => { const renderComponent = () => { @@ -47,6 +45,6 @@ describe('ListPage Component', () => { }); it('renders the TopicsTable', () => { - expect(screen.getByText('TopicsTableMock')).toBeInTheDocument(); + expect(screen.getByText('TopicTableMock')).toBeInTheDocument(); }); }); diff --git a/kafka-ui-react-app/src/components/Topics/List/__tests__/TopicTable.spec.tsx b/kafka-ui-react-app/src/components/Topics/List/__tests__/TopicTable.spec.tsx new file mode 100644 index 0000000000..6031428895 --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/List/__tests__/TopicTable.spec.tsx @@ -0,0 +1,324 @@ +import React from 'react'; +import { render, WithRoute } from 'lib/testHelpers'; +import { act, screen, waitFor, within } from '@testing-library/react'; +import { CleanUpPolicy, TopicsResponse } from 'generated-sources'; +import { externalTopicPayload, topicsPayload } from 'lib/fixtures/topics'; +import ClusterContext from 'components/contexts/ClusterContext'; +import userEvent from '@testing-library/user-event'; +import { + useDeleteTopic, + useRecreateTopic, + useTopics, +} from 'lib/hooks/api/topics'; +import TopicTable from 'components/Topics/List/TopicTable'; +import { clusterTopicsPath } from 'lib/paths'; + +const clusterName = 'test-cluster'; +const mockUnwrap = jest.fn(); +const useDispatchMock = () => jest.fn(() => ({ unwrap: mockUnwrap })); + +const getButtonByName = (name: string) => screen.getByRole('button', { name }); + +jest.mock('lib/hooks/redux', () => ({ + ...jest.requireActual('lib/hooks/redux'), + useAppDispatch: useDispatchMock, +})); + +jest.mock('lib/hooks/api/topics', () => ({ + ...jest.requireActual('lib/hooks/api/topics'), + useDeleteTopic: jest.fn(), + useRecreateTopic: jest.fn(), + useTopics: jest.fn(), +})); + +const deleteTopicMock = jest.fn(); +const recreateTopicMock = jest.fn(); + +describe('TopicTable Components', () => { + beforeEach(() => { + (useDeleteTopic as jest.Mock).mockImplementation(() => ({ + mutateAsync: deleteTopicMock, + })); + (useRecreateTopic as jest.Mock).mockImplementation(() => ({ + mutateAsync: recreateTopicMock, + })); + }); + + const renderComponent = ( + currentData: TopicsResponse | undefined = undefined, + isReadOnly = false, + isTopicDeletionAllowed = true + ) => { + (useTopics as jest.Mock).mockImplementation(() => ({ + data: currentData, + })); + + return render( + + + + + , + { initialEntries: [clusterTopicsPath(clusterName)] } + ); + }; + + describe('without data', () => { + it('renders empty table when payload is undefined', () => { + renderComponent(); + expect( + screen.getByRole('row', { name: 'No topics found' }) + ).toBeInTheDocument(); + }); + + it('renders empty table when payload is empty', () => { + renderComponent({ topics: [] }); + expect( + screen.getByRole('row', { name: 'No topics found' }) + ).toBeInTheDocument(); + }); + }); + describe('with topics', () => { + it('renders correct rows', () => { + renderComponent({ topics: topicsPayload, pageCount: 1 }); + expect( + screen.getByRole('link', { name: '__internal.topic' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('row', { name: '__internal.topic 1 0 1 0 0Bytes' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'external.topic' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('row', { name: 'external.topic 1 0 1 0 1KB' }) + ).toBeInTheDocument(); + + expect(screen.getAllByRole('checkbox').length).toEqual(3); + }); + describe('Selectable rows', () => { + it('renders selectable rows', () => { + renderComponent({ topics: topicsPayload, pageCount: 1 }); + expect(screen.getAllByRole('checkbox').length).toEqual(3); + // Disable checkbox for internal topic + expect(screen.getAllByRole('checkbox')[1]).toBeDisabled(); + // Disable checkbox for external topic + expect(screen.getAllByRole('checkbox')[2]).toBeEnabled(); + }); + it('does not render selectable rows for read-only cluster', () => { + renderComponent({ topics: topicsPayload, pageCount: 1 }, true); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + }); + describe('Batch actions bar', () => { + beforeEach(() => { + const payload = { + topics: [ + externalTopicPayload, + { ...externalTopicPayload, name: 'test-topic' }, + ], + totalPages: 1, + }; + renderComponent(payload); + expect(screen.getAllByRole('checkbox').length).toEqual(3); + expect(screen.getAllByRole('checkbox')[1]).toBeEnabled(); + expect(screen.getAllByRole('checkbox')[2]).toBeEnabled(); + }); + describe('when only one topic is selected', () => { + beforeEach(() => { + userEvent.click(screen.getAllByRole('checkbox')[1]); + }); + it('renders batch actions bar', () => { + expect(getButtonByName('Delete selected topics')).toBeEnabled(); + expect(getButtonByName('Copy selected topic')).toBeEnabled(); + expect( + getButtonByName('Purge messages of selected topics') + ).toBeEnabled(); + }); + }); + describe('when more then one topics are selected', () => { + beforeEach(() => { + userEvent.click(screen.getAllByRole('checkbox')[1]); + userEvent.click(screen.getAllByRole('checkbox')[2]); + }); + it('renders batch actions bar', () => { + expect(getButtonByName('Delete selected topics')).toBeEnabled(); + expect(getButtonByName('Copy selected topic')).toBeDisabled(); + expect( + getButtonByName('Purge messages of selected topics') + ).toBeEnabled(); + }); + it('handels delete button click', async () => { + const button = getButtonByName('Delete selected topics'); + await act(() => userEvent.click(button)); + expect( + screen.getByText( + 'Are you sure you want to remove selected topics?' + ) + ).toBeInTheDocument(); + const confirmBtn = getButtonByName('Confirm'); + expect(confirmBtn).toBeInTheDocument(); + expect(deleteTopicMock).not.toHaveBeenCalled(); + await act(() => userEvent.click(confirmBtn)); + expect(deleteTopicMock).toHaveBeenCalledTimes(2); + expect(screen.getAllByRole('checkbox')[1]).not.toBeChecked(); + expect(screen.getAllByRole('checkbox')[2]).not.toBeChecked(); + }); + it('handels purge messages button click', async () => { + const button = getButtonByName('Purge messages of selected topics'); + await act(() => userEvent.click(button)); + expect( + screen.getByText( + 'Are you sure you want to purge messages of selected topics?' + ) + ).toBeInTheDocument(); + const confirmBtn = getButtonByName('Confirm'); + expect(confirmBtn).toBeInTheDocument(); + expect(mockUnwrap).not.toHaveBeenCalled(); + await act(() => userEvent.click(confirmBtn)); + expect(mockUnwrap).toHaveBeenCalledTimes(2); + expect(screen.getAllByRole('checkbox')[1]).not.toBeChecked(); + expect(screen.getAllByRole('checkbox')[2]).not.toBeChecked(); + }); + }); + }); + }); + describe('Action buttons', () => { + const expectDropdownExists = () => { + const btn = screen.getByRole('button', { + name: 'Dropdown Toggle', + }); + expect(btn).toBeEnabled(); + userEvent.click(btn); + expect(screen.getByRole('menu')).toBeInTheDocument(); + }; + it('renders disable action buttons for read-only cluster', () => { + renderComponent({ topics: topicsPayload, pageCount: 1 }, true); + const btns = screen.getAllByRole('button', { name: 'Dropdown Toggle' }); + expect(btns[0]).toBeDisabled(); + expect(btns[1]).toBeDisabled(); + }); + it('renders action buttons', () => { + renderComponent({ topics: topicsPayload, pageCount: 1 }); + expect( + screen.getAllByRole('button', { name: 'Dropdown Toggle' }).length + ).toEqual(2); + // Internal topic action buttons are disabled + const internalTopicRow = screen.getByRole('row', { + name: '__internal.topic 1 0 1 0 0Bytes', + }); + expect(internalTopicRow).toBeInTheDocument(); + expect( + within(internalTopicRow).getByRole('button', { + name: 'Dropdown Toggle', + }) + ).toBeDisabled(); + // External topic action buttons are enabled + const externalTopicRow = screen.getByRole('row', { + name: 'external.topic 1 0 1 0 1KB', + }); + expect(externalTopicRow).toBeInTheDocument(); + const extBtn = within(externalTopicRow).getByRole('button', { + name: 'Dropdown Toggle', + }); + expect(extBtn).toBeEnabled(); + userEvent.click(extBtn); + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); + describe('and clear messages action', () => { + it('is visible for topic with CleanUpPolicy.DELETE', async () => { + renderComponent({ + topics: [ + { + ...topicsPayload[1], + cleanUpPolicy: CleanUpPolicy.DELETE, + }, + ], + }); + expectDropdownExists(); + const actionBtn = screen.getAllByRole('menuitem'); + expect(actionBtn[0]).toHaveTextContent('Clear Messages'); + expect(actionBtn[0]).not.toHaveAttribute('aria-disabled'); + }); + it('is disabled for topic without CleanUpPolicy.DELETE', async () => { + renderComponent({ + topics: [ + { + ...topicsPayload[1], + cleanUpPolicy: CleanUpPolicy.COMPACT, + }, + ], + }); + expectDropdownExists(); + const actionBtn = screen.getAllByRole('menuitem'); + expect(actionBtn[0]).toHaveTextContent('Clear Messages'); + expect(actionBtn[0]).toHaveAttribute('aria-disabled'); + }); + it('works as expected', async () => { + renderComponent({ + topics: [ + { + ...topicsPayload[1], + cleanUpPolicy: CleanUpPolicy.DELETE, + }, + ], + }); + expectDropdownExists(); + userEvent.click(screen.getByText('Clear Messages')); + expect( + screen.getByText('Are you sure want to clear topic messages?') + ).toBeInTheDocument(); + await act(() => + userEvent.click(screen.getByRole('button', { name: 'Confirm' })) + ); + expect(mockUnwrap).toHaveBeenCalled(); + }); + }); + + describe('and remove topic action', () => { + it('is visible only when topic deletion allowed for cluster', async () => { + renderComponent({ topics: [topicsPayload[1]] }); + expectDropdownExists(); + const actionBtn = screen.getAllByRole('menuitem'); + expect(actionBtn[2]).toHaveTextContent('Remove Topic'); + expect(actionBtn[2]).not.toHaveAttribute('aria-disabled'); + }); + it('is disabled when topic deletion is not allowed for cluster', async () => { + renderComponent({ topics: [topicsPayload[1]] }, false, false); + expectDropdownExists(); + const actionBtn = screen.getAllByRole('menuitem'); + expect(actionBtn[2]).toHaveTextContent('Remove Topic'); + expect(actionBtn[2]).toHaveAttribute('aria-disabled'); + }); + it('works as expected', async () => { + renderComponent({ topics: [topicsPayload[1]] }); + expectDropdownExists(); + userEvent.click(screen.getByText('Remove Topic')); + expect(screen.getByText('Confirm the action')).toBeInTheDocument(); + await waitFor(() => + userEvent.click(screen.getByRole('button', { name: 'Confirm' })) + ); + await waitFor(() => expect(deleteTopicMock).toHaveBeenCalled()); + }); + }); + describe('and recreate topic action', () => { + it('works as expected', async () => { + renderComponent({ topics: [topicsPayload[1]] }); + expectDropdownExists(); + userEvent.click(screen.getByText('Recreate Topic')); + expect(screen.getByText('Confirm the action')).toBeInTheDocument(); + await waitFor(() => + userEvent.click(screen.getByRole('button', { name: 'Confirm' })) + ); + await waitFor(() => expect(recreateTopicMock).toHaveBeenCalled()); + }); + }); + }); + }); +}); diff --git a/kafka-ui-react-app/src/components/Topics/List/__tests__/TopicsTable.spec.tsx b/kafka-ui-react-app/src/components/Topics/List/__tests__/TopicsTable.spec.tsx deleted file mode 100644 index 6116cc9819..0000000000 --- a/kafka-ui-react-app/src/components/Topics/List/__tests__/TopicsTable.spec.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import React from 'react'; -import { render, WithRoute } from 'lib/testHelpers'; -import { act, screen, within } from '@testing-library/react'; -import { externalTopicPayload } from 'lib/fixtures/topics'; -import ClusterContext from 'components/contexts/ClusterContext'; -import userEvent from '@testing-library/user-event'; -import { useDeleteTopic, useTopics } from 'lib/hooks/api/topics'; -import TopicsTable from 'components/Topics/List/TopicsTable'; -import { clusterTopicsPath } from 'lib/paths'; - -const mockUnwrap = jest.fn(); -const useDispatchMock = () => jest.fn(() => ({ unwrap: mockUnwrap })); - -jest.mock('lib/hooks/redux', () => ({ - ...jest.requireActual('lib/hooks/redux'), - useAppDispatch: useDispatchMock, -})); - -jest.mock('lib/hooks/api/topics', () => ({ - ...jest.requireActual('lib/hooks/api/topics'), - useDeleteTopic: jest.fn(), - useTopics: jest.fn(), -})); - -const deleteTopicMock = jest.fn(); -const refetchMock = jest.fn(); - -const clusterName = 'test-cluster'; - -const getCheckboxInput = (at: number) => { - const rows = screen.getAllByRole('row'); - return within(rows[at + 1]).getByRole('checkbox'); -}; - -describe('TopicsTable Component', () => { - beforeEach(() => { - (useDeleteTopic as jest.Mock).mockImplementation(() => ({ - mutateAsync: deleteTopicMock, - })); - (useTopics as jest.Mock).mockImplementation(() => ({ - data: { - topics: [ - externalTopicPayload, - { ...externalTopicPayload, name: 'test-topic' }, - ], - totalPages: 1, - }, - refetch: refetchMock, - })); - }); - - const renderComponent = () => { - return render( - - - - - , - { initialEntries: [clusterTopicsPath(clusterName)] } - ); - }; - - beforeEach(() => { - renderComponent(); - }); - - const getButtonByName = (name: string) => - screen.getByRole('button', { name }); - - const queryButtonByName = (name: string) => - screen.queryByRole('button', { name }); - - it('renders the table', () => { - expect(screen.getByRole('table')).toBeInTheDocument(); - }); - - it('renders batch actions bar', () => { - expect(screen.getByRole('table')).toBeInTheDocument(); - - // check batch actions bar is hidden - const firstCheckbox = getCheckboxInput(0); - expect(firstCheckbox).not.toBeChecked(); - expect(queryButtonByName('Delete selected topics')).not.toBeInTheDocument(); - - // select firsr row - userEvent.click(firstCheckbox); - expect(firstCheckbox).toBeChecked(); - - // check batch actions bar is shown - expect(getButtonByName('Delete selected topics')).toBeInTheDocument(); - expect(getButtonByName('Copy selected topic')).toBeInTheDocument(); - expect( - getButtonByName('Purge messages of selected topics') - ).toBeInTheDocument(); - - // select second row - const secondCheckbox = getCheckboxInput(1); - expect(secondCheckbox).not.toBeChecked(); - userEvent.click(secondCheckbox); - expect(secondCheckbox).toBeChecked(); - - // check batch actions bar is still shown - expect(getButtonByName('Delete selected topics')).toBeInTheDocument(); - expect( - getButtonByName('Purge messages of selected topics') - ).toBeInTheDocument(); - - // check Copy button is hidden - expect(queryButtonByName('Copy selected topic')).not.toBeInTheDocument(); - }); - - describe('', () => { - beforeEach(() => { - userEvent.click(getCheckboxInput(0)); - userEvent.click(getCheckboxInput(1)); - }); - - it('handels delete button click', async () => { - const button = getButtonByName('Delete selected topics'); - expect(button).toBeInTheDocument(); - - await act(() => userEvent.click(button)); - - expect( - screen.getByText('Are you sure you want to remove selected topics?') - ).toBeInTheDocument(); - - const confirmBtn = getButtonByName('Confirm'); - expect(confirmBtn).toBeInTheDocument(); - expect(deleteTopicMock).not.toHaveBeenCalled(); - await act(() => userEvent.click(confirmBtn)); - - expect(deleteTopicMock).toHaveBeenCalledTimes(2); - - expect(getCheckboxInput(0)).not.toBeChecked(); - expect(getCheckboxInput(1)).not.toBeChecked(); - }); - - it('handels purge messages button click', async () => { - const button = getButtonByName('Purge messages of selected topics'); - expect(button).toBeInTheDocument(); - - await act(() => userEvent.click(button)); - - expect( - screen.getByText( - 'Are you sure you want to purge messages of selected topics?' - ) - ).toBeInTheDocument(); - - const confirmBtn = getButtonByName('Confirm'); - expect(confirmBtn).toBeInTheDocument(); - expect(mockUnwrap).not.toHaveBeenCalled(); - await act(() => userEvent.click(confirmBtn)); - expect(mockUnwrap).toHaveBeenCalledTimes(2); - - expect(getCheckboxInput(0)).not.toBeChecked(); - expect(getCheckboxInput(1)).not.toBeChecked(); - }); - }); -}); diff --git a/kafka-ui-react-app/src/components/Topics/List/__tests__/TopicsTableCells.spec.tsx b/kafka-ui-react-app/src/components/Topics/List/__tests__/TopicsTableCells.spec.tsx deleted file mode 100644 index 008159acd5..0000000000 --- a/kafka-ui-react-app/src/components/Topics/List/__tests__/TopicsTableCells.spec.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import React from 'react'; -import { render } from 'lib/testHelpers'; -import { - MessagesCell, - OutOfSyncReplicasCell, - TitleCell, - TopicSizeCell, -} from 'components/Topics/List/TopicsTableCells'; -import { TableState } from 'lib/hooks/useTableState'; -import { screen } from '@testing-library/react'; -import { Topic } from 'generated-sources'; -import { topicsPayload } from 'lib/fixtures/topics'; - -describe('TopicsTableCells Components', () => { - const mockTableState: TableState = { - data: topicsPayload, - selectedIds: new Set([]), - idSelector: jest.fn(), - isRowSelectable: jest.fn(), - selectedCount: 0, - setRowsSelection: jest.fn(), - toggleSelection: jest.fn(), - }; - - describe('TitleCell Component', () => { - it('should check the TitleCell component Render without the internal option', () => { - const currentData = topicsPayload[1]; - render( - - ); - expect(screen.queryByText('IN')).not.toBeInTheDocument(); - expect(screen.getByText(currentData.name)).toBeInTheDocument(); - }); - - it('should check the TitleCell component Render without the internal option', () => { - const currentData = topicsPayload[0]; - render( - - ); - expect(screen.getByText('IN')).toBeInTheDocument(); - expect(screen.getByText(currentData.name)).toBeInTheDocument(); - }); - }); - - describe('TopicSizeCell Component', () => { - const currentData = topicsPayload[1]; - it('should check the TopicSizeCell component Render', () => { - render( - - ); - expect(screen.getByText('1KB')).toBeInTheDocument(); - }); - }); - - describe('OutOfSyncReplicasCell Component', () => { - it('returns 0 if no partition is empty array', () => { - const currentData = topicsPayload[0]; - currentData.partitions = []; - render( - - ); - expect(screen.getByText('0')).toBeInTheDocument(); - }); - - it('returns 0 if no partition is found', () => { - const currentData = topicsPayload[1]; - currentData.partitions = undefined; - render( - - ); - expect(screen.getByText('0')).toBeInTheDocument(); - }); - - it('returns number of out of sync partitions', () => { - const currentData = { - ...topicsPayload[1], - partitions: [ - { - partition: 0, - leader: 1, - replicas: [{ broker: 1, leader: false, inSync: false }], - offsetMax: 0, - offsetMin: 0, - }, - ], - }; - render( - - ); - expect(screen.getByText('1')).toBeInTheDocument(); - }); - - it('should check the content of the OutOfSyncReplicasCell with the correct partition number', () => { - const currentData = topicsPayload[0]; - const partitionNumber = currentData.partitions?.reduce( - (memo, { replicas }) => { - const outOfSync = replicas?.filter(({ inSync }) => !inSync); - return memo + (outOfSync?.length || 0); - }, - 0 - ); - - render( - - ); - expect( - screen.getByText(partitionNumber ? partitionNumber.toString() : '0') - ).toBeInTheDocument(); - }); - }); - - describe('MessagesCell Component', () => { - it('returns 0 if partition is empty array ', () => { - render( - - ); - expect(screen.getByText('0')).toBeInTheDocument(); - }); - - it('returns 0 if no partition is found', () => { - render( - - ); - expect(screen.getByText('0')).toBeInTheDocument(); - }); - - it('returns the correct messages number', () => { - const offsetMax = 10034; - const offsetMin = 345; - const currentData = { - ...topicsPayload[0], - partitions: [ - { - partition: 0, - leader: 1, - replicas: [{ broker: 1, leader: false, inSync: false }], - offsetMax, - offsetMin, - }, - ], - }; - render( - - ); - expect(offsetMax - offsetMin).toEqual(9689); - expect(screen.getByText(offsetMax - offsetMin)).toBeInTheDocument(); - }); - }); -}); diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Overview/Overview.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/Overview/Overview.tsx index d69c005774..2bd1c4a78a 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/Details/Overview/Overview.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Overview/Overview.tsx @@ -90,68 +90,66 @@ const Overview: React.FC = () => { -
-
- - - - - - - - - - - - {data?.partitions?.map((partition: Partition) => ( - - - + + + + + + + + + + + {data?.partitions?.map((partition: Partition) => ( + + + + + + + - - - - - - ))} - {data?.partitions?.length === 0 && ( - - - - )} - -
{partition.partition} - {partition.replicas?.map(({ broker, leader }: Replica) => ( - +
{partition.partition} + {partition.replicas?.map(({ broker, leader }: Replica) => ( + + {broker} + + ))} + {partition.offsetMin}{partition.offsetMax}{partition.offsetMax - partition.offsetMin} + {!data?.internal && + !isReadOnly && + data?.cleanUpPolicy === 'DELETE' ? ( + + + dispatch( + clearTopicMessages({ + clusterName, + topicName, + partitions: [partition.partition], + }) + ).unwrap() + } + danger > - {broker} - - ))} - {partition.offsetMin}{partition.offsetMax}{partition.offsetMax - partition.offsetMin} - {!data?.internal && - !isReadOnly && - data?.cleanUpPolicy === 'DELETE' ? ( - - - dispatch( - clearTopicMessages({ - clusterName, - topicName, - partitions: [partition.partition], - }) - ).unwrap() - } - danger - > - Clear Messages - - - ) : null} -
No Partitions found
- + Clear Messages + + + ) : null} + + + ))} + {data?.partitions?.length === 0 && ( + + No Partitions found + + )} + + ); }; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/PartitionTable.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/PartitionTable.tsx index 9db1e5a35b..b019f0bc42 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/PartitionTable.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/PartitionTable.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/no-unstable-nested-components */ import React from 'react'; import { TopicAnalysisStats } from 'generated-sources'; import { ColumnDef } from '@tanstack/react-table'; diff --git a/kafka-ui-react-app/src/components/common/Dropdown/Dropdown.styled.ts b/kafka-ui-react-app/src/components/common/Dropdown/Dropdown.styled.ts index ee3aa494ba..cb22cdb37b 100644 --- a/kafka-ui-react-app/src/components/common/Dropdown/Dropdown.styled.ts +++ b/kafka-ui-react-app/src/components/common/Dropdown/Dropdown.styled.ts @@ -51,6 +51,7 @@ export const Dropdown = styled(ControlledMenu)( ${menuItemSelector.disabled} { cursor: not-allowed; + opacity: 0.5; } ` ); @@ -61,6 +62,12 @@ export const DropdownButton = styled.button` display: flex; cursor: pointer; align-self: center; + float: right; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } `; export const DangerItem = styled.div` diff --git a/kafka-ui-react-app/src/components/common/Dropdown/Dropdown.tsx b/kafka-ui-react-app/src/components/common/Dropdown/Dropdown.tsx index 0a7da0e956..4d82d7b015 100644 --- a/kafka-ui-react-app/src/components/common/Dropdown/Dropdown.tsx +++ b/kafka-ui-react-app/src/components/common/Dropdown/Dropdown.tsx @@ -7,9 +7,10 @@ import * as S from './Dropdown.styled'; interface DropdownProps extends PropsWithChildren> { label?: React.ReactNode; + disabled?: boolean; } -const Dropdown: React.FC = ({ label, children }) => { +const Dropdown: React.FC = ({ label, disabled, children }) => { const ref = useRef(null); const { isOpen, setClose, setOpen } = useModal(false); @@ -25,6 +26,7 @@ const Dropdown: React.FC = ({ label, children }) => { onClick={handleClick} ref={ref} aria-label="Dropdown Toggle" + disabled={disabled} > {label || } diff --git a/kafka-ui-react-app/src/components/common/IndeterminateCheckbox/IndeterminateCheckbox.tsx b/kafka-ui-react-app/src/components/common/IndeterminateCheckbox/IndeterminateCheckbox.tsx new file mode 100644 index 0000000000..58afff859c --- /dev/null +++ b/kafka-ui-react-app/src/components/common/IndeterminateCheckbox/IndeterminateCheckbox.tsx @@ -0,0 +1,24 @@ +import React, { HTMLProps } from 'react'; +import styled from 'styled-components'; + +interface IndeterminateCheckboxProps extends HTMLProps { + indeterminate?: boolean; +} + +const IndeterminateCheckbox: React.FC = ({ + indeterminate, + ...rest +}) => { + const ref = React.useRef(null); + React.useEffect(() => { + if (typeof indeterminate === 'boolean' && ref.current) { + ref.current.indeterminate = !rest.checked && indeterminate; + } + }, [ref, indeterminate]); + + return ; +}; + +export default styled(IndeterminateCheckbox)` + cursor: pointer; +`; diff --git a/kafka-ui-react-app/src/components/common/NewTable/SelectRowCell.tsx b/kafka-ui-react-app/src/components/common/NewTable/SelectRowCell.tsx new file mode 100644 index 0000000000..3f12583f01 --- /dev/null +++ b/kafka-ui-react-app/src/components/common/NewTable/SelectRowCell.tsx @@ -0,0 +1,15 @@ +import { CellContext } from '@tanstack/react-table'; +import React from 'react'; +import IndeterminateCheckbox from 'components/common/IndeterminateCheckbox/IndeterminateCheckbox'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const SelectRowCell: React.FC> = ({ row }) => ( + +); + +export default SelectRowCell; diff --git a/kafka-ui-react-app/src/components/common/NewTable/SelectRowHeader.tsx b/kafka-ui-react-app/src/components/common/NewTable/SelectRowHeader.tsx new file mode 100644 index 0000000000..48d78bf81b --- /dev/null +++ b/kafka-ui-react-app/src/components/common/NewTable/SelectRowHeader.tsx @@ -0,0 +1,14 @@ +import { HeaderContext } from '@tanstack/react-table'; +import React from 'react'; +import IndeterminateCheckbox from 'components/common/IndeterminateCheckbox/IndeterminateCheckbox'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const SelectRowHeader: React.FC> = ({ table }) => ( + +); + +export default SelectRowHeader; diff --git a/kafka-ui-react-app/src/components/common/NewTable/SizeCell.tsx b/kafka-ui-react-app/src/components/common/NewTable/SizeCell.tsx new file mode 100644 index 0000000000..53b3577998 --- /dev/null +++ b/kafka-ui-react-app/src/components/common/NewTable/SizeCell.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { CellContext } from '@tanstack/react-table'; +import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const SizeCell: React.FC> = ({ getValue }) => ( + +); + +export default SizeCell; diff --git a/kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts b/kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts index f80d62ce5f..83f4fdda5c 100644 --- a/kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts +++ b/kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts @@ -19,7 +19,7 @@ interface ThProps { const sortableMixin = (normalColor: string, hoverColor: string) => ` cursor: pointer; - padding-right: 18px; + padding-left: 14px; position: relative; &::before, @@ -28,7 +28,7 @@ const sortableMixin = (normalColor: string, hoverColor: string) => ` content: ''; display: block; height: 0; - right: 5px; + left: 0px; top: 50%; position: absolute; } @@ -51,13 +51,13 @@ const ASCMixin = (color: string) => ` border-bottom-color: ${color}; } &:after { - border-top-color: rgba(0, 0, 0, 0.2); + border-top-color: rgba(0, 0, 0, 0.1); } `; const DESCMixin = (color: string) => ` color: ${color}; &:before { - border-bottom-color: rgba(0, 0, 0, 0.2); + border-bottom-color: rgba(0, 0, 0, 0.1); } &:after { border-top-color: ${color}; @@ -65,8 +65,15 @@ const DESCMixin = (color: string) => ` `; export const Th = styled.th( - ({ theme: { table }, sortable, sortOrder, expander }) => ` - padding: 4px 0 4px 24px; + ({ + theme: { + table: { th }, + }, + sortable, + sortOrder, + expander, + }) => ` + padding: 8px 0 8px 24px; border-bottom-width: 1px; vertical-align: middle; text-align: left; @@ -77,17 +84,16 @@ export const Th = styled.th( line-height: 16px; letter-spacing: 0em; text-align: left; - background: ${table.th.backgroundColor.normal}; + background: ${th.backgroundColor.normal}; width: ${expander ? '5px' : 'auto'}; + white-space: nowrap; & > div { cursor: default; - color: ${table.th.color.normal}; - ${ - sortable ? sortableMixin(table.th.color.normal, table.th.color.hover) : '' - } - ${sortable && sortOrder === 'asc' && ASCMixin(table.th.color.active)} - ${sortable && sortOrder === 'desc' && DESCMixin(table.th.color.active)} + color: ${th.color.normal}; + ${sortable ? sortableMixin(th.color.sortable, th.color.hover) : ''} + ${sortable && sortOrder === 'asc' && ASCMixin(th.color.active)} + ${sortable && sortOrder === 'desc' && DESCMixin(th.color.active)} } ` ); @@ -118,6 +124,14 @@ export const Nowrap = styled.div` white-space: nowrap; `; +export const TableActionsBar = styled.div` + padding: 8px; + background-color: ${({ theme }) => theme.table.actionBar.backgroundColor}; + margin: 16px 0; + display: flex; + gap: 8px; +`; + export const Table = styled.table( ({ theme: { table } }) => ` width: 100%; @@ -129,18 +143,34 @@ export const Table = styled.table( padding: 8px 8px 8px 24px; color: ${table.td.color.normal}; vertical-align: middle; - max-width: 350px; word-wrap: break-word; - & > a { - color: ${table.link.color}; + & a { + color: ${table.link.color.normal}; font-weight: 500; + max-width: 450px; + white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; + display: block; + + &:hover { + color: ${table.link.color.hover}; + } + + &:active { + color: ${table.link.color.active}; + } } } ` ); +export const EmptyTableMessageCell = styled.td` + padding: 16px; + text-align: center; +`; + export const Pagination = styled.div` display: flex; justify-content: space-between; diff --git a/kafka-ui-react-app/src/components/common/NewTable/Table.tsx b/kafka-ui-react-app/src/components/common/NewTable/Table.tsx index 244caeb03f..80f5477fd1 100644 --- a/kafka-ui-react-app/src/components/common/NewTable/Table.tsx +++ b/kafka-ui-react-app/src/components/common/NewTable/Table.tsx @@ -21,8 +21,10 @@ import * as S from './Table.styled'; import updateSortingState from './utils/updateSortingState'; import updatePaginationState from './utils/updatePaginationState'; import ExpanderCell from './ExpanderCell'; +import SelectRowCell from './SelectRowCell'; +import SelectRowHeader from './SelectRowHeader'; -interface TableProps { +export interface TableProps { data: TData[]; pageCount?: number; columns: ColumnDef[]; @@ -30,10 +32,69 @@ interface TableProps { getRowCanExpand?: (row: Row) => boolean; serverSideProcessing?: boolean; enableSorting?: boolean; + enableRowSelection?: boolean | ((row: Row) => boolean); + batchActionsBar?: React.FC<{ rows: Row[]; resetRowSelection(): void }>; + emptyMessage?: string; } type UpdaterFn = (previousState: T) => T; +const getPaginationFromSearchParams = (searchParams: URLSearchParams) => { + const page = searchParams.get('page'); + const perPage = searchParams.get('perPage'); + const pageIndex = page ? Number(page) - 1 : 0; + return { + pageIndex, + pageSize: Number(perPage || PER_PAGE), + }; +}; + +const getSortingFromSearchParams = (searchParams: URLSearchParams) => { + const sortBy = searchParams.get('sortBy'); + const sortDirection = searchParams.get('sortDirection'); + if (!sortBy) return []; + return [{ id: sortBy, desc: sortDirection === 'desc' }]; +}; + +/** + * Table component that uses the react-table library to render a table. + * https://tanstack.com/table/v8 + * + * The most important props are: + * - `data`: the data to render in the table + * - `columns`: ColumnsDef. You can finde more info about it on https://tanstack.com/table/v8/docs/guide/column-defs + * - `emptyMessage`: the message to show when there is no data to render + * + * Usecases: + * 1. Sortable table + * - set `enableSorting` property of component to true. It will enable sorting for all columns. + * If you want to disable sorting for some particular columns you can pass + * `enableSorting = false` to the column def. + * - table component stores the sorting state in URLSearchParams. Use `sortBy` and `sortDirection` + * search param to set default sortings. + * + * 2. Pagination + * - pagination enabled by default. + * - use `perPage` search param to manage default page size. + * - use `page` search param to manage default page index. + * - use `pageCount` prop to set the total number of pages only in case of server side processing. + * + * 3. Expandable rows + * - use `getRowCanExpand` prop to set a function that returns true if the row can be expanded. + * - use `renderSubComponent` prop to provide a sub component for each expanded row. + * + * 4. Row selection + * - use `enableRowSelection` prop to enable row selection. This prop can be a boolean or + * a function that returns true if the particular row can be selected. + * - use `batchActionsBar` prop to provide a component that will be rendered at the top of the table + * when row selection is enabled and there are selected rows. + * + * 5. Server side processing: + * - set `serverSideProcessing` to true + * - set `pageCount` to the total number of pages + * - use URLSearchParams to get the pagination and sorting state from the url for your server side processing. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any const Table: React.FC> = ({ data, @@ -43,77 +104,45 @@ const Table: React.FC> = ({ renderSubComponent, serverSideProcessing = false, enableSorting = false, + enableRowSelection = false, + batchActionsBar, + emptyMessage, }) => { const [searchParams, setSearchParams] = useSearchParams(); - const [sorting, setSorting] = React.useState([]); - const [{ pageIndex, pageSize }, setPagination] = - React.useState({ - pageIndex: 0, - pageSize: PER_PAGE, - }); - + const [rowSelection, setRowSelection] = React.useState({}); const onSortingChange = React.useCallback( (updater: UpdaterFn) => { const newState = updateSortingState(updater, searchParams); setSearchParams(searchParams); - setSorting(newState); return newState; }, [searchParams] ); - const onPaginationChange = React.useCallback( (updater: UpdaterFn) => { const newState = updatePaginationState(updater, searchParams); setSearchParams(searchParams); - setPagination(newState); return newState; }, [searchParams] ); React.useEffect(() => { - const sortBy = searchParams.get('sortBy'); - const sortDirection = searchParams.get('sortDirection'); - const page = searchParams.get('page'); - const perPage = searchParams.get('perPage'); - - if (sortBy) { - setSorting([ - { - id: sortBy, - desc: sortDirection === 'desc', - }, - ]); - } else { - setSorting([]); - } - if (page || perPage) { - setPagination({ - pageIndex: Number(page || 0), - pageSize: Number(perPage || PER_PAGE), - }); - } - }, []); - - const pagination = React.useMemo( - () => ({ - pageIndex, - pageSize, - }), - [pageIndex, pageSize] - ); + setRowSelection({}); + }, [searchParams]); const table = useReactTable({ data, pageCount, columns, state: { - sorting, - pagination, + sorting: getSortingFromSearchParams(searchParams), + pagination: getPaginationFromSearchParams(searchParams), + rowSelection, }, onSortingChange: onSortingChange as OnChangeFn, onPaginationChange: onPaginationChange as OnChangeFn, + onRowSelectionChange: setRowSelection, getRowCanExpand, getCoreRowModel: getCoreRowModel(), getExpandedRowModel: getExpandedRowModel(), @@ -122,14 +151,34 @@ const Table: React.FC> = ({ manualSorting: serverSideProcessing, manualPagination: serverSideProcessing, enableSorting, + autoResetPageIndex: false, + enableRowSelection, }); + const Bar = batchActionsBar; + return ( <> + {table.getSelectedRowModel().flatRows.length > 0 && Bar && ( + + + + )} {table.getHeaderGroups().map((headerGroup) => ( + {!!enableRowSelection && ( + + {flexRender( + SelectRowHeader, + headerGroup.headers[0].getContext() + )} + + )} {table.getCanSomeRowsExpand() && ( )} @@ -160,6 +209,14 @@ const Table: React.FC> = ({ expanded={row.getIsExpanded()} onClick={() => row.getCanExpand() && row.toggleExpanded()} > + {!!enableRowSelection && ( + + {flexRender( + SelectRowCell, + row.getVisibleCells()[0].getContext() + )} + + )} {row.getCanExpand() && ( {flexRender( @@ -168,15 +225,15 @@ const Table: React.FC> = ({ )} )} - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} + {row + .getVisibleCells() + .map(({ id, getContext, column: { columnDef } }) => ( + {flexRender(columnDef.cell, getContext())} + ))} {row.getIsExpanded() && renderSubComponent && ( - + {renderSubComponent({ row })} @@ -185,6 +242,13 @@ const Table: React.FC> = ({ )} ))} + {table.getRowModel().rows.length === 0 && ( + + + {emptyMessage || 'No rows found'} + + + )} {table.getPageCount() > 1 && ( @@ -231,9 +295,9 @@ const Table: React.FC> = ({ inputSize="M" max={table.getPageCount()} min={1} - onChange={(e) => { - const page = e.target.value ? Number(e.target.value) - 1 : 0; - table.setPageIndex(page); + onChange={({ target: { value } }) => { + const index = value ? Number(value) - 1 : 0; + table.setPageIndex(index); }} /> diff --git a/kafka-ui-react-app/src/components/common/NewTable/__test__/Table.spec.tsx b/kafka-ui-react-app/src/components/common/NewTable/__test__/Table.spec.tsx index e9b21c3e03..3e4dddc410 100644 --- a/kafka-ui-react-app/src/components/common/NewTable/__test__/Table.spec.tsx +++ b/kafka-ui-react-app/src/components/common/NewTable/__test__/Table.spec.tsx @@ -1,20 +1,25 @@ import React from 'react'; import { render, WithRoute } from 'lib/testHelpers'; -import Table, { TimestampCell } from 'components/common/NewTable'; +import Table, { + TableProps, + TimestampCell, + SizeCell, +} from 'components/common/NewTable'; import { screen, waitFor } from '@testing-library/dom'; -import { ColumnDef } from '@tanstack/react-table'; +import { ColumnDef, Row } from '@tanstack/react-table'; import userEvent from '@testing-library/user-event'; import { formatTimestamp } from 'lib/dateTimeHelpers'; import { act } from '@testing-library/react'; -const data = [ - { timestamp: 1660034383725, text: 'lorem' }, - { timestamp: 1660034399999, text: 'ipsum' }, - { timestamp: 1660034399922, text: 'dolor' }, - { timestamp: 1660034199922, text: 'sit' }, -]; type Datum = typeof data[0]; +const data = [ + { timestamp: 1660034383725, text: 'lorem', selectable: false, size: 1234 }, + { timestamp: 1660034399999, text: 'ipsum', selectable: true, size: 3 }, + { timestamp: 1660034399922, text: 'dolor', selectable: true, size: 50000 }, + { timestamp: 1660034199922, text: 'sit', selectable: false, size: 1_312_323 }, +]; + const columns: ColumnDef[] = [ { header: 'DateTime', @@ -25,27 +30,30 @@ const columns: ColumnDef[] = [ header: 'Text', accessorKey: 'text', }, + { + header: 'Size', + accessorKey: 'size', + cell: SizeCell, + }, ]; const ExpandedRow: React.FC = () =>
I am expanded row
; -interface Props { +interface Props extends TableProps { path?: string; - canExpand?: boolean; } -const renderComponent = ({ path, canExpand }: Props = {}) => { +const renderComponent = (props: Partial = {}) => { render( !!canExpand} - enableSorting + {...props} /> , - { initialEntries: [path || ''] } + { initialEntries: [props.path || ''] } ); }; @@ -53,6 +61,30 @@ describe('Table', () => { it('renders table', () => { renderComponent(); expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getAllByRole('row').length).toEqual(data.length + 1); + }); + + it('renders empty table', () => { + renderComponent({ data: [] }); + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getAllByRole('row').length).toEqual(2); + expect(screen.getByText('No rows found')).toBeInTheDocument(); + }); + + it('renders empty table with custom message', () => { + const emptyMessage = 'Super custom message'; + renderComponent({ data: [], emptyMessage }); + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getAllByRole('row').length).toEqual(2); + expect(screen.getByText(emptyMessage)).toBeInTheDocument(); + }); + + it('renders SizeCell', () => { + renderComponent(); + expect(screen.getByText('1KB')).toBeInTheDocument(); + expect(screen.getByText('3Bytes')).toBeInTheDocument(); + expect(screen.getByText('49KB')).toBeInTheDocument(); + expect(screen.getByText('1MB')).toBeInTheDocument(); }); it('renders TimestampCell', () => { @@ -64,7 +96,7 @@ describe('Table', () => { describe('ExpanderCell', () => { it('renders button', () => { - renderComponent({ canExpand: true }); + renderComponent({ getRowCanExpand: () => true }); const btns = screen.getAllByRole('button', { name: 'Expand row' }); expect(btns.length).toEqual(data.length); @@ -76,7 +108,7 @@ describe('Table', () => { }); it('does not render button', () => { - renderComponent({ canExpand: false }); + renderComponent({ getRowCanExpand: () => false }); expect( screen.queryByRole('button', { name: 'Expand row' }) ).not.toBeInTheDocument(); @@ -147,7 +179,10 @@ describe('Table', () => { describe('Sorting', () => { it('sort rows', async () => { await act(() => - renderComponent({ path: '/?sortBy=text&&sortDirection=desc' }) + renderComponent({ + path: '/?sortBy=text&&sortDirection=desc', + enableSorting: true, + }) ); expect(screen.getAllByRole('row').length).toEqual(data.length + 1); const th = screen.getByRole('columnheader', { name: 'Text' }); @@ -178,4 +213,31 @@ describe('Table', () => { expect(rows[4].textContent?.indexOf('sit')).toBeGreaterThan(-1); }); }); + + describe('Row Selecting', () => { + beforeEach(() => { + renderComponent({ + enableRowSelection: (row: Row) => row.original.selectable, + batchActionsBar: () =>
I am Action Bar
, + }); + }); + it('renders selectable rows', () => { + expect(screen.getAllByRole('row').length).toEqual(data.length + 1); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toEqual(data.length + 1); + expect(checkboxes[1]).toBeDisabled(); + expect(checkboxes[2]).toBeEnabled(); + expect(checkboxes[3]).toBeEnabled(); + expect(checkboxes[4]).toBeDisabled(); + }); + + it('renders action bar', () => { + expect(screen.getAllByRole('row').length).toEqual(data.length + 1); + expect(screen.queryByText('I am Action Bar')).not.toBeInTheDocument(); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toEqual(data.length + 1); + userEvent.click(checkboxes[2]); + expect(screen.getByText('I am Action Bar')).toBeInTheDocument(); + }); + }); }); diff --git a/kafka-ui-react-app/src/components/common/NewTable/index.ts b/kafka-ui-react-app/src/components/common/NewTable/index.ts index b2c25771e5..08a5f85ad1 100644 --- a/kafka-ui-react-app/src/components/common/NewTable/index.ts +++ b/kafka-ui-react-app/src/components/common/NewTable/index.ts @@ -1,7 +1,9 @@ -import Table from './Table'; +import Table, { TableProps } from './Table'; import TimestampCell from './TimestampCell'; -import ExpanderCell from './ExpanderCell'; +import SizeCell from './SizeCell'; -export { TimestampCell, ExpanderCell }; +export type { TableProps }; + +export { TimestampCell, SizeCell }; export default Table; diff --git a/kafka-ui-react-app/src/components/common/NewTable/utils/updatePaginationState.ts b/kafka-ui-react-app/src/components/common/NewTable/utils/updatePaginationState.ts index 85dfd580bc..8b6bc3dc82 100644 --- a/kafka-ui-react-app/src/components/common/NewTable/utils/updatePaginationState.ts +++ b/kafka-ui-react-app/src/components/common/NewTable/utils/updatePaginationState.ts @@ -7,21 +7,14 @@ export default ( updater: UpdaterFn, searchParams: URLSearchParams ) => { + const page = searchParams.get('page'); const previousState: PaginationState = { - pageIndex: Number(searchParams.get('page') || 0), + // Page number starts at 1, but the pageIndex starts at 0 + pageIndex: page ? Number(page) - 1 : 0, pageSize: Number(searchParams.get('perPage') || PER_PAGE), }; const newState = updater(previousState); - if (newState.pageIndex !== 0) { - searchParams.set('page', newState.pageIndex.toString()); - } else { - searchParams.delete('page'); - } - - if (newState.pageSize !== PER_PAGE) { - searchParams.set('perPage', newState.pageSize.toString()); - } else { - searchParams.delete('perPage'); - } - return newState; + searchParams.set('page', String(newState.pageIndex + 1)); + searchParams.set('perPage', newState.pageSize.toString()); + return previousState; }; diff --git a/kafka-ui-react-app/src/components/common/table/Table/TableKeyLink.styled.ts b/kafka-ui-react-app/src/components/common/table/Table/TableKeyLink.styled.ts index 3b5fdf5953..493c525b2d 100644 --- a/kafka-ui-react-app/src/components/common/table/Table/TableKeyLink.styled.ts +++ b/kafka-ui-react-app/src/components/common/table/Table/TableKeyLink.styled.ts @@ -3,9 +3,17 @@ import styled, { css } from 'styled-components'; const tableLinkMixin = css( ({ theme }) => ` & > a { - color: ${theme.table.link.color}; + color: ${theme.table.link.color.normal}; font-weight: 500; text-overflow: ellipsis; + + &:hover { + color: ${theme.table.link.color.hover}; + } + + &:active { + color: ${theme.table.link.color.active}; + } } ` ); diff --git a/kafka-ui-react-app/src/lib/fixtures/brokers.ts b/kafka-ui-react-app/src/lib/fixtures/brokers.ts index 37e3f7ecfb..fb718e560d 100644 --- a/kafka-ui-react-app/src/lib/fixtures/brokers.ts +++ b/kafka-ui-react-app/src/lib/fixtures/brokers.ts @@ -32,4 +32,8 @@ export const brokerLogDirsPayload: BrokersLogdirs[] = [ }, ], }, + { + error: 'NONE', + name: '/opt/kafka/data-1/logs', + }, ]; diff --git a/kafka-ui-react-app/src/theme/theme.ts b/kafka-ui-react-app/src/theme/theme.ts index 799aaa5e5f..44e3be8641 100644 --- a/kafka-ui-react-app/src/theme/theme.ts +++ b/kafka-ui-react-app/src/theme/theme.ts @@ -299,12 +299,16 @@ const theme = { deletionTextColor: Colors.neutral[70], }, table: { + actionBar: { + backgroundColor: Colors.neutral[0], + }, th: { backgroundColor: { normal: Colors.neutral[0], }, color: { - normal: Colors.neutral[50], + sortable: Colors.neutral[30], + normal: Colors.neutral[60], hover: Colors.brand[50], active: Colors.brand[50], }, @@ -326,6 +330,8 @@ const theme = { link: { color: { normal: Colors.neutral[90], + hover: Colors.neutral[50], + active: Colors.neutral[90], }, }, expander: {