Repository: provectus/kafka-ui
Issue: Hide KC connectors' stacktraces into a spoiler
Labels: type/enhancement
good first issue
scope/frontend
status/accepted

Hide the stacktrace into a spoiler:
<img width="1291" alt="image" src="https://user-images.githubusercontent.com/1494347/180452189-f6240195-5c9b-4156-be91-95d4cdd8517b.png">
like this:
<img width="276" alt="image" src="https://user-images.githubusercontent.com/1494347/180452263-2e488170-9e68-4431-aa00-d9372bf8058d.png">


Issue comments:

Pull request review comments:
let's try to hide expander for rows with empty trace. You can use `getRowCanExpand` to check if row.original.trace.length > 0 
let's add confirmation modal. just add `confirm` attr to DropdownItem
```suggestion
          const trace = getValue<string>() || '';
```

Patch that resolved the issue:
diff --git a/kafka-ui-react-app/src/components/Connect/Details/Tasks/ActionsCellTasks.tsx b/kafka-ui-react-app/src/components/Connect/Details/Tasks/ActionsCellTasks.tsx
new file mode 100644
index 00000000000..6d2cf845e1b
--- /dev/null
+++ b/kafka-ui-react-app/src/components/Connect/Details/Tasks/ActionsCellTasks.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { Task } from 'generated-sources';
+import { CellContext } from '@tanstack/react-table';
+import useAppParams from 'lib/hooks/useAppParams';
+import { useRestartConnectorTask } from 'lib/hooks/api/kafkaConnect';
+import { Dropdown, DropdownItem } from 'components/common/Dropdown';
+import { RouterParamsClusterConnectConnector } from 'lib/paths';
+
+const ActionsCellTasks: React.FC<CellContext<Task, unknown>> = ({ row }) => {
+  const { id } = row.original;
+  const routerProps = useAppParams<RouterParamsClusterConnectConnector>();
+  const restartMutation = useRestartConnectorTask(routerProps);
+
+  const restartTaskHandler = (taskId?: number) => {
+    if (taskId === undefined) return;
+    restartMutation.mutateAsync(taskId);
+  };
+
+  return (
+    <Dropdown>
+      <DropdownItem
+        onClick={() => restartTaskHandler(id?.task)}
+        danger
+        confirm="Are you sure you want to restart the task?"
+      >
+        <span>Restart task</span>
+      </DropdownItem>
+    </Dropdown>
+  );
+};
+
+export default ActionsCellTasks;
diff --git a/kafka-ui-react-app/src/components/Connect/Details/Tasks/Tasks.tsx b/kafka-ui-react-app/src/components/Connect/Details/Tasks/Tasks.tsx
index 74dd89ab873..bb21e895380 100644
--- a/kafka-ui-react-app/src/components/Connect/Details/Tasks/Tasks.tsx
+++ b/kafka-ui-react-app/src/components/Connect/Details/Tasks/Tasks.tsx
@@ -1,69 +1,58 @@
 import React from 'react';
-import { Table } from 'components/common/table/Table/Table.styled';
-import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
-import {
-  useConnectorTasks,
-  useRestartConnectorTask,
-} from 'lib/hooks/api/kafkaConnect';
+import { useConnectorTasks } from 'lib/hooks/api/kafkaConnect';
 import useAppParams from 'lib/hooks/useAppParams';
 import { RouterParamsClusterConnectConnector } from 'lib/paths';
-import getTagColor from 'components/common/Tag/getTagColor';
-import { Tag } from 'components/common/Tag/Tag.styled';
-import { Dropdown, DropdownItem } from 'components/common/Dropdown';
+import { ColumnDef, Row } from '@tanstack/react-table';
+import { Task } from 'generated-sources';
+import Table, { TagCell } from 'components/common/NewTable';
+
+import ActionsCellTasks from './ActionsCellTasks';
+
+const ExpandedTaskRow: React.FC<{ row: Row<Task> }> = ({ row }) => {
+  return <div>{row.original.status.trace}</div>;
+};
+
+const MAX_LENGTH = 100;
 
 const Tasks: React.FC = () => {
   const routerProps = useAppParams<RouterParamsClusterConnectConnector>();
-  const { data: tasks } = useConnectorTasks(routerProps);
-  const restartMutation = useRestartConnectorTask(routerProps);
+  const { data = [] } = useConnectorTasks(routerProps);
 
-  const restartTaskHandler = (taskId?: number) => {
-    if (taskId === undefined) return;
-    restartMutation.mutateAsync(taskId);
-  };
+  const columns = React.useMemo<ColumnDef<Task>[]>(
+    () => [
+      { header: 'ID', accessorKey: 'status.id' },
+      { header: 'Worker', accessorKey: 'status.workerId' },
+      { header: 'State', accessorKey: 'status.state', cell: TagCell },
+      {
+        header: 'Trace',
+        accessorKey: 'status.trace',
+        enableSorting: false,
+        cell: ({ getValue }) => {
+          const trace = getValue<string>() || '';
+          return trace.toString().length > MAX_LENGTH
+            ? `${trace.toString().substring(0, MAX_LENGTH - 3)}...`
+            : trace;
+        },
+        meta: { width: '70%' },
+      },
+      {
+        id: 'actions',
+        header: '',
+        cell: ActionsCellTasks,
+      },
+    ],
+    []
+  );
 
   return (
-    <Table isFullwidth>
-      <thead>
-        <tr>
-          <TableHeaderCell title="ID" />
-          <TableHeaderCell title="Worker" />
-          <TableHeaderCell title="State" />
-          <TableHeaderCell title="Trace" />
-          <TableHeaderCell />
-        </tr>
-      </thead>
-      <tbody>
-        {tasks?.length === 0 && (
-          <tr>
-            <td colSpan={10}>No tasks found</td>
-          </tr>
-        )}
-        {tasks?.map((task) => (
-          <tr key={task.status?.id}>
-            <td>{task.status?.id}</td>
-            <td>{task.status?.workerId}</td>
-            <td>
-              <Tag color={getTagColor(task.status.state)}>
-                {task.status.state}
-              </Tag>
-            </td>
-            <td>{task.status.trace || 'null'}</td>
-            <td style={{ width: '5%' }}>
-              <div>
-                <Dropdown>
-                  <DropdownItem
-                    onClick={() => restartTaskHandler(task.id?.task)}
-                    danger
-                  >
-                    <span>Restart task</span>
-                  </DropdownItem>
-                </Dropdown>
-              </div>
-            </td>
-          </tr>
-        ))}
-      </tbody>
-    </Table>
+    <Table
+      columns={columns}
+      data={data}
+      emptyMessage="No tasks found"
+      enableSorting
+      getRowCanExpand={(row) => row.original.status.trace?.length > 0}
+      renderSubComponent={ExpandedTaskRow}
+    />
   );
 };
 
diff --git a/kafka-ui-react-app/src/components/Connect/Details/Tasks/__tests__/Tasks.spec.tsx b/kafka-ui-react-app/src/components/Connect/Details/Tasks/__tests__/Tasks.spec.tsx
index efb72d1812c..da38068a203 100644
--- a/kafka-ui-react-app/src/components/Connect/Details/Tasks/__tests__/Tasks.spec.tsx
+++ b/kafka-ui-react-app/src/components/Connect/Details/Tasks/__tests__/Tasks.spec.tsx
@@ -3,8 +3,13 @@ import { render, WithRoute } from 'lib/testHelpers';
 import { clusterConnectConnectorTasksPath } from 'lib/paths';
 import Tasks from 'components/Connect/Details/Tasks/Tasks';
 import { tasks } from 'lib/fixtures/kafkaConnect';
-import { screen } from '@testing-library/dom';
-import { useConnectorTasks } from 'lib/hooks/api/kafkaConnect';
+import { screen, within, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {
+  useConnectorTasks,
+  useRestartConnectorTask,
+} from 'lib/hooks/api/kafkaConnect';
+import { Task } from 'generated-sources';
 
 jest.mock('lib/hooks/api/kafkaConnect', () => ({
   useConnectorTasks: jest.fn(),
@@ -13,30 +18,109 @@ jest.mock('lib/hooks/api/kafkaConnect', () => ({
 
 const path = clusterConnectConnectorTasksPath('local', 'ghp', '1');
 
+const restartConnectorMock = jest.fn();
+
 describe('Tasks', () => {
-  const renderComponent = () =>
+  beforeEach(() => {
+    (useRestartConnectorTask as jest.Mock).mockImplementation(() => ({
+      mutateAsync: restartConnectorMock,
+    }));
+  });
+
+  const renderComponent = (currentData: Task[] | undefined = undefined) => {
+    (useConnectorTasks as jest.Mock).mockImplementation(() => ({
+      data: currentData,
+    }));
+
     render(
       <WithRoute path={clusterConnectConnectorTasksPath()}>
         <Tasks />
       </WithRoute>,
       { initialEntries: [path] }
     );
+  };
 
   it('renders empty table', () => {
-    (useConnectorTasks as jest.Mock).mockImplementation(() => ({
-      data: [],
-    }));
-
     renderComponent();
     expect(screen.getByRole('table')).toBeInTheDocument();
     expect(screen.getByText('No tasks found')).toBeInTheDocument();
   });
 
   it('renders tasks table', () => {
-    (useConnectorTasks as jest.Mock).mockImplementation(() => ({
-      data: tasks,
-    }));
-    renderComponent();
+    renderComponent(tasks);
     expect(screen.getAllByRole('row').length).toEqual(tasks.length + 1);
+
+    expect(
+      screen.getByRole('row', {
+        name: '1 kafka-connect0:8083 RUNNING',
+      })
+    ).toBeInTheDocument();
+  });
+
+  it('renders truncates long trace and expands', () => {
+    renderComponent(tasks);
+
+    const trace = tasks[2]?.status?.trace || '';
+    const truncatedTrace = trace.toString().substring(0, 100 - 3);
+
+    const thirdRow = screen.getByRole('row', {
+      name: `3 kafka-connect0:8083 RUNNING ${truncatedTrace}...`,
+    });
+    expect(thirdRow).toBeInTheDocument();
+
+    const expandedDetails = screen.queryByText(trace);
+    //  Full trace is not visible
+    expect(expandedDetails).not.toBeInTheDocument();
+
+    userEvent.click(thirdRow);
+
+    expect(
+      screen.getByRole('row', {
+        name: trace,
+      })
+    ).toBeInTheDocument();
+  });
+
+  describe('Action button', () => {
+    const expectDropdownExists = () => {
+      const firstTaskRow = screen.getByRole('row', {
+        name: '1 kafka-connect0:8083 RUNNING',
+      });
+      expect(firstTaskRow).toBeInTheDocument();
+      const extBtn = within(firstTaskRow).getByRole('button', {
+        name: 'Dropdown Toggle',
+      });
+      expect(extBtn).toBeEnabled();
+      userEvent.click(extBtn);
+      expect(screen.getByRole('menu')).toBeInTheDocument();
+    };
+
+    it('renders action button', () => {
+      renderComponent(tasks);
+      expectDropdownExists();
+      expect(
+        screen.getAllByRole('button', { name: 'Dropdown Toggle' }).length
+      ).toEqual(tasks.length);
+      // Action buttons are enabled
+      const actionBtn = screen.getAllByRole('menuitem');
+      expect(actionBtn[0]).toHaveTextContent('Restart task');
+    });
+
+    it('works as expected', async () => {
+      renderComponent(tasks);
+      expectDropdownExists();
+      const actionBtn = screen.getAllByRole('menuitem');
+      expect(actionBtn[0]).toHaveTextContent('Restart task');
+
+      userEvent.click(actionBtn[0]);
+      expect(
+        screen.getByText('Are you sure you want to restart the task?')
+      ).toBeInTheDocument();
+
+      expect(screen.getByText('Confirm the action')).toBeInTheDocument();
+      userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
+
+      await waitFor(() => expect(restartConnectorMock).toHaveBeenCalled());
+    });
   });
 });
diff --git a/kafka-ui-react-app/src/components/common/NewTable/ExpanderCell.tsx b/kafka-ui-react-app/src/components/common/NewTable/ExpanderCell.tsx
index d53c3d79028..ff5e501d527 100644
--- a/kafka-ui-react-app/src/components/common/NewTable/ExpanderCell.tsx
+++ b/kafka-ui-react-app/src/components/common/NewTable/ExpanderCell.tsx
@@ -12,6 +12,7 @@ const ExpanderCell: React.FC<CellContext<unknown, unknown>> = ({ row }) => (
     xmlns="http://www.w3.org/2000/svg"
     role="button"
     aria-label="Expand row"
+    $disabled={!row.getCanExpand()}
   >
     {row.getIsExpanded() ? (
       <path
diff --git a/kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts b/kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts
index 5fc02176ee5..75671beca90 100644
--- a/kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts
+++ b/kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts
@@ -1,14 +1,15 @@
-import styled from 'styled-components';
+import styled, { css } from 'styled-components';
 
-export const ExpaderButton = styled.svg(
-  ({ theme: { table } }) => `
-  & > path {
-    fill: ${table.expander.normal};
-    &:hover {
-      fill: ${table.expander.hover};
+export const ExpaderButton = styled.svg<{ $disabled: boolean }>(
+  ({ theme: { table }, $disabled }) => css`
+    & > path {
+      fill: ${table.expander[$disabled ? 'disabled' : 'normal']};
     }
-  }
-`
+
+    &:hover > path {
+      fill: ${table.expander[$disabled ? 'disabled' : 'hover']};
+    }
+  `
 );
 
 interface ThProps {
diff --git a/kafka-ui-react-app/src/components/common/NewTable/Table.tsx b/kafka-ui-react-app/src/components/common/NewTable/Table.tsx
index aaf02ea4082..3b3a780674c 100644
--- a/kafka-ui-react-app/src/components/common/NewTable/Table.tsx
+++ b/kafka-ui-react-app/src/components/common/NewTable/Table.tsx
@@ -246,15 +246,15 @@ const Table: React.FC<TableProps<any>> = ({
                 }
               >
                 {!!enableRowSelection && (
-                  <td key={`${row.id}-select`}>
+                  <td key={`${row.id}-select`} style={{ width: '1px' }}>
                     {flexRender(
                       SelectRowCell,
                       row.getVisibleCells()[0].getContext()
                     )}
                   </td>
                 )}
-                {row.getCanExpand() && (
-                  <td key={`${row.id}-expander`}>
+                {table.getCanSomeRowsExpand() && (
+                  <td key={`${row.id}-expander`} style={{ width: '1px' }}>
                     {flexRender(
                       ExpanderCell,
                       row.getVisibleCells()[0].getContext()
@@ -264,7 +264,9 @@ const Table: React.FC<TableProps<any>> = ({
                 {row
                   .getVisibleCells()
                   .map(({ id, getContext, column: { columnDef } }) => (
-                    <td key={id}>{flexRender(columnDef.cell, getContext())}</td>
+                    <td key={id} style={columnDef.meta}>
+                      {flexRender(columnDef.cell, getContext())}
+                    </td>
                   ))}
               </S.Row>
               {row.getIsExpanded() && renderSubComponent && (
diff --git a/kafka-ui-react-app/src/lib/fixtures/kafkaConnect.ts b/kafka-ui-react-app/src/lib/fixtures/kafkaConnect.ts
index f885ea405df..8a79760e661 100644
--- a/kafka-ui-react-app/src/lib/fixtures/kafkaConnect.ts
+++ b/kafka-ui-react-app/src/lib/fixtures/kafkaConnect.ts
@@ -93,6 +93,8 @@ export const tasks: Task[] = [
       id: 3,
       state: ConnectorTaskStatus.RUNNING,
       workerId: 'kafka-connect0:8083',
+      trace:
+        'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
     },
     config: {
       'batch.size': '3000',
diff --git a/kafka-ui-react-app/src/theme/theme.ts b/kafka-ui-react-app/src/theme/theme.ts
index 4c057645ef1..a962b2a5356 100644
--- a/kafka-ui-react-app/src/theme/theme.ts
+++ b/kafka-ui-react-app/src/theme/theme.ts
@@ -346,6 +346,7 @@ const theme = {
     expander: {
       normal: Colors.brand[50],
       hover: Colors.brand[20],
+      disabled: Colors.neutral[10],
     },
   },
   primaryTab: {


Test patch: