* #2325 Make connectors table rows clickable and #2076 Implement connectors sorting * #2325 fix status sorting * fix ConnectorsTests * #2325 code review * #2325 add test coverage * #2325 code review fix * #2325 fix redirects for topics Co-authored-by: Roman Zabaluev <rzabaluev@provectus.com> Co-authored-by: VladSenyuta <vlad.senyuta@gmail.com>
This commit is contained in:
parent
80eb2dccfe
commit
7d5b7de992
9 changed files with 251 additions and 217 deletions
|
@ -28,14 +28,14 @@ public class KafkaConnectList {
|
|||
|
||||
@Step
|
||||
public KafkaConnectList openConnector(String connectorName) {
|
||||
$(By.linkText(connectorName)).click();
|
||||
$x("//tbody//td[1][text()='" + connectorName + "']").shouldBe(Condition.enabled).click();
|
||||
return this;
|
||||
}
|
||||
|
||||
@Step
|
||||
public boolean isConnectorVisible(String connectorName) {
|
||||
$(By.xpath("//table")).shouldBe(Condition.visible);
|
||||
return isVisible($x("//tbody//td[1]//a[text()='" + connectorName + "']"));
|
||||
return isVisible($x("//tbody//td[1][text()='" + connectorName + "']"));
|
||||
}
|
||||
|
||||
@Step
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
import { FullConnectorInfo } from 'generated-sources';
|
||||
import { CellContext } from '@tanstack/react-table';
|
||||
import { ClusterNameRoute } from 'lib/paths';
|
||||
import useAppParams from 'lib/hooks/useAppParams';
|
||||
import { Dropdown, DropdownItem } from 'components/common/Dropdown';
|
||||
import { useDeleteConnector } from 'lib/hooks/api/kafkaConnect';
|
||||
import { useConfirm } from 'lib/hooks/useConfirm';
|
||||
|
||||
const ActionsCell: React.FC<CellContext<FullConnectorInfo, unknown>> = ({
|
||||
row,
|
||||
}) => {
|
||||
const { connect, name } = row.original;
|
||||
|
||||
const { clusterName } = useAppParams<ClusterNameRoute>();
|
||||
|
||||
const confirm = useConfirm();
|
||||
const deleteMutation = useDeleteConnector({
|
||||
clusterName,
|
||||
connectName: connect,
|
||||
connectorName: name,
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
confirm(
|
||||
<>
|
||||
Are you sure want to remove <b>{name}</b> connector?
|
||||
</>,
|
||||
async () => {
|
||||
await deleteMutation.mutateAsync();
|
||||
}
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownItem onClick={handleDelete} danger>
|
||||
Remove Connector
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionsCell;
|
|
@ -3,4 +3,10 @@ import styled from 'styled-components';
|
|||
export const TagsWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
span {
|
||||
color: rgb(76, 76, 255) !important;
|
||||
&:hover {
|
||||
color: rgb(23, 23, 207) !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -1,14 +1,18 @@
|
|||
import React from 'react';
|
||||
import useAppParams from 'lib/hooks/useAppParams';
|
||||
import { ClusterNameRoute } from 'lib/paths';
|
||||
import { Table } from 'components/common/table/Table/Table.styled';
|
||||
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
|
||||
import { clusterConnectConnectorPath, ClusterNameRoute } from 'lib/paths';
|
||||
import Table, { TagCell } from 'components/common/NewTable';
|
||||
import { FullConnectorInfo } from 'generated-sources';
|
||||
import { useConnectors } from 'lib/hooks/api/kafkaConnect';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import ListItem from './ListItem';
|
||||
import ActionsCell from './ActionsCell';
|
||||
import TopicsCell from './TopicsCell';
|
||||
import RunningTasksCell from './RunningTasksCell';
|
||||
|
||||
const List: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { clusterName } = useAppParams<ClusterNameRoute>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: connectors } = useConnectors(
|
||||
|
@ -16,35 +20,30 @@ const List: React.FC = () => {
|
|||
searchParams.get('q') || ''
|
||||
);
|
||||
|
||||
const columns = React.useMemo<ColumnDef<FullConnectorInfo>[]>(
|
||||
() => [
|
||||
{ header: 'Name', accessorKey: 'name' },
|
||||
{ header: 'Connect', accessorKey: 'connect' },
|
||||
{ header: 'Type', accessorKey: 'type' },
|
||||
{ header: 'Plugin', accessorKey: 'connectorClass' },
|
||||
{ header: 'Topics', cell: TopicsCell },
|
||||
{ header: 'Status', accessorKey: 'status.state', cell: TagCell },
|
||||
{ header: 'Running Tasks', cell: RunningTasksCell },
|
||||
{ header: '', id: 'action', cell: ActionsCell },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Table isFullwidth>
|
||||
<thead>
|
||||
<tr>
|
||||
<TableHeaderCell title="Name" />
|
||||
<TableHeaderCell title="Connect" />
|
||||
<TableHeaderCell title="Type" />
|
||||
<TableHeaderCell title="Plugin" />
|
||||
<TableHeaderCell title="Topics" />
|
||||
<TableHeaderCell title="Status" />
|
||||
<TableHeaderCell title="Running Tasks" />
|
||||
<TableHeaderCell> </TableHeaderCell>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(!connectors || connectors.length) === 0 && (
|
||||
<tr>
|
||||
<td colSpan={10}>No connectors found</td>
|
||||
</tr>
|
||||
)}
|
||||
{connectors?.map((connector) => (
|
||||
<ListItem
|
||||
key={connector.name}
|
||||
connector={connector}
|
||||
clusterName={clusterName}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
<Table
|
||||
data={connectors || []}
|
||||
columns={columns}
|
||||
enableSorting
|
||||
onRowClick={({ original: { connect, name } }) =>
|
||||
navigate(clusterConnectConnectorPath(clusterName, connect, name))
|
||||
}
|
||||
emptyMessage="No connectors found"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,98 +0,0 @@
|
|||
import React from 'react';
|
||||
import { FullConnectorInfo } from 'generated-sources';
|
||||
import { clusterConnectConnectorPath, clusterTopicPath } from 'lib/paths';
|
||||
import { ClusterName } from 'redux/interfaces';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
import { Tag } from 'components/common/Tag/Tag.styled';
|
||||
import { TableKeyLink } from 'components/common/table/Table/TableKeyLink.styled';
|
||||
import getTagColor from 'components/common/Tag/getTagColor';
|
||||
import { useDeleteConnector } from 'lib/hooks/api/kafkaConnect';
|
||||
import { Dropdown, DropdownItem } from 'components/common/Dropdown';
|
||||
import { useConfirm } from 'lib/hooks/useConfirm';
|
||||
|
||||
import * as S from './List.styled';
|
||||
|
||||
export interface ListItemProps {
|
||||
clusterName: ClusterName;
|
||||
connector: FullConnectorInfo;
|
||||
}
|
||||
|
||||
const ListItem: React.FC<ListItemProps> = ({
|
||||
clusterName,
|
||||
connector: {
|
||||
name,
|
||||
connect,
|
||||
type,
|
||||
connectorClass,
|
||||
topics,
|
||||
status,
|
||||
tasksCount,
|
||||
failedTasksCount,
|
||||
},
|
||||
}) => {
|
||||
const confirm = useConfirm();
|
||||
const deleteMutation = useDeleteConnector({
|
||||
clusterName,
|
||||
connectName: connect,
|
||||
connectorName: name,
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
confirm(
|
||||
<>
|
||||
Are you sure want to remove <b>{name}</b> connector?
|
||||
</>,
|
||||
async () => {
|
||||
await deleteMutation.mutateAsync();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const runningTasks = React.useMemo(() => {
|
||||
if (!tasksCount) return null;
|
||||
return tasksCount - (failedTasksCount || 0);
|
||||
}, [tasksCount, failedTasksCount]);
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<TableKeyLink>
|
||||
<NavLink to={clusterConnectConnectorPath(clusterName, connect, name)}>
|
||||
{name}
|
||||
</NavLink>
|
||||
</TableKeyLink>
|
||||
<td>{connect}</td>
|
||||
<td>{type}</td>
|
||||
<td>{connectorClass}</td>
|
||||
<td>
|
||||
<S.TagsWrapper>
|
||||
{topics?.map((t) => (
|
||||
<Tag key={t} color="gray">
|
||||
<Link to={clusterTopicPath(clusterName, t)}>{t}</Link>
|
||||
</Tag>
|
||||
))}
|
||||
</S.TagsWrapper>
|
||||
</td>
|
||||
<td>
|
||||
{status && <Tag color={getTagColor(status.state)}>{status.state}</Tag>}
|
||||
</td>
|
||||
<td>
|
||||
{runningTasks && (
|
||||
<span>
|
||||
{runningTasks} of {tasksCount}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<Dropdown>
|
||||
<DropdownItem onClick={handleDelete} danger>
|
||||
Remove Connector
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListItem;
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import { FullConnectorInfo } from 'generated-sources';
|
||||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
const RunningTasksCell: React.FC<CellContext<FullConnectorInfo, unknown>> = ({
|
||||
row,
|
||||
}) => {
|
||||
const { tasksCount, failedTasksCount } = row.original;
|
||||
|
||||
if (!tasksCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{tasksCount - (failedTasksCount || 0)} of {tasksCount}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RunningTasksCell;
|
|
@ -0,0 +1,45 @@
|
|||
import React from 'react';
|
||||
import { FullConnectorInfo } from 'generated-sources';
|
||||
import { CellContext } from '@tanstack/react-table';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Tag } from 'components/common/Tag/Tag.styled';
|
||||
import { ClusterNameRoute, clusterTopicPath } from 'lib/paths';
|
||||
import useAppParams from 'lib/hooks/useAppParams';
|
||||
|
||||
import * as S from './List.styled';
|
||||
|
||||
const TopicsCell: React.FC<CellContext<FullConnectorInfo, unknown>> = ({
|
||||
row,
|
||||
}) => {
|
||||
const { topics } = row.original;
|
||||
const { clusterName } = useAppParams<ClusterNameRoute>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navigateToTopic = (
|
||||
e: React.KeyboardEvent | React.MouseEvent,
|
||||
topic: string
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigate(clusterTopicPath(clusterName, topic));
|
||||
};
|
||||
|
||||
return (
|
||||
<S.TagsWrapper>
|
||||
{topics?.map((t) => (
|
||||
<Tag key={t} color="gray">
|
||||
<span
|
||||
role="link"
|
||||
onClick={(e) => navigateToTopic(e, t)}
|
||||
onKeyDown={(e) => navigateToTopic(e, t)}
|
||||
tabIndex={0}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
</Tag>
|
||||
))}
|
||||
</S.TagsWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopicsCell;
|
|
@ -5,49 +5,120 @@ import ClusterContext, {
|
|||
initialValue,
|
||||
} from 'components/contexts/ClusterContext';
|
||||
import List from 'components/Connect/List/List';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, WithRoute } from 'lib/testHelpers';
|
||||
import { clusterConnectorsPath } from 'lib/paths';
|
||||
import { useConnectors } from 'lib/hooks/api/kafkaConnect';
|
||||
import { clusterConnectConnectorPath, clusterConnectorsPath } from 'lib/paths';
|
||||
import { useConnectors, useDeleteConnector } from 'lib/hooks/api/kafkaConnect';
|
||||
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
const mockDelete = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockedUsedNavigate,
|
||||
}));
|
||||
|
||||
jest.mock('components/Connect/List/ListItem', () => () => (
|
||||
<tr>
|
||||
<td>List Item</td>
|
||||
</tr>
|
||||
));
|
||||
jest.mock('lib/hooks/api/kafkaConnect', () => ({
|
||||
useConnectors: jest.fn(),
|
||||
useDeleteConnector: jest.fn(),
|
||||
}));
|
||||
|
||||
const clusterName = 'local';
|
||||
|
||||
const renderComponent = (contextValue: ContextProps = initialValue) =>
|
||||
render(
|
||||
<ClusterContext.Provider value={contextValue}>
|
||||
<WithRoute path={clusterConnectorsPath()}>
|
||||
<List />
|
||||
</WithRoute>
|
||||
</ClusterContext.Provider>,
|
||||
{ initialEntries: [clusterConnectorsPath(clusterName)] }
|
||||
);
|
||||
|
||||
describe('Connectors List', () => {
|
||||
const renderComponent = (contextValue: ContextProps = initialValue) =>
|
||||
render(
|
||||
<ClusterContext.Provider value={contextValue}>
|
||||
<WithRoute path={clusterConnectorsPath()}>
|
||||
<List />
|
||||
</WithRoute>
|
||||
</ClusterContext.Provider>,
|
||||
{ initialEntries: [clusterConnectorsPath(clusterName)] }
|
||||
);
|
||||
describe('when the connectors are loaded', () => {
|
||||
beforeEach(() => {
|
||||
(useConnectors as jest.Mock).mockImplementation(() => ({
|
||||
data: connectors,
|
||||
}));
|
||||
});
|
||||
|
||||
it('renders empty connectors Table', async () => {
|
||||
(useConnectors as jest.Mock).mockImplementation(() => ({
|
||||
data: [],
|
||||
}));
|
||||
it('renders', async () => {
|
||||
renderComponent();
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('row').length).toEqual(3);
|
||||
});
|
||||
|
||||
await renderComponent();
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
expect(screen.getByText('No connectors found')).toBeInTheDocument();
|
||||
it('opens broker when row clicked', async () => {
|
||||
renderComponent();
|
||||
await act(() => {
|
||||
userEvent.click(
|
||||
screen.getByRole('row', {
|
||||
name: 'hdfs-source-connector first SOURCE FileStreamSource a b c RUNNING 2 of 2',
|
||||
})
|
||||
);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(mockedUsedNavigate).toBeCalledWith(
|
||||
clusterConnectConnectorPath(
|
||||
clusterName,
|
||||
'first',
|
||||
'hdfs-source-connector'
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders connectors Table', async () => {
|
||||
(useConnectors as jest.Mock).mockImplementation(() => ({
|
||||
data: connectors,
|
||||
}));
|
||||
await renderComponent();
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('List Item').length).toEqual(2);
|
||||
describe('when table is empty', () => {
|
||||
beforeEach(() => {
|
||||
(useConnectors as jest.Mock).mockImplementation(() => ({
|
||||
data: [],
|
||||
}));
|
||||
});
|
||||
|
||||
it('renders empty table', async () => {
|
||||
renderComponent();
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('row', { name: 'No connectors found' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when remove connector modal is open', () => {
|
||||
beforeEach(() => {
|
||||
(useConnectors as jest.Mock).mockImplementation(() => ({
|
||||
data: connectors,
|
||||
}));
|
||||
(useDeleteConnector as jest.Mock).mockImplementation(() => ({
|
||||
mutateAsync: mockDelete,
|
||||
}));
|
||||
});
|
||||
|
||||
it('calls removeConnector on confirm', async () => {
|
||||
renderComponent();
|
||||
const removeButton = screen.getAllByText('Remove Connector')[0];
|
||||
await waitFor(() => userEvent.click(removeButton));
|
||||
|
||||
const submitButton = screen.getAllByRole('button', {
|
||||
name: 'Confirm',
|
||||
})[0];
|
||||
await act(() => userEvent.click(submitButton));
|
||||
expect(mockDelete).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('closes the modal when cancel button is clicked', async () => {
|
||||
renderComponent();
|
||||
const removeButton = screen.getAllByText('Remove Connector')[0];
|
||||
await waitFor(() => userEvent.click(removeButton));
|
||||
|
||||
const cancelButton = screen.getAllByRole('button', {
|
||||
name: 'Cancel',
|
||||
})[0];
|
||||
await waitFor(() => userEvent.click(cancelButton));
|
||||
expect(cancelButton).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
import React from 'react';
|
||||
import { connectors } from 'lib/fixtures/kafkaConnect';
|
||||
import ListItem, { ListItemProps } from 'components/Connect/List/ListItem';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { render } from 'lib/testHelpers';
|
||||
|
||||
describe('Connectors ListItem', () => {
|
||||
const connector = connectors[0];
|
||||
const setupWrapper = (props: Partial<ListItemProps> = {}) => (
|
||||
<table>
|
||||
<tbody>
|
||||
<ListItem clusterName="local" connector={connector} {...props} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
it('renders item', () => {
|
||||
render(setupWrapper());
|
||||
expect(screen.getAllByRole('cell')[6]).toHaveTextContent('2 of 2');
|
||||
});
|
||||
|
||||
it('topics tags are sorted', () => {
|
||||
render(setupWrapper());
|
||||
const getLink = screen.getAllByRole('link');
|
||||
expect(getLink[1]).toHaveTextContent('a');
|
||||
expect(getLink[2]).toHaveTextContent('b');
|
||||
expect(getLink[3]).toHaveTextContent('c');
|
||||
});
|
||||
|
||||
it('renders item with failed tasks', () => {
|
||||
render(
|
||||
setupWrapper({
|
||||
connector: {
|
||||
...connector,
|
||||
failedTasksCount: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(screen.getAllByRole('cell')[6]).toHaveTextContent('1 of 2');
|
||||
});
|
||||
|
||||
it('does not render info about tasks if taksCount is undefined', () => {
|
||||
render(
|
||||
setupWrapper({
|
||||
connector: {
|
||||
...connector,
|
||||
tasksCount: undefined,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(screen.getAllByRole('cell')[6]).toHaveTextContent('');
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue