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:
parent
5fdcd2124c
commit
21f17ad39e
35 changed files with 1107 additions and 1179 deletions
|
@ -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>();
|
||||
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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) || [];
|
||||
};
|
|
@ -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,19 +35,19 @@ 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 && (
|
||||
<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={
|
||||
|
@ -61,23 +59,25 @@ const ActionsCell: React.FC<TableCellProps<Topic, string>> = ({
|
|||
>
|
||||
Recreate Topic
|
||||
</DropdownItem>
|
||||
{isTopicDeletionAllowed && (
|
||||
<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>
|
||||
)}
|
||||
</S.ActionsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ActionsCell);
|
||||
export default ActionsCell;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
`;
|
|
@ -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,14 +59,11 @@ const ListPage: React.FC = () => {
|
|||
)}
|
||||
</PageHeading>
|
||||
<ControlPanelWrapper hasInput>
|
||||
<div>
|
||||
<Search
|
||||
handleSearch={handleSearchQuery}
|
||||
placeholder="Search by Topic Name"
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<Switch
|
||||
name="ShowInternalTopics"
|
||||
|
@ -75,10 +72,9 @@ const ListPage: React.FC = () => {
|
|||
/>
|
||||
Show Internal Topics
|
||||
</label>
|
||||
</div>
|
||||
</ControlPanelWrapper>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<TopicsTable />
|
||||
<TopicTable />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
|
|
113
kafka-ui-react-app/src/components/Topics/List/TopicTable.tsx
Normal file
113
kafka-ui-react-app/src/components/Topics/List/TopicTable.tsx
Normal 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;
|
|
@ -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>
|
||||
|
||||
</>
|
||||
)}
|
||||
{name}
|
||||
</NavLink>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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>;
|
||||
};
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -90,7 +90,6 @@ const Overview: React.FC = () => {
|
|||
</Metrics.Indicator>
|
||||
</Metrics.Section>
|
||||
</Metrics.Wrapper>
|
||||
<div>
|
||||
<Table isFullwidth>
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -151,7 +150,6 @@ const Overview: React.FC = () => {
|
|||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
`;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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('page', String(newState.pageIndex + 1));
|
||||
searchParams.set('perPage', newState.pageSize.toString());
|
||||
} else {
|
||||
searchParams.delete('perPage');
|
||||
}
|
||||
return newState;
|
||||
return previousState;
|
||||
};
|
||||
|
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
`
|
||||
);
|
||||
|
|
|
@ -32,4 +32,8 @@ export const brokerLogDirsPayload: BrokersLogdirs[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
error: 'NONE',
|
||||
name: '/opt/kafka/data-1/logs',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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: {
|
||||
|
|
Loading…
Add table
Reference in a new issue