Use new table component for topics list (#2426)

* Use new table component for topics list

* Fix styling

* Migrate BrokerLogdir to new tables

* Improve test coverage
This commit is contained in:
Oleg Shur 2022-08-12 15:36:07 +03:00 committed by GitHub
parent 5fdcd2124c
commit 21f17ad39e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1107 additions and 1179 deletions

View file

@ -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<ClusterBrokerParam>();

View file

@ -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<ClusterBrokerParam>();
const { data: logDirs } = useBrokerLogDirs(clusterName, Number(brokerId));
const { data } = useBrokerLogDirs(clusterName, Number(brokerId));
const preparedRows = translateLogdirs(logDirs);
const tableState = useTableState<BrokerLogdirState, string>(preparedRows, {
idSelector: ({ name }) => name,
totalPages: 0,
});
const columns = React.useMemo<ColumnDef<BrokersLogdirs>[]>(
() => [
{ header: 'Name', accessorKey: 'name' },
{ header: 'Error', accessorKey: 'error' },
{
header: 'Topics',
accessorKey: 'topics',
cell: ({ getValue }) =>
getValue<BrokersLogdirs['topics']>()?.length || 0,
enableSorting: false,
},
{
id: 'partitions',
header: 'Partitions',
accessorKey: 'topics',
cell: ({ getValue }) => {
const topics = getValue<BrokersLogdirs['topics']>();
if (!topics) {
return 0;
}
return topics.reduce(
(acc, topic) => acc + (topic.partitions?.length || 0),
0
);
},
enableSorting: false,
},
],
[]
);
return (
<SmartTable
tableState={tableState}
placeholder="Log dir data not available"
isFullwidth
>
<TableColumn title="Name" field="name" />
<TableColumn title="Error" field="error" />
<TableColumn title="Topics" field="topics" />
<TableColumn title="Partitions" field="partitions" />
</SmartTable>
<Table
data={data || []}
columns={columns}
emptyMessage="Log dir data not available"
enableSorting
/>
);
};

View file

@ -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();
});
});

View file

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

View file

@ -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]
);
});
});
});

View file

@ -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) || [];
};

View file

@ -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<TableCellProps<Topic, string>> = ({
hovered,
dataItem: { internal, cleanUpPolicy, name },
}) => {
const ActionsCell: React.FC<CellContext<Topic, unknown>> = ({ row }) => {
const { name, internal, cleanUpPolicy } = row.original;
const { isReadOnly, isTopicDeletionAllowed } =
React.useContext(ClusterContext);
const dispatch = useAppDispatch();
@ -28,7 +26,7 @@ const ActionsCell: React.FC<TableCellProps<Topic, string>> = ({
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<TableCellProps<Topic, string>> = ({
queryClient.invalidateQueries(topicKeys.all(clusterName));
};
const isCleanupDisabled = cleanUpPolicy !== CleanUpPolicy.DELETE;
return (
<S.ActionsContainer>
{!isHidden && (
<Dropdown>
{cleanUpPolicy === CleanUpPolicy.DELETE && (
<DropdownItem
onClick={clearTopicMessagesHandler}
confirm="Are you sure want to clear topic messages?"
danger
>
Clear Messages
</DropdownItem>
)}
<DropdownItem
onClick={recreateTopic.mutateAsync}
confirm={
<>
Are you sure to recreate <b>{name}</b> topic?
</>
}
danger
>
Recreate Topic
</DropdownItem>
{isTopicDeletionAllowed && (
<DropdownItem
onClick={() => deleteTopic.mutateAsync(name)}
confirm={
<>
Are you sure want to remove <b>{name}</b> topic?
</>
}
danger
>
Remove Topic
</DropdownItem>
)}
</Dropdown>
)}
</S.ActionsContainer>
<Dropdown disabled={isHidden}>
<DropdownItem
disabled={isCleanupDisabled}
onClick={clearTopicMessagesHandler}
confirm="Are you sure want to clear topic messages?"
danger
title="Cleanup is alowed only for topics with DELETE policy"
>
Clear Messages
</DropdownItem>
<DropdownItem
onClick={recreateTopic.mutateAsync}
confirm={
<>
Are you sure to recreate <b>{name}</b> topic?
</>
}
danger
>
Recreate Topic
</DropdownItem>
<DropdownItem
disabled={!isTopicDeletionAllowed}
onClick={() => deleteTopic.mutateAsync(name)}
confirm={
<>
Are you sure want to remove <b>{name}</b> topic?
</>
}
title={
isTopicDeletionAllowed
? 'The topic deletion is restricted by app configuration'
: ''
}
danger
>
Remove Topic
</DropdownItem>
</Dropdown>
);
};
export default React.memo(ActionsCell);
export default ActionsCell;

View file

@ -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<Topic>[];
resetRowSelection(): void;
}
const BatchActionsbar: React.FC<BatchActionsbarProps> = ({
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 (
<>
<Button
buttonSize="M"
buttonType="secondary"
onClick={deleteTopicsHandler}
>
Delete selected topics
</Button>
<Button
buttonSize="M"
buttonType="secondary"
disabled={selectedTopics.length > 1}
to={getCopyTopicPath()}
>
Copy selected topic
</Button>
<Button
buttonSize="M"
buttonType="secondary"
onClick={purgeTopicsHandler}
>
Purge messages of selected topics
</Button>
</>
);
};
export default BatchActionsbar;

View file

@ -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;
`;

View file

@ -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 = () => {
)}
</PageHeading>
<ControlPanelWrapper hasInput>
<div>
<Search
handleSearch={handleSearchQuery}
placeholder="Search by Topic Name"
value={searchQuery}
<Search
handleSearch={handleSearchQuery}
placeholder="Search by Topic Name"
value={searchQuery}
/>
<label>
<Switch
name="ShowInternalTopics"
checked={!searchParams.has('hideInternal')}
onChange={handleSwitch}
/>
</div>
<div>
<label>
<Switch
name="ShowInternalTopics"
checked={!searchParams.has('hideInternal')}
onChange={handleSwitch}
/>
Show Internal Topics
</label>
</div>
Show Internal Topics
</label>
</ControlPanelWrapper>
<Suspense fallback={<PageLoader />}>
<TopicsTable />
<TopicTable />
</Suspense>
</>
);

View file

@ -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<ColumnDef<Topic>[]>(
() => [
{
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<Topic['partitions']>();
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<Topic['partitions']>();
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 (
<Table
data={topics}
pageCount={pageCount}
columns={columns}
enableSorting
serverSideProcessing
batchActionsBar={BatchActionsbar}
enableRowSelection={
!isReadOnly ? (row) => !row.original.internal : undefined
}
emptyMessage="No topics found"
/>
);
};
export default TopicTable;

View file

@ -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<CellContext<Topic, unknown>> = ({
row: { original },
}) => {
const { internal, name } = original;
return (
<NavLink to={name} title={name}>
{internal && (
<>
<Tag color="gray">IN</Tag>
&nbsp;
</>
)}
{name}
</NavLink>
);
};

View file

@ -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<Topic, string>(
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 && (
<ControlPanelWrapper>
<Button
buttonSize="M"
buttonType="secondary"
onClick={deleteTopicsHandler}
>
Delete selected topics
</Button>
{tableState.selectedCount === 1 && (
<Button
buttonSize="M"
buttonType="secondary"
to={{
pathname: clusterTopicCopyRelativePath,
search: `?${getSelectedTopic()}`,
}}
>
Copy selected topic
</Button>
)}
<Button
buttonSize="M"
buttonType="secondary"
onClick={purgeTopicsHandler}
>
Purge messages of selected topics
</Button>
</ControlPanelWrapper>
)}
<SmartTable
selectable={!isReadOnly}
tableState={tableState}
placeholder="No topics found"
isFullwidth
paginated
hoverable
>
<TableColumn
maxWidth="350px"
title="Topic Name"
cell={TitleCell}
orderValue={TopicColumnsToSort.NAME}
/>
<TableColumn
title="Total Partitions"
field="partitions.length"
orderValue={TopicColumnsToSort.TOTAL_PARTITIONS}
/>
<TableColumn
title="Out of sync replicas"
cell={OutOfSyncReplicasCell}
orderValue={TopicColumnsToSort.OUT_OF_SYNC_REPLICAS}
/>
<TableColumn title="Replication Factor" field="replicationFactor" />
<TableColumn title="Number of messages" cell={MessagesCell} />
<TableColumn
title="Size"
cell={TopicSizeCell}
orderValue={TopicColumnsToSort.SIZE}
/>
<TableColumn maxWidth="4%" cell={ActionsCell} customTd={ActionsTd} />
</SmartTable>
</>
);
};
export default TopicsTable;

View file

@ -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<TableCellProps<Topic, string>> = ({
dataItem: { internal, name },
}) => {
return (
<>
{internal && <Tag color="gray">IN</Tag>}
<S.Link to={name} $isInternal={internal}>
{name}
</S.Link>
</>
);
};
export const TopicSizeCell: React.FC<TableCellProps<Topic, string>> = ({
dataItem: { segmentSize },
}) => {
return <BytesFormatted value={segmentSize} />;
};
export const OutOfSyncReplicasCell: React.FC<TableCellProps<Topic, string>> = ({
dataItem: { partitions },
}) => {
const data = React.useMemo(() => {
if (partitions === undefined || partitions.length === 0) {
return 0;
}
return partitions.reduce((memo, { replicas }) => {
const outOfSync = replicas?.filter(({ inSync }) => !inSync);
return memo + (outOfSync?.length || 0);
}, 0);
}, [partitions]);
return <span>{data}</span>;
};
export const MessagesCell: React.FC<TableCellProps<Topic, string>> = ({
dataItem: { partitions },
}) => {
const data = React.useMemo(() => {
if (partitions === undefined || partitions.length === 0) {
return 0;
}
return partitions.reduce((memo, { offsetMax, offsetMin }) => {
return memo + (offsetMax - offsetMin);
}, 0);
}, [partitions]);
return <span>{data}</span>;
};

View file

@ -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<Topic, string> = {
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(
<ClusterContext.Provider
value={{
isReadOnly,
hasKafkaConnectConfigured: true,
hasSchemaRegistryConfigured: true,
isTopicDeletionAllowed,
}}
>
<ActionsCell
rowIndex={1}
dataItem={currentData}
tableState={mockTableState}
hovered={hovered}
/>
</ClusterContext.Provider>
);
};
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());
});
});
});
});

View file

@ -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();
});
});

View file

@ -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(
<ClusterContext.Provider
value={{
isReadOnly,
hasKafkaConnectConfigured: true,
hasSchemaRegistryConfigured: true,
isTopicDeletionAllowed,
}}
>
<WithRoute path={clusterTopicsPath()}>
<TopicTable />
</WithRoute>
</ClusterContext.Provider>,
{ 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());
});
});
});
});
});

View file

@ -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(
<ClusterContext.Provider
value={{
isReadOnly: false,
hasKafkaConnectConfigured: true,
hasSchemaRegistryConfigured: true,
isTopicDeletionAllowed: true,
}}
>
<WithRoute path={clusterTopicsPath()}>
<TopicsTable />
</WithRoute>
</ClusterContext.Provider>,
{ 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();
});
});
});

View file

@ -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<Topic, string> = {
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(
<TitleCell
rowIndex={1}
dataItem={currentData}
tableState={mockTableState}
/>
);
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(
<TitleCell
rowIndex={1}
dataItem={currentData}
tableState={mockTableState}
/>
);
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(
<TopicSizeCell
rowIndex={1}
dataItem={currentData}
tableState={mockTableState}
/>
);
expect(screen.getByText('1KB')).toBeInTheDocument();
});
});
describe('OutOfSyncReplicasCell Component', () => {
it('returns 0 if no partition is empty array', () => {
const currentData = topicsPayload[0];
currentData.partitions = [];
render(
<OutOfSyncReplicasCell
rowIndex={1}
dataItem={currentData}
tableState={mockTableState}
/>
);
expect(screen.getByText('0')).toBeInTheDocument();
});
it('returns 0 if no partition is found', () => {
const currentData = topicsPayload[1];
currentData.partitions = undefined;
render(
<OutOfSyncReplicasCell
rowIndex={1}
dataItem={currentData}
tableState={mockTableState}
/>
);
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(
<OutOfSyncReplicasCell
rowIndex={1}
dataItem={currentData}
tableState={mockTableState}
/>
);
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(
<OutOfSyncReplicasCell
rowIndex={1}
dataItem={currentData}
tableState={mockTableState}
/>
);
expect(
screen.getByText(partitionNumber ? partitionNumber.toString() : '0')
).toBeInTheDocument();
});
});
describe('MessagesCell Component', () => {
it('returns 0 if partition is empty array ', () => {
render(
<MessagesCell
rowIndex={1}
dataItem={{ ...topicsPayload[0], partitions: [] }}
tableState={mockTableState}
/>
);
expect(screen.getByText('0')).toBeInTheDocument();
});
it('returns 0 if no partition is found', () => {
render(
<MessagesCell
rowIndex={1}
dataItem={{ ...topicsPayload[0], partitions: undefined }}
tableState={mockTableState}
/>
);
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(
<MessagesCell
rowIndex={1}
dataItem={currentData}
tableState={mockTableState}
/>
);
expect(offsetMax - offsetMin).toEqual(9689);
expect(screen.getByText(offsetMax - offsetMin)).toBeInTheDocument();
});
});
});

View file

@ -90,68 +90,66 @@ const Overview: React.FC = () => {
</Metrics.Indicator>
</Metrics.Section>
</Metrics.Wrapper>
<div>
<Table isFullwidth>
<thead>
<tr>
<TableHeaderCell title="Partition ID" />
<TableHeaderCell title="Replicas" />
<TableHeaderCell title="First Offset" />
<TableHeaderCell title="Next Offset" />
<TableHeaderCell title="Message Count" />
<TableHeaderCell title=" " />
</tr>
</thead>
<tbody>
{data?.partitions?.map((partition: Partition) => (
<tr key={`partition-list-item-key-${partition.partition}`}>
<td>{partition.partition}</td>
<td>
{partition.replicas?.map(({ broker, leader }: Replica) => (
<S.Replica
leader={leader}
key={broker}
title={leader ? 'Leader' : ''}
<Table isFullwidth>
<thead>
<tr>
<TableHeaderCell title="Partition ID" />
<TableHeaderCell title="Replicas" />
<TableHeaderCell title="First Offset" />
<TableHeaderCell title="Next Offset" />
<TableHeaderCell title="Message Count" />
<TableHeaderCell title=" " />
</tr>
</thead>
<tbody>
{data?.partitions?.map((partition: Partition) => (
<tr key={`partition-list-item-key-${partition.partition}`}>
<td>{partition.partition}</td>
<td>
{partition.replicas?.map(({ broker, leader }: Replica) => (
<S.Replica
leader={leader}
key={broker}
title={leader ? 'Leader' : ''}
>
{broker}
</S.Replica>
))}
</td>
<td>{partition.offsetMin}</td>
<td>{partition.offsetMax}</td>
<td>{partition.offsetMax - partition.offsetMin}</td>
<td style={{ width: '5%' }}>
{!data?.internal &&
!isReadOnly &&
data?.cleanUpPolicy === 'DELETE' ? (
<Dropdown>
<DropdownItem
onClick={() =>
dispatch(
clearTopicMessages({
clusterName,
topicName,
partitions: [partition.partition],
})
).unwrap()
}
danger
>
{broker}
</S.Replica>
))}
</td>
<td>{partition.offsetMin}</td>
<td>{partition.offsetMax}</td>
<td>{partition.offsetMax - partition.offsetMin}</td>
<td style={{ width: '5%' }}>
{!data?.internal &&
!isReadOnly &&
data?.cleanUpPolicy === 'DELETE' ? (
<Dropdown>
<DropdownItem
onClick={() =>
dispatch(
clearTopicMessages({
clusterName,
topicName,
partitions: [partition.partition],
})
).unwrap()
}
danger
>
Clear Messages
</DropdownItem>
</Dropdown>
) : null}
</td>
</tr>
))}
{data?.partitions?.length === 0 && (
<tr>
<td colSpan={10}>No Partitions found</td>
</tr>
)}
</tbody>
</Table>
</div>
Clear Messages
</DropdownItem>
</Dropdown>
) : null}
</td>
</tr>
))}
{data?.partitions?.length === 0 && (
<tr>
<td colSpan={10}>No Partitions found</td>
</tr>
)}
</tbody>
</Table>
</>
);
};

View file

@ -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';

View file

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

View file

@ -7,9 +7,10 @@ import * as S from './Dropdown.styled';
interface DropdownProps extends PropsWithChildren<Partial<MenuProps>> {
label?: React.ReactNode;
disabled?: boolean;
}
const Dropdown: React.FC<DropdownProps> = ({ label, children }) => {
const Dropdown: React.FC<DropdownProps> = ({ label, disabled, children }) => {
const ref = useRef(null);
const { isOpen, setClose, setOpen } = useModal(false);
@ -25,6 +26,7 @@ const Dropdown: React.FC<DropdownProps> = ({ label, children }) => {
onClick={handleClick}
ref={ref}
aria-label="Dropdown Toggle"
disabled={disabled}
>
{label || <VerticalElipsisIcon />}
</S.DropdownButton>

View file

@ -0,0 +1,24 @@
import React, { HTMLProps } from 'react';
import styled from 'styled-components';
interface IndeterminateCheckboxProps extends HTMLProps<HTMLInputElement> {
indeterminate?: boolean;
}
const IndeterminateCheckbox: React.FC<IndeterminateCheckboxProps> = ({
indeterminate,
...rest
}) => {
const ref = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (typeof indeterminate === 'boolean' && ref.current) {
ref.current.indeterminate = !rest.checked && indeterminate;
}
}, [ref, indeterminate]);
return <input type="checkbox" ref={ref} {...rest} />;
};
export default styled(IndeterminateCheckbox)`
cursor: pointer;
`;

View file

@ -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<CellContext<any, unknown>> = ({ row }) => (
<IndeterminateCheckbox
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
);
export default SelectRowCell;

View file

@ -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<HeaderContext<any, unknown>> = ({ table }) => (
<IndeterminateCheckbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
/>
);
export default SelectRowHeader;

View file

@ -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<CellContext<any, any>> = ({ getValue }) => (
<BytesFormatted value={getValue()} />
);
export default SizeCell;

View file

@ -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<ThProps>(
({ 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<ThProps>(
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;

View file

@ -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<TData> {
export interface TableProps<TData> {
data: TData[];
pageCount?: number;
columns: ColumnDef<TData>[];
@ -30,10 +32,69 @@ interface TableProps<TData> {
getRowCanExpand?: (row: Row<TData>) => boolean;
serverSideProcessing?: boolean;
enableSorting?: boolean;
enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
batchActionsBar?: React.FC<{ rows: Row<TData>[]; resetRowSelection(): void }>;
emptyMessage?: string;
}
type UpdaterFn<T> = (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<TableProps<any>> = ({
data,
@ -43,77 +104,45 @@ const Table: React.FC<TableProps<any>> = ({
renderSubComponent,
serverSideProcessing = false,
enableSorting = false,
enableRowSelection = false,
batchActionsBar,
emptyMessage,
}) => {
const [searchParams, setSearchParams] = useSearchParams();
const [sorting, setSorting] = React.useState<SortingState>([]);
const [{ pageIndex, pageSize }, setPagination] =
React.useState<PaginationState>({
pageIndex: 0,
pageSize: PER_PAGE,
});
const [rowSelection, setRowSelection] = React.useState({});
const onSortingChange = React.useCallback(
(updater: UpdaterFn<SortingState>) => {
const newState = updateSortingState(updater, searchParams);
setSearchParams(searchParams);
setSorting(newState);
return newState;
},
[searchParams]
);
const onPaginationChange = React.useCallback(
(updater: UpdaterFn<PaginationState>) => {
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<SortingState>,
onPaginationChange: onPaginationChange as OnChangeFn<PaginationState>,
onRowSelectionChange: setRowSelection,
getRowCanExpand,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
@ -122,14 +151,34 @@ const Table: React.FC<TableProps<any>> = ({
manualSorting: serverSideProcessing,
manualPagination: serverSideProcessing,
enableSorting,
autoResetPageIndex: false,
enableRowSelection,
});
const Bar = batchActionsBar;
return (
<>
{table.getSelectedRowModel().flatRows.length > 0 && Bar && (
<S.TableActionsBar>
<Bar
rows={table.getSelectedRowModel().flatRows}
resetRowSelection={table.resetRowSelection}
/>
</S.TableActionsBar>
)}
<S.Table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{!!enableRowSelection && (
<S.Th key={`${headerGroup.id}-select`}>
{flexRender(
SelectRowHeader,
headerGroup.headers[0].getContext()
)}
</S.Th>
)}
{table.getCanSomeRowsExpand() && (
<S.Th expander key={`${headerGroup.id}-expander`} />
)}
@ -160,6 +209,14 @@ const Table: React.FC<TableProps<any>> = ({
expanded={row.getIsExpanded()}
onClick={() => row.getCanExpand() && row.toggleExpanded()}
>
{!!enableRowSelection && (
<td key={`${row.id}-select`}>
{flexRender(
SelectRowCell,
row.getVisibleCells()[0].getContext()
)}
</td>
)}
{row.getCanExpand() && (
<td key={`${row.id}-expander`}>
{flexRender(
@ -168,15 +225,15 @@ const Table: React.FC<TableProps<any>> = ({
)}
</td>
)}
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
{row
.getVisibleCells()
.map(({ id, getContext, column: { columnDef } }) => (
<td key={id}>{flexRender(columnDef.cell, getContext())}</td>
))}
</S.Row>
{row.getIsExpanded() && renderSubComponent && (
<S.Row expanded>
<td colSpan={row.getVisibleCells().length + 1}>
<td colSpan={row.getVisibleCells().length + 2}>
<S.ExpandedRowInfo>
{renderSubComponent({ row })}
</S.ExpandedRowInfo>
@ -185,6 +242,13 @@ const Table: React.FC<TableProps<any>> = ({
)}
</React.Fragment>
))}
{table.getRowModel().rows.length === 0 && (
<S.Row>
<S.EmptyTableMessageCell colSpan={100}>
{emptyMessage || 'No rows found'}
</S.EmptyTableMessageCell>
</S.Row>
)}
</tbody>
</S.Table>
{table.getPageCount() > 1 && (
@ -231,9 +295,9 @@ const Table: React.FC<TableProps<any>> = ({
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);
}}
/>
</S.GoToPage>

View file

@ -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<Datum>[] = [
{
header: 'DateTime',
@ -25,27 +30,30 @@ const columns: ColumnDef<Datum>[] = [
header: 'Text',
accessorKey: 'text',
},
{
header: 'Size',
accessorKey: 'size',
cell: SizeCell,
},
];
const ExpandedRow: React.FC = () => <div>I am expanded row</div>;
interface Props {
interface Props extends TableProps<Datum> {
path?: string;
canExpand?: boolean;
}
const renderComponent = ({ path, canExpand }: Props = {}) => {
const renderComponent = (props: Partial<Props> = {}) => {
render(
<WithRoute path="/">
<Table
columns={columns}
data={data}
renderSubComponent={ExpandedRow}
getRowCanExpand={() => !!canExpand}
enableSorting
{...props}
/>
</WithRoute>,
{ 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<Datum>) => row.original.selectable,
batchActionsBar: () => <div>I am Action Bar</div>,
});
});
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();
});
});
});

View file

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

View file

@ -7,21 +7,14 @@ export default (
updater: UpdaterFn<PaginationState>,
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;
};

View file

@ -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};
}
}
`
);

View file

@ -32,4 +32,8 @@ export const brokerLogDirsPayload: BrokersLogdirs[] = [
},
],
},
{
error: 'NONE',
name: '/opt/kafka/data-1/logs',
},
];

View file

@ -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: {