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 Navbar from 'components/common/Navigation/Navbar.styled';
|
||||||
import PageLoader from 'components/common/PageLoader/PageLoader';
|
import PageLoader from 'components/common/PageLoader/PageLoader';
|
||||||
|
|
||||||
export interface BrokerLogdirState {
|
|
||||||
name: string;
|
|
||||||
error: string;
|
|
||||||
topics: number;
|
|
||||||
partitions: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Broker: React.FC = () => {
|
const Broker: React.FC = () => {
|
||||||
const { clusterName, brokerId } = useAppParams<ClusterBrokerParam>();
|
const { clusterName, brokerId } = useAppParams<ClusterBrokerParam>();
|
||||||
|
|
||||||
|
|
|
@ -1,40 +1,53 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import useAppParams from 'lib/hooks/useAppParams';
|
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 { ClusterBrokerParam } from 'lib/paths';
|
||||||
import { useBrokerLogDirs } from 'lib/hooks/api/brokers';
|
import { useBrokerLogDirs } from 'lib/hooks/api/brokers';
|
||||||
|
import Table from 'components/common/NewTable';
|
||||||
interface BrokerLogdirState {
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
name: string;
|
import { BrokersLogdirs } from 'generated-sources';
|
||||||
error: string;
|
|
||||||
topics: number;
|
|
||||||
partitions: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BrokerLogdir: React.FC = () => {
|
const BrokerLogdir: React.FC = () => {
|
||||||
const { clusterName, brokerId } = useAppParams<ClusterBrokerParam>();
|
const { clusterName, brokerId } = useAppParams<ClusterBrokerParam>();
|
||||||
const { data: logDirs } = useBrokerLogDirs(clusterName, Number(brokerId));
|
const { data } = useBrokerLogDirs(clusterName, Number(brokerId));
|
||||||
|
|
||||||
const preparedRows = translateLogdirs(logDirs);
|
const columns = React.useMemo<ColumnDef<BrokersLogdirs>[]>(
|
||||||
const tableState = useTableState<BrokerLogdirState, string>(preparedRows, {
|
() => [
|
||||||
idSelector: ({ name }) => name,
|
{ header: 'Name', accessorKey: 'name' },
|
||||||
totalPages: 0,
|
{ 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 (
|
return (
|
||||||
<SmartTable
|
<Table
|
||||||
tableState={tableState}
|
data={data || []}
|
||||||
placeholder="Log dir data not available"
|
columns={columns}
|
||||||
isFullwidth
|
emptyMessage="Log dir data not available"
|
||||||
>
|
enableSorting
|
||||||
<TableColumn title="Name" field="name" />
|
/>
|
||||||
<TableColumn title="Error" field="error" />
|
|
||||||
<TableColumn title="Topics" field="topics" />
|
|
||||||
<TableColumn title="Partitions" field="partitions" />
|
|
||||||
</SmartTable>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ const clusterName = 'local';
|
||||||
const brokerId = 1;
|
const brokerId = 1;
|
||||||
|
|
||||||
describe('BrokerLogdir Component', () => {
|
describe('BrokerLogdir Component', () => {
|
||||||
const renderComponent = async (payload: BrokerLogdirs[] = []) => {
|
const renderComponent = async (payload?: BrokerLogdirs[]) => {
|
||||||
(useBrokerLogDirs as jest.Mock).mockImplementation(() => ({
|
(useBrokerLogDirs as jest.Mock).mockImplementation(() => ({
|
||||||
data: payload,
|
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();
|
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);
|
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';
|
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 = {
|
export const brokerMetricsPayload: BrokerMetrics = {
|
||||||
segmentSize: 23,
|
segmentSize: 23,
|
||||||
segmentCount: 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 React from 'react';
|
||||||
import { CleanUpPolicy, Topic } from 'generated-sources';
|
import { CleanUpPolicy, Topic } from 'generated-sources';
|
||||||
|
import { CellContext } from '@tanstack/react-table';
|
||||||
import { useAppDispatch } from 'lib/hooks/redux';
|
import { useAppDispatch } from 'lib/hooks/redux';
|
||||||
import { TableCellProps } from 'components/common/SmartTable/TableColumn';
|
|
||||||
import ClusterContext from 'components/contexts/ClusterContext';
|
import ClusterContext from 'components/contexts/ClusterContext';
|
||||||
import * as S from 'components/Topics/List/List.styled';
|
|
||||||
import { ClusterNameRoute } from 'lib/paths';
|
import { ClusterNameRoute } from 'lib/paths';
|
||||||
import useAppParams from 'lib/hooks/useAppParams';
|
import useAppParams from 'lib/hooks/useAppParams';
|
||||||
import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice';
|
import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice';
|
||||||
|
@ -15,10 +14,9 @@ import {
|
||||||
useRecreateTopic,
|
useRecreateTopic,
|
||||||
} from 'lib/hooks/api/topics';
|
} from 'lib/hooks/api/topics';
|
||||||
|
|
||||||
const ActionsCell: React.FC<TableCellProps<Topic, string>> = ({
|
const ActionsCell: React.FC<CellContext<Topic, unknown>> = ({ row }) => {
|
||||||
hovered,
|
const { name, internal, cleanUpPolicy } = row.original;
|
||||||
dataItem: { internal, cleanUpPolicy, name },
|
|
||||||
}) => {
|
|
||||||
const { isReadOnly, isTopicDeletionAllowed } =
|
const { isReadOnly, isTopicDeletionAllowed } =
|
||||||
React.useContext(ClusterContext);
|
React.useContext(ClusterContext);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
@ -28,7 +26,7 @@ const ActionsCell: React.FC<TableCellProps<Topic, string>> = ({
|
||||||
const deleteTopic = useDeleteTopic(clusterName);
|
const deleteTopic = useDeleteTopic(clusterName);
|
||||||
const recreateTopic = useRecreateTopic({ clusterName, topicName: name });
|
const recreateTopic = useRecreateTopic({ clusterName, topicName: name });
|
||||||
|
|
||||||
const isHidden = internal || isReadOnly || !hovered;
|
const isHidden = internal || isReadOnly;
|
||||||
|
|
||||||
const clearTopicMessagesHandler = async () => {
|
const clearTopicMessagesHandler = async () => {
|
||||||
await dispatch(
|
await dispatch(
|
||||||
|
@ -37,47 +35,49 @@ const ActionsCell: React.FC<TableCellProps<Topic, string>> = ({
|
||||||
queryClient.invalidateQueries(topicKeys.all(clusterName));
|
queryClient.invalidateQueries(topicKeys.all(clusterName));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isCleanupDisabled = cleanUpPolicy !== CleanUpPolicy.DELETE;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<S.ActionsContainer>
|
<Dropdown disabled={isHidden}>
|
||||||
{!isHidden && (
|
<DropdownItem
|
||||||
<Dropdown>
|
disabled={isCleanupDisabled}
|
||||||
{cleanUpPolicy === CleanUpPolicy.DELETE && (
|
onClick={clearTopicMessagesHandler}
|
||||||
<DropdownItem
|
confirm="Are you sure want to clear topic messages?"
|
||||||
onClick={clearTopicMessagesHandler}
|
danger
|
||||||
confirm="Are you sure want to clear topic messages?"
|
title="Cleanup is alowed only for topics with DELETE policy"
|
||||||
danger
|
>
|
||||||
>
|
Clear Messages
|
||||||
Clear Messages
|
</DropdownItem>
|
||||||
</DropdownItem>
|
<DropdownItem
|
||||||
)}
|
onClick={recreateTopic.mutateAsync}
|
||||||
<DropdownItem
|
confirm={
|
||||||
onClick={recreateTopic.mutateAsync}
|
<>
|
||||||
confirm={
|
Are you sure to recreate <b>{name}</b> topic?
|
||||||
<>
|
</>
|
||||||
Are you sure to recreate <b>{name}</b> topic?
|
}
|
||||||
</>
|
danger
|
||||||
}
|
>
|
||||||
danger
|
Recreate Topic
|
||||||
>
|
</DropdownItem>
|
||||||
Recreate Topic
|
<DropdownItem
|
||||||
</DropdownItem>
|
disabled={!isTopicDeletionAllowed}
|
||||||
{isTopicDeletionAllowed && (
|
onClick={() => deleteTopic.mutateAsync(name)}
|
||||||
<DropdownItem
|
confirm={
|
||||||
onClick={() => deleteTopic.mutateAsync(name)}
|
<>
|
||||||
confirm={
|
Are you sure want to remove <b>{name}</b> topic?
|
||||||
<>
|
</>
|
||||||
Are you sure want to remove <b>{name}</b> topic?
|
}
|
||||||
</>
|
title={
|
||||||
}
|
isTopicDeletionAllowed
|
||||||
danger
|
? 'The topic deletion is restricted by app configuration'
|
||||||
>
|
: ''
|
||||||
Remove Topic
|
}
|
||||||
</DropdownItem>
|
danger
|
||||||
)}
|
>
|
||||||
</Dropdown>
|
Remove Topic
|
||||||
)}
|
</DropdownItem>
|
||||||
</S.ActionsContainer>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 PlusIcon from 'components/common/Icons/PlusIcon';
|
||||||
import useSearch from 'lib/hooks/useSearch';
|
import useSearch from 'lib/hooks/useSearch';
|
||||||
import PageLoader from 'components/common/PageLoader/PageLoader';
|
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 ListPage: React.FC = () => {
|
||||||
const { isReadOnly } = React.useContext(ClusterContext);
|
const { isReadOnly } = React.useContext(ClusterContext);
|
||||||
|
@ -29,7 +29,7 @@ const ListPage: React.FC = () => {
|
||||||
) {
|
) {
|
||||||
searchParams.set('hideInternal', 'true');
|
searchParams.set('hideInternal', 'true');
|
||||||
}
|
}
|
||||||
setSearchParams(searchParams, { replace: true });
|
setSearchParams(searchParams);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSwitch = () => {
|
const handleSwitch = () => {
|
||||||
|
@ -41,8 +41,8 @@ const ListPage: React.FC = () => {
|
||||||
searchParams.set('hideInternal', 'true');
|
searchParams.set('hideInternal', 'true');
|
||||||
}
|
}
|
||||||
// Page must be reset when the switch is toggled
|
// Page must be reset when the switch is toggled
|
||||||
searchParams.delete('page');
|
searchParams.set('page', '1');
|
||||||
setSearchParams(searchParams.toString(), { replace: true });
|
setSearchParams(searchParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -59,26 +59,22 @@ const ListPage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</PageHeading>
|
</PageHeading>
|
||||||
<ControlPanelWrapper hasInput>
|
<ControlPanelWrapper hasInput>
|
||||||
<div>
|
<Search
|
||||||
<Search
|
handleSearch={handleSearchQuery}
|
||||||
handleSearch={handleSearchQuery}
|
placeholder="Search by Topic Name"
|
||||||
placeholder="Search by Topic Name"
|
value={searchQuery}
|
||||||
value={searchQuery}
|
/>
|
||||||
|
<label>
|
||||||
|
<Switch
|
||||||
|
name="ShowInternalTopics"
|
||||||
|
checked={!searchParams.has('hideInternal')}
|
||||||
|
onChange={handleSwitch}
|
||||||
/>
|
/>
|
||||||
</div>
|
Show Internal Topics
|
||||||
<div>
|
</label>
|
||||||
<label>
|
|
||||||
<Switch
|
|
||||||
name="ShowInternalTopics"
|
|
||||||
checked={!searchParams.has('hideInternal')}
|
|
||||||
onChange={handleSwitch}
|
|
||||||
/>
|
|
||||||
Show Internal Topics
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</ControlPanelWrapper>
|
</ControlPanelWrapper>
|
||||||
<Suspense fallback={<PageLoader />}>
|
<Suspense fallback={<PageLoader />}>
|
||||||
<TopicsTable />
|
<TopicTable />
|
||||||
</Suspense>
|
</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';
|
const clusterName = 'test-cluster';
|
||||||
|
|
||||||
jest.mock('components/Topics/List/TopicsTable', () => () => (
|
jest.mock('components/Topics/List/TopicTable', () => () => <>TopicTableMock</>);
|
||||||
<>TopicsTableMock</>
|
|
||||||
));
|
|
||||||
|
|
||||||
describe('ListPage Component', () => {
|
describe('ListPage Component', () => {
|
||||||
const renderComponent = () => {
|
const renderComponent = () => {
|
||||||
|
@ -47,6 +45,6 @@ describe('ListPage Component', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the TopicsTable', () => {
|
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,68 +90,66 @@ const Overview: React.FC = () => {
|
||||||
</Metrics.Indicator>
|
</Metrics.Indicator>
|
||||||
</Metrics.Section>
|
</Metrics.Section>
|
||||||
</Metrics.Wrapper>
|
</Metrics.Wrapper>
|
||||||
<div>
|
<Table isFullwidth>
|
||||||
<Table isFullwidth>
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<TableHeaderCell title="Partition ID" />
|
||||||
<TableHeaderCell title="Partition ID" />
|
<TableHeaderCell title="Replicas" />
|
||||||
<TableHeaderCell title="Replicas" />
|
<TableHeaderCell title="First Offset" />
|
||||||
<TableHeaderCell title="First Offset" />
|
<TableHeaderCell title="Next Offset" />
|
||||||
<TableHeaderCell title="Next Offset" />
|
<TableHeaderCell title="Message Count" />
|
||||||
<TableHeaderCell title="Message Count" />
|
<TableHeaderCell title=" " />
|
||||||
<TableHeaderCell title=" " />
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
{data?.partitions?.map((partition: Partition) => (
|
||||||
{data?.partitions?.map((partition: Partition) => (
|
<tr key={`partition-list-item-key-${partition.partition}`}>
|
||||||
<tr key={`partition-list-item-key-${partition.partition}`}>
|
<td>{partition.partition}</td>
|
||||||
<td>{partition.partition}</td>
|
<td>
|
||||||
<td>
|
{partition.replicas?.map(({ broker, leader }: Replica) => (
|
||||||
{partition.replicas?.map(({ broker, leader }: Replica) => (
|
<S.Replica
|
||||||
<S.Replica
|
leader={leader}
|
||||||
leader={leader}
|
key={broker}
|
||||||
key={broker}
|
title={leader ? 'Leader' : ''}
|
||||||
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}
|
Clear Messages
|
||||||
</S.Replica>
|
</DropdownItem>
|
||||||
))}
|
</Dropdown>
|
||||||
</td>
|
) : null}
|
||||||
<td>{partition.offsetMin}</td>
|
</td>
|
||||||
<td>{partition.offsetMax}</td>
|
</tr>
|
||||||
<td>{partition.offsetMax - partition.offsetMin}</td>
|
))}
|
||||||
<td style={{ width: '5%' }}>
|
{data?.partitions?.length === 0 && (
|
||||||
{!data?.internal &&
|
<tr>
|
||||||
!isReadOnly &&
|
<td colSpan={10}>No Partitions found</td>
|
||||||
data?.cleanUpPolicy === 'DELETE' ? (
|
</tr>
|
||||||
<Dropdown>
|
)}
|
||||||
<DropdownItem
|
</tbody>
|
||||||
onClick={() =>
|
</Table>
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
/* eslint-disable react/no-unstable-nested-components */
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { TopicAnalysisStats } from 'generated-sources';
|
import { TopicAnalysisStats } from 'generated-sources';
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
|
@ -51,6 +51,7 @@ export const Dropdown = styled(ControlledMenu)(
|
||||||
|
|
||||||
${menuItemSelector.disabled} {
|
${menuItemSelector.disabled} {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
@ -61,6 +62,12 @@ export const DropdownButton = styled.button`
|
||||||
display: flex;
|
display: flex;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
|
float: right;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const DangerItem = styled.div`
|
export const DangerItem = styled.div`
|
||||||
|
|
|
@ -7,9 +7,10 @@ import * as S from './Dropdown.styled';
|
||||||
|
|
||||||
interface DropdownProps extends PropsWithChildren<Partial<MenuProps>> {
|
interface DropdownProps extends PropsWithChildren<Partial<MenuProps>> {
|
||||||
label?: React.ReactNode;
|
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 ref = useRef(null);
|
||||||
const { isOpen, setClose, setOpen } = useModal(false);
|
const { isOpen, setClose, setOpen } = useModal(false);
|
||||||
|
|
||||||
|
@ -25,6 +26,7 @@ const Dropdown: React.FC<DropdownProps> = ({ label, children }) => {
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
aria-label="Dropdown Toggle"
|
aria-label="Dropdown Toggle"
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{label || <VerticalElipsisIcon />}
|
{label || <VerticalElipsisIcon />}
|
||||||
</S.DropdownButton>
|
</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) => `
|
const sortableMixin = (normalColor: string, hoverColor: string) => `
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding-right: 18px;
|
padding-left: 14px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&::before,
|
&::before,
|
||||||
|
@ -28,7 +28,7 @@ const sortableMixin = (normalColor: string, hoverColor: string) => `
|
||||||
content: '';
|
content: '';
|
||||||
display: block;
|
display: block;
|
||||||
height: 0;
|
height: 0;
|
||||||
right: 5px;
|
left: 0px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
@ -51,13 +51,13 @@ const ASCMixin = (color: string) => `
|
||||||
border-bottom-color: ${color};
|
border-bottom-color: ${color};
|
||||||
}
|
}
|
||||||
&:after {
|
&:after {
|
||||||
border-top-color: rgba(0, 0, 0, 0.2);
|
border-top-color: rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const DESCMixin = (color: string) => `
|
const DESCMixin = (color: string) => `
|
||||||
color: ${color};
|
color: ${color};
|
||||||
&:before {
|
&:before {
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.2);
|
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
&:after {
|
&:after {
|
||||||
border-top-color: ${color};
|
border-top-color: ${color};
|
||||||
|
@ -65,8 +65,15 @@ const DESCMixin = (color: string) => `
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const Th = styled.th<ThProps>(
|
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;
|
border-bottom-width: 1px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
@ -77,17 +84,16 @@ export const Th = styled.th<ThProps>(
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
letter-spacing: 0em;
|
letter-spacing: 0em;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: ${table.th.backgroundColor.normal};
|
background: ${th.backgroundColor.normal};
|
||||||
width: ${expander ? '5px' : 'auto'};
|
width: ${expander ? '5px' : 'auto'};
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
& > div {
|
& > div {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
color: ${table.th.color.normal};
|
color: ${th.color.normal};
|
||||||
${
|
${sortable ? sortableMixin(th.color.sortable, th.color.hover) : ''}
|
||||||
sortable ? sortableMixin(table.th.color.normal, table.th.color.hover) : ''
|
${sortable && sortOrder === 'asc' && ASCMixin(th.color.active)}
|
||||||
}
|
${sortable && sortOrder === 'desc' && DESCMixin(th.color.active)}
|
||||||
${sortable && sortOrder === 'asc' && ASCMixin(table.th.color.active)}
|
|
||||||
${sortable && sortOrder === 'desc' && DESCMixin(table.th.color.active)}
|
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
@ -118,6 +124,14 @@ export const Nowrap = styled.div`
|
||||||
white-space: nowrap;
|
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(
|
export const Table = styled.table(
|
||||||
({ theme: { table } }) => `
|
({ theme: { table } }) => `
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -129,18 +143,34 @@ export const Table = styled.table(
|
||||||
padding: 8px 8px 8px 24px;
|
padding: 8px 8px 8px 24px;
|
||||||
color: ${table.td.color.normal};
|
color: ${table.td.color.normal};
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
max-width: 350px;
|
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
|
||||||
& > a {
|
& a {
|
||||||
color: ${table.link.color};
|
color: ${table.link.color.normal};
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
max-width: 450px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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`
|
export const Pagination = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
|
@ -21,8 +21,10 @@ import * as S from './Table.styled';
|
||||||
import updateSortingState from './utils/updateSortingState';
|
import updateSortingState from './utils/updateSortingState';
|
||||||
import updatePaginationState from './utils/updatePaginationState';
|
import updatePaginationState from './utils/updatePaginationState';
|
||||||
import ExpanderCell from './ExpanderCell';
|
import ExpanderCell from './ExpanderCell';
|
||||||
|
import SelectRowCell from './SelectRowCell';
|
||||||
|
import SelectRowHeader from './SelectRowHeader';
|
||||||
|
|
||||||
interface TableProps<TData> {
|
export interface TableProps<TData> {
|
||||||
data: TData[];
|
data: TData[];
|
||||||
pageCount?: number;
|
pageCount?: number;
|
||||||
columns: ColumnDef<TData>[];
|
columns: ColumnDef<TData>[];
|
||||||
|
@ -30,10 +32,69 @@ interface TableProps<TData> {
|
||||||
getRowCanExpand?: (row: Row<TData>) => boolean;
|
getRowCanExpand?: (row: Row<TData>) => boolean;
|
||||||
serverSideProcessing?: boolean;
|
serverSideProcessing?: boolean;
|
||||||
enableSorting?: 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;
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const Table: React.FC<TableProps<any>> = ({
|
const Table: React.FC<TableProps<any>> = ({
|
||||||
data,
|
data,
|
||||||
|
@ -43,77 +104,45 @@ const Table: React.FC<TableProps<any>> = ({
|
||||||
renderSubComponent,
|
renderSubComponent,
|
||||||
serverSideProcessing = false,
|
serverSideProcessing = false,
|
||||||
enableSorting = false,
|
enableSorting = false,
|
||||||
|
enableRowSelection = false,
|
||||||
|
batchActionsBar,
|
||||||
|
emptyMessage,
|
||||||
}) => {
|
}) => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
const [rowSelection, setRowSelection] = React.useState({});
|
||||||
const [{ pageIndex, pageSize }, setPagination] =
|
|
||||||
React.useState<PaginationState>({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: PER_PAGE,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSortingChange = React.useCallback(
|
const onSortingChange = React.useCallback(
|
||||||
(updater: UpdaterFn<SortingState>) => {
|
(updater: UpdaterFn<SortingState>) => {
|
||||||
const newState = updateSortingState(updater, searchParams);
|
const newState = updateSortingState(updater, searchParams);
|
||||||
setSearchParams(searchParams);
|
setSearchParams(searchParams);
|
||||||
setSorting(newState);
|
|
||||||
return newState;
|
return newState;
|
||||||
},
|
},
|
||||||
[searchParams]
|
[searchParams]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPaginationChange = React.useCallback(
|
const onPaginationChange = React.useCallback(
|
||||||
(updater: UpdaterFn<PaginationState>) => {
|
(updater: UpdaterFn<PaginationState>) => {
|
||||||
const newState = updatePaginationState(updater, searchParams);
|
const newState = updatePaginationState(updater, searchParams);
|
||||||
setSearchParams(searchParams);
|
setSearchParams(searchParams);
|
||||||
setPagination(newState);
|
|
||||||
return newState;
|
return newState;
|
||||||
},
|
},
|
||||||
[searchParams]
|
[searchParams]
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const sortBy = searchParams.get('sortBy');
|
setRowSelection({});
|
||||||
const sortDirection = searchParams.get('sortDirection');
|
}, [searchParams]);
|
||||||
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]
|
|
||||||
);
|
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
pageCount,
|
pageCount,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting: getSortingFromSearchParams(searchParams),
|
||||||
pagination,
|
pagination: getPaginationFromSearchParams(searchParams),
|
||||||
|
rowSelection,
|
||||||
},
|
},
|
||||||
onSortingChange: onSortingChange as OnChangeFn<SortingState>,
|
onSortingChange: onSortingChange as OnChangeFn<SortingState>,
|
||||||
onPaginationChange: onPaginationChange as OnChangeFn<PaginationState>,
|
onPaginationChange: onPaginationChange as OnChangeFn<PaginationState>,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
getRowCanExpand,
|
getRowCanExpand,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getExpandedRowModel: getExpandedRowModel(),
|
getExpandedRowModel: getExpandedRowModel(),
|
||||||
|
@ -122,14 +151,34 @@ const Table: React.FC<TableProps<any>> = ({
|
||||||
manualSorting: serverSideProcessing,
|
manualSorting: serverSideProcessing,
|
||||||
manualPagination: serverSideProcessing,
|
manualPagination: serverSideProcessing,
|
||||||
enableSorting,
|
enableSorting,
|
||||||
|
autoResetPageIndex: false,
|
||||||
|
enableRowSelection,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const Bar = batchActionsBar;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{table.getSelectedRowModel().flatRows.length > 0 && Bar && (
|
||||||
|
<S.TableActionsBar>
|
||||||
|
<Bar
|
||||||
|
rows={table.getSelectedRowModel().flatRows}
|
||||||
|
resetRowSelection={table.resetRowSelection}
|
||||||
|
/>
|
||||||
|
</S.TableActionsBar>
|
||||||
|
)}
|
||||||
<S.Table>
|
<S.Table>
|
||||||
<thead>
|
<thead>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
|
{!!enableRowSelection && (
|
||||||
|
<S.Th key={`${headerGroup.id}-select`}>
|
||||||
|
{flexRender(
|
||||||
|
SelectRowHeader,
|
||||||
|
headerGroup.headers[0].getContext()
|
||||||
|
)}
|
||||||
|
</S.Th>
|
||||||
|
)}
|
||||||
{table.getCanSomeRowsExpand() && (
|
{table.getCanSomeRowsExpand() && (
|
||||||
<S.Th expander key={`${headerGroup.id}-expander`} />
|
<S.Th expander key={`${headerGroup.id}-expander`} />
|
||||||
)}
|
)}
|
||||||
|
@ -160,6 +209,14 @@ const Table: React.FC<TableProps<any>> = ({
|
||||||
expanded={row.getIsExpanded()}
|
expanded={row.getIsExpanded()}
|
||||||
onClick={() => row.getCanExpand() && row.toggleExpanded()}
|
onClick={() => row.getCanExpand() && row.toggleExpanded()}
|
||||||
>
|
>
|
||||||
|
{!!enableRowSelection && (
|
||||||
|
<td key={`${row.id}-select`}>
|
||||||
|
{flexRender(
|
||||||
|
SelectRowCell,
|
||||||
|
row.getVisibleCells()[0].getContext()
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
{row.getCanExpand() && (
|
{row.getCanExpand() && (
|
||||||
<td key={`${row.id}-expander`}>
|
<td key={`${row.id}-expander`}>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
|
@ -168,15 +225,15 @@ const Table: React.FC<TableProps<any>> = ({
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row
|
||||||
<td key={cell.id}>
|
.getVisibleCells()
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
.map(({ id, getContext, column: { columnDef } }) => (
|
||||||
</td>
|
<td key={id}>{flexRender(columnDef.cell, getContext())}</td>
|
||||||
))}
|
))}
|
||||||
</S.Row>
|
</S.Row>
|
||||||
{row.getIsExpanded() && renderSubComponent && (
|
{row.getIsExpanded() && renderSubComponent && (
|
||||||
<S.Row expanded>
|
<S.Row expanded>
|
||||||
<td colSpan={row.getVisibleCells().length + 1}>
|
<td colSpan={row.getVisibleCells().length + 2}>
|
||||||
<S.ExpandedRowInfo>
|
<S.ExpandedRowInfo>
|
||||||
{renderSubComponent({ row })}
|
{renderSubComponent({ row })}
|
||||||
</S.ExpandedRowInfo>
|
</S.ExpandedRowInfo>
|
||||||
|
@ -185,6 +242,13 @@ const Table: React.FC<TableProps<any>> = ({
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
|
{table.getRowModel().rows.length === 0 && (
|
||||||
|
<S.Row>
|
||||||
|
<S.EmptyTableMessageCell colSpan={100}>
|
||||||
|
{emptyMessage || 'No rows found'}
|
||||||
|
</S.EmptyTableMessageCell>
|
||||||
|
</S.Row>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</S.Table>
|
</S.Table>
|
||||||
{table.getPageCount() > 1 && (
|
{table.getPageCount() > 1 && (
|
||||||
|
@ -231,9 +295,9 @@ const Table: React.FC<TableProps<any>> = ({
|
||||||
inputSize="M"
|
inputSize="M"
|
||||||
max={table.getPageCount()}
|
max={table.getPageCount()}
|
||||||
min={1}
|
min={1}
|
||||||
onChange={(e) => {
|
onChange={({ target: { value } }) => {
|
||||||
const page = e.target.value ? Number(e.target.value) - 1 : 0;
|
const index = value ? Number(value) - 1 : 0;
|
||||||
table.setPageIndex(page);
|
table.setPageIndex(index);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</S.GoToPage>
|
</S.GoToPage>
|
||||||
|
|
|
@ -1,20 +1,25 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, WithRoute } from 'lib/testHelpers';
|
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 { 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 userEvent from '@testing-library/user-event';
|
||||||
import { formatTimestamp } from 'lib/dateTimeHelpers';
|
import { formatTimestamp } from 'lib/dateTimeHelpers';
|
||||||
import { act } from '@testing-library/react';
|
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];
|
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>[] = [
|
const columns: ColumnDef<Datum>[] = [
|
||||||
{
|
{
|
||||||
header: 'DateTime',
|
header: 'DateTime',
|
||||||
|
@ -25,27 +30,30 @@ const columns: ColumnDef<Datum>[] = [
|
||||||
header: 'Text',
|
header: 'Text',
|
||||||
accessorKey: 'text',
|
accessorKey: 'text',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: 'Size',
|
||||||
|
accessorKey: 'size',
|
||||||
|
cell: SizeCell,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const ExpandedRow: React.FC = () => <div>I am expanded row</div>;
|
const ExpandedRow: React.FC = () => <div>I am expanded row</div>;
|
||||||
|
|
||||||
interface Props {
|
interface Props extends TableProps<Datum> {
|
||||||
path?: string;
|
path?: string;
|
||||||
canExpand?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderComponent = ({ path, canExpand }: Props = {}) => {
|
const renderComponent = (props: Partial<Props> = {}) => {
|
||||||
render(
|
render(
|
||||||
<WithRoute path="/">
|
<WithRoute path="/">
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={data}
|
data={data}
|
||||||
renderSubComponent={ExpandedRow}
|
renderSubComponent={ExpandedRow}
|
||||||
getRowCanExpand={() => !!canExpand}
|
{...props}
|
||||||
enableSorting
|
|
||||||
/>
|
/>
|
||||||
</WithRoute>,
|
</WithRoute>,
|
||||||
{ initialEntries: [path || ''] }
|
{ initialEntries: [props.path || ''] }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -53,6 +61,30 @@ describe('Table', () => {
|
||||||
it('renders table', () => {
|
it('renders table', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
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', () => {
|
it('renders TimestampCell', () => {
|
||||||
|
@ -64,7 +96,7 @@ describe('Table', () => {
|
||||||
|
|
||||||
describe('ExpanderCell', () => {
|
describe('ExpanderCell', () => {
|
||||||
it('renders button', () => {
|
it('renders button', () => {
|
||||||
renderComponent({ canExpand: true });
|
renderComponent({ getRowCanExpand: () => true });
|
||||||
const btns = screen.getAllByRole('button', { name: 'Expand row' });
|
const btns = screen.getAllByRole('button', { name: 'Expand row' });
|
||||||
expect(btns.length).toEqual(data.length);
|
expect(btns.length).toEqual(data.length);
|
||||||
|
|
||||||
|
@ -76,7 +108,7 @@ describe('Table', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render button', () => {
|
it('does not render button', () => {
|
||||||
renderComponent({ canExpand: false });
|
renderComponent({ getRowCanExpand: () => false });
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole('button', { name: 'Expand row' })
|
screen.queryByRole('button', { name: 'Expand row' })
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
@ -147,7 +179,10 @@ describe('Table', () => {
|
||||||
describe('Sorting', () => {
|
describe('Sorting', () => {
|
||||||
it('sort rows', async () => {
|
it('sort rows', async () => {
|
||||||
await act(() =>
|
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);
|
expect(screen.getAllByRole('row').length).toEqual(data.length + 1);
|
||||||
const th = screen.getByRole('columnheader', { name: 'Text' });
|
const th = screen.getByRole('columnheader', { name: 'Text' });
|
||||||
|
@ -178,4 +213,31 @@ describe('Table', () => {
|
||||||
expect(rows[4].textContent?.indexOf('sit')).toBeGreaterThan(-1);
|
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 TimestampCell from './TimestampCell';
|
||||||
import ExpanderCell from './ExpanderCell';
|
import SizeCell from './SizeCell';
|
||||||
|
|
||||||
export { TimestampCell, ExpanderCell };
|
export type { TableProps };
|
||||||
|
|
||||||
|
export { TimestampCell, SizeCell };
|
||||||
|
|
||||||
export default Table;
|
export default Table;
|
||||||
|
|
|
@ -7,21 +7,14 @@ export default (
|
||||||
updater: UpdaterFn<PaginationState>,
|
updater: UpdaterFn<PaginationState>,
|
||||||
searchParams: URLSearchParams
|
searchParams: URLSearchParams
|
||||||
) => {
|
) => {
|
||||||
|
const page = searchParams.get('page');
|
||||||
const previousState: PaginationState = {
|
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),
|
pageSize: Number(searchParams.get('perPage') || PER_PAGE),
|
||||||
};
|
};
|
||||||
const newState = updater(previousState);
|
const newState = updater(previousState);
|
||||||
if (newState.pageIndex !== 0) {
|
searchParams.set('page', String(newState.pageIndex + 1));
|
||||||
searchParams.set('page', newState.pageIndex.toString());
|
searchParams.set('perPage', newState.pageSize.toString());
|
||||||
} else {
|
return previousState;
|
||||||
searchParams.delete('page');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newState.pageSize !== PER_PAGE) {
|
|
||||||
searchParams.set('perPage', newState.pageSize.toString());
|
|
||||||
} else {
|
|
||||||
searchParams.delete('perPage');
|
|
||||||
}
|
|
||||||
return newState;
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,9 +3,17 @@ import styled, { css } from 'styled-components';
|
||||||
const tableLinkMixin = css(
|
const tableLinkMixin = css(
|
||||||
({ theme }) => `
|
({ theme }) => `
|
||||||
& > a {
|
& > a {
|
||||||
color: ${theme.table.link.color};
|
color: ${theme.table.link.color.normal};
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-overflow: ellipsis;
|
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],
|
deletionTextColor: Colors.neutral[70],
|
||||||
},
|
},
|
||||||
table: {
|
table: {
|
||||||
|
actionBar: {
|
||||||
|
backgroundColor: Colors.neutral[0],
|
||||||
|
},
|
||||||
th: {
|
th: {
|
||||||
backgroundColor: {
|
backgroundColor: {
|
||||||
normal: Colors.neutral[0],
|
normal: Colors.neutral[0],
|
||||||
},
|
},
|
||||||
color: {
|
color: {
|
||||||
normal: Colors.neutral[50],
|
sortable: Colors.neutral[30],
|
||||||
|
normal: Colors.neutral[60],
|
||||||
hover: Colors.brand[50],
|
hover: Colors.brand[50],
|
||||||
active: Colors.brand[50],
|
active: Colors.brand[50],
|
||||||
},
|
},
|
||||||
|
@ -326,6 +330,8 @@ const theme = {
|
||||||
link: {
|
link: {
|
||||||
color: {
|
color: {
|
||||||
normal: Colors.neutral[90],
|
normal: Colors.neutral[90],
|
||||||
|
hover: Colors.neutral[50],
|
||||||
|
active: Colors.neutral[90],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expander: {
|
expander: {
|
||||||
|
|
Loading…
Add table
Reference in a new issue