Issues/1525 add sort order consumer group page (#1747)
* Adding Consumer Groups Paged Reducer and action creator and get Selector logic * Adding fetchConsumerGroupsPaged to the ConsumerGroups Paged * Code refactoring and adding general fixtures For Consumer Group List * Adding Container Redux Connect structure to the List of Consumer Groups * Adding Selectors and action creators for orderBy and Sort Order * Adding All Necessary components to render the smart Tables in Consumer Groups * Adding SmartTable to the Consumer Groups page + fixing allSelectable Checkbox bug in the SmartTable Component * Primitive Tests for ConsumerGroupsTableCells to test rendering and mockup the component and table data * Finalizing Tests for consumer Groups table , minor code bug fix in the Consumer Groups table structure * Adding Order By to the Consumer Groups Table , View part * Adding order By to the Consumer Groups pages * Adding order By to the Consumer Groups pages with SortBy functionality * Code refactor in the ConsumerGroups component and its related tests * Code refactor in the ConsumerGroups component and its related tests * adding Tests in the Consumer Groups List * Fixing the Sorting styling Bug in the Table order * Adding additional Tests to the ConsumerGroups List tests * Adding additional Tests for TableHeaderCell styled component * Deleting obsolete codes from the consumer Groups Slice + minor table header test type fix * Adding Tests for the consumerGroupSlice * Adding Tests for the consumerGroupSlice * Consumer Groups table minor code modifications * Minor Code bug fixes in the SmartTable Component
This commit is contained in:
parent
8908d6839c
commit
73266f86af
15 changed files with 588 additions and 127 deletions
|
@ -3,22 +3,32 @@ import { ClusterName } from 'redux/interfaces';
|
||||||
import { Switch, useParams } from 'react-router-dom';
|
import { Switch, useParams } from 'react-router-dom';
|
||||||
import PageLoader from 'components/common/PageLoader/PageLoader';
|
import PageLoader from 'components/common/PageLoader/PageLoader';
|
||||||
import Details from 'components/ConsumerGroups/Details/Details';
|
import Details from 'components/ConsumerGroups/Details/Details';
|
||||||
import List from 'components/ConsumerGroups/List/List';
|
import ListContainer from 'components/ConsumerGroups/List/ListContainer';
|
||||||
import ResetOffsets from 'components/ConsumerGroups/Details/ResetOffsets/ResetOffsets';
|
import ResetOffsets from 'components/ConsumerGroups/Details/ResetOffsets/ResetOffsets';
|
||||||
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
|
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
|
||||||
import {
|
import {
|
||||||
fetchConsumerGroups,
|
fetchConsumerGroupsPaged,
|
||||||
getAreConsumerGroupsFulfilled,
|
getAreConsumerGroupsPagedFulfilled,
|
||||||
|
getConsumerGroupsOrderBy,
|
||||||
|
getConsumerGroupsSortOrder,
|
||||||
} from 'redux/reducers/consumerGroups/consumerGroupsSlice';
|
} from 'redux/reducers/consumerGroups/consumerGroupsSlice';
|
||||||
import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
|
import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
|
||||||
|
|
||||||
const ConsumerGroups: React.FC = () => {
|
const ConsumerGroups: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { clusterName } = useParams<{ clusterName: ClusterName }>();
|
const { clusterName } = useParams<{ clusterName: ClusterName }>();
|
||||||
const isFetched = useAppSelector(getAreConsumerGroupsFulfilled);
|
const isFetched = useAppSelector(getAreConsumerGroupsPagedFulfilled);
|
||||||
|
const orderBy = useAppSelector(getConsumerGroupsOrderBy);
|
||||||
|
const sortOrder = useAppSelector(getConsumerGroupsSortOrder);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
dispatch(fetchConsumerGroups(clusterName));
|
dispatch(
|
||||||
}, [clusterName, dispatch]);
|
fetchConsumerGroupsPaged({
|
||||||
|
clusterName,
|
||||||
|
orderBy: orderBy || undefined,
|
||||||
|
sortOrder,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [clusterName, orderBy, sortOrder, dispatch]);
|
||||||
|
|
||||||
if (isFetched) {
|
if (isFetched) {
|
||||||
return (
|
return (
|
||||||
|
@ -26,7 +36,7 @@ const ConsumerGroups: React.FC = () => {
|
||||||
<BreadcrumbRoute
|
<BreadcrumbRoute
|
||||||
exact
|
exact
|
||||||
path="/ui/clusters/:clusterName/consumer-groups"
|
path="/ui/clusters/:clusterName/consumer-groups"
|
||||||
component={List}
|
component={ListContainer}
|
||||||
/>
|
/>
|
||||||
<BreadcrumbRoute
|
<BreadcrumbRoute
|
||||||
exact
|
exact
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
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 getTagColor from 'components/ConsumerGroups/Utils/TagColor';
|
||||||
|
import { ConsumerGroup } from 'generated-sources';
|
||||||
|
import { SmartTableKeyLink } from 'components/common/table/Table/TableKeyLink.styled';
|
||||||
|
|
||||||
|
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={`consumer-groups/${groupId}`}>{groupId}</Link>
|
||||||
|
</SmartTableKeyLink>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,18 +1,61 @@
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Table } from 'components/common/table/Table/Table.styled';
|
|
||||||
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
|
|
||||||
import PageHeading from 'components/common/PageHeading/PageHeading';
|
import PageHeading from 'components/common/PageHeading/PageHeading';
|
||||||
import Search from 'components/common/Search/Search';
|
import Search from 'components/common/Search/Search';
|
||||||
import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
|
import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
|
||||||
import { useAppSelector } from 'lib/hooks/redux';
|
import {
|
||||||
import { selectAll } from 'redux/reducers/consumerGroups/consumerGroupsSlice';
|
ConsumerGroupDetails,
|
||||||
|
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 ListItem from './ListItem';
|
export interface Props {
|
||||||
|
consumerGroups: ConsumerGroupDetails[];
|
||||||
|
orderBy: ConsumerGroupOrdering | null;
|
||||||
|
sortOrder: SortOrder;
|
||||||
|
totalPages: number;
|
||||||
|
setConsumerGroupsSortOrderBy(orderBy: ConsumerGroupOrdering | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
const List: React.FC = () => {
|
const List: React.FC<Props> = ({
|
||||||
const consumerGroups = useAppSelector(selectAll);
|
consumerGroups,
|
||||||
|
sortOrder,
|
||||||
|
orderBy,
|
||||||
|
totalPages,
|
||||||
|
setConsumerGroupsSortOrderBy,
|
||||||
|
}) => {
|
||||||
const [searchText, setSearchText] = React.useState<string>('');
|
const [searchText, setSearchText] = React.useState<string>('');
|
||||||
|
|
||||||
|
const tableData = useMemo(() => {
|
||||||
|
return consumerGroups.filter(
|
||||||
|
(consumerGroup) =>
|
||||||
|
!searchText || consumerGroup?.groupId?.indexOf(searchText) >= 0
|
||||||
|
);
|
||||||
|
}, [searchText, consumerGroups]);
|
||||||
|
|
||||||
|
const tableState = useTableState<
|
||||||
|
ConsumerGroupDetails,
|
||||||
|
string,
|
||||||
|
ConsumerGroupOrdering
|
||||||
|
>(
|
||||||
|
tableData,
|
||||||
|
{
|
||||||
|
totalPages,
|
||||||
|
idSelector: (consumerGroup) => consumerGroup.groupId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
handleOrderBy: setConsumerGroupsSortOrderBy,
|
||||||
|
orderBy,
|
||||||
|
sortOrder,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const handleInputChange = (search: string) => {
|
const handleInputChange = (search: string) => {
|
||||||
setSearchText(search);
|
setSearchText(search);
|
||||||
};
|
};
|
||||||
|
@ -27,36 +70,31 @@ const List: React.FC = () => {
|
||||||
handleSearch={handleInputChange}
|
handleSearch={handleInputChange}
|
||||||
/>
|
/>
|
||||||
</ControlPanelWrapper>
|
</ControlPanelWrapper>
|
||||||
<Table isFullwidth>
|
<SmartTable
|
||||||
<thead>
|
tableState={tableState}
|
||||||
<tr>
|
isFullwidth
|
||||||
<TableHeaderCell title="Consumer Group ID" />
|
placeholder="No active consumer groups"
|
||||||
<TableHeaderCell title="Num Of Members" />
|
hoverable
|
||||||
<TableHeaderCell title="Num Of Topics" />
|
>
|
||||||
<TableHeaderCell title="Messages Behind" />
|
<TableColumn
|
||||||
<TableHeaderCell title="Coordinator" />
|
title="Consumer Group ID"
|
||||||
<TableHeaderCell title="State" />
|
cell={GroupIDCell}
|
||||||
</tr>
|
orderValue={ConsumerGroupOrdering.NAME}
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{consumerGroups
|
|
||||||
.filter(
|
|
||||||
(consumerGroup) =>
|
|
||||||
!searchText || consumerGroup?.groupId?.indexOf(searchText) >= 0
|
|
||||||
)
|
|
||||||
.map((consumerGroup) => (
|
|
||||||
<ListItem
|
|
||||||
key={consumerGroup.groupId}
|
|
||||||
consumerGroup={consumerGroup}
|
|
||||||
/>
|
/>
|
||||||
))}
|
<TableColumn
|
||||||
{consumerGroups.length === 0 && (
|
title="Num Of Members"
|
||||||
<tr>
|
field="members"
|
||||||
<td colSpan={10}>No active consumer groups</td>
|
orderValue={ConsumerGroupOrdering.MEMBERS}
|
||||||
</tr>
|
/>
|
||||||
)}
|
<TableColumn title="Num Of Topics" field="topics" />
|
||||||
</tbody>
|
<TableColumn title="Messages Behind" field="messagesBehind" />
|
||||||
</Table>
|
<TableColumn title="Coordinator" field="coordinator.id" />
|
||||||
|
<TableColumn
|
||||||
|
title="State"
|
||||||
|
cell={StatusCell}
|
||||||
|
orderValue={ConsumerGroupOrdering.STATE}
|
||||||
|
/>
|
||||||
|
</SmartTable>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { RootState } from 'redux/interfaces';
|
||||||
|
import {
|
||||||
|
getConsumerGroupsOrderBy,
|
||||||
|
getConsumerGroupsSortOrder,
|
||||||
|
getConsumerGroupsTotalPages,
|
||||||
|
sortBy,
|
||||||
|
selectAll,
|
||||||
|
} 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),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
setConsumerGroupsSortOrderBy: sortBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(List);
|
|
@ -0,0 +1,64 @@
|
||||||
|
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, never> = {
|
||||||
|
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',
|
||||||
|
`/consumer-groups/${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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,27 +1,40 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import List from 'components/ConsumerGroups/List/List';
|
import List, { Props } from 'components/ConsumerGroups/List/List';
|
||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { render } from 'lib/testHelpers';
|
import { render } from 'lib/testHelpers';
|
||||||
import { store } from 'redux/store';
|
import { consumerGroups as consumerGroupMock } from 'redux/reducers/consumerGroups/__test__/fixtures';
|
||||||
import { fetchConsumerGroups } from 'redux/reducers/consumerGroups/consumerGroupsSlice';
|
import { ConsumerGroupOrdering, SortOrder } from 'generated-sources';
|
||||||
import { consumerGroups } from 'redux/reducers/consumerGroups/__test__/fixtures';
|
import theme from 'theme/theme';
|
||||||
|
|
||||||
describe('List', () => {
|
describe('List', () => {
|
||||||
beforeEach(() => render(<List />, { store }));
|
const setUpComponent = (props: Partial<Props> = {}) => {
|
||||||
|
const {
|
||||||
|
consumerGroups,
|
||||||
|
orderBy,
|
||||||
|
sortOrder,
|
||||||
|
totalPages,
|
||||||
|
setConsumerGroupsSortOrderBy,
|
||||||
|
} = props;
|
||||||
|
return render(
|
||||||
|
<List
|
||||||
|
consumerGroups={consumerGroups || []}
|
||||||
|
orderBy={orderBy || ConsumerGroupOrdering.NAME}
|
||||||
|
sortOrder={sortOrder || SortOrder.ASC}
|
||||||
|
setConsumerGroupsSortOrderBy={setConsumerGroupsSortOrderBy || jest.fn()}
|
||||||
|
totalPages={totalPages || 1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
it('renders empty table', () => {
|
it('renders empty table', () => {
|
||||||
|
setUpComponent();
|
||||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||||
expect(screen.getByText('No active consumer groups')).toBeInTheDocument();
|
expect(screen.getByText('No active consumer groups')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('consumerGroups are fetched', () => {
|
describe('consumerGroups are fetched', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => setUpComponent({ consumerGroups: consumerGroupMock }));
|
||||||
store.dispatch({
|
|
||||||
type: fetchConsumerGroups.fulfilled.type,
|
|
||||||
payload: consumerGroups,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders all rows with consumers', () => {
|
it('renders all rows with consumers', () => {
|
||||||
expect(screen.getByText('groupId1')).toBeInTheDocument();
|
expect(screen.getByText('groupId1')).toBeInTheDocument();
|
||||||
|
@ -33,13 +46,64 @@ describe('List', () => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
userEvent.type(
|
userEvent.type(
|
||||||
screen.getByPlaceholderText('Search by Consumer Group ID'),
|
screen.getByPlaceholderText('Search by Consumer Group ID'),
|
||||||
'groupId1'
|
consumerGroupMock[0].groupId
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText('groupId1')).toBeInTheDocument();
|
expect(
|
||||||
expect(screen.getByText('groupId2')).toBeInTheDocument();
|
screen.getByText(consumerGroupMock[0].groupId)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(consumerGroupMock[1].groupId)
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders will not render a list since not found in the list', async () => {
|
||||||
|
await waitFor(() => {
|
||||||
|
userEvent.type(
|
||||||
|
screen.getByPlaceholderText('Search by Consumer Group ID'),
|
||||||
|
'NotFoundedText'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/No active consumer groups/i)
|
||||||
|
).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}`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { consumerGroups } from 'redux/reducers/consumerGroups/__test__/fixtures'
|
||||||
import { render } from 'lib/testHelpers';
|
import { render } from 'lib/testHelpers';
|
||||||
import fetchMock from 'fetch-mock';
|
import fetchMock from 'fetch-mock';
|
||||||
import { Route } from 'react-router';
|
import { Route } from 'react-router';
|
||||||
|
import { ConsumerGroupOrdering, SortOrder } from 'generated-sources';
|
||||||
|
|
||||||
const clusterName = 'cluster1';
|
const clusterName = 'cluster1';
|
||||||
|
|
||||||
|
@ -24,21 +25,19 @@ const renderComponent = () =>
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('ConsumerGroup', () => {
|
describe('ConsumerGroup', () => {
|
||||||
afterEach(() => {
|
|
||||||
fetchMock.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders with initial state', async () => {
|
it('renders with initial state', async () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Fetching Mock', () => {
|
||||||
|
const url = `/api/clusters/${clusterName}/consumer-groups/paged?orderBy=${ConsumerGroupOrdering.NAME}&sortOrder=${SortOrder.ASC}`;
|
||||||
|
afterEach(() => {
|
||||||
|
fetchMock.reset();
|
||||||
|
});
|
||||||
it('renders with 404 from consumer groups', async () => {
|
it('renders with 404 from consumer groups', async () => {
|
||||||
const consumerGroupsMock = fetchMock.getOnce(
|
const consumerGroupsMock = fetchMock.getOnce(url, 404);
|
||||||
`/api/clusters/${clusterName}/consumer-groups`,
|
|
||||||
404
|
|
||||||
);
|
|
||||||
|
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
|
@ -49,10 +48,10 @@ describe('ConsumerGroup', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders with 200 from consumer groups', async () => {
|
it('renders with 200 from consumer groups', async () => {
|
||||||
const consumerGroupsMock = fetchMock.getOnce(
|
const consumerGroupsMock = fetchMock.getOnce(url, {
|
||||||
`/api/clusters/${clusterName}/consumer-groups`,
|
pagedCount: 1,
|
||||||
consumerGroups
|
consumerGroups,
|
||||||
);
|
});
|
||||||
|
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
|
@ -62,4 +61,5 @@ describe('ConsumerGroup', () => {
|
||||||
expect(screen.getByText('Consumers')).toBeInTheDocument();
|
expect(screen.getByText('Consumers')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -69,9 +69,10 @@ export const SmartTable = <T, TId extends IdType, OT = never>({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return (
|
let checkboxElement = null;
|
||||||
<tr>
|
|
||||||
{allSelectable ? (
|
if (selectable) {
|
||||||
|
checkboxElement = allSelectable ? (
|
||||||
<SelectCell
|
<SelectCell
|
||||||
rowIndex={-1}
|
rowIndex={-1}
|
||||||
el="th"
|
el="th"
|
||||||
|
@ -81,7 +82,12 @@ export const SmartTable = <T, TId extends IdType, OT = never>({
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<S.TableHeaderCell />
|
<S.TableHeaderCell />
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
{checkboxElement}
|
||||||
{headerCells}
|
{headerCells}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { TableState } from 'lib/hooks/useTableState';
|
import { TableState } from 'lib/hooks/useTableState';
|
||||||
import { SortOrder } from 'generated-sources';
|
import { SortOrder } from 'generated-sources';
|
||||||
|
import * as S from 'components/common/table/TableHeaderCell/TableHeaderCell.styled';
|
||||||
|
import { DefaultTheme, StyledComponent } from 'styled-components';
|
||||||
|
|
||||||
export interface OrderableProps<OT> {
|
export interface OrderableProps<OT> {
|
||||||
orderBy: OT | null;
|
orderBy: OT | null;
|
||||||
|
@ -77,7 +79,12 @@ export const SelectCell: React.FC<SelectCellProps> = ({
|
||||||
onChange(e.target.checked);
|
onChange(e.target.checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
const El = el;
|
let El: 'td' | StyledComponent<'th', DefaultTheme>;
|
||||||
|
if (el === 'th') {
|
||||||
|
El = S.TableHeaderCell;
|
||||||
|
} else {
|
||||||
|
El = el;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<El>
|
<El>
|
||||||
|
|
|
@ -1,9 +1,19 @@
|
||||||
import styled from 'styled-components';
|
import styled, { css } from 'styled-components';
|
||||||
|
|
||||||
export const TableKeyLink = styled.td`
|
const tableLinkMixin = css(
|
||||||
|
({ theme }) => `
|
||||||
& > a {
|
& > a {
|
||||||
color: ${({ theme }) => theme.table.link.color};
|
color: ${theme.table.link.color};
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TableKeyLink = styled.td`
|
||||||
|
${tableLinkMixin}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SmartTableKeyLink = styled.div`
|
||||||
|
${tableLinkMixin}
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import styled, { css } from 'styled-components';
|
import styled, { css } from 'styled-components';
|
||||||
|
import { SortOrder } from 'generated-sources';
|
||||||
|
|
||||||
interface TitleProps {
|
export interface TitleProps {
|
||||||
isOrderable?: boolean;
|
isOrderable?: boolean;
|
||||||
isOrdered?: boolean;
|
isOrdered?: boolean;
|
||||||
|
sortOrder?: SortOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderableMixin = css(
|
const orderableMixin = css(
|
||||||
|
@ -45,20 +47,28 @@ const orderableMixin = css(
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
const orderedMixin = css(
|
const ASCMixin = css(
|
||||||
({ theme: { table } }) => `
|
({ theme: { table } }) => `
|
||||||
color: ${table.th.color.active};
|
color: ${table.th.color.active};
|
||||||
&::before {
|
|
||||||
|
&:before {
|
||||||
border-bottom-color: ${table.th.color.active};
|
border-bottom-color: ${table.th.color.active};
|
||||||
}
|
}
|
||||||
&::after {
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
const DESCMixin = css(
|
||||||
|
({ theme: { table } }) => `
|
||||||
|
color: ${table.th.color.active};
|
||||||
|
|
||||||
|
&:after {
|
||||||
border-top-color: ${table.th.color.active};
|
border-top-color: ${table.th.color.active};
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Title = styled.span<TitleProps>(
|
export const Title = styled.span<TitleProps>(
|
||||||
({ isOrderable, isOrdered, theme: { table } }) => css`
|
({ isOrderable, isOrdered, sortOrder, theme: { table } }) => css`
|
||||||
font-family: Inter, sans-serif;
|
font-family: Inter, sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
@ -75,7 +85,9 @@ export const Title = styled.span<TitleProps>(
|
||||||
|
|
||||||
${isOrderable && orderableMixin}
|
${isOrderable && orderableMixin}
|
||||||
|
|
||||||
${isOrderable && isOrdered && orderedMixin}
|
${isOrderable && isOrdered && sortOrder === SortOrder.ASC && ASCMixin}
|
||||||
|
|
||||||
|
${isOrderable && isOrdered && sortOrder === SortOrder.DESC && DESCMixin}
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,94 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render } from 'lib/testHelpers';
|
||||||
|
import * as S from 'components/common/table/TableHeaderCell/TableHeaderCell.styled';
|
||||||
|
import { SortOrder } from 'generated-sources';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import theme from 'theme/theme';
|
||||||
|
|
||||||
|
describe('TableHeaderCell.Styled', () => {
|
||||||
|
describe('Title Component', () => {
|
||||||
|
const DEFAULT_TITLE_TEXT = 'Text';
|
||||||
|
const setUpComponent = (
|
||||||
|
props: Partial<S.TitleProps> = {},
|
||||||
|
text: string = DEFAULT_TITLE_TEXT
|
||||||
|
) => {
|
||||||
|
render(
|
||||||
|
<S.Title
|
||||||
|
isOrderable={'isOrderable' in props ? props.isOrderable : true}
|
||||||
|
isOrdered={'isOrdered' in props ? props.isOrdered : true}
|
||||||
|
sortOrder={props.sortOrder || SortOrder.ASC}
|
||||||
|
>
|
||||||
|
{text || DEFAULT_TITLE_TEXT}
|
||||||
|
</S.Title>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
describe('test the default Parameters', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setUpComponent();
|
||||||
|
});
|
||||||
|
it('should test the props of Title Component', () => {
|
||||||
|
const titleElement = screen.getByText(DEFAULT_TITLE_TEXT);
|
||||||
|
expect(titleElement).toBeInTheDocument();
|
||||||
|
expect(titleElement).toHaveStyle(
|
||||||
|
`color: ${theme.table.th.color.active};`
|
||||||
|
);
|
||||||
|
expect(titleElement).toHaveStyleRule(
|
||||||
|
'border-bottom-color',
|
||||||
|
theme.table.th.color.active,
|
||||||
|
{
|
||||||
|
modifier: '&:before',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Custom props', () => {
|
||||||
|
it('should test the sort order styling of Title Component', () => {
|
||||||
|
setUpComponent({
|
||||||
|
sortOrder: SortOrder.DESC,
|
||||||
|
});
|
||||||
|
|
||||||
|
const titleElement = screen.getByText(DEFAULT_TITLE_TEXT);
|
||||||
|
expect(titleElement).toBeInTheDocument();
|
||||||
|
expect(titleElement).toHaveStyleRule(
|
||||||
|
'color',
|
||||||
|
theme.table.th.color.active
|
||||||
|
);
|
||||||
|
expect(titleElement).toHaveStyleRule(
|
||||||
|
'border-top-color',
|
||||||
|
theme.table.th.color.active,
|
||||||
|
{
|
||||||
|
modifier: '&:after',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should test the Title Component styling without the ordering', () => {
|
||||||
|
setUpComponent({
|
||||||
|
isOrderable: false,
|
||||||
|
isOrdered: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const titleElement = screen.getByText(DEFAULT_TITLE_TEXT);
|
||||||
|
expect(titleElement).toHaveStyleRule('cursor', 'default');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Preview Component', () => {
|
||||||
|
const DEFAULT_TEXT = 'DEFAULT_TEXT';
|
||||||
|
it('should render the preview and check themes values', () => {
|
||||||
|
render(<S.Preview>{DEFAULT_TEXT}</S.Preview>);
|
||||||
|
const element = screen.getByText(DEFAULT_TEXT);
|
||||||
|
expect(element).toBeInTheDocument();
|
||||||
|
expect(element).toHaveStyleRule(
|
||||||
|
'background',
|
||||||
|
theme.table.th.backgroundColor.normal
|
||||||
|
);
|
||||||
|
expect(element).toHaveStyleRule(
|
||||||
|
'color',
|
||||||
|
theme.table.th.previewColor.normal
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { store } from 'redux/store';
|
||||||
|
import {
|
||||||
|
sortBy,
|
||||||
|
getConsumerGroupsOrderBy,
|
||||||
|
getConsumerGroupsSortOrder,
|
||||||
|
getAreConsumerGroupsPagedFulfilled,
|
||||||
|
fetchConsumerGroupsPaged,
|
||||||
|
selectAll,
|
||||||
|
} from 'redux/reducers/consumerGroups/consumerGroupsSlice';
|
||||||
|
import { ConsumerGroupOrdering, SortOrder } from 'generated-sources';
|
||||||
|
import { consumerGroups } from 'redux/reducers/consumerGroups/__test__/fixtures';
|
||||||
|
|
||||||
|
describe('Consumer Groups Slice', () => {
|
||||||
|
describe('Actions', () => {
|
||||||
|
it('should test the sortBy actions', () => {
|
||||||
|
expect(store.getState().consumerGroups.sortOrder).toBe(SortOrder.ASC);
|
||||||
|
|
||||||
|
store.dispatch(sortBy(ConsumerGroupOrdering.STATE));
|
||||||
|
expect(getConsumerGroupsOrderBy(store.getState())).toBe(
|
||||||
|
ConsumerGroupOrdering.STATE
|
||||||
|
);
|
||||||
|
expect(getConsumerGroupsSortOrder(store.getState())).toBe(SortOrder.DESC);
|
||||||
|
store.dispatch(sortBy(ConsumerGroupOrdering.STATE));
|
||||||
|
expect(getConsumerGroupsSortOrder(store.getState())).toBe(SortOrder.ASC);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Thunk Actions', () => {
|
||||||
|
it('should check the fetchConsumerPaged ', () => {
|
||||||
|
store.dispatch({
|
||||||
|
type: fetchConsumerGroupsPaged.fulfilled.type,
|
||||||
|
payload: {
|
||||||
|
consumerGroups,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getAreConsumerGroupsPagedFulfilled(store.getState())).toBeTruthy();
|
||||||
|
expect(selectAll(store.getState())).toEqual(consumerGroups);
|
||||||
|
|
||||||
|
store.dispatch({
|
||||||
|
type: fetchConsumerGroupsPaged.fulfilled.type,
|
||||||
|
payload: {
|
||||||
|
consumerGroups: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(selectAll(store.getState())).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -25,6 +25,11 @@ export const consumerGroups = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const consumerGroupsPage = {
|
||||||
|
totalPages: 1,
|
||||||
|
consumerGroups,
|
||||||
|
};
|
||||||
|
|
||||||
export const consumerGroupPayload = {
|
export const consumerGroupPayload = {
|
||||||
groupId: 'amazon.msk.canary.group.broker-1',
|
groupId: 'amazon.msk.canary.group.broker-1',
|
||||||
members: 0,
|
members: 0,
|
||||||
|
|
|
@ -3,12 +3,15 @@ import {
|
||||||
createEntityAdapter,
|
createEntityAdapter,
|
||||||
createSlice,
|
createSlice,
|
||||||
createSelector,
|
createSelector,
|
||||||
|
PayloadAction,
|
||||||
} from '@reduxjs/toolkit';
|
} from '@reduxjs/toolkit';
|
||||||
import {
|
import {
|
||||||
Configuration,
|
Configuration,
|
||||||
ConsumerGroup,
|
|
||||||
ConsumerGroupDetails,
|
ConsumerGroupDetails,
|
||||||
|
ConsumerGroupOrdering,
|
||||||
ConsumerGroupsApi,
|
ConsumerGroupsApi,
|
||||||
|
ConsumerGroupsPageResponse,
|
||||||
|
SortOrder,
|
||||||
} from 'generated-sources';
|
} from 'generated-sources';
|
||||||
import { BASE_PARAMS } from 'lib/constants';
|
import { BASE_PARAMS } from 'lib/constants';
|
||||||
import { getResponse } from 'lib/errorHandling';
|
import { getResponse } from 'lib/errorHandling';
|
||||||
|
@ -19,20 +22,28 @@ import {
|
||||||
RootState,
|
RootState,
|
||||||
} from 'redux/interfaces';
|
} from 'redux/interfaces';
|
||||||
import { createFetchingSelector } from 'redux/reducers/loader/selectors';
|
import { createFetchingSelector } from 'redux/reducers/loader/selectors';
|
||||||
|
import { EntityState } from '@reduxjs/toolkit/src/entities/models';
|
||||||
|
|
||||||
const apiClientConf = new Configuration(BASE_PARAMS);
|
const apiClientConf = new Configuration(BASE_PARAMS);
|
||||||
export const api = new ConsumerGroupsApi(apiClientConf);
|
export const api = new ConsumerGroupsApi(apiClientConf);
|
||||||
|
|
||||||
export const fetchConsumerGroups = createAsyncThunk<
|
export const fetchConsumerGroupsPaged = createAsyncThunk<
|
||||||
ConsumerGroup[],
|
ConsumerGroupsPageResponse,
|
||||||
ClusterName
|
{
|
||||||
|
clusterName: ClusterName;
|
||||||
|
orderBy?: ConsumerGroupOrdering;
|
||||||
|
sortOrder?: SortOrder;
|
||||||
|
}
|
||||||
>(
|
>(
|
||||||
'consumerGroups/fetchConsumerGroups',
|
'consumerGroups/fetchConsumerGroupsPaged',
|
||||||
async (clusterName: ClusterName, { rejectWithValue }) => {
|
async ({ clusterName, orderBy, sortOrder }, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
return await api.getConsumerGroups({
|
const response = await api.getConsumerGroupsPageRaw({
|
||||||
clusterName,
|
clusterName,
|
||||||
|
orderBy,
|
||||||
|
sortOrder,
|
||||||
});
|
});
|
||||||
|
return await response.value();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return rejectWithValue(await getResponse(error as Response));
|
return rejectWithValue(await getResponse(error as Response));
|
||||||
}
|
}
|
||||||
|
@ -105,19 +116,45 @@ export const resetConsumerGroupOffsets = createAsyncThunk<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
const SCHEMAS_PAGE_COUNT = 1;
|
||||||
|
|
||||||
const consumerGroupsAdapter = createEntityAdapter<ConsumerGroupDetails>({
|
const consumerGroupsAdapter = createEntityAdapter<ConsumerGroupDetails>({
|
||||||
selectId: (consumerGroup) => consumerGroup.groupId,
|
selectId: (consumerGroup) => consumerGroup.groupId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const consumerGroupsSlice = createSlice({
|
interface ConsumerGroupState extends EntityState<ConsumerGroupDetails> {
|
||||||
|
orderBy: ConsumerGroupOrdering | null;
|
||||||
|
sortOrder: SortOrder;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ConsumerGroupState = {
|
||||||
|
orderBy: ConsumerGroupOrdering.NAME,
|
||||||
|
sortOrder: SortOrder.ASC,
|
||||||
|
totalPages: SCHEMAS_PAGE_COUNT,
|
||||||
|
...consumerGroupsAdapter.getInitialState(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const consumerGroupsSlice = createSlice({
|
||||||
name: 'consumerGroups',
|
name: 'consumerGroups',
|
||||||
initialState: consumerGroupsAdapter.getInitialState(),
|
initialState,
|
||||||
reducers: {},
|
reducers: {
|
||||||
|
sortBy: (state, action: PayloadAction<ConsumerGroupOrdering>) => {
|
||||||
|
state.orderBy = action.payload;
|
||||||
|
state.sortOrder =
|
||||||
|
state.orderBy === action.payload && state.sortOrder === SortOrder.ASC
|
||||||
|
? SortOrder.DESC
|
||||||
|
: SortOrder.ASC;
|
||||||
|
},
|
||||||
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
builder.addCase(fetchConsumerGroups.fulfilled, (state, { payload }) => {
|
builder.addCase(
|
||||||
consumerGroupsAdapter.setAll(state, payload);
|
fetchConsumerGroupsPaged.fulfilled,
|
||||||
});
|
(state, { payload }) => {
|
||||||
|
state.totalPages = payload.pageCount || SCHEMAS_PAGE_COUNT;
|
||||||
|
consumerGroupsAdapter.setAll(state, payload.consumerGroups || []);
|
||||||
|
}
|
||||||
|
);
|
||||||
builder.addCase(fetchConsumerGroupDetails.fulfilled, (state, { payload }) =>
|
builder.addCase(fetchConsumerGroupDetails.fulfilled, (state, { payload }) =>
|
||||||
consumerGroupsAdapter.upsertOne(state, payload)
|
consumerGroupsAdapter.upsertOne(state, payload)
|
||||||
);
|
);
|
||||||
|
@ -127,13 +164,17 @@ const consumerGroupsSlice = createSlice({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { selectAll, selectById } =
|
export const { sortBy } = consumerGroupsSlice.actions;
|
||||||
consumerGroupsAdapter.getSelectors<RootState>(
|
|
||||||
({ consumerGroups }) => consumerGroups
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getAreConsumerGroupsFulfilled = createSelector(
|
const consumerGroupsState = ({
|
||||||
createFetchingSelector('consumerGroups/fetchConsumerGroups'),
|
consumerGroups,
|
||||||
|
}: RootState): ConsumerGroupState => consumerGroups;
|
||||||
|
|
||||||
|
export const { selectAll, selectById } =
|
||||||
|
consumerGroupsAdapter.getSelectors<RootState>(consumerGroupsState);
|
||||||
|
|
||||||
|
export const getAreConsumerGroupsPagedFulfilled = createSelector(
|
||||||
|
createFetchingSelector('consumerGroups/fetchConsumerGroupsPaged'),
|
||||||
(status) => status === 'fulfilled'
|
(status) => status === 'fulfilled'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -152,4 +193,19 @@ export const getIsOffsetReseted = createSelector(
|
||||||
(status) => status === 'fulfilled'
|
(status) => status === 'fulfilled'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getConsumerGroupsOrderBy = createSelector(
|
||||||
|
consumerGroupsState,
|
||||||
|
(state) => state.orderBy
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getConsumerGroupsSortOrder = createSelector(
|
||||||
|
consumerGroupsState,
|
||||||
|
(state) => state.sortOrder
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getConsumerGroupsTotalPages = createSelector(
|
||||||
|
consumerGroupsState,
|
||||||
|
(state) => state.totalPages
|
||||||
|
);
|
||||||
|
|
||||||
export default consumerGroupsSlice.reducer;
|
export default consumerGroupsSlice.reducer;
|
||||||
|
|
Loading…
Add table
Reference in a new issue