Get rid of SmartTable component (#2444)

* Get rid of SmartTable component

* Clickable rows

* Improve test coverage
This commit is contained in:
Oleg Shur 2022-08-15 13:46:13 +03:00 committed by GitHub
parent 21f17ad39e
commit e1fb6bacc3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 597 additions and 1647 deletions

View file

@ -1,5 +0,0 @@
import styled from 'styled-components';
export const ClickableRow = styled.tr`
cursor: pointer;
`;

View file

@ -1,25 +1,21 @@
import React from 'react';
import { ClusterName } from 'redux/interfaces';
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
import { NavLink, useNavigate } from 'react-router-dom';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import { Table } from 'components/common/table/Table/Table.styled';
import { useNavigate } from 'react-router-dom';
import PageHeading from 'components/common/PageHeading/PageHeading';
import * as Metrics from 'components/common/Metrics';
import useAppParams from 'lib/hooks/useAppParams';
import { useBrokers } from 'lib/hooks/api/brokers';
import { useClusterStats } from 'lib/hooks/api/clusters';
import { ClickableRow } from './BrokersList.style';
import Table, { LinkCell, SizeCell } from 'components/common/NewTable';
import { ColumnDef } from '@tanstack/react-table';
import { clusterBrokerPath } from 'lib/paths';
const BrokersList: React.FC = () => {
const navigate = useNavigate();
const { clusterName } = useAppParams<{ clusterName: ClusterName }>();
const { data: clusterStats } = useClusterStats(clusterName);
const { data: clusterStats = {} } = useClusterStats(clusterName);
const { data: brokers } = useBrokers(clusterName);
if (!clusterStats) return null;
const {
brokerCount,
activeControllers,
@ -32,6 +28,32 @@ const BrokersList: React.FC = () => {
version,
} = clusterStats;
const rows = React.useMemo(() => {
if (!diskUsage) return [];
return diskUsage.map(({ brokerId, segmentSize, segmentCount }) => {
const broker = brokers?.find(({ id }) => id === brokerId);
return {
brokerId,
size: segmentSize,
count: segmentCount,
port: broker?.port,
host: broker?.host,
};
});
}, [diskUsage, brokers]);
const columns = React.useMemo<ColumnDef<typeof rows>[]>(
() => [
{ header: 'Broker ID', accessorKey: 'brokerId', cell: LinkCell },
{ header: 'Segment Size', accessorKey: 'size', cell: SizeCell },
{ header: 'Segment Count', accessorKey: 'count' },
{ header: 'Port', accessorKey: 'port' },
{ header: 'Host', accessorKey: 'host' },
],
[]
);
const replicas = (inSyncReplicasCount ?? 0) + (outOfSyncReplicasCount ?? 0);
const areAllInSync = inSyncReplicasCount && replicas === inSyncReplicasCount;
const partitionIsOffline = offlinePartitionCount && offlinePartitionCount > 0;
@ -95,49 +117,15 @@ const BrokersList: React.FC = () => {
</Metrics.Indicator>
</Metrics.Section>
</Metrics.Wrapper>
<Table isFullwidth>
<thead>
<tr>
<TableHeaderCell title="Broker" />
<TableHeaderCell title="Segment Size" />
<TableHeaderCell title="Segment Count" />
<TableHeaderCell title="Port" />
<TableHeaderCell title="Host" />
</tr>
</thead>
<tbody>
{(!diskUsage || diskUsage.length === 0) && (
<tr>
<td colSpan={10}>Disk usage data not available</td>
</tr>
)}
{diskUsage &&
diskUsage.length !== 0 &&
diskUsage.map(({ brokerId, segmentSize, segmentCount }) => {
const brokerItem = brokers?.find(({ id }) => id === brokerId);
return (
<ClickableRow
key={brokerId}
onClick={() => navigate(`${brokerId}`)}
>
<td>
<NavLink to={`${brokerId}`} role="link">
{brokerId}
</NavLink>
</td>
<td>
<BytesFormatted value={segmentSize} />
</td>
<td>{segmentCount}</td>
<td>{brokerItem?.port}</td>
<td>{brokerItem?.host}</td>
</ClickableRow>
);
})}
</tbody>
</Table>
<Table
columns={columns}
data={rows}
enableSorting
onRowClick={({ original: { brokerId } }) =>
navigate(clusterBrokerPath(clusterName, brokerId))
}
emptyMessage="Disk usage data not available"
/>
</>
);
};

View file

@ -1,7 +1,7 @@
import React from 'react';
import { render, WithRoute } from 'lib/testHelpers';
import { screen, waitFor } from '@testing-library/dom';
import { clusterBrokersPath } from 'lib/paths';
import { clusterBrokerPath, clusterBrokersPath } from 'lib/paths';
import { act } from '@testing-library/react';
import BrokersList from 'components/Brokers/BrokersList/BrokersList';
import userEvent from '@testing-library/user-event';
@ -41,84 +41,108 @@ describe('BrokersList Component', () => {
);
describe('BrokersList', () => {
beforeEach(() => {
(useBrokers as jest.Mock).mockImplementation(() => ({
data: brokersPayload,
}));
(useClusterStats as jest.Mock).mockImplementation(() => ({
data: clusterStatsPayload,
}));
});
it('renders', async () => {
renderComponent();
expect(screen.getByRole('table')).toBeInTheDocument();
const rows = screen.getAllByRole('row');
expect(rows.length).toEqual(3);
});
it('opens broker when row clicked', async () => {
renderComponent();
await act(() => {
userEvent.click(screen.getByRole('cell', { name: '0' }));
describe('when the brokers are loaded', () => {
beforeEach(() => {
(useBrokers as jest.Mock).mockImplementation(() => ({
data: brokersPayload,
}));
(useClusterStats as jest.Mock).mockImplementation(() => ({
data: clusterStatsPayload,
}));
});
it('renders', async () => {
renderComponent();
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getAllByRole('row').length).toEqual(3);
});
it('opens broker when row clicked', async () => {
renderComponent();
await act(() => {
userEvent.click(screen.getByRole('cell', { name: '0' }));
});
await waitFor(() =>
expect(mockedUsedNavigate).toBeCalledWith(
clusterBrokerPath(clusterName, '0')
)
);
});
it('shows warning when offlinePartitionCount > 0', async () => {
(useClusterStats as jest.Mock).mockImplementation(() => ({
data: {
...clusterStatsPayload,
offlinePartitionCount: 1345,
},
}));
renderComponent();
const onlineWidget = screen.getByText(
clusterStatsPayload.onlinePartitionCount
);
expect(onlineWidget).toBeInTheDocument();
expect(onlineWidget).toHaveStyle({ color: '#E51A1A' });
});
it('shows right count when offlinePartitionCount > 0', async () => {
(useClusterStats as jest.Mock).mockImplementation(() => ({
data: {
...clusterStatsPayload,
inSyncReplicasCount: testInSyncReplicasCount,
outOfSyncReplicasCount: testOutOfSyncReplicasCount,
},
}));
renderComponent();
const onlineWidgetDef = screen.getByText(testInSyncReplicasCount);
const onlineWidget = screen.getByText(
`of ${testInSyncReplicasCount + testOutOfSyncReplicasCount}`
);
expect(onlineWidgetDef).toBeInTheDocument();
expect(onlineWidget).toBeInTheDocument();
});
it('shows right count when inSyncReplicasCount: undefined && outOfSyncReplicasCount: 1', async () => {
(useClusterStats as jest.Mock).mockImplementation(() => ({
data: {
...clusterStatsPayload,
inSyncReplicasCount: undefined,
outOfSyncReplicasCount: testOutOfSyncReplicasCount,
},
}));
renderComponent();
const onlineWidget = screen.getByText(
`of ${testOutOfSyncReplicasCount}`
);
expect(onlineWidget).toBeInTheDocument();
});
it(`shows right count when inSyncReplicasCount: ${testInSyncReplicasCount} outOfSyncReplicasCount: undefined`, async () => {
(useClusterStats as jest.Mock).mockImplementation(() => ({
data: {
...clusterStatsPayload,
inSyncReplicasCount: testInSyncReplicasCount,
outOfSyncReplicasCount: undefined,
},
}));
renderComponent();
const onlineWidgetDef = screen.getByText(testInSyncReplicasCount);
const onlineWidget = screen.getByText(`of ${testInSyncReplicasCount}`);
expect(onlineWidgetDef).toBeInTheDocument();
expect(onlineWidget).toBeInTheDocument();
});
await waitFor(() => expect(mockedUsedNavigate).toBeCalledWith('0'));
});
it('shows warning when offlinePartitionCount > 0', async () => {
(useClusterStats as jest.Mock).mockImplementation(() => ({
data: {
...clusterStatsPayload,
offlinePartitionCount: 1345,
},
}));
renderComponent();
const onlineWidget = screen.getByText(
clusterStatsPayload.onlinePartitionCount
);
expect(onlineWidget).toBeInTheDocument();
expect(onlineWidget).toHaveStyle({ color: '#E51A1A' });
});
it('shows right count when offlinePartitionCount > 0', async () => {
(useClusterStats as jest.Mock).mockImplementation(() => ({
data: {
...clusterStatsPayload,
inSyncReplicasCount: testInSyncReplicasCount,
outOfSyncReplicasCount: testOutOfSyncReplicasCount,
},
}));
renderComponent();
const onlineWidgetDef = screen.getByText(testInSyncReplicasCount);
const onlineWidget = screen.getByText(
`of ${testInSyncReplicasCount + testOutOfSyncReplicasCount}`
);
expect(onlineWidgetDef).toBeInTheDocument();
expect(onlineWidget).toBeInTheDocument();
});
it('shows right count when inSyncReplicasCount: undefined outOfSyncReplicasCount: 1', async () => {
(useClusterStats as jest.Mock).mockImplementation(() => ({
data: {
...clusterStatsPayload,
inSyncReplicasCount: undefined,
outOfSyncReplicasCount: testOutOfSyncReplicasCount,
},
}));
renderComponent();
const onlineWidget = screen.getByText(`of ${testOutOfSyncReplicasCount}`);
expect(onlineWidget).toBeInTheDocument();
});
it(`shows right count when inSyncReplicasCount: ${testInSyncReplicasCount} outOfSyncReplicasCount: undefined`, async () => {
(useClusterStats as jest.Mock).mockImplementation(() => ({
data: {
...clusterStatsPayload,
inSyncReplicasCount: testInSyncReplicasCount,
outOfSyncReplicasCount: undefined,
},
}));
renderComponent();
const onlineWidgetDef = screen.getByText(testInSyncReplicasCount);
const onlineWidget = screen.getByText(`of ${testInSyncReplicasCount}`);
expect(onlineWidgetDef).toBeInTheDocument();
expect(onlineWidget).toBeInTheDocument();
describe('when diskUsage is empty', () => {
beforeEach(() => {
(useBrokers as jest.Mock).mockImplementation(() => ({
data: brokersPayload,
}));
(useClusterStats as jest.Mock).mockImplementation(() => ({
data: { ...clusterStatsPayload, diskUsage: undefined },
}));
});
it('renders empty table', async () => {
renderComponent();
expect(screen.getByRole('table')).toBeInTheDocument();
expect(
screen.getByRole('row', { name: 'Disk usage data not available' })
).toBeInTheDocument();
});
});
});
});

View file

@ -35,7 +35,7 @@ const Overview: React.FC = () => {
</Metrics.Indicator>
)}
<Metrics.Indicator label="State">
<C.Tag color={getTagColor(connector.status)}>
<C.Tag color={getTagColor(connector.status.state)}>
{connector.status.state}
</C.Tag>
</Metrics.Indicator>

View file

@ -43,7 +43,9 @@ const Tasks: React.FC = () => {
<td>{task.status?.id}</td>
<td>{task.status?.workerId}</td>
<td>
<Tag color={getTagColor(task.status)}>{task.status.state}</Tag>
<Tag color={getTagColor(task.status.state)}>
{task.status.state}
</Tag>
</td>
<td>{task.status.trace || 'null'}</td>
<td style={{ width: '5%' }}>

View file

@ -72,7 +72,9 @@ const ListItem: React.FC<ListItemProps> = ({
))}
</S.TagsWrapper>
</td>
<td>{status && <Tag color={getTagColor(status)}>{status.state}</Tag>}</td>
<td>
{status && <Tag color={getTagColor(status.state)}>{status.state}</Tag>}
</td>
<td>
{runningTasks && (
<span>

View file

@ -82,7 +82,9 @@ const Details: React.FC = () => {
<Metrics.Wrapper>
<Metrics.Section>
<Metrics.Indicator label="State">
<Tag color={getTagColor(consumerGroup)}>{consumerGroup.state}</Tag>
<Tag color={getTagColor(consumerGroup.state)}>
{consumerGroup.state}
</Tag>
</Metrics.Indicator>
<Metrics.Indicator label="Members">
{consumerGroup.members}

View file

@ -1,23 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Tag } from 'components/common/Tag/Tag.styled';
import { TableCellProps } from 'components/common/SmartTable/TableColumn';
import { ConsumerGroup } from 'generated-sources';
import { SmartTableKeyLink } from 'components/common/table/Table/TableKeyLink.styled';
import getTagColor from 'components/common/Tag/getTagColor';
export const StatusCell: React.FC<TableCellProps<ConsumerGroup, string>> = ({
dataItem,
}) => {
return <Tag color={getTagColor(dataItem)}>{dataItem.state}</Tag>;
};
export const GroupIDCell: React.FC<TableCellProps<ConsumerGroup, string>> = ({
dataItem: { groupId },
}) => {
return (
<SmartTableKeyLink>
<Link to={groupId}>{groupId}</Link>
</SmartTableKeyLink>
);
};

View file

@ -7,75 +7,84 @@ import {
ConsumerGroupOrdering,
SortOrder,
} from 'generated-sources';
import { useTableState } from 'lib/hooks/useTableState';
import { SmartTable } from 'components/common/SmartTable/SmartTable';
import { TableColumn } from 'components/common/SmartTable/TableColumn';
import {
GroupIDCell,
StatusCell,
} from 'components/ConsumerGroups/List/ConsumerGroupsTableCells';
import usePagination from 'lib/hooks/usePagination';
import useSearch from 'lib/hooks/useSearch';
import { useAppDispatch } from 'lib/hooks/redux';
import useAppParams from 'lib/hooks/useAppParams';
import { ClusterNameRoute } from 'lib/paths';
import { clusterConsumerGroupDetailsPath, ClusterNameRoute } from 'lib/paths';
import { fetchConsumerGroupsPaged } from 'redux/reducers/consumerGroups/consumerGroupsSlice';
import PageLoader from 'components/common/PageLoader/PageLoader';
import { ColumnDef } from '@tanstack/react-table';
import Table, { TagCell, LinkCell } from 'components/common/NewTable';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { PER_PAGE } from 'lib/constants';
export interface Props {
consumerGroups: ConsumerGroupDetails[];
orderBy: string | null;
sortOrder: SortOrder;
totalPages: number;
isFetched: boolean;
setConsumerGroupsSortOrderBy(orderBy: string | null): void;
}
const List: React.FC<Props> = ({
consumerGroups,
sortOrder,
orderBy,
totalPages,
isFetched,
setConsumerGroupsSortOrderBy,
}) => {
const { page, perPage } = usePagination();
const List: React.FC<Props> = ({ consumerGroups, totalPages }) => {
const [searchText, handleSearchText] = useSearch();
const dispatch = useAppDispatch();
const { clusterName } = useAppParams<ClusterNameRoute>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
React.useEffect(() => {
dispatch(
fetchConsumerGroupsPaged({
clusterName,
orderBy: (orderBy as ConsumerGroupOrdering) || undefined,
sortOrder,
page,
perPage,
orderBy:
(searchParams.get('sortBy') as ConsumerGroupOrdering) || undefined,
sortOrder:
(searchParams.get('sortDirection')?.toUpperCase() as SortOrder) ||
undefined,
page: Number(searchParams.get('page') || 1),
perPage: Number(searchParams.get('perPage') || PER_PAGE),
search: searchText,
})
);
}, [clusterName, orderBy, searchText, sortOrder, page, perPage, dispatch]);
}, [clusterName, searchText, dispatch, searchParams]);
const tableState = useTableState<ConsumerGroupDetails, string>(
consumerGroups,
{
totalPages,
idSelector: (consumerGroup) => consumerGroup.groupId,
},
{
handleOrderBy: setConsumerGroupsSortOrderBy,
orderBy,
sortOrder,
}
const columns = React.useMemo<ColumnDef<ConsumerGroupDetails>[]>(
() => [
{
id: ConsumerGroupOrdering.NAME,
header: 'Group ID',
accessorKey: 'groupId',
cell: LinkCell,
},
{
id: ConsumerGroupOrdering.MEMBERS,
header: 'Num Of Members',
accessorKey: 'members',
},
{
header: 'Num Of Topics',
accessorKey: 'topics',
enableSorting: false,
},
{
header: 'Messages Behind',
accessorKey: 'messagesBehind',
enableSorting: false,
},
{
header: 'Coordinator',
accessorKey: 'coordinator.id',
enableSorting: false,
},
{
id: ConsumerGroupOrdering.STATE,
header: 'State',
accessorKey: 'state',
cell: TagCell,
},
],
[]
);
if (!isFetched) {
return <PageLoader />;
}
return (
<div>
<>
<PageHeading text="Consumers" />
<ControlPanelWrapper hasInput>
<Search
@ -84,33 +93,20 @@ const List: React.FC<Props> = ({
handleSearch={handleSearchText}
/>
</ControlPanelWrapper>
<SmartTable
tableState={tableState}
isFullwidth
placeholder="No active consumer groups"
hoverable
paginated
>
<TableColumn
title="Consumer Group ID"
cell={GroupIDCell}
orderValue={ConsumerGroupOrdering.NAME}
/>
<TableColumn
title="Num Of Members"
field="members"
orderValue={ConsumerGroupOrdering.MEMBERS}
/>
<TableColumn title="Num Of Topics" field="topics" />
<TableColumn title="Messages Behind" field="messagesBehind" />
<TableColumn title="Coordinator" field="coordinator.id" />
<TableColumn
title="State"
cell={StatusCell}
orderValue={ConsumerGroupOrdering.STATE}
/>
</SmartTable>
</div>
<Table
columns={columns}
pageCount={totalPages}
data={consumerGroups}
emptyMessage="No active consumer groups found"
serverSideProcessing
enableSorting
onRowClick={({ original }) =>
navigate(
clusterConsumerGroupDetailsPath(clusterName, original.groupId)
)
}
/>
</>
);
};

View file

@ -2,24 +2,15 @@ import { connect } from 'react-redux';
import { RootState } from 'redux/interfaces';
import {
getConsumerGroupsOrderBy,
getConsumerGroupsSortOrder,
getConsumerGroupsTotalPages,
sortBy,
selectAll,
getAreConsumerGroupsPagedFulfilled,
} from 'redux/reducers/consumerGroups/consumerGroupsSlice';
import List from 'components/ConsumerGroups/List/List';
const mapStateToProps = (state: RootState) => ({
consumerGroups: selectAll(state),
orderBy: getConsumerGroupsOrderBy(state),
sortOrder: getConsumerGroupsSortOrder(state),
totalPages: getConsumerGroupsTotalPages(state),
isFetched: getAreConsumerGroupsPagedFulfilled(state),
});
const mapDispatchToProps = {
setConsumerGroupsSortOrderBy: sortBy,
};
export default connect(mapStateToProps, mapDispatchToProps)(List);
export default connect(mapStateToProps)(List);

View file

@ -1,29 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ConsumerGroup } from 'generated-sources';
import { Tag } from 'components/common/Tag/Tag.styled';
import { TableKeyLink } from 'components/common/table/Table/TableKeyLink.styled';
import getTagColor from 'components/common/Tag/getTagColor';
const ListItem: React.FC<{ consumerGroup: ConsumerGroup }> = ({
consumerGroup,
}) => {
return (
<tr>
<TableKeyLink>
<Link to={`consumer-groups/${consumerGroup.groupId}`}>
{consumerGroup.groupId}
</Link>
</TableKeyLink>
<td>{consumerGroup.members}</td>
<td>{consumerGroup.topics}</td>
<td>{consumerGroup.messagesBehind}</td>
<td>{consumerGroup.coordinator?.id}</td>
<td>
<Tag color={getTagColor(consumerGroup)}>{consumerGroup.state}</Tag>
</td>
</tr>
);
};
export default ListItem;

View file

@ -1,61 +0,0 @@
import React from 'react';
import { render } from 'lib/testHelpers';
import {
GroupIDCell,
StatusCell,
} from 'components/ConsumerGroups/List/ConsumerGroupsTableCells';
import { TableState } from 'lib/hooks/useTableState';
import { ConsumerGroup, ConsumerGroupState } from 'generated-sources';
import { screen } from '@testing-library/react';
describe('Consumer Groups Table Cells', () => {
const consumerGroup: ConsumerGroup = {
groupId: 'groupId',
members: 1,
topics: 1,
simple: true,
state: ConsumerGroupState.STABLE,
coordinator: {
id: 6598,
},
};
const mockTableState: TableState<ConsumerGroup, string> = {
data: [consumerGroup],
selectedIds: new Set([]),
idSelector: jest.fn(),
isRowSelectable: jest.fn(),
selectedCount: 0,
setRowsSelection: jest.fn(),
toggleSelection: jest.fn(),
};
describe('StatusCell', () => {
it('should Tag props render normally', () => {
render(
<GroupIDCell
rowIndex={1}
dataItem={consumerGroup}
tableState={mockTableState}
/>
);
const linkElement = screen.getByRole('link');
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute('href', `/${consumerGroup.groupId}`);
});
});
describe('GroupIdCell', () => {
it('should GroupIdCell props render normally', () => {
render(
<StatusCell
rowIndex={1}
dataItem={consumerGroup}
tableState={mockTableState}
/>
);
expect(
screen.getByText(consumerGroup.state as string)
).toBeInTheDocument();
});
});
});

View file

@ -1,77 +1,59 @@
import React from 'react';
import List, { Props } from 'components/ConsumerGroups/List/List';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from 'lib/testHelpers';
import { consumerGroups as consumerGroupMock } from 'redux/reducers/consumerGroups/__test__/fixtures';
import { ConsumerGroupOrdering, SortOrder } from 'generated-sources';
import theme from 'theme/theme';
import { clusterConsumerGroupDetailsPath } from 'lib/paths';
import userEvent from '@testing-library/user-event';
import ListContainer from 'components/ConsumerGroups/List/ListContainer';
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedUsedNavigate,
}));
describe('ListContainer', () => {
it('renders correctly', () => {
render(<ListContainer />);
expect(screen.getByRole('table')).toBeInTheDocument();
});
});
describe('List', () => {
const setUpComponent = (props: Partial<Props> = {}) => {
const {
consumerGroups,
orderBy,
sortOrder,
totalPages,
setConsumerGroupsSortOrderBy,
} = props;
const renderComponent = (props: Partial<Props> = {}) => {
const { consumerGroups, totalPages } = props;
return render(
<List
consumerGroups={consumerGroups || []}
orderBy={orderBy || ConsumerGroupOrdering.NAME}
sortOrder={sortOrder || SortOrder.ASC}
setConsumerGroupsSortOrderBy={setConsumerGroupsSortOrderBy || jest.fn()}
totalPages={totalPages || 1}
isFetched={'isFetched' in props ? !!props.isFetched : true}
/>
);
};
it('renders empty table', () => {
setUpComponent();
renderComponent();
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByText('No active consumer groups')).toBeInTheDocument();
expect(
screen.getByText('No active consumer groups found')
).toBeInTheDocument();
});
describe('consumerGroups are fetched', () => {
beforeEach(() => setUpComponent({ consumerGroups: consumerGroupMock }));
beforeEach(() => renderComponent({ consumerGroups: consumerGroupMock }));
it('renders all rows with consumers', () => {
expect(screen.getByText('groupId1')).toBeInTheDocument();
expect(screen.getByText('groupId2')).toBeInTheDocument();
});
describe('Testing the Ordering', () => {
it('should test the sort order functionality', async () => {
const thElement = screen.getByText(/consumer group id/i);
expect(thElement).toBeInTheDocument();
expect(thElement).toHaveStyle(`color:${theme.table.th.color.active}`);
});
});
});
describe('consumerGroups are fetched with custom parameters', () => {
it('should test the order by functionality of another element', async () => {
const sortOrder = jest.fn();
setUpComponent({
consumerGroups: consumerGroupMock,
setConsumerGroupsSortOrderBy: sortOrder,
});
const thElement = screen.getByText(/num of members/i);
expect(thElement).toBeInTheDocument();
userEvent.click(thElement);
expect(sortOrder).toBeCalled();
});
it('should view the ordered list with the right prop', () => {
setUpComponent({
consumerGroups: consumerGroupMock,
orderBy: ConsumerGroupOrdering.MEMBERS,
});
expect(screen.getByText(/num of members/i)).toHaveStyle(
`color:${theme.table.th.color.active}`
it('handles onRowClick', () => {
const row = screen.getByRole('row', { name: 'groupId1 0 1 1' });
expect(row).toBeInTheDocument();
userEvent.click(row);
expect(mockedUsedNavigate).toHaveBeenCalledWith(
clusterConsumerGroupDetailsPath(':clusterName', 'groupId1')
);
});
});

View file

@ -1,86 +0,0 @@
import React from 'react';
import ListItem from 'components/ConsumerGroups/List/ListItem';
import { ConsumerGroupState, ConsumerGroup } from 'generated-sources';
import { screen } from '@testing-library/react';
import { render } from 'lib/testHelpers';
describe('List', () => {
const mockConsumerGroup = {
groupId: 'groupId',
members: 0,
topics: 1,
simple: false,
partitionAssignor: '',
coordinator: {
id: 1,
host: 'host',
},
partitions: [
{
consumerId: null,
currentOffset: 0,
endOffset: 0,
host: null,
messagesBehind: 0,
partition: 1,
topic: 'topic',
},
],
};
const setupWrapper = (consumerGroup: ConsumerGroup) => (
<table>
<tbody>
<ListItem consumerGroup={consumerGroup} />
</tbody>
</table>
);
const getCell = () => screen.getAllByRole('cell')[5];
it('render empty ListItem', () => {
render(setupWrapper(mockConsumerGroup));
expect(screen.getByRole('row')).toBeInTheDocument();
});
it('renders item with stable status', () => {
render(
setupWrapper({
...mockConsumerGroup,
state: ConsumerGroupState.STABLE,
})
);
expect(screen.getByRole('row')).toHaveTextContent(
ConsumerGroupState.STABLE
);
});
it('renders item with dead status', () => {
render(
setupWrapper({
...mockConsumerGroup,
state: ConsumerGroupState.DEAD,
})
);
expect(getCell()).toHaveTextContent(ConsumerGroupState.DEAD);
});
it('renders item with empty status', () => {
render(
setupWrapper({
...mockConsumerGroup,
state: ConsumerGroupState.EMPTY,
})
);
expect(getCell()).toHaveTextContent(ConsumerGroupState.EMPTY);
});
it('renders item with empty-string status', () => {
render(
setupWrapper({
...mockConsumerGroup,
state: ConsumerGroupState.UNKNOWN,
})
);
expect(getCell()).toHaveTextContent(ConsumerGroupState.UNKNOWN);
});
});

View file

@ -1,22 +1,27 @@
import React from 'react';
import { clusterConsumerGroupsPath, getNonExactPath } from 'lib/paths';
import {
act,
screen,
waitFor,
waitForElementToBeRemoved,
} from '@testing-library/react';
clusterConsumerGroupDetailsPath,
clusterConsumerGroupResetOffsetsPath,
clusterConsumerGroupsPath,
getNonExactPath,
} from 'lib/paths';
import { screen } from '@testing-library/react';
import ConsumerGroups from 'components/ConsumerGroups/ConsumerGroups';
import {
consumerGroups,
noConsumerGroupsResponse,
} from 'redux/reducers/consumerGroups/__test__/fixtures';
import { render, WithRoute } from 'lib/testHelpers';
import fetchMock from 'fetch-mock';
import { ConsumerGroupOrdering, SortOrder } from 'generated-sources';
const clusterName = 'cluster1';
jest.mock('components/ConsumerGroups/List/ListContainer', () => () => (
<div>ListContainerMock</div>
));
jest.mock('components/ConsumerGroups/Details/Details', () => () => (
<div>DetailsMock</div>
));
jest.mock(
'components/ConsumerGroups/Details/ResetOffsets/ResetOffsets',
() => () => <div>ResetOffsetsMock</div>
);
const renderComponent = (path?: string) =>
render(
<WithRoute path={getNonExactPath(clusterConsumerGroupsPath())}>
@ -28,104 +33,18 @@ const renderComponent = (path?: string) =>
);
describe('ConsumerGroups', () => {
it('renders with initial state', async () => {
it('renders ListContainer', async () => {
renderComponent();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
expect(screen.getByText('ListContainerMock')).toBeInTheDocument();
});
describe('Default Route and Fetching Consumer Groups', () => {
const url = `/api/clusters/${clusterName}/consumer-groups/paged`;
afterEach(() => {
fetchMock.reset();
});
it('renders empty table on no consumer group response', async () => {
fetchMock.getOnce(url, noConsumerGroupsResponse, {
query: {
orderBy: ConsumerGroupOrdering.NAME,
sortOrder: SortOrder.ASC,
},
});
await act(() => {
renderComponent();
});
expect(fetchMock.calls().length).toBe(1);
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByText('No active consumer groups')).toBeInTheDocument();
});
it('renders with 404 from consumer groups', async () => {
const consumerGroupsMock = fetchMock.getOnce(url, 404, {
query: {
orderBy: ConsumerGroupOrdering.NAME,
sortOrder: SortOrder.ASC,
},
});
renderComponent();
await waitFor(() => expect(consumerGroupsMock.called()).toBeTruthy());
expect(screen.queryByText('Consumers')).not.toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
it('renders with 200 from consumer groups', async () => {
const consumerGroupsMock = fetchMock.getOnce(
url,
{
pagedCount: 1,
consumerGroups,
},
{
query: {
orderBy: ConsumerGroupOrdering.NAME,
sortOrder: SortOrder.ASC,
},
}
);
renderComponent();
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
await waitFor(() => expect(consumerGroupsMock.called()).toBeTruthy());
expect(screen.getByText('Consumers')).toBeInTheDocument();
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByText(consumerGroups[0].groupId)).toBeInTheDocument();
expect(screen.getByText(consumerGroups[1].groupId)).toBeInTheDocument();
});
it('renders with 200 from consumer groups with Searched Query ', async () => {
const searchResult = consumerGroups[0];
const searchText = searchResult.groupId;
const consumerGroupsMock = fetchMock.getOnce(
url,
{
pagedCount: 1,
consumerGroups: [searchResult],
},
{
query: {
orderBy: ConsumerGroupOrdering.NAME,
sortOrder: SortOrder.ASC,
search: searchText,
},
}
);
renderComponent(
`${clusterConsumerGroupsPath(clusterName)}?q=${searchText}`
);
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
await waitFor(() => expect(consumerGroupsMock.called()).toBeTruthy());
expect(screen.getByText(searchText)).toBeInTheDocument();
expect(
screen.queryByText(consumerGroups[1].groupId)
).not.toBeInTheDocument();
});
it('renders ResetOffsets', async () => {
renderComponent(
clusterConsumerGroupResetOffsetsPath(clusterName, 'groupId1')
);
expect(screen.getByText('ResetOffsetsMock')).toBeInTheDocument();
});
it('renders Details', async () => {
renderComponent(clusterConsumerGroupDetailsPath(clusterName, 'groupId1'));
expect(screen.getByText('DetailsMock')).toBeInTheDocument();
});
});

View file

@ -1,10 +1,9 @@
import React from 'react';
import PageLoader from 'components/common/PageLoader/PageLoader';
import { KsqlStreamDescription, KsqlTableDescription } from 'generated-sources';
import { useTableState } from 'lib/hooks/useTableState';
import { SmartTable } from 'components/common/SmartTable/SmartTable';
import { TableColumn } from 'components/common/SmartTable/TableColumn';
import { ksqlRowData } from 'components/KsqlDb/List/KsqlDbItem/utils/ksqlRowData';
import Table from 'components/common/NewTable';
import { ColumnDef } from '@tanstack/react-table';
export enum KsqlDbItemType {
Tables = 'tables',
@ -31,27 +30,28 @@ export interface KsqlTableState {
const KsqlDbItem: React.FC<KsqlDbItemProps> = ({ type, fetching, rows }) => {
const preparedRows = rows[type]?.map(ksqlRowData) || [];
const tableState = useTableState<KsqlTableState, string>(preparedRows, {
idSelector: ({ name }) => name,
totalPages: 0,
});
const columns = React.useMemo<ColumnDef<KsqlTableState>[]>(
() => [
{ header: 'Name', accessorKey: 'name' },
{ header: 'Topic', accessorKey: 'topic' },
{ header: 'Key Format', accessorKey: 'keyFormat' },
{ header: 'Value Format', accessorKey: 'valueFormat' },
{ header: 'Is Windowed', accessorKey: 'isWindowed' },
],
[]
);
if (fetching) {
return <PageLoader />;
}
return (
<SmartTable
tableState={tableState}
isFullwidth
placeholder="No tables or streams found"
hoverable
>
<TableColumn title="Name" field="name" />
<TableColumn title="Topic" field="topic" />
<TableColumn title="Key Format" field="keyFormat" />
<TableColumn title="Value Format" field="valueFormat" />
<TableColumn title="Is Windowed" field="isWindowed" />
</SmartTable>
<Table
data={preparedRows}
columns={columns}
emptyMessage="No tables or streams found"
enableSorting={false}
/>
);
};

View file

@ -7,59 +7,56 @@ import KsqlDbItem, {
} from 'components/KsqlDb/List/KsqlDbItem/KsqlDbItem';
import { screen } from '@testing-library/dom';
import { fetchKsqlDbTablesPayload } from 'redux/reducers/ksqlDb/__test__/fixtures';
import { act } from '@testing-library/react';
describe('KsqlDbItem', () => {
const tablesPathname = clusterKsqlDbTablesPath();
const component = (props: Partial<KsqlDbItemProps> = {}) => (
<WithRoute path={tablesPathname}>
<KsqlDbItem
type={KsqlDbItemType.Tables}
fetching={false}
rows={{ tables: [], streams: [] }}
{...props}
/>
</WithRoute>
);
it('renders progressbar when fetching tables and streams', () => {
render(component({ fetching: true }), {
initialEntries: [clusterKsqlDbTablesPath()],
});
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
it('show no text if no data found', () => {
render(component({}), {
initialEntries: [clusterKsqlDbTablesPath()],
});
expect(screen.getByText('No tables or streams found')).toBeInTheDocument();
});
it('renders with tables', () => {
const renderComponent = (props: Partial<KsqlDbItemProps> = {}) => {
render(
component({
rows: {
tables: fetchKsqlDbTablesPayload.tables,
streams: [],
},
}),
<WithRoute path={tablesPathname}>
<KsqlDbItem
type={KsqlDbItemType.Tables}
fetching={false}
rows={{ tables: [], streams: [] }}
{...props}
/>
</WithRoute>,
{
initialEntries: [clusterKsqlDbTablesPath()],
}
);
};
it('renders progressbar when fetching tables and streams', async () => {
await act(() => renderComponent({ fetching: true }));
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
it('show no text if no data found', async () => {
await act(() => renderComponent({}));
expect(screen.getByText('No tables or streams found')).toBeInTheDocument();
});
it('renders with tables', async () => {
await act(() =>
renderComponent({
rows: {
tables: fetchKsqlDbTablesPayload.tables,
streams: [],
},
})
);
expect(screen.getByRole('table').querySelectorAll('td')).toHaveLength(10);
});
it('renders with streams', () => {
render(
component({
it('renders with streams', async () => {
await act(() =>
renderComponent({
type: KsqlDbItemType.Streams,
rows: {
tables: [],
streams: fetchKsqlDbTablesPayload.streams,
},
}),
{
initialEntries: [clusterKsqlDbTablesPath()],
}
})
);
expect(screen.getByRole('table').querySelectorAll('td')).toHaveLength(10);
});

View file

@ -3,15 +3,14 @@ import List from 'components/KsqlDb/List/List';
import { render } from 'lib/testHelpers';
import fetchMock from 'fetch-mock';
import { screen } from '@testing-library/dom';
const renderComponent = () => {
render(<List />);
};
import { act } from '@testing-library/react';
describe('KsqlDb List', () => {
afterEach(() => fetchMock.reset());
it('renders List component with Tables and Streams tabs', async () => {
renderComponent();
await act(() => {
render(<List />);
});
const Tables = screen.getByTitle('Tables');
const Streams = screen.getByTitle('Streams');

View file

@ -2,22 +2,21 @@ import React from 'react';
import Select from 'components/common/Select/Select';
import { CompatibilityLevelCompatibilityEnum } from 'generated-sources';
import { useAppDispatch } from 'lib/hooks/redux';
import usePagination from 'lib/hooks/usePagination';
import useSearch from 'lib/hooks/useSearch';
import useAppParams from 'lib/hooks/useAppParams';
import { fetchSchemas } from 'redux/reducers/schemas/schemasSlice';
import { ClusterNameRoute } from 'lib/paths';
import { schemasApiClient } from 'lib/api';
import { showServerError } from 'lib/errorHandling';
import { useConfirm } from 'lib/hooks/useConfirm';
import { useSearchParams } from 'react-router-dom';
import { PER_PAGE } from 'lib/constants';
import * as S from './GlobalSchemaSelector.styled';
const GlobalSchemaSelector: React.FC = () => {
const { clusterName } = useAppParams<ClusterNameRoute>();
const dispatch = useAppDispatch();
const [searchText] = useSearch();
const { page, perPage } = usePagination();
const [searchParams] = useSearchParams();
const confirm = useConfirm();
const [currentCompatibilityLevel, setCurrentCompatibilityLevel] =
@ -61,7 +60,12 @@ const GlobalSchemaSelector: React.FC = () => {
});
setCurrentCompatibilityLevel(nextLevel);
dispatch(
fetchSchemas({ clusterName, page, perPage, search: searchText })
fetchSchemas({
clusterName,
page: Number(searchParams.get('page') || 1),
perPage: Number(searchParams.get('perPage') || PER_PAGE),
search: searchParams.get('q') || '',
})
);
} catch (e) {
showServerError(e as Response);

View file

@ -82,7 +82,7 @@ describe('GlobalSchemaSelector', () => {
}
);
const getSchemasMock = fetchMock.getOnce(
`api/clusters/${clusterName}/schemas`,
`api/clusters/${clusterName}/schemas?page=1&perPage=25`,
200
);
await waitFor(() => {

View file

@ -1,8 +1,10 @@
import React from 'react';
import { ClusterNameRoute, clusterSchemaNewRelativePath } from 'lib/paths';
import {
ClusterNameRoute,
clusterSchemaNewRelativePath,
clusterSchemaPath,
} from 'lib/paths';
import ClusterContext from 'components/contexts/ClusterContext';
import * as C from 'components/common/table/Table/Table.styled';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import { Button } from 'components/common/Button/Button';
import PageHeading from 'components/common/PageHeading/PageHeading';
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
@ -13,36 +15,53 @@ import {
getAreSchemasFulfilled,
SCHEMAS_FETCH_ACTION,
} from 'redux/reducers/schemas/schemasSlice';
import usePagination from 'lib/hooks/usePagination';
import PageLoader from 'components/common/PageLoader/PageLoader';
import Pagination from 'components/common/Pagination/Pagination';
import { resetLoaderById } from 'redux/reducers/loader/loaderSlice';
import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
import Search from 'components/common/Search/Search';
import useSearch from 'lib/hooks/useSearch';
import PlusIcon from 'components/common/Icons/PlusIcon';
import Table, { LinkCell } from 'components/common/NewTable';
import { ColumnDef } from '@tanstack/react-table';
import { SchemaSubject } from 'generated-sources';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { PER_PAGE } from 'lib/constants';
import ListItem from './ListItem';
import GlobalSchemaSelector from './GlobalSchemaSelector/GlobalSchemaSelector';
const List: React.FC = () => {
const dispatch = useAppDispatch();
const { isReadOnly } = React.useContext(ClusterContext);
const { clusterName } = useAppParams<ClusterNameRoute>();
const navigate = useNavigate();
const schemas = useAppSelector(selectAllSchemas);
const isFetched = useAppSelector(getAreSchemasFulfilled);
const totalPages = useAppSelector((state) => state.schemas.totalPages);
const [searchParams] = useSearchParams();
const [searchText, handleSearchText] = useSearch();
const { page, perPage } = usePagination();
React.useEffect(() => {
dispatch(fetchSchemas({ clusterName, page, perPage, search: searchText }));
dispatch(
fetchSchemas({
clusterName,
page: Number(searchParams.get('page') || 1),
perPage: Number(searchParams.get('perPage') || PER_PAGE),
search: searchParams.get('q') || '',
})
);
return () => {
dispatch(resetLoaderById(SCHEMAS_FETCH_ACTION));
};
}, [clusterName, dispatch, page, perPage, searchText]);
}, [clusterName, dispatch, searchParams]);
const columns = React.useMemo<ColumnDef<SchemaSubject>[]>(
() => [
{ header: 'Subject', accessorKey: 'subject', cell: LinkCell },
{ header: 'Version', accessorKey: 'version' },
{ header: 'Compatibility', accessorKey: 'compatibilityLevel' },
],
[]
);
return (
<>
@ -68,31 +87,16 @@ const List: React.FC = () => {
/>
</ControlPanelWrapper>
{isFetched ? (
<>
<C.Table isFullwidth>
<thead>
<tr>
<TableHeaderCell title="Schema Name" />
<TableHeaderCell title="Version" />
<TableHeaderCell title="Compatibility" />
</tr>
</thead>
<tbody>
{schemas.length === 0 && (
<tr>
<td colSpan={10}>No schemas found</td>
</tr>
)}
{schemas.map((subject) => (
<ListItem
key={[subject.id, subject.subject].join('-')}
subject={subject}
/>
))}
</tbody>
</C.Table>
<Pagination totalPages={totalPages} />
</>
<Table
columns={columns}
data={schemas}
pageCount={totalPages}
emptyMessage="No schemas found"
onRowClick={(row) =>
navigate(clusterSchemaPath(clusterName, row.original.subject))
}
serverSideProcessing
/>
) : (
<PageLoader />
)}

View file

@ -1,26 +0,0 @@
import React from 'react';
import { SchemaSubject } from 'generated-sources';
import { NavLink } from 'react-router-dom';
import * as S from 'components/common/table/Table/TableKeyLink.styled';
export interface ListItemProps {
subject: SchemaSubject;
}
const ListItem: React.FC<ListItemProps> = ({
subject: { subject, version, compatibilityLevel },
}) => {
return (
<tr>
<S.TableKeyLink>
<NavLink to={subject} role="link">
{subject}
</NavLink>
</S.TableKeyLink>
<td role="cell">{version}</td>
<td role="cell">{compatibilityLevel}</td>
</tr>
);
};
export default ListItem;

View file

@ -1,7 +1,7 @@
import React from 'react';
import List from 'components/Schemas/List/List';
import { render, WithRoute } from 'lib/testHelpers';
import { clusterSchemasPath } from 'lib/paths';
import { clusterSchemaPath, clusterSchemasPath } from 'lib/paths';
import { act, screen } from '@testing-library/react';
import {
schemasFulfilledState,
@ -15,12 +15,20 @@ import ClusterContext, {
} from 'components/contexts/ClusterContext';
import { RootState } from 'redux/interfaces';
import fetchMock from 'fetch-mock';
import userEvent from '@testing-library/user-event';
import { schemasPayload, schemasEmptyPayload } from './fixtures';
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedUsedNavigate,
}));
const clusterName = 'testClusterName';
const schemasAPIUrl = `/api/clusters/${clusterName}/schemas`;
const schemasAPICompabilityUrl = `${schemasAPIUrl}/compatibility`;
const schemasAPIUrl = `/api/clusters/${clusterName}/schemas?page=1&perPage=25`;
const schemasAPICompabilityUrl = `/api/clusters/${clusterName}/schemas/compatibility`;
const renderComponent = (
initialState: RootState['schemas'] = schemasInitialState,
context: ContextProps = contextInitialValue
@ -101,6 +109,17 @@ describe('List', () => {
expect(screen.getByText(schemaVersion1.subject)).toBeInTheDocument();
expect(screen.getByText(schemaVersion2.subject)).toBeInTheDocument();
});
it('handles onRowClick', () => {
const { subject, version, compatibilityLevel } = schemaVersion2;
const row = screen.getByRole('row', {
name: `${subject} ${version} ${compatibilityLevel}`,
});
expect(row).toBeInTheDocument();
userEvent.click(row);
expect(mockedUsedNavigate).toHaveBeenCalledWith(
clusterSchemaPath(clusterName, subject)
);
});
});
describe('responded with readonly cluster schemas', () => {
@ -109,6 +128,7 @@ describe('List', () => {
schemasAPIUrl,
schemasPayload
);
fetchMock.getOnce(schemasAPICompabilityUrl, 200);
await act(() => {
renderComponent(schemasFulfilledState, {
...contextInitialValue,

View file

@ -1,23 +0,0 @@
import React from 'react';
import ListItem, { ListItemProps } from 'components/Schemas/List/ListItem';
import { screen } from '@testing-library/react';
import { render } from 'lib/testHelpers';
import { schemas } from './fixtures';
describe('ListItem', () => {
const setupComponent = (props: ListItemProps = { subject: schemas[0] }) =>
render(
<table>
<tbody>
<ListItem {...props} />
</tbody>
</table>
);
it('renders schemas', () => {
setupComponent();
expect(screen.getAllByRole('link').length).toEqual(1);
expect(screen.getAllByRole('cell').length).toEqual(3);
});
});

View file

@ -3,7 +3,7 @@ import {
schemaVersion2,
} from 'redux/reducers/schemas/__test__/fixtures';
export const schemas = [schemaVersion1, schemaVersion2];
const schemas = [schemaVersion1, schemaVersion2];
export const schemasPayload = {
pageCount: 1,

View file

@ -44,7 +44,7 @@ const TopicConsumerGroups: React.FC = () => {
<td>{consumer.coordinator?.id}</td>
<td>
{consumer.state && (
<Tag color={getTagColor(consumer)}>{`${consumer.state
<Tag color={getTagColor(consumer.state)}>{`${consumer.state
.charAt(0)
.toUpperCase()}${consumer.state
.slice(1)

View file

@ -69,22 +69,6 @@ export const MetadataMeta = styled.p`
font-size: 12px;
`;
export const PaginationButton = styled.button`
display: flex;
align-items: center;
padding: 6px 12px;
height: 32px;
border: 1px solid ${({ theme }) => theme.pagination.borderColor.normal};
box-sizing: border-box;
border-radius: 4px;
color: ${({ theme }) => theme.pagination.color.normal};
background: none;
font-family: Inter;
margin-right: 13px;
cursor: pointer;
font-size: 14px;
`;
export const Tab = styled.button<{ $active?: boolean }>(
({ theme, $active }) => css`
background-color: ${theme.secondaryTab.backgroundColor[

View file

@ -1,20 +0,0 @@
import React from 'react';
import * as S from 'components/Topics/Topic/Details/Messages/MessageContent/MessageContent.styled';
import { render } from 'lib/testHelpers';
import { screen } from '@testing-library/react';
import theme from 'theme/theme';
describe('MessageContent Styled Components', () => {
describe('PaginationComponent', () => {
beforeEach(() => {
render(<S.PaginationButton />);
});
it('should test the Pagination Button theme related Props', () => {
const button = screen.getByRole('button');
expect(button).toHaveStyle(`color: ${theme.pagination.color.normal}`);
expect(button).toHaveStyle(
`border: 1px solid ${theme.pagination.borderColor.normal}`
);
});
});
});

View file

@ -3,8 +3,7 @@ import React from 'react';
import * as S from './Table.styled';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ExpanderCell: React.FC<CellContext<any, unknown>> = ({ row }) => (
const ExpanderCell: React.FC<CellContext<unknown, unknown>> = ({ row }) => (
<S.ExpaderButton
width="16"
height="20"

View file

@ -0,0 +1,16 @@
import React from 'react';
import { CellContext } from '@tanstack/react-table';
import { NavLink } from 'react-router-dom';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const LinkCell: React.FC<CellContext<any, unknown>> = ({ getValue }) => {
const value = `${getValue<string | number>()}`;
const handleClick: React.MouseEventHandler = (e) => e.stopPropagation();
return (
<NavLink to={value} title={value} onClick={handleClick}>
{value}
</NavLink>
);
};
export default LinkCell;

View file

@ -2,8 +2,7 @@ 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 }) => (
const SelectRowCell: React.FC<CellContext<unknown, unknown>> = ({ row }) => (
<IndeterminateCheckbox
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}

View file

@ -2,8 +2,9 @@ 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 }) => (
const SelectRowHeader: React.FC<HeaderContext<unknown, unknown>> = ({
table,
}) => (
<IndeterminateCheckbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}

View file

@ -3,8 +3,8 @@ 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()} />
const SizeCell: React.FC<CellContext<any, unknown>> = ({ getValue }) => (
<BytesFormatted value={getValue<string | number>()} />
);
export default SizeCell;

View file

@ -99,13 +99,13 @@ export const Th = styled.th<ThProps>(
);
interface RowProps {
expandable?: boolean;
clickable?: boolean;
expanded?: boolean;
}
export const Row = styled.tr<RowProps>(
({ theme: { table }, expanded, expandable }) => `
cursor: ${expandable ? 'pointer' : 'default'};
({ theme: { table }, expanded, clickable }) => `
cursor: ${clickable ? 'pointer' : 'default'};
background-color: ${table.tr.backgroundColor[expanded ? 'hover' : 'normal']};
&:hover {
background-color: ${table.tr.backgroundColor.hover};

View file

@ -28,13 +28,26 @@ export interface TableProps<TData> {
data: TData[];
pageCount?: number;
columns: ColumnDef<TData>[];
renderSubComponent?: React.FC<{ row: Row<TData> }>;
getRowCanExpand?: (row: Row<TData>) => boolean;
// Server-side processing: sorting, pagination
serverSideProcessing?: boolean;
enableSorting?: boolean;
enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
batchActionsBar?: React.FC<{ rows: Row<TData>[]; resetRowSelection(): void }>;
// Expandeble rows
getRowCanExpand?: (row: Row<TData>) => boolean; // Enables the ability to expand row. Use `() => true` when want to expand all rows.
renderSubComponent?: React.FC<{ row: Row<TData> }>; // Component to render expanded row.
// Selectable rows
enableRowSelection?: boolean | ((row: Row<TData>) => boolean); // Enables the ability to select row.
batchActionsBar?: React.FC<{ rows: Row<TData>[]; resetRowSelection(): void }>; // Component to render batch actions bar for slected rows
// Sorting.
enableSorting?: boolean; // Enables sorting for table.
// Placeholder for empty table
emptyMessage?: string;
// Handles row click. Can not be combined with `enableRowSelection` && expandable rows.
onRowClick?: (row: Row<TData>) => void;
}
type UpdaterFn<T> = (previousState: T) => T;
@ -72,6 +85,7 @@ const getSortingFromSearchParams = (searchParams: URLSearchParams) => {
* `enableSorting = false` to the column def.
* - table component stores the sorting state in URLSearchParams. Use `sortBy` and `sortDirection`
* search param to set default sortings.
* - use `id` property of the column def to set the sortBy for server side sorting.
*
* 2. Pagination
* - pagination enabled by default.
@ -107,6 +121,7 @@ const Table: React.FC<TableProps<any>> = ({
enableRowSelection = false,
batchActionsBar,
emptyMessage,
onRowClick,
}) => {
const [searchParams, setSearchParams] = useSearchParams();
const [rowSelection, setRowSelection] = React.useState({});
@ -157,6 +172,24 @@ const Table: React.FC<TableProps<any>> = ({
const Bar = batchActionsBar;
const handleRowClick = (row: Row<typeof data>) => (e: React.MouseEvent) => {
// If row selection is enabled do not handle row click.
if (enableRowSelection) return undefined;
// If row can be expanded do not handle row click.
if (row.getCanExpand()) {
e.stopPropagation();
return row.toggleExpanded();
}
if (onRowClick) {
e.stopPropagation();
return onRowClick(row);
}
return undefined;
};
return (
<>
{table.getSelectedRowModel().flatRows.length > 0 && Bar && (
@ -205,9 +238,12 @@ const Table: React.FC<TableProps<any>> = ({
{table.getRowModel().rows.map((row) => (
<React.Fragment key={row.id}>
<S.Row
expandable={row.getCanExpand()}
expanded={row.getIsExpanded()}
onClick={() => row.getCanExpand() && row.toggleExpanded()}
onClick={handleRowClick(row)}
clickable={
!enableRowSelection &&
(row.getCanExpand() || onRowClick !== undefined)
}
>
{!!enableRowSelection && (
<td key={`${row.id}-select`}>

View file

@ -0,0 +1,12 @@
import { CellContext } from '@tanstack/react-table';
import React from 'react';
import getTagColor from 'components/common/Tag/getTagColor';
import { Tag } from 'components/common/Tag/Tag.styled';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TagCell: React.FC<CellContext<any, unknown>> = ({ getValue }) => {
const value = getValue<string>();
return <Tag color={getTagColor(value)}>{value}</Tag>;
};
export default TagCell;

View file

@ -5,8 +5,8 @@ import React from 'react';
import * as S from './Table.styled';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TimestampCell: React.FC<CellContext<any, any>> = ({ getValue }) => (
<S.Nowrap>{formatTimestamp(getValue())}</S.Nowrap>
const TimestampCell: React.FC<CellContext<any, unknown>> = ({ getValue }) => (
<S.Nowrap>{formatTimestamp(getValue<string | number>())}</S.Nowrap>
);
export default TimestampCell;

View file

@ -4,20 +4,54 @@ import Table, {
TableProps,
TimestampCell,
SizeCell,
LinkCell,
TagCell,
} from 'components/common/NewTable';
import { screen, waitFor } from '@testing-library/dom';
import { ColumnDef, Row } from '@tanstack/react-table';
import userEvent from '@testing-library/user-event';
import { formatTimestamp } from 'lib/dateTimeHelpers';
import { act } from '@testing-library/react';
import { ConnectorState, ConsumerGroupState } from 'generated-sources';
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedUsedNavigate,
}));
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 },
{
timestamp: 1660034383725,
text: 'lorem',
selectable: false,
size: 1234,
tag: ConnectorState.RUNNING,
},
{
timestamp: 1660034399999,
text: 'ipsum',
selectable: true,
size: 3,
tag: ConnectorState.FAILED,
},
{
timestamp: 1660034399922,
text: 'dolor',
selectable: true,
size: 50000,
tag: ConsumerGroupState.EMPTY,
},
{
timestamp: 1660034199922,
text: 'sit',
selectable: false,
size: 1_312_323,
tag: 'some_string',
},
];
const columns: ColumnDef<Datum>[] = [
@ -29,12 +63,18 @@ const columns: ColumnDef<Datum>[] = [
{
header: 'Text',
accessorKey: 'text',
cell: LinkCell,
},
{
header: 'Size',
accessorKey: 'size',
cell: SizeCell,
},
{
header: 'Tag',
accessorKey: 'tag',
cell: TagCell,
},
];
const ExpandedRow: React.FC = () => <div>I am expanded row</div>;
@ -45,7 +85,7 @@ interface Props extends TableProps<Datum> {
const renderComponent = (props: Partial<Props> = {}) => {
render(
<WithRoute path="/">
<WithRoute path="/*">
<Table
columns={columns}
data={data}
@ -94,6 +134,21 @@ describe('Table', () => {
).toBeInTheDocument();
});
describe('LinkCell', () => {
it('renders link', () => {
renderComponent();
expect(screen.getByRole('link', { name: 'lorem' })).toBeInTheDocument();
});
it('link click stops propagation', () => {
const onRowClick = jest.fn();
renderComponent({ onRowClick });
const link = screen.getByRole('link', { name: 'lorem' });
userEvent.click(link);
expect(onRowClick).not.toHaveBeenCalled();
});
});
describe('ExpanderCell', () => {
it('renders button', () => {
renderComponent({ getRowCanExpand: () => true });
@ -116,6 +171,14 @@ describe('Table', () => {
});
});
it('renders TagCell', () => {
renderComponent();
expect(screen.getByText(data[0].tag)).toBeInTheDocument();
expect(screen.getByText(data[1].tag)).toBeInTheDocument();
expect(screen.getByText(data[2].tag)).toBeInTheDocument();
expect(screen.getByText(data[3].tag)).toBeInTheDocument();
});
describe('Pagination', () => {
it('does not render page buttons', () => {
renderComponent();
@ -240,4 +303,34 @@ describe('Table', () => {
expect(screen.getByText('I am Action Bar')).toBeInTheDocument();
});
});
describe('Clickable Row', () => {
const onRowClick = jest.fn();
it('handles onRowClick', () => {
renderComponent({ onRowClick });
const rows = screen.getAllByRole('row');
expect(rows.length).toEqual(data.length + 1);
userEvent.click(rows[1]);
expect(onRowClick).toHaveBeenCalledTimes(1);
});
it('does nothing unless onRowClick is provided', () => {
renderComponent();
const rows = screen.getAllByRole('row');
expect(rows.length).toEqual(data.length + 1);
userEvent.click(rows[1]);
});
it('does not handle onRowClick if enableRowSelection', () => {
renderComponent({ onRowClick, enableRowSelection: true });
const rows = screen.getAllByRole('row');
expect(rows.length).toEqual(data.length + 1);
userEvent.click(rows[1]);
expect(onRowClick).not.toHaveBeenCalled();
});
it('does not handle onRowClick if expandable rows', () => {
renderComponent({ onRowClick, getRowCanExpand: () => true });
const rows = screen.getAllByRole('row');
expect(rows.length).toEqual(data.length + 1);
userEvent.click(rows[1]);
expect(onRowClick).not.toHaveBeenCalled();
});
});
});

View file

@ -1,9 +1,11 @@
import Table, { TableProps } from './Table';
import TimestampCell from './TimestampCell';
import SizeCell from './SizeCell';
import LinkCell from './LinkCell';
import TagCell from './TagCell';
export type { TableProps };
export { TimestampCell, SizeCell };
export { TimestampCell, SizeCell, LinkCell, TagCell };
export default Table;

View file

@ -1,26 +0,0 @@
import React from 'react';
import { PaginationLink } from './Pagination.styled';
export interface PageControlProps {
current: boolean;
url: string;
page: number;
}
const PageControl: React.FC<PageControlProps> = ({ current, url, page }) => {
return (
<li>
<PaginationLink
to={url}
aria-label={`Goto page ${page}`}
$isCurrent={current}
role="button"
>
{page}
</PaginationLink>
</li>
);
};
export default PageControl;

View file

@ -1,87 +0,0 @@
import styled from 'styled-components';
import { Link } from 'react-router-dom';
import theme from 'theme/theme';
export const Wrapper = styled.nav`
display: flex;
align-items: flex-end;
padding: 25px 16px;
gap: 15px;
& > ul {
display: flex;
align-items: flex-end;
& > li:not(:last-child) {
margin-right: 12px;
}
}
`;
export const PaginationLink = styled(Link)<{ $isCurrent: boolean }>`
display: flex;
justify-content: center;
align-items: center;
height: 32px;
width: 33px;
border-radius: 4px;
border: 1px solid
${({ $isCurrent }) =>
$isCurrent
? theme.pagination.currentPage
: theme.pagination.borderColor.normal};
background-color: ${({ $isCurrent }) =>
$isCurrent
? theme.pagination.currentPage
: theme.pagination.backgroundColor};
color: ${theme.pagination.color.normal};
&:hover {
border: 1px solid
${({ $isCurrent }) =>
$isCurrent
? theme.pagination.currentPage
: theme.pagination.borderColor.hover};
color: ${(props) => props.theme.pagination.color.hover};
cursor: ${({ $isCurrent }) => ($isCurrent ? 'default' : 'pointer')};
}
`;
export const PaginationButton = styled(Link)`
display: flex;
align-items: center;
padding: 6px 12px;
height: 32px;
border: 1px solid ${theme.pagination.borderColor.normal};
border-radius: 4px;
color: ${theme.pagination.color.normal};
&:hover {
border: 1px solid ${theme.pagination.borderColor.hover};
color: ${theme.pagination.color.hover};
cursor: pointer;
}
&:active {
border: 1px solid ${theme.pagination.borderColor.active};
color: ${theme.pagination.color.active};
}
&:disabled {
border: 1px solid ${theme.pagination.borderColor.disabled};
color: ${theme.pagination.color.disabled};
cursor: not-allowed;
}
`;
export const DisabledButton = styled.button`
display: flex;
align-items: center;
padding: 6px 12px;
height: 32px;
border: 1px solid ${theme.pagination.borderColor.disabled};
background-color: ${theme.pagination.backgroundColor};
border-radius: 4px;
font-size: 16px;
color: ${theme.pagination.color.disabled};
`;

View file

@ -1,128 +0,0 @@
import { PER_PAGE } from 'lib/constants';
import usePagination from 'lib/hooks/usePagination';
import range from 'lodash/range';
import React from 'react';
import PageControl from 'components/common/Pagination/PageControl';
import { useSearchParams } from 'react-router-dom';
import * as S from './Pagination.styled';
export interface PaginationProps {
totalPages: number;
}
const NEIGHBOURS = 2;
const Pagination: React.FC<PaginationProps> = ({ totalPages }) => {
const { page, perPage, pathname } = usePagination();
const [searchParams] = useSearchParams();
const currentPage = page || 1;
const currentPerPage = perPage || PER_PAGE;
const getPath = (newPage: number) => {
searchParams.set('page', Math.max(newPage, 1).toString());
searchParams.set('perPage', currentPerPage.toString());
return `${pathname}?${searchParams.toString()}`;
};
const pages = React.useMemo(() => {
// Total visible numbers: neighbours, current, first & last
const totalNumbers = NEIGHBOURS * 2 + 3;
// totalNumbers + `...`*2
const totalBlocks = totalNumbers + 2;
if (totalPages <= totalBlocks) {
return range(1, totalPages + 1);
}
const startPage = Math.max(
2,
Math.min(currentPage - NEIGHBOURS, totalPages)
);
const endPage = Math.min(
totalPages - 1,
Math.min(currentPage + NEIGHBOURS, totalPages)
);
let p = range(startPage, endPage + 1);
const hasLeftSpill = startPage > 2;
const hasRightSpill = totalPages - endPage > 1;
const spillOffset = totalNumbers - (p.length + 1);
switch (true) {
case hasLeftSpill && !hasRightSpill: {
p = [...range(startPage - spillOffset - 1, startPage - 1), ...p];
break;
}
case !hasLeftSpill && hasRightSpill: {
p = [...p, ...range(endPage + 1, endPage + spillOffset + 1)];
break;
}
default:
break;
}
return p;
}, [currentPage, totalPages]);
return (
<S.Wrapper role="navigation" aria-label="pagination">
{currentPage > 1 ? (
<S.PaginationButton to={getPath(currentPage - 1)}>
Previous
</S.PaginationButton>
) : (
<S.DisabledButton disabled>Previous</S.DisabledButton>
)}
{totalPages > 1 && (
<ul>
{!pages.includes(1) && (
<PageControl
page={1}
current={currentPage === 1}
url={getPath(1)}
/>
)}
{!pages.includes(2) && (
<li>
<span>&hellip;</span>
</li>
)}
{pages.map((p) => (
<PageControl
key={`page-${p}`}
page={p}
current={p === currentPage}
url={getPath(p)}
/>
))}
{!pages.includes(totalPages - 1) && (
<li>
<span>&hellip;</span>
</li>
)}
{!pages.includes(totalPages) && (
<PageControl
page={totalPages}
current={currentPage === totalPages}
url={getPath(totalPages)}
/>
)}
</ul>
)}
{currentPage < totalPages ? (
<S.PaginationButton to={getPath(currentPage + 1)}>
Next
</S.PaginationButton>
) : (
<S.DisabledButton disabled>Next</S.DisabledButton>
)}
</S.Wrapper>
);
};
export default Pagination;

View file

@ -1,35 +0,0 @@
import React from 'react';
import PageControl, {
PageControlProps,
} from 'components/common/Pagination/PageControl';
import { screen } from '@testing-library/react';
import { render } from 'lib/testHelpers';
import theme from 'theme/theme';
const page = 138;
describe('PageControl', () => {
const setupComponent = (props: Partial<PageControlProps> = {}) =>
render(<PageControl url="/test" page={page} current {...props} />);
const getButton = () => screen.getByRole('button');
it('renders current page', () => {
setupComponent({ current: true });
expect(getButton()).toHaveStyle(
`background-color: ${theme.pagination.currentPage}`
);
});
it('renders non-current page', () => {
setupComponent({ current: false });
expect(getButton()).toHaveStyle(
`background-color: ${theme.pagination.backgroundColor}`
);
});
it('renders page number', () => {
setupComponent({ current: false });
expect(getButton()).toHaveTextContent(String(page));
});
});

View file

@ -1,91 +0,0 @@
import React from 'react';
import Pagination, {
PaginationProps,
} from 'components/common/Pagination/Pagination';
import theme from 'theme/theme';
import { render } from 'lib/testHelpers';
import { screen } from '@testing-library/react';
describe('Pagination', () => {
const setupComponent = (
search = '',
props: Partial<PaginationProps> = {}
) => {
const defaultPath = '/my/test/path/23';
const pathName = search ? `${defaultPath}${search}` : defaultPath;
return render(<Pagination totalPages={11} {...props} />, {
initialEntries: [pathName],
});
};
describe('next & prev buttons', () => {
it('renders disable prev button and enabled next link', () => {
setupComponent('?page=1');
expect(screen.getByText('Previous')).toBeDisabled();
expect(screen.getByText('Next')).toBeInTheDocument();
});
it('renders disable next button and enabled prev link', () => {
setupComponent('?page=11');
expect(screen.getByText('Previous')).toBeInTheDocument();
expect(screen.getByText('Next')).toBeDisabled();
});
it('renders next & prev links with correct path', () => {
setupComponent('?page=5&perPage=20');
expect(screen.getByText('Previous')).toBeInTheDocument();
expect(screen.getByText('Next')).toBeInTheDocument();
expect(screen.getByText('Previous')).toHaveAttribute(
'href',
'/my/test/path/23?page=4&perPage=20'
);
expect(screen.getByText('Next')).toHaveAttribute(
'href',
'/my/test/path/23?page=6&perPage=20'
);
});
});
describe('spread', () => {
it('renders 1 spread element after first page control', () => {
setupComponent('?page=8');
expect(screen.getAllByRole('listitem')[1]).toHaveTextContent('…');
});
it('renders 1 spread element before last spread control', () => {
setupComponent('?page=2');
expect(screen.getAllByRole('listitem')[7]).toHaveTextContent('…');
});
it('renders 2 spread elements', () => {
setupComponent('?page=6');
expect(screen.getAllByText('…').length).toEqual(2);
expect(screen.getAllByRole('listitem')[0]).toHaveTextContent('1');
expect(screen.getAllByRole('listitem')[1]).toHaveTextContent('…');
expect(screen.getAllByRole('listitem')[7]).toHaveTextContent('…');
expect(screen.getAllByRole('listitem')[8]).toHaveTextContent('11');
});
it('renders 0 spread elements', () => {
setupComponent('?page=2', { totalPages: 8 });
expect(screen.queryAllByText('…').length).toEqual(0);
expect(screen.getAllByRole('listitem').length).toEqual(8);
});
});
describe('current page', () => {
it('check if it sets page 8 as current when page param is set', () => {
setupComponent('?page=8');
expect(screen.getByText('8')).toHaveStyle(
`background-color: ${theme.pagination.currentPage}`
);
});
it('check if it sets first page as current when page param not set', () => {
setupComponent('', { totalPages: 8 });
expect(screen.getByText('1')).toHaveStyle(
`background-color: ${theme.pagination.currentPage}`
);
});
});
});

View file

@ -1,134 +0,0 @@
import React from 'react';
import Pagination from 'components/common/Pagination/Pagination';
import { Table } from 'components/common/table/Table/Table.styled';
import * as S from 'components/common/table/TableHeaderCell/TableHeaderCell.styled';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import { TableState } from 'lib/hooks/useTableState';
import {
isColumnElement,
SelectCell,
TableHeaderCellProps,
} from './TableColumn';
import { TableRow } from './TableRow';
interface SmartTableProps<T, TId extends IdType> {
tableState: TableState<T, TId>;
allSelectable?: boolean;
selectable?: boolean;
className?: string;
placeholder?: string;
isFullwidth?: boolean;
paginated?: boolean;
hoverable?: boolean;
}
export const SmartTable = <T, TId extends IdType>({
children,
tableState,
selectable = false,
allSelectable = false,
placeholder = 'No Data Found',
isFullwidth = false,
paginated = false,
hoverable = false,
}: React.PropsWithChildren<SmartTableProps<T, TId>>) => {
const handleRowSelection = (row: T, checked: boolean) => {
tableState.setRowsSelection([row], checked);
};
const headerRow = React.useMemo(() => {
const headerCells = React.Children.map(children, (child) => {
if (!isColumnElement<T, TId>(child)) {
return child;
}
const { headerCell, title, orderValue } = child.props;
const HeaderCell = headerCell as React.FC<TableHeaderCellProps<T, TId>>;
return HeaderCell ? (
<S.TableHeaderCell>
<HeaderCell
orderValue={orderValue}
orderable={tableState.orderable}
tableState={tableState}
/>
</S.TableHeaderCell>
) : (
// TODO types will be changed after fixing TableHeaderCell
<TableHeaderCell
{...tableState.orderable}
orderValue={orderValue}
title={title}
/>
);
});
let checkboxElement = null;
if (selectable) {
checkboxElement = allSelectable ? (
<SelectCell
rowIndex={-1}
el="th"
selectable
selected={tableState.selectedCount === tableState.data.length}
onChange={tableState.toggleSelection}
/>
) : (
<S.TableHeaderCell />
);
}
return (
<tr>
{checkboxElement}
{headerCells}
</tr>
);
}, [children, allSelectable, tableState]);
const bodyRows = React.useMemo(() => {
if (tableState.data.length === 0) {
const colspan = React.Children.count(children) + +selectable;
return (
<tr>
<td colSpan={colspan}>{placeholder}</td>
</tr>
);
}
return tableState.data.map((dataItem, index) => {
return (
<TableRow
key={tableState.idSelector(dataItem)}
index={index}
hoverable={hoverable}
dataItem={dataItem}
tableState={tableState}
selectable={selectable}
onSelectChange={handleRowSelection}
>
{children}
</TableRow>
);
});
}, [
children,
handleRowSelection,
hoverable,
placeholder,
selectable,
tableState,
]);
return (
<>
<Table isFullwidth={isFullwidth}>
<thead>{headerRow}</thead>
<tbody>{bodyRows}</tbody>
</Table>
{paginated && tableState.totalPages !== undefined && (
<Pagination totalPages={tableState.totalPages} />
)}
</>
);
};

View file

@ -1,102 +0,0 @@
import React from 'react';
import { TableState } from 'lib/hooks/useTableState';
import { SortOrder } from 'generated-sources';
import * as S from 'components/common/table/TableHeaderCell/TableHeaderCell.styled';
import { DefaultTheme, StyledComponent } from 'styled-components';
export interface OrderableProps {
orderBy: string | null;
sortOrder: SortOrder;
handleOrderBy: (orderBy: string | null) => void;
}
interface TableCellPropsBase<T, TId extends IdType> {
tableState: TableState<T, TId>;
}
export interface TableHeaderCellProps<T, TId extends IdType>
extends TableCellPropsBase<T, TId> {
orderable?: OrderableProps;
orderValue?: string;
}
export interface TableCellProps<T, TId extends IdType>
extends TableCellPropsBase<T, TId> {
rowIndex: number;
dataItem: T;
hovered?: boolean;
}
interface TableColumnProps<T, TId extends IdType> {
cell?: React.FC<TableCellProps<T, TId>>;
children?: React.ReactElement;
headerCell?: React.FC<TableHeaderCellProps<T, TId>>;
field?: string;
title?: string;
maxWidth?: string;
className?: string;
orderValue?: string;
customTd?: typeof S.Td;
}
export const TableColumn = <T, TId extends IdType>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_props: React.PropsWithChildren<TableColumnProps<T, TId>>
): React.ReactElement => {
return <td />;
};
export function isColumnElement<T, TId extends IdType>(
element: React.ReactNode
): element is React.ReactElement<TableColumnProps<T, TId>> {
if (!React.isValidElement(element)) {
return false;
}
const elementType = (element as React.ReactElement).type;
return (
elementType === TableColumn ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(elementType as any).originalType === TableColumn
);
}
interface SelectCellProps {
selected: boolean;
selectable: boolean;
el: 'td' | 'th';
rowIndex: number;
onChange: (checked: boolean) => void;
}
export const SelectCell: React.FC<SelectCellProps> = ({
selected,
selectable,
rowIndex,
onChange,
el,
}) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.checked);
};
let El: 'td' | StyledComponent<'th', DefaultTheme>;
if (el === 'th') {
El = S.TableHeaderCell;
} else {
El = el;
}
return (
<El>
{selectable && (
<input
data-row={rowIndex}
onChange={handleChange}
type="checkbox"
checked={selected}
/>
)}
</El>
);
};

View file

@ -1,86 +0,0 @@
import React from 'react';
import { propertyLookup } from 'lib/propertyLookup';
import { TableState } from 'lib/hooks/useTableState';
import { Td } from 'components/common/table/TableHeaderCell/TableHeaderCell.styled';
import { isColumnElement, SelectCell, TableCellProps } from './TableColumn';
interface TableRowProps<T, TId extends IdType = never> {
index: number;
id?: TId;
hoverable?: boolean;
tableState: TableState<T, TId>;
dataItem: T;
selectable: boolean;
onSelectChange?: (row: T, checked: boolean) => void;
}
export const TableRow = <T, TId extends IdType>({
children,
hoverable = false,
id,
index,
dataItem,
selectable,
tableState,
onSelectChange,
}: React.PropsWithChildren<TableRowProps<T, TId>>): React.ReactElement => {
const [hovered, setHovered] = React.useState(false);
const handleMouseEnter = () => {
setHovered(true);
};
const handleMouseLeave = () => {
setHovered(false);
};
const handleSelectChange = (checked: boolean) => {
onSelectChange?.(dataItem, checked);
};
return (
<tr
tabIndex={index}
id={id as string}
onMouseEnter={hoverable ? handleMouseEnter : undefined}
onMouseLeave={hoverable ? handleMouseLeave : undefined}
>
{selectable && (
<SelectCell
rowIndex={index}
el="td"
selectable={tableState.isRowSelectable(dataItem)}
selected={tableState.selectedIds.has(tableState.idSelector(dataItem))}
onChange={handleSelectChange}
/>
)}
{React.Children.map(children, (child) => {
if (!isColumnElement<T, TId>(child)) {
return child;
}
const { cell, field, maxWidth, customTd } = child.props;
const Cell = cell as React.FC<TableCellProps<T, TId>> | undefined;
const TdComponent = customTd || Td;
const content = Cell ? (
<Cell
tableState={tableState}
hovered={hovered}
rowIndex={index}
dataItem={dataItem}
/>
) : (
field && propertyLookup(field, dataItem)
);
return (
<TdComponent maxWidth={maxWidth}>
{content as React.ReactNode}
</TdComponent>
);
})}
</tr>
);
};

View file

@ -4,7 +4,7 @@ interface Props {
color: 'green' | 'gray' | 'yellow' | 'red' | 'white' | 'blue';
}
export const Tag = styled.p<Props>`
export const Tag = styled.span.attrs({ role: 'widget' })<Props>`
border: none;
border-radius: 16px;
height: 20px;
@ -17,4 +17,5 @@ export const Tag = styled.p<Props>`
padding-right: 0.75em;
text-align: center;
width: max-content;
margin: 2px 0;
`;

View file

@ -1,14 +1,6 @@
import {
ConnectorState,
ConnectorStatus,
ConsumerGroup,
ConsumerGroupState,
TaskStatus,
} from 'generated-sources';
import { ConnectorState, ConsumerGroupState } from 'generated-sources';
const getTagColor = ({
state,
}: ConnectorStatus | TaskStatus | ConsumerGroup) => {
const getTagColor = (state?: string) => {
switch (state) {
case ConnectorState.RUNNING:
case ConsumerGroupState.STABLE:

View file

@ -21,7 +21,3 @@ const tableLinkMixin = css(
export const TableKeyLink = styled.td`
${tableLinkMixin}
`;
export const SmartTableKeyLink = styled.div`
${tableLinkMixin}
`;

View file

@ -67,12 +67,6 @@ const DESCMixin = css(
`
);
export const Td = styled.td<{ maxWidth?: string }>`
overflow: hidden;
text-overflow: ellipsis;
max-width: ${(props) => props.maxWidth};
`;
export const Title = styled.span<TitleProps>(
({ isOrderable, isOrdered, sortOrder, theme: { table } }) => css`
font-family: Inter, sans-serif;

View file

@ -1,18 +0,0 @@
import { propertyLookup } from 'lib/propertyLookup';
describe('Property Lookup', () => {
const entityObject = {
prop: {
nestedProp: 1,
},
};
it('returns undefined if property not found', () => {
expect(
propertyLookup('prop.nonExistingProp', entityObject)
).toBeUndefined();
});
it('returns value of nested property if it exists', () => {
expect(propertyLookup('prop.nestedProp', entityObject)).toBe(1);
});
});

View file

@ -1,17 +0,0 @@
import { useLocation } from 'react-router-dom';
const usePagination = () => {
const { search, pathname } = useLocation();
const params = new URLSearchParams(search);
const page = params.get('page');
const perPage = params.get('perPage');
return {
page: page ? Number(page) : undefined,
perPage: perPage ? Number(perPage) : undefined,
pathname,
};
};
export default usePagination;

View file

@ -1,78 +0,0 @@
import React, { useCallback } from 'react';
import { OrderableProps } from 'components/common/SmartTable/TableColumn';
export interface TableState<T, TId extends IdType> {
data: T[];
selectedIds: Set<TId>;
totalPages?: number;
idSelector: (row: T) => TId;
isRowSelectable: (row: T) => boolean;
selectedCount: number;
setRowsSelection: (rows: T[], selected: boolean) => void;
toggleSelection: (selected: boolean) => void;
orderable?: OrderableProps;
}
export const useTableState = <T, TId extends IdType>(
data: T[],
options: {
totalPages: number;
isRowSelectable?: (row: T) => boolean;
idSelector: (row: T) => TId;
},
orderable?: OrderableProps
): TableState<T, TId> => {
const [selectedIds, setSelectedIds] = React.useState(new Set<TId>());
const { idSelector, totalPages, isRowSelectable = () => true } = options;
const selectedCount = selectedIds.size;
const setRowsSelection = useCallback(
(rows: T[], selected: boolean) => {
rows.forEach((row) => {
const id = idSelector(row);
const newSet = new Set(selectedIds);
if (selected) {
newSet.add(id);
} else {
newSet.delete(id);
}
setSelectedIds(newSet);
});
},
[idSelector, selectedIds]
);
const toggleSelection = useCallback(
(selected: boolean) => {
const newSet = new Set(selected ? data.map((r) => idSelector(r)) : []);
setSelectedIds(newSet);
},
[data, idSelector]
);
return React.useMemo<TableState<T, TId>>(() => {
return {
data,
totalPages,
selectedIds,
orderable,
selectedCount,
idSelector,
isRowSelectable,
setRowsSelection,
toggleSelection,
};
}, [
data,
orderable,
selectedIds,
totalPages,
selectedCount,
idSelector,
isRowSelectable,
setRowsSelection,
toggleSelection,
]);
};

View file

@ -1,9 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function propertyLookup<T extends { [key: string]: any }>(
path: string,
obj: T
) {
return path.split('.').reduce((prev, curr) => {
return prev ? prev[curr] : null;
}, obj);
}

View file

@ -25,11 +25,6 @@ export const consumerGroups = [
},
];
export const noConsumerGroupsResponse = {
pageCount: 1,
consumerGroups: [],
};
export const consumerGroupPayload = {
groupId: 'amazon.msk.canary.group.broker-1',
members: 0,

View file

@ -447,22 +447,6 @@ const theme = {
},
color: Colors.neutral[90],
},
pagination: {
backgroundColor: Colors.neutral[0],
currentPage: Colors.neutral[10],
borderColor: {
normal: Colors.neutral[30],
hover: Colors.neutral[50],
active: Colors.neutral[70],
disabled: Colors.neutral[20],
},
color: {
normal: Colors.neutral[90],
hover: Colors.neutral[90],
active: Colors.neutral[90],
disabled: Colors.neutral[20],
},
},
switch: {
unchecked: Colors.brand[20],
checked: Colors.brand[50],