Repository: provectus/kafka-ui
Issue: Make connectors table rows clickable
Labels: type/enhancement
good first issue
scope/frontend
status/accepted
hacktoberfest


Issue comments:

Pull request review comments:
Can we use existing `NewTable/TagCell` component?
```suggestion
const TopicsCell: React.FC<CellContext<FullConnectorInfo, unknown>> = ({
```
useMemo is redundant here
```suggestion
 if (!tasksCount) {
    return null;
  }
  
  return (tasksCount ? 
    <>
      {tasksCount - (failedTasksCount || 0)} of {tasksCount}
    </>
  );
```
Pls use styled components attr to change link color. Or update Tag styles to show link correctly

Patch that resolved the issue:
diff --git a/kafka-ui-e2e-checks/src/main/java/com/provectus/kafka/ui/pages/connector/KafkaConnectList.java b/kafka-ui-e2e-checks/src/main/java/com/provectus/kafka/ui/pages/connector/KafkaConnectList.java
index b5986c7919e..89a73ff6931 100644
--- a/kafka-ui-e2e-checks/src/main/java/com/provectus/kafka/ui/pages/connector/KafkaConnectList.java
+++ b/kafka-ui-e2e-checks/src/main/java/com/provectus/kafka/ui/pages/connector/KafkaConnectList.java
@@ -28,14 +28,14 @@ public ConnectorCreateForm clickCreateConnectorButton() {
 
     @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
diff --git a/kafka-ui-react-app/src/components/Connect/List/ActionsCell.tsx b/kafka-ui-react-app/src/components/Connect/List/ActionsCell.tsx
new file mode 100644
index 00000000000..30b3df8a56d
--- /dev/null
+++ b/kafka-ui-react-app/src/components/Connect/List/ActionsCell.tsx
@@ -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;
diff --git a/kafka-ui-react-app/src/components/Connect/List/List.styled.ts b/kafka-ui-react-app/src/components/Connect/List/List.styled.ts
index f0e2631d2c1..799915fcb11 100644
--- a/kafka-ui-react-app/src/components/Connect/List/List.styled.ts
+++ b/kafka-ui-react-app/src/components/Connect/List/List.styled.ts
@@ -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;
+    }
+  }
 `;
diff --git a/kafka-ui-react-app/src/components/Connect/List/List.tsx b/kafka-ui-react-app/src/components/Connect/List/List.tsx
index 75a782fa0a6..b5935e7bab2 100644
--- a/kafka-ui-react-app/src/components/Connect/List/List.tsx
+++ b/kafka-ui-react-app/src/components/Connect/List/List.tsx
@@ -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"
+    />
   );
 };
 
diff --git a/kafka-ui-react-app/src/components/Connect/List/ListItem.tsx b/kafka-ui-react-app/src/components/Connect/List/ListItem.tsx
deleted file mode 100644
index 4d10001947b..00000000000
--- a/kafka-ui-react-app/src/components/Connect/List/ListItem.tsx
+++ /dev/null
@@ -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;
diff --git a/kafka-ui-react-app/src/components/Connect/List/RunningTasksCell.tsx b/kafka-ui-react-app/src/components/Connect/List/RunningTasksCell.tsx
new file mode 100644
index 00000000000..4c3293d44c9
--- /dev/null
+++ b/kafka-ui-react-app/src/components/Connect/List/RunningTasksCell.tsx
@@ -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;
diff --git a/kafka-ui-react-app/src/components/Connect/List/TopicsCell.tsx b/kafka-ui-react-app/src/components/Connect/List/TopicsCell.tsx
new file mode 100644
index 00000000000..ee48c2d2d3f
--- /dev/null
+++ b/kafka-ui-react-app/src/components/Connect/List/TopicsCell.tsx
@@ -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;
diff --git a/kafka-ui-react-app/src/components/Connect/List/__tests__/List.spec.tsx b/kafka-ui-react-app/src/components/Connect/List/__tests__/List.spec.tsx
index a76b8092f25..04a7ba81506 100644
--- a/kafka-ui-react-app/src/components/Connect/List/__tests__/List.spec.tsx
+++ b/kafka-ui-react-app/src/components/Connect/List/__tests__/List.spec.tsx
@@ -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';
-
-jest.mock('components/Connect/List/ListItem', () => () => (
-  <tr>
-    <td>List Item</td>
-  </tr>
-));
+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('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)] }
-    );
-
-  it('renders empty connectors Table', async () => {
-    (useConnectors as jest.Mock).mockImplementation(() => ({
-      data: [],
-    }));
-
-    await renderComponent();
-    expect(screen.getByRole('table')).toBeInTheDocument();
-    expect(screen.getByText('No connectors found')).toBeInTheDocument();
+  describe('when the connectors are loaded', () => {
+    beforeEach(() => {
+      (useConnectors as jest.Mock).mockImplementation(() => ({
+        data: connectors,
+      }));
+    });
+
+    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('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();
+    });
   });
 });
diff --git a/kafka-ui-react-app/src/components/Connect/List/__tests__/ListItem.spec.tsx b/kafka-ui-react-app/src/components/Connect/List/__tests__/ListItem.spec.tsx
deleted file mode 100644
index 715d0aeb4d3..00000000000
--- a/kafka-ui-react-app/src/components/Connect/List/__tests__/ListItem.spec.tsx
+++ /dev/null
@@ -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('');
-  });
-});


Test patch: