Browse Source

Use new table component for topics list (#2426)

* Use new table component for topics list

* Fix styling

* Migrate BrokerLogdir to new tables

* Improve test coverage
Oleg Shur 2 years ago
parent
commit
21f17ad39e
35 changed files with 1105 additions and 1177 deletions
  1. 0 7
      kafka-ui-react-app/src/components/Brokers/Broker/Broker.tsx
  2. 40 27
      kafka-ui-react-app/src/components/Brokers/Broker/BrokerLogdir/BrokerLogdir.tsx
  3. 27 5
      kafka-ui-react-app/src/components/Brokers/Broker/BrokerLogdir/__test__/BrokerLogdir.spec.tsx
  4. 0 16
      kafka-ui-react-app/src/components/Brokers/utils/__test__/fixtures.ts
  5. 0 35
      kafka-ui-react-app/src/components/Brokers/utils/__test__/translateLogdirs.spec.tsx
  6. 0 23
      kafka-ui-react-app/src/components/Brokers/utils/translateLogdirs.ts
  7. 47 47
      kafka-ui-react-app/src/components/Topics/List/ActionsCell.tsx
  8. 111 0
      kafka-ui-react-app/src/components/Topics/List/BatchActionsBar.tsx
  9. 0 32
      kafka-ui-react-app/src/components/Topics/List/List.styled.ts
  10. 17 21
      kafka-ui-react-app/src/components/Topics/List/ListPage.tsx
  11. 113 0
      kafka-ui-react-app/src/components/Topics/List/TopicTable.tsx
  12. 22 0
      kafka-ui-react-app/src/components/Topics/List/TopicTitleCell.tsx
  13. 0 201
      kafka-ui-react-app/src/components/Topics/List/TopicsTable.tsx
  14. 0 59
      kafka-ui-react-app/src/components/Topics/List/TopicsTableCells.tsx
  15. 0 179
      kafka-ui-react-app/src/components/Topics/List/__tests__/ActionCell.spec.tsx
  16. 2 4
      kafka-ui-react-app/src/components/Topics/List/__tests__/ListPage.spec.tsx
  17. 324 0
      kafka-ui-react-app/src/components/Topics/List/__tests__/TopicTable.spec.tsx
  18. 0 168
      kafka-ui-react-app/src/components/Topics/List/__tests__/TopicsTable.spec.tsx
  19. 0 189
      kafka-ui-react-app/src/components/Topics/List/__tests__/TopicsTableCells.spec.tsx
  20. 58 60
      kafka-ui-react-app/src/components/Topics/Topic/Details/Overview/Overview.tsx
  21. 0 1
      kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/PartitionTable.tsx
  22. 7 0
      kafka-ui-react-app/src/components/common/Dropdown/Dropdown.styled.ts
  23. 3 1
      kafka-ui-react-app/src/components/common/Dropdown/Dropdown.tsx
  24. 24 0
      kafka-ui-react-app/src/components/common/IndeterminateCheckbox/IndeterminateCheckbox.tsx
  25. 15 0
      kafka-ui-react-app/src/components/common/NewTable/SelectRowCell.tsx
  26. 14 0
      kafka-ui-react-app/src/components/common/NewTable/SelectRowHeader.tsx
  27. 10 0
      kafka-ui-react-app/src/components/common/NewTable/SizeCell.tsx
  28. 46 16
      kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts
  29. 116 52
      kafka-ui-react-app/src/components/common/NewTable/Table.tsx
  30. 78 16
      kafka-ui-react-app/src/components/common/NewTable/__test__/Table.spec.tsx
  31. 5 3
      kafka-ui-react-app/src/components/common/NewTable/index.ts
  32. 6 13
      kafka-ui-react-app/src/components/common/NewTable/utils/updatePaginationState.ts
  33. 9 1
      kafka-ui-react-app/src/components/common/table/Table/TableKeyLink.styled.ts
  34. 4 0
      kafka-ui-react-app/src/lib/fixtures/brokers.ts
  35. 7 1
      kafka-ui-react-app/src/theme/theme.ts

+ 0 - 7
kafka-ui-react-app/src/components/Brokers/Broker/Broker.tsx

@@ -17,13 +17,6 @@ import BrokerMetrics from 'components/Brokers/Broker/BrokerMetrics/BrokerMetrics
 import Navbar from 'components/common/Navigation/Navbar.styled';
 import PageLoader from 'components/common/PageLoader/PageLoader';
 
-export interface BrokerLogdirState {
-  name: string;
-  error: string;
-  topics: number;
-  partitions: number;
-}
-
 const Broker: React.FC = () => {
   const { clusterName, brokerId } = useAppParams<ClusterBrokerParam>();
 

+ 40 - 27
kafka-ui-react-app/src/components/Brokers/Broker/BrokerLogdir/BrokerLogdir.tsx

@@ -1,40 +1,53 @@
 import React from 'react';
 import useAppParams from 'lib/hooks/useAppParams';
-import { translateLogdirs } from 'components/Brokers/utils/translateLogdirs';
-import { SmartTable } from 'components/common/SmartTable/SmartTable';
-import { TableColumn } from 'components/common/SmartTable/TableColumn';
-import { useTableState } from 'lib/hooks/useTableState';
 import { ClusterBrokerParam } from 'lib/paths';
 import { useBrokerLogDirs } from 'lib/hooks/api/brokers';
-
-interface BrokerLogdirState {
-  name: string;
-  error: string;
-  topics: number;
-  partitions: number;
-}
+import Table from 'components/common/NewTable';
+import { ColumnDef } from '@tanstack/react-table';
+import { BrokersLogdirs } from 'generated-sources';
 
 const BrokerLogdir: React.FC = () => {
   const { clusterName, brokerId } = useAppParams<ClusterBrokerParam>();
-  const { data: logDirs } = useBrokerLogDirs(clusterName, Number(brokerId));
+  const { data } = useBrokerLogDirs(clusterName, Number(brokerId));
 
-  const preparedRows = translateLogdirs(logDirs);
-  const tableState = useTableState<BrokerLogdirState, string>(preparedRows, {
-    idSelector: ({ name }) => name,
-    totalPages: 0,
-  });
+  const columns = React.useMemo<ColumnDef<BrokersLogdirs>[]>(
+    () => [
+      { header: 'Name', accessorKey: 'name' },
+      { header: 'Error', accessorKey: 'error' },
+      {
+        header: 'Topics',
+        accessorKey: 'topics',
+        cell: ({ getValue }) =>
+          getValue<BrokersLogdirs['topics']>()?.length || 0,
+        enableSorting: false,
+      },
+      {
+        id: 'partitions',
+        header: 'Partitions',
+        accessorKey: 'topics',
+        cell: ({ getValue }) => {
+          const topics = getValue<BrokersLogdirs['topics']>();
+          if (!topics) {
+            return 0;
+          }
+          return topics.reduce(
+            (acc, topic) => acc + (topic.partitions?.length || 0),
+            0
+          );
+        },
+        enableSorting: false,
+      },
+    ],
+    []
+  );
 
   return (
-    <SmartTable
-      tableState={tableState}
-      placeholder="Log dir data not available"
-      isFullwidth
-    >
-      <TableColumn title="Name" field="name" />
-      <TableColumn title="Error" field="error" />
-      <TableColumn title="Topics" field="topics" />
-      <TableColumn title="Partitions" field="partitions" />
-    </SmartTable>
+    <Table
+      data={data || []}
+      columns={columns}
+      emptyMessage="Log dir data not available"
+      enableSorting
+    />
   );
 };
 

+ 27 - 5
kafka-ui-react-app/src/components/Brokers/Broker/BrokerLogdir/__test__/BrokerLogdir.spec.tsx

@@ -16,7 +16,7 @@ const clusterName = 'local';
 const brokerId = 1;
 
 describe('BrokerLogdir Component', () => {
-  const renderComponent = async (payload: BrokerLogdirs[] = []) => {
+  const renderComponent = async (payload?: BrokerLogdirs[]) => {
     (useBrokerLogDirs as jest.Mock).mockImplementation(() => ({
       data: payload,
     }));
@@ -32,13 +32,35 @@ describe('BrokerLogdir Component', () => {
     });
   };
 
-  it('shows warning when server returns empty logDirs response', async () => {
+  it('shows warning when server returns undefined logDirs response', async () => {
     await renderComponent();
-    expect(screen.getByText('Log dir data not available')).toBeInTheDocument();
+    expect(
+      screen.getByRole('row', { name: 'Log dir data not available' })
+    ).toBeInTheDocument();
   });
 
-  it('shows broker', async () => {
+  it('shows warning when server returns empty logDirs response', async () => {
+    await renderComponent([]);
+    expect(
+      screen.getByRole('row', { name: 'Log dir data not available' })
+    ).toBeInTheDocument();
+  });
+
+  it('shows brokers', async () => {
     await renderComponent(brokerLogDirsPayload);
-    expect(screen.getByText('/opt/kafka/data-0/logs')).toBeInTheDocument();
+    expect(
+      screen.queryByRole('row', { name: 'Log dir data not available' })
+    ).not.toBeInTheDocument();
+
+    expect(
+      screen.getByRole('row', {
+        name: '/opt/kafka/data-0/logs NONE 3 4',
+      })
+    ).toBeInTheDocument();
+    expect(
+      screen.getByRole('row', {
+        name: '/opt/kafka/data-1/logs NONE 0 0',
+      })
+    ).toBeInTheDocument();
   });
 });

+ 0 - 16
kafka-ui-react-app/src/components/Brokers/utils/__test__/fixtures.ts

@@ -1,21 +1,5 @@
-import { BrokerLogdirState } from 'components/Brokers/Broker/Broker';
 import { BrokerMetrics } from 'generated-sources';
 
-export const transformedBrokerLogDirsPayload: BrokerLogdirState[] = [
-  {
-    error: 'NONE',
-    name: '/opt/kafka/data-0/logs',
-    topics: 3,
-    partitions: 4,
-  },
-];
-export const defaultTransformedBrokerLogDirsPayload: BrokerLogdirState = {
-  error: '-',
-  name: '-',
-  topics: 0,
-  partitions: 0,
-};
-
 export const brokerMetricsPayload: BrokerMetrics = {
   segmentSize: 23,
   segmentCount: 23,

+ 0 - 35
kafka-ui-react-app/src/components/Brokers/utils/__test__/translateLogdirs.spec.tsx

@@ -1,35 +0,0 @@
-import {
-  translateLogdir,
-  translateLogdirs,
-} from 'components/Brokers/utils/translateLogdirs';
-import { brokerLogDirsPayload } from 'lib/fixtures/brokers';
-
-import {
-  defaultTransformedBrokerLogDirsPayload,
-  transformedBrokerLogDirsPayload,
-} from './fixtures';
-
-describe('translateLogdir and translateLogdirs', () => {
-  describe('translateLogdirs', () => {
-    it('returns empty array when broker logdirs is not defined', () => {
-      expect(translateLogdirs(undefined)).toEqual([]);
-    });
-    it('returns transformed LogDirs array when broker logdirs defined', () => {
-      expect(translateLogdirs(brokerLogDirsPayload)).toEqual(
-        transformedBrokerLogDirsPayload
-      );
-    });
-  });
-  describe('translateLogdir', () => {
-    it('returns default data when broker logdir is empty', () => {
-      expect(translateLogdir({})).toEqual(
-        defaultTransformedBrokerLogDirsPayload
-      );
-    });
-    it('returns transformed LogDir when broker logdir defined', () => {
-      expect(translateLogdir(brokerLogDirsPayload[0])).toEqual(
-        transformedBrokerLogDirsPayload[0]
-      );
-    });
-  });
-});

+ 0 - 23
kafka-ui-react-app/src/components/Brokers/utils/translateLogdirs.ts

@@ -1,23 +0,0 @@
-import { BrokersLogdirs } from 'generated-sources';
-import { BrokerLogdirState } from 'components/Brokers/Broker/Broker';
-
-export const translateLogdir = (data: BrokersLogdirs): BrokerLogdirState => {
-  const partitionsCount =
-    data.topics?.reduce(
-      (prevValue, value) => prevValue + (value.partitions?.length || 0),
-      0
-    ) || 0;
-
-  return {
-    name: data.name || '-',
-    error: data.error || '-',
-    topics: data.topics?.length || 0,
-    partitions: partitionsCount,
-  };
-};
-
-export const translateLogdirs = (
-  data: BrokersLogdirs[] | undefined
-): BrokerLogdirState[] => {
-  return data?.map(translateLogdir) || [];
-};

+ 47 - 47
kafka-ui-react-app/src/components/Topics/List/ActionsCell.tsx

@@ -1,9 +1,8 @@
 import React from 'react';
 import { CleanUpPolicy, Topic } from 'generated-sources';
+import { CellContext } from '@tanstack/react-table';
 import { useAppDispatch } from 'lib/hooks/redux';
-import { TableCellProps } from 'components/common/SmartTable/TableColumn';
 import ClusterContext from 'components/contexts/ClusterContext';
-import * as S from 'components/Topics/List/List.styled';
 import { ClusterNameRoute } from 'lib/paths';
 import useAppParams from 'lib/hooks/useAppParams';
 import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice';
@@ -15,10 +14,9 @@ import {
   useRecreateTopic,
 } from 'lib/hooks/api/topics';
 
-const ActionsCell: React.FC<TableCellProps<Topic, string>> = ({
-  hovered,
-  dataItem: { internal, cleanUpPolicy, name },
-}) => {
+const ActionsCell: React.FC<CellContext<Topic, unknown>> = ({ row }) => {
+  const { name, internal, cleanUpPolicy } = row.original;
+
   const { isReadOnly, isTopicDeletionAllowed } =
     React.useContext(ClusterContext);
   const dispatch = useAppDispatch();
@@ -28,7 +26,7 @@ const ActionsCell: React.FC<TableCellProps<Topic, string>> = ({
   const deleteTopic = useDeleteTopic(clusterName);
   const recreateTopic = useRecreateTopic({ clusterName, topicName: name });
 
-  const isHidden = internal || isReadOnly || !hovered;
+  const isHidden = internal || isReadOnly;
 
   const clearTopicMessagesHandler = async () => {
     await dispatch(
@@ -37,47 +35,49 @@ const ActionsCell: React.FC<TableCellProps<Topic, string>> = ({
     queryClient.invalidateQueries(topicKeys.all(clusterName));
   };
 
+  const isCleanupDisabled = cleanUpPolicy !== CleanUpPolicy.DELETE;
+
   return (
-    <S.ActionsContainer>
-      {!isHidden && (
-        <Dropdown>
-          {cleanUpPolicy === CleanUpPolicy.DELETE && (
-            <DropdownItem
-              onClick={clearTopicMessagesHandler}
-              confirm="Are you sure want to clear topic messages?"
-              danger
-            >
-              Clear Messages
-            </DropdownItem>
-          )}
-          <DropdownItem
-            onClick={recreateTopic.mutateAsync}
-            confirm={
-              <>
-                Are you sure to recreate <b>{name}</b> topic?
-              </>
-            }
-            danger
-          >
-            Recreate Topic
-          </DropdownItem>
-          {isTopicDeletionAllowed && (
-            <DropdownItem
-              onClick={() => deleteTopic.mutateAsync(name)}
-              confirm={
-                <>
-                  Are you sure want to remove <b>{name}</b> topic?
-                </>
-              }
-              danger
-            >
-              Remove Topic
-            </DropdownItem>
-          )}
-        </Dropdown>
-      )}
-    </S.ActionsContainer>
+    <Dropdown disabled={isHidden}>
+      <DropdownItem
+        disabled={isCleanupDisabled}
+        onClick={clearTopicMessagesHandler}
+        confirm="Are you sure want to clear topic messages?"
+        danger
+        title="Cleanup is alowed only for topics with DELETE policy"
+      >
+        Clear Messages
+      </DropdownItem>
+      <DropdownItem
+        onClick={recreateTopic.mutateAsync}
+        confirm={
+          <>
+            Are you sure to recreate <b>{name}</b> topic?
+          </>
+        }
+        danger
+      >
+        Recreate Topic
+      </DropdownItem>
+      <DropdownItem
+        disabled={!isTopicDeletionAllowed}
+        onClick={() => deleteTopic.mutateAsync(name)}
+        confirm={
+          <>
+            Are you sure want to remove <b>{name}</b> topic?
+          </>
+        }
+        title={
+          isTopicDeletionAllowed
+            ? 'The topic deletion is restricted by app configuration'
+            : ''
+        }
+        danger
+      >
+        Remove Topic
+      </DropdownItem>
+    </Dropdown>
   );
 };
 
-export default React.memo(ActionsCell);
+export default ActionsCell;

+ 111 - 0
kafka-ui-react-app/src/components/Topics/List/BatchActionsBar.tsx

@@ -0,0 +1,111 @@
+import React from 'react';
+import { Row } from '@tanstack/react-table';
+import { Topic } from 'generated-sources';
+import useAppParams from 'lib/hooks/useAppParams';
+import { ClusterName } from 'redux/interfaces';
+import { topicKeys, useDeleteTopic } from 'lib/hooks/api/topics';
+import { useConfirm } from 'lib/hooks/useConfirm';
+import { Button } from 'components/common/Button/Button';
+import { useAppDispatch } from 'lib/hooks/redux';
+import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice';
+import { clusterTopicCopyRelativePath } from 'lib/paths';
+import { useQueryClient } from '@tanstack/react-query';
+
+interface BatchActionsbarProps {
+  rows: Row<Topic>[];
+  resetRowSelection(): void;
+}
+
+const BatchActionsbar: React.FC<BatchActionsbarProps> = ({
+  rows,
+  resetRowSelection,
+}) => {
+  const { clusterName } = useAppParams<{ clusterName: ClusterName }>();
+  const confirm = useConfirm();
+  const dispatch = useAppDispatch();
+  const deleteTopic = useDeleteTopic(clusterName);
+  const selectedTopics = rows.map(({ original }) => original.name);
+  const client = useQueryClient();
+
+  const deleteTopicsHandler = () => {
+    confirm('Are you sure you want to remove selected topics?', async () => {
+      try {
+        await Promise.all(
+          selectedTopics.map((topicName) => deleteTopic.mutateAsync(topicName))
+        );
+        resetRowSelection();
+      } catch (e) {
+        // do nothing;
+      }
+    });
+  };
+
+  const purgeTopicsHandler = () => {
+    confirm(
+      'Are you sure you want to purge messages of selected topics?',
+      async () => {
+        try {
+          await Promise.all(
+            selectedTopics.map((topicName) =>
+              dispatch(clearTopicMessages({ clusterName, topicName })).unwrap()
+            )
+          );
+          resetRowSelection();
+        } catch (e) {
+          // do nothing;
+        } finally {
+          client.invalidateQueries(topicKeys.all(clusterName));
+        }
+      }
+    );
+  };
+
+  type Tuple = [string, string];
+
+  const getCopyTopicPath = () => {
+    const topic = rows[0].original;
+
+    const search = Object.keys(topic).reduce((acc: Tuple[], key) => {
+      const value = topic[key as keyof typeof topic];
+      if (!value || key === 'partitions' || key === 'internal') {
+        return acc;
+      }
+      const tuple: Tuple = [key, value.toString()];
+      return [...acc, tuple];
+    }, []);
+
+    return {
+      pathname: clusterTopicCopyRelativePath,
+      search: new URLSearchParams(search).toString(),
+    };
+  };
+
+  return (
+    <>
+      <Button
+        buttonSize="M"
+        buttonType="secondary"
+        onClick={deleteTopicsHandler}
+      >
+        Delete selected topics
+      </Button>
+      <Button
+        buttonSize="M"
+        buttonType="secondary"
+        disabled={selectedTopics.length > 1}
+        to={getCopyTopicPath()}
+      >
+        Copy selected topic
+      </Button>
+      <Button
+        buttonSize="M"
+        buttonType="secondary"
+        onClick={purgeTopicsHandler}
+      >
+        Purge messages of selected topics
+      </Button>
+    </>
+  );
+};
+
+export default BatchActionsbar;

+ 0 - 32
kafka-ui-react-app/src/components/Topics/List/List.styled.ts

@@ -1,32 +0,0 @@
-import { Td } from 'components/common/table/TableHeaderCell/TableHeaderCell.styled';
-import { NavLink } from 'react-router-dom';
-import styled, { css } from 'styled-components';
-
-export const Link = styled(NavLink)<{
-  $isInternal?: boolean;
-}>(
-  ({ theme, $isInternal }) => css`
-    color: ${theme.topicsList.color.normal};
-    font-weight: 500;
-    padding-left: ${$isInternal ? '5px' : 0};
-
-    &:hover {
-      background-color: ${theme.topicsList.backgroundColor.hover};
-      color: ${theme.topicsList.color.hover};
-    }
-
-    &.active {
-      background-color: ${theme.topicsList.backgroundColor.active};
-      color: ${theme.topicsList.color.active};
-    }
-  `
-);
-
-export const ActionsTd = styled(Td)`
-  overflow: visible;
-  width: 50px;
-`;
-
-export const ActionsContainer = styled.div`
-  text-align: right !important;
-`;

+ 17 - 21
kafka-ui-react-app/src/components/Topics/List/ListPage.tsx

@@ -11,7 +11,7 @@ import Switch from 'components/common/Switch/Switch';
 import PlusIcon from 'components/common/Icons/PlusIcon';
 import useSearch from 'lib/hooks/useSearch';
 import PageLoader from 'components/common/PageLoader/PageLoader';
-import TopicsTable from 'components/Topics/List/TopicsTable';
+import TopicTable from 'components/Topics/List/TopicTable';
 
 const ListPage: React.FC = () => {
   const { isReadOnly } = React.useContext(ClusterContext);
@@ -29,7 +29,7 @@ const ListPage: React.FC = () => {
     ) {
       searchParams.set('hideInternal', 'true');
     }
-    setSearchParams(searchParams, { replace: true });
+    setSearchParams(searchParams);
   }, []);
 
   const handleSwitch = () => {
@@ -41,8 +41,8 @@ const ListPage: React.FC = () => {
       searchParams.set('hideInternal', 'true');
     }
     // Page must be reset when the switch is toggled
-    searchParams.delete('page');
-    setSearchParams(searchParams.toString(), { replace: true });
+    searchParams.set('page', '1');
+    setSearchParams(searchParams);
   };
 
   return (
@@ -59,26 +59,22 @@ const ListPage: React.FC = () => {
         )}
       </PageHeading>
       <ControlPanelWrapper hasInput>
-        <div>
-          <Search
-            handleSearch={handleSearchQuery}
-            placeholder="Search by Topic Name"
-            value={searchQuery}
+        <Search
+          handleSearch={handleSearchQuery}
+          placeholder="Search by Topic Name"
+          value={searchQuery}
+        />
+        <label>
+          <Switch
+            name="ShowInternalTopics"
+            checked={!searchParams.has('hideInternal')}
+            onChange={handleSwitch}
           />
-        </div>
-        <div>
-          <label>
-            <Switch
-              name="ShowInternalTopics"
-              checked={!searchParams.has('hideInternal')}
-              onChange={handleSwitch}
-            />
-            Show Internal Topics
-          </label>
-        </div>
+          Show Internal Topics
+        </label>
       </ControlPanelWrapper>
       <Suspense fallback={<PageLoader />}>
-        <TopicsTable />
+        <TopicTable />
       </Suspense>
     </>
   );

+ 113 - 0
kafka-ui-react-app/src/components/Topics/List/TopicTable.tsx

@@ -0,0 +1,113 @@
+import React from 'react';
+import { SortOrder, Topic, TopicColumnsToSort } from 'generated-sources';
+import { ColumnDef } from '@tanstack/react-table';
+import Table, { SizeCell } from 'components/common/NewTable';
+import useAppParams from 'lib/hooks/useAppParams';
+import { ClusterName } from 'redux/interfaces';
+import { useSearchParams } from 'react-router-dom';
+import ClusterContext from 'components/contexts/ClusterContext';
+import { useTopics } from 'lib/hooks/api/topics';
+import { PER_PAGE } from 'lib/constants';
+
+import { TopicTitleCell } from './TopicTitleCell';
+import ActionsCell from './ActionsCell';
+import BatchActionsbar from './BatchActionsBar';
+
+const TopicTable: React.FC = () => {
+  const { clusterName } = useAppParams<{ clusterName: ClusterName }>();
+  const [searchParams] = useSearchParams();
+  const { isReadOnly } = React.useContext(ClusterContext);
+  const { data } = useTopics({
+    clusterName,
+    page: Number(searchParams.get('page') || 1),
+    perPage: Number(searchParams.get('perPage') || PER_PAGE),
+    search: searchParams.get('q') || undefined,
+    showInternal: !searchParams.has('hideInternal'),
+    orderBy: (searchParams.get('sortBy') as TopicColumnsToSort) || undefined,
+    sortOrder:
+      (searchParams.get('sortDirection')?.toUpperCase() as SortOrder) ||
+      undefined,
+  });
+
+  const topics = data?.topics || [];
+  const pageCount = data?.pageCount || 0;
+
+  const columns = React.useMemo<ColumnDef<Topic>[]>(
+    () => [
+      {
+        id: TopicColumnsToSort.NAME,
+        header: 'Topic Name',
+        accessorKey: 'name',
+        cell: TopicTitleCell,
+      },
+      {
+        id: TopicColumnsToSort.TOTAL_PARTITIONS,
+        header: 'Total Partitions',
+        accessorKey: 'partitionCount',
+      },
+      {
+        id: TopicColumnsToSort.OUT_OF_SYNC_REPLICAS,
+        header: 'Out of sync replicas',
+        accessorKey: 'partitions',
+        cell: ({ getValue }) => {
+          const partitions = getValue<Topic['partitions']>();
+          if (partitions === undefined || partitions.length === 0) {
+            return 0;
+          }
+          return partitions.reduce((memo, { replicas }) => {
+            const outOfSync = replicas?.filter(({ inSync }) => !inSync);
+            return memo + (outOfSync?.length || 0);
+          }, 0);
+        },
+      },
+      {
+        header: 'Replication Factor',
+        accessorKey: 'replicationFactor',
+        enableSorting: false,
+      },
+      {
+        header: 'Number of messages',
+        accessorKey: 'partitions',
+        enableSorting: false,
+        cell: ({ getValue }) => {
+          const partitions = getValue<Topic['partitions']>();
+          if (partitions === undefined || partitions.length === 0) {
+            return 0;
+          }
+          return partitions.reduce((memo, { offsetMax, offsetMin }) => {
+            return memo + (offsetMax - offsetMin);
+          }, 0);
+        },
+      },
+      {
+        id: TopicColumnsToSort.SIZE,
+        header: 'Size',
+        accessorKey: 'segmentSize',
+        cell: SizeCell,
+      },
+      {
+        id: 'actions',
+        header: '',
+        cell: ActionsCell,
+      },
+    ],
+    []
+  );
+
+  return (
+    <Table
+      data={topics}
+      pageCount={pageCount}
+      columns={columns}
+      enableSorting
+      serverSideProcessing
+      batchActionsBar={BatchActionsbar}
+      enableRowSelection={
+        !isReadOnly ? (row) => !row.original.internal : undefined
+      }
+      emptyMessage="No topics found"
+    />
+  );
+};
+
+export default TopicTable;

+ 22 - 0
kafka-ui-react-app/src/components/Topics/List/TopicTitleCell.tsx

@@ -0,0 +1,22 @@
+import React from 'react';
+import { CellContext } from '@tanstack/react-table';
+import { Tag } from 'components/common/Tag/Tag.styled';
+import { Topic } from 'generated-sources';
+import { NavLink } from 'react-router-dom';
+
+export const TopicTitleCell: React.FC<CellContext<Topic, unknown>> = ({
+  row: { original },
+}) => {
+  const { internal, name } = original;
+  return (
+    <NavLink to={name} title={name}>
+      {internal && (
+        <>
+          <Tag color="gray">IN</Tag>
+          &nbsp;
+        </>
+      )}
+      {name}
+    </NavLink>
+  );
+};

+ 0 - 201
kafka-ui-react-app/src/components/Topics/List/TopicsTable.tsx

@@ -1,201 +0,0 @@
-import React from 'react';
-import { SortOrder, Topic, TopicColumnsToSort } from 'generated-sources';
-import { useSearchParams } from 'react-router-dom';
-import { useDeleteTopic, useTopics } from 'lib/hooks/api/topics';
-import useAppParams from 'lib/hooks/useAppParams';
-import { ClusterName } from 'redux/interfaces';
-import { PER_PAGE } from 'lib/constants';
-import { useTableState } from 'lib/hooks/useTableState';
-import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
-import { Button } from 'components/common/Button/Button';
-import { clusterTopicCopyRelativePath } from 'lib/paths';
-import { useConfirm } from 'lib/hooks/useConfirm';
-import { SmartTable } from 'components/common/SmartTable/SmartTable';
-import { TableColumn } from 'components/common/SmartTable/TableColumn';
-import ClusterContext from 'components/contexts/ClusterContext';
-import { useAppDispatch } from 'lib/hooks/redux';
-import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice';
-
-import {
-  MessagesCell,
-  OutOfSyncReplicasCell,
-  TitleCell,
-  TopicSizeCell,
-} from './TopicsTableCells';
-import ActionsCell from './ActionsCell';
-import { ActionsTd } from './List.styled';
-
-const TopicsTable: React.FC = () => {
-  const { clusterName } = useAppParams<{ clusterName: ClusterName }>();
-  const [searchParams, setSearchParams] = useSearchParams();
-  const { isReadOnly } = React.useContext(ClusterContext);
-  const dispatch = useAppDispatch();
-  const confirm = useConfirm();
-  const deleteTopic = useDeleteTopic(clusterName);
-  const { data, refetch } = useTopics({
-    clusterName,
-    page: Number(searchParams.get('page') || 1),
-    perPage: Number(searchParams.get('perPage') || PER_PAGE),
-    search: searchParams.get('q') || undefined,
-    showInternal: !searchParams.has('hideInternal'),
-    orderBy: (searchParams.get('orderBy') as TopicColumnsToSort) || undefined,
-    sortOrder: (searchParams.get('sortOrder') as SortOrder) || undefined,
-  });
-
-  const handleOrderBy = (orderBy: string | null) => {
-    const currentOrderBy = searchParams.get('orderBy');
-    const currentSortOrder = searchParams.get('sortOrder');
-
-    if (orderBy) {
-      if (orderBy === currentOrderBy) {
-        searchParams.set(
-          'sortOrder',
-          currentSortOrder === SortOrder.DESC ? SortOrder.ASC : SortOrder.DESC
-        );
-      }
-
-      searchParams.set('orderBy', orderBy);
-    } else {
-      searchParams.delete('orderBy');
-      searchParams.delete('sortOrder');
-    }
-    setSearchParams(searchParams, { replace: true });
-  };
-
-  const tableState = useTableState<Topic, string>(
-    data?.topics || [],
-    {
-      idSelector: (topic) => topic.name,
-      totalPages: data?.pageCount || 0,
-      isRowSelectable: (topic) => !topic.internal,
-    },
-    {
-      handleOrderBy,
-      orderBy: searchParams.get('orderBy'),
-      sortOrder: (searchParams.get('sortOrder') as SortOrder) || SortOrder.ASC,
-    }
-  );
-
-  const getSelectedTopic = (): string => {
-    const name = Array.from(tableState.selectedIds)[0];
-    const selectedTopic =
-      tableState.data.find((topic: Topic) => topic.name === name) || {};
-
-    return Object.keys(selectedTopic)
-      .map((x: string) => {
-        const value = selectedTopic[x as keyof typeof selectedTopic];
-        return value && x !== 'partitions' ? `${x}=${value}` : null;
-      })
-      .join('&');
-  };
-
-  const clearSelectedTopics = () => tableState.toggleSelection(false);
-
-  const deleteTopicsHandler = () => {
-    const selectedTopics = Array.from(tableState.selectedIds);
-    confirm('Are you sure you want to remove selected topics?', async () => {
-      try {
-        await Promise.all(
-          selectedTopics.map((topicName) => deleteTopic.mutateAsync(topicName))
-        );
-        clearSelectedTopics();
-      } catch (e) {
-        // do nothing;
-      } finally {
-        refetch();
-      }
-    });
-  };
-
-  const purgeTopicsHandler = () => {
-    const selectedTopics = Array.from(tableState.selectedIds);
-    confirm(
-      'Are you sure you want to purge messages of selected topics?',
-      async () => {
-        try {
-          await Promise.all(
-            selectedTopics.map((topicName) =>
-              dispatch(clearTopicMessages({ clusterName, topicName })).unwrap()
-            )
-          );
-          clearSelectedTopics();
-        } catch (e) {
-          // do nothing;
-        } finally {
-          refetch();
-        }
-      }
-    );
-  };
-
-  return (
-    <>
-      {tableState.selectedCount > 0 && (
-        <ControlPanelWrapper>
-          <Button
-            buttonSize="M"
-            buttonType="secondary"
-            onClick={deleteTopicsHandler}
-          >
-            Delete selected topics
-          </Button>
-          {tableState.selectedCount === 1 && (
-            <Button
-              buttonSize="M"
-              buttonType="secondary"
-              to={{
-                pathname: clusterTopicCopyRelativePath,
-                search: `?${getSelectedTopic()}`,
-              }}
-            >
-              Copy selected topic
-            </Button>
-          )}
-
-          <Button
-            buttonSize="M"
-            buttonType="secondary"
-            onClick={purgeTopicsHandler}
-          >
-            Purge messages of selected topics
-          </Button>
-        </ControlPanelWrapper>
-      )}
-      <SmartTable
-        selectable={!isReadOnly}
-        tableState={tableState}
-        placeholder="No topics found"
-        isFullwidth
-        paginated
-        hoverable
-      >
-        <TableColumn
-          maxWidth="350px"
-          title="Topic Name"
-          cell={TitleCell}
-          orderValue={TopicColumnsToSort.NAME}
-        />
-        <TableColumn
-          title="Total Partitions"
-          field="partitions.length"
-          orderValue={TopicColumnsToSort.TOTAL_PARTITIONS}
-        />
-        <TableColumn
-          title="Out of sync replicas"
-          cell={OutOfSyncReplicasCell}
-          orderValue={TopicColumnsToSort.OUT_OF_SYNC_REPLICAS}
-        />
-        <TableColumn title="Replication Factor" field="replicationFactor" />
-        <TableColumn title="Number of messages" cell={MessagesCell} />
-        <TableColumn
-          title="Size"
-          cell={TopicSizeCell}
-          orderValue={TopicColumnsToSort.SIZE}
-        />
-        <TableColumn maxWidth="4%" cell={ActionsCell} customTd={ActionsTd} />
-      </SmartTable>
-    </>
-  );
-};
-
-export default TopicsTable;

+ 0 - 59
kafka-ui-react-app/src/components/Topics/List/TopicsTableCells.tsx

@@ -1,59 +0,0 @@
-import React from 'react';
-import { TableCellProps } from 'components/common/SmartTable/TableColumn';
-import { Tag } from 'components/common/Tag/Tag.styled';
-import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
-import { Topic } from 'generated-sources';
-
-import * as S from './List.styled';
-
-export const TitleCell: React.FC<TableCellProps<Topic, string>> = ({
-  dataItem: { internal, name },
-}) => {
-  return (
-    <>
-      {internal && <Tag color="gray">IN</Tag>}
-      <S.Link to={name} $isInternal={internal}>
-        {name}
-      </S.Link>
-    </>
-  );
-};
-
-export const TopicSizeCell: React.FC<TableCellProps<Topic, string>> = ({
-  dataItem: { segmentSize },
-}) => {
-  return <BytesFormatted value={segmentSize} />;
-};
-
-export const OutOfSyncReplicasCell: React.FC<TableCellProps<Topic, string>> = ({
-  dataItem: { partitions },
-}) => {
-  const data = React.useMemo(() => {
-    if (partitions === undefined || partitions.length === 0) {
-      return 0;
-    }
-
-    return partitions.reduce((memo, { replicas }) => {
-      const outOfSync = replicas?.filter(({ inSync }) => !inSync);
-      return memo + (outOfSync?.length || 0);
-    }, 0);
-  }, [partitions]);
-
-  return <span>{data}</span>;
-};
-
-export const MessagesCell: React.FC<TableCellProps<Topic, string>> = ({
-  dataItem: { partitions },
-}) => {
-  const data = React.useMemo(() => {
-    if (partitions === undefined || partitions.length === 0) {
-      return 0;
-    }
-
-    return partitions.reduce((memo, { offsetMax, offsetMin }) => {
-      return memo + (offsetMax - offsetMin);
-    }, 0);
-  }, [partitions]);
-
-  return <span>{data}</span>;
-};

+ 0 - 179
kafka-ui-react-app/src/components/Topics/List/__tests__/ActionCell.spec.tsx

@@ -1,179 +0,0 @@
-import React from 'react';
-import { render } from 'lib/testHelpers';
-import { TableState } from 'lib/hooks/useTableState';
-import { act, screen, waitFor } from '@testing-library/react';
-import { CleanUpPolicy, Topic } from 'generated-sources';
-import { topicsPayload } from 'lib/fixtures/topics';
-import ActionsCell from 'components/Topics/List/ActionsCell';
-import ClusterContext from 'components/contexts/ClusterContext';
-import userEvent from '@testing-library/user-event';
-import { useDeleteTopic, useRecreateTopic } from 'lib/hooks/api/topics';
-
-const mockUnwrap = jest.fn();
-const useDispatchMock = () => jest.fn(() => ({ unwrap: mockUnwrap }));
-
-jest.mock('lib/hooks/redux', () => ({
-  ...jest.requireActual('lib/hooks/redux'),
-  useAppDispatch: useDispatchMock,
-}));
-
-jest.mock('lib/hooks/api/topics', () => ({
-  ...jest.requireActual('lib/hooks/api/topics'),
-  useDeleteTopic: jest.fn(),
-  useRecreateTopic: jest.fn(),
-}));
-
-const deleteTopicMock = jest.fn();
-const recreateTopicMock = jest.fn();
-
-describe('ActionCell Components', () => {
-  beforeEach(() => {
-    (useDeleteTopic as jest.Mock).mockImplementation(() => ({
-      mutateAsync: deleteTopicMock,
-    }));
-    (useRecreateTopic as jest.Mock).mockImplementation(() => ({
-      mutateAsync: recreateTopicMock,
-    }));
-  });
-
-  const mockTableState: TableState<Topic, string> = {
-    data: topicsPayload,
-    selectedIds: new Set([]),
-    idSelector: jest.fn(),
-    isRowSelectable: jest.fn(),
-    selectedCount: 0,
-    setRowsSelection: jest.fn(),
-    toggleSelection: jest.fn(),
-  };
-
-  const renderComponent = (
-    currentData: Topic,
-    isReadOnly = false,
-    hovered = true,
-    isTopicDeletionAllowed = true
-  ) => {
-    return render(
-      <ClusterContext.Provider
-        value={{
-          isReadOnly,
-          hasKafkaConnectConfigured: true,
-          hasSchemaRegistryConfigured: true,
-          isTopicDeletionAllowed,
-        }}
-      >
-        <ActionsCell
-          rowIndex={1}
-          dataItem={currentData}
-          tableState={mockTableState}
-          hovered={hovered}
-        />
-      </ClusterContext.Provider>
-    );
-  };
-
-  const expectCellIsEmpty = () => {
-    expect(
-      screen.queryByRole('button', { name: 'Dropdown Toggle' })
-    ).not.toBeInTheDocument();
-  };
-
-  const expectDropdownExists = () => {
-    const btn = screen.getByRole('button', { name: 'Dropdown Toggle' });
-    expect(btn).toBeInTheDocument();
-    userEvent.click(btn);
-    expect(screen.getByRole('menu')).toBeInTheDocument();
-  };
-
-  describe('is empty', () => {
-    it('for internal topic', () => {
-      renderComponent(topicsPayload[0]);
-      expectCellIsEmpty();
-    });
-    it('for readonly cluster', () => {
-      renderComponent(topicsPayload[1], true);
-      expectCellIsEmpty();
-    });
-    it('for non-hovered row', () => {
-      renderComponent(topicsPayload[1], false, false);
-      expectCellIsEmpty();
-    });
-  });
-
-  describe('is not empty', () => {
-    it('for external topic', async () => {
-      renderComponent(topicsPayload[1]);
-      expectDropdownExists();
-    });
-    describe('and clear messages action', () => {
-      it('is visible for topic with CleanUpPolicy.DELETE', async () => {
-        renderComponent({
-          ...topicsPayload[1],
-          cleanUpPolicy: CleanUpPolicy.DELETE,
-        });
-        expectDropdownExists();
-        expect(screen.getByText('Clear Messages')).toBeInTheDocument();
-      });
-      it('is hidden for topic without CleanUpPolicy.DELETE', async () => {
-        renderComponent({
-          ...topicsPayload[1],
-          cleanUpPolicy: CleanUpPolicy.COMPACT,
-        });
-        expectDropdownExists();
-        expect(screen.queryByText('Clear Messages')).not.toBeInTheDocument();
-      });
-      it('works as expected', async () => {
-        renderComponent({
-          ...topicsPayload[1],
-          cleanUpPolicy: CleanUpPolicy.DELETE,
-        });
-        expectDropdownExists();
-        userEvent.click(screen.getByText('Clear Messages'));
-        expect(
-          screen.getByText('Are you sure want to clear topic messages?')
-        ).toBeInTheDocument();
-        await act(() =>
-          userEvent.click(screen.getByRole('button', { name: 'Confirm' }))
-        );
-        expect(mockUnwrap).toHaveBeenCalled();
-      });
-    });
-
-    describe('and remove topic action', () => {
-      it('is visible only when topic deletion allowed for cluster', async () => {
-        renderComponent(topicsPayload[1]);
-        expectDropdownExists();
-        expect(screen.getByText('Remove Topic')).toBeInTheDocument();
-      });
-      it('is hidden when topic deletion is not allowed for cluster', async () => {
-        renderComponent(topicsPayload[1], false, true, false);
-        expectDropdownExists();
-        expect(screen.queryByText('Remove Topic')).not.toBeInTheDocument();
-      });
-      it('works as expected', async () => {
-        renderComponent(topicsPayload[1]);
-        expectDropdownExists();
-        userEvent.click(screen.getByText('Remove Topic'));
-        expect(screen.getByText('Confirm the action')).toBeInTheDocument();
-        expect(screen.getByText('external.topic')).toBeInTheDocument();
-        await waitFor(() =>
-          userEvent.click(screen.getByRole('button', { name: 'Confirm' }))
-        );
-        await waitFor(() => expect(deleteTopicMock).toHaveBeenCalled());
-      });
-    });
-
-    describe('and recreate topic action', () => {
-      it('works as expected', async () => {
-        renderComponent(topicsPayload[1]);
-        expectDropdownExists();
-        userEvent.click(screen.getByText('Recreate Topic'));
-        expect(screen.getByText('Confirm the action')).toBeInTheDocument();
-        expect(screen.getByText('external.topic')).toBeInTheDocument();
-        await waitFor(() =>
-          userEvent.click(screen.getByRole('button', { name: 'Confirm' }))
-        );
-        await waitFor(() => expect(recreateTopicMock).toHaveBeenCalled());
-      });
-    });
-  });
-});

+ 2 - 4
kafka-ui-react-app/src/components/Topics/List/__tests__/ListPage.spec.tsx

@@ -8,9 +8,7 @@ import ListPage from 'components/Topics/List/ListPage';
 
 const clusterName = 'test-cluster';
 
-jest.mock('components/Topics/List/TopicsTable', () => () => (
-  <>TopicsTableMock</>
-));
+jest.mock('components/Topics/List/TopicTable', () => () => <>TopicTableMock</>);
 
 describe('ListPage Component', () => {
   const renderComponent = () => {
@@ -47,6 +45,6 @@ describe('ListPage Component', () => {
   });
 
   it('renders the TopicsTable', () => {
-    expect(screen.getByText('TopicsTableMock')).toBeInTheDocument();
+    expect(screen.getByText('TopicTableMock')).toBeInTheDocument();
   });
 });

+ 324 - 0
kafka-ui-react-app/src/components/Topics/List/__tests__/TopicTable.spec.tsx

@@ -0,0 +1,324 @@
+import React from 'react';
+import { render, WithRoute } from 'lib/testHelpers';
+import { act, screen, waitFor, within } from '@testing-library/react';
+import { CleanUpPolicy, TopicsResponse } from 'generated-sources';
+import { externalTopicPayload, topicsPayload } from 'lib/fixtures/topics';
+import ClusterContext from 'components/contexts/ClusterContext';
+import userEvent from '@testing-library/user-event';
+import {
+  useDeleteTopic,
+  useRecreateTopic,
+  useTopics,
+} from 'lib/hooks/api/topics';
+import TopicTable from 'components/Topics/List/TopicTable';
+import { clusterTopicsPath } from 'lib/paths';
+
+const clusterName = 'test-cluster';
+const mockUnwrap = jest.fn();
+const useDispatchMock = () => jest.fn(() => ({ unwrap: mockUnwrap }));
+
+const getButtonByName = (name: string) => screen.getByRole('button', { name });
+
+jest.mock('lib/hooks/redux', () => ({
+  ...jest.requireActual('lib/hooks/redux'),
+  useAppDispatch: useDispatchMock,
+}));
+
+jest.mock('lib/hooks/api/topics', () => ({
+  ...jest.requireActual('lib/hooks/api/topics'),
+  useDeleteTopic: jest.fn(),
+  useRecreateTopic: jest.fn(),
+  useTopics: jest.fn(),
+}));
+
+const deleteTopicMock = jest.fn();
+const recreateTopicMock = jest.fn();
+
+describe('TopicTable Components', () => {
+  beforeEach(() => {
+    (useDeleteTopic as jest.Mock).mockImplementation(() => ({
+      mutateAsync: deleteTopicMock,
+    }));
+    (useRecreateTopic as jest.Mock).mockImplementation(() => ({
+      mutateAsync: recreateTopicMock,
+    }));
+  });
+
+  const renderComponent = (
+    currentData: TopicsResponse | undefined = undefined,
+    isReadOnly = false,
+    isTopicDeletionAllowed = true
+  ) => {
+    (useTopics as jest.Mock).mockImplementation(() => ({
+      data: currentData,
+    }));
+
+    return render(
+      <ClusterContext.Provider
+        value={{
+          isReadOnly,
+          hasKafkaConnectConfigured: true,
+          hasSchemaRegistryConfigured: true,
+          isTopicDeletionAllowed,
+        }}
+      >
+        <WithRoute path={clusterTopicsPath()}>
+          <TopicTable />
+        </WithRoute>
+      </ClusterContext.Provider>,
+      { initialEntries: [clusterTopicsPath(clusterName)] }
+    );
+  };
+
+  describe('without data', () => {
+    it('renders empty table when payload is undefined', () => {
+      renderComponent();
+      expect(
+        screen.getByRole('row', { name: 'No topics found' })
+      ).toBeInTheDocument();
+    });
+
+    it('renders empty table when payload is empty', () => {
+      renderComponent({ topics: [] });
+      expect(
+        screen.getByRole('row', { name: 'No topics found' })
+      ).toBeInTheDocument();
+    });
+  });
+  describe('with topics', () => {
+    it('renders correct rows', () => {
+      renderComponent({ topics: topicsPayload, pageCount: 1 });
+      expect(
+        screen.getByRole('link', { name: '__internal.topic' })
+      ).toBeInTheDocument();
+      expect(
+        screen.getByRole('row', { name: '__internal.topic 1 0 1 0 0Bytes' })
+      ).toBeInTheDocument();
+      expect(
+        screen.getByRole('link', { name: 'external.topic' })
+      ).toBeInTheDocument();
+      expect(
+        screen.getByRole('row', { name: 'external.topic 1 0 1 0 1KB' })
+      ).toBeInTheDocument();
+
+      expect(screen.getAllByRole('checkbox').length).toEqual(3);
+    });
+    describe('Selectable rows', () => {
+      it('renders selectable rows', () => {
+        renderComponent({ topics: topicsPayload, pageCount: 1 });
+        expect(screen.getAllByRole('checkbox').length).toEqual(3);
+        // Disable checkbox for internal topic
+        expect(screen.getAllByRole('checkbox')[1]).toBeDisabled();
+        // Disable checkbox for external topic
+        expect(screen.getAllByRole('checkbox')[2]).toBeEnabled();
+      });
+      it('does not render selectable rows for read-only cluster', () => {
+        renderComponent({ topics: topicsPayload, pageCount: 1 }, true);
+        expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
+      });
+      describe('Batch actions bar', () => {
+        beforeEach(() => {
+          const payload = {
+            topics: [
+              externalTopicPayload,
+              { ...externalTopicPayload, name: 'test-topic' },
+            ],
+            totalPages: 1,
+          };
+          renderComponent(payload);
+          expect(screen.getAllByRole('checkbox').length).toEqual(3);
+          expect(screen.getAllByRole('checkbox')[1]).toBeEnabled();
+          expect(screen.getAllByRole('checkbox')[2]).toBeEnabled();
+        });
+        describe('when only one topic is selected', () => {
+          beforeEach(() => {
+            userEvent.click(screen.getAllByRole('checkbox')[1]);
+          });
+          it('renders batch actions bar', () => {
+            expect(getButtonByName('Delete selected topics')).toBeEnabled();
+            expect(getButtonByName('Copy selected topic')).toBeEnabled();
+            expect(
+              getButtonByName('Purge messages of selected topics')
+            ).toBeEnabled();
+          });
+        });
+        describe('when more then one topics are selected', () => {
+          beforeEach(() => {
+            userEvent.click(screen.getAllByRole('checkbox')[1]);
+            userEvent.click(screen.getAllByRole('checkbox')[2]);
+          });
+          it('renders batch actions bar', () => {
+            expect(getButtonByName('Delete selected topics')).toBeEnabled();
+            expect(getButtonByName('Copy selected topic')).toBeDisabled();
+            expect(
+              getButtonByName('Purge messages of selected topics')
+            ).toBeEnabled();
+          });
+          it('handels delete button click', async () => {
+            const button = getButtonByName('Delete selected topics');
+            await act(() => userEvent.click(button));
+            expect(
+              screen.getByText(
+                'Are you sure you want to remove selected topics?'
+              )
+            ).toBeInTheDocument();
+            const confirmBtn = getButtonByName('Confirm');
+            expect(confirmBtn).toBeInTheDocument();
+            expect(deleteTopicMock).not.toHaveBeenCalled();
+            await act(() => userEvent.click(confirmBtn));
+            expect(deleteTopicMock).toHaveBeenCalledTimes(2);
+            expect(screen.getAllByRole('checkbox')[1]).not.toBeChecked();
+            expect(screen.getAllByRole('checkbox')[2]).not.toBeChecked();
+          });
+          it('handels purge messages button click', async () => {
+            const button = getButtonByName('Purge messages of selected topics');
+            await act(() => userEvent.click(button));
+            expect(
+              screen.getByText(
+                'Are you sure you want to purge messages of selected topics?'
+              )
+            ).toBeInTheDocument();
+            const confirmBtn = getButtonByName('Confirm');
+            expect(confirmBtn).toBeInTheDocument();
+            expect(mockUnwrap).not.toHaveBeenCalled();
+            await act(() => userEvent.click(confirmBtn));
+            expect(mockUnwrap).toHaveBeenCalledTimes(2);
+            expect(screen.getAllByRole('checkbox')[1]).not.toBeChecked();
+            expect(screen.getAllByRole('checkbox')[2]).not.toBeChecked();
+          });
+        });
+      });
+    });
+    describe('Action buttons', () => {
+      const expectDropdownExists = () => {
+        const btn = screen.getByRole('button', {
+          name: 'Dropdown Toggle',
+        });
+        expect(btn).toBeEnabled();
+        userEvent.click(btn);
+        expect(screen.getByRole('menu')).toBeInTheDocument();
+      };
+      it('renders disable action buttons for read-only cluster', () => {
+        renderComponent({ topics: topicsPayload, pageCount: 1 }, true);
+        const btns = screen.getAllByRole('button', { name: 'Dropdown Toggle' });
+        expect(btns[0]).toBeDisabled();
+        expect(btns[1]).toBeDisabled();
+      });
+      it('renders action buttons', () => {
+        renderComponent({ topics: topicsPayload, pageCount: 1 });
+        expect(
+          screen.getAllByRole('button', { name: 'Dropdown Toggle' }).length
+        ).toEqual(2);
+        // Internal topic action buttons are disabled
+        const internalTopicRow = screen.getByRole('row', {
+          name: '__internal.topic 1 0 1 0 0Bytes',
+        });
+        expect(internalTopicRow).toBeInTheDocument();
+        expect(
+          within(internalTopicRow).getByRole('button', {
+            name: 'Dropdown Toggle',
+          })
+        ).toBeDisabled();
+        // External topic action buttons are enabled
+        const externalTopicRow = screen.getByRole('row', {
+          name: 'external.topic 1 0 1 0 1KB',
+        });
+        expect(externalTopicRow).toBeInTheDocument();
+        const extBtn = within(externalTopicRow).getByRole('button', {
+          name: 'Dropdown Toggle',
+        });
+        expect(extBtn).toBeEnabled();
+        userEvent.click(extBtn);
+        expect(screen.getByRole('menu')).toBeInTheDocument();
+      });
+      describe('and clear messages action', () => {
+        it('is visible for topic with CleanUpPolicy.DELETE', async () => {
+          renderComponent({
+            topics: [
+              {
+                ...topicsPayload[1],
+                cleanUpPolicy: CleanUpPolicy.DELETE,
+              },
+            ],
+          });
+          expectDropdownExists();
+          const actionBtn = screen.getAllByRole('menuitem');
+          expect(actionBtn[0]).toHaveTextContent('Clear Messages');
+          expect(actionBtn[0]).not.toHaveAttribute('aria-disabled');
+        });
+        it('is disabled for topic without CleanUpPolicy.DELETE', async () => {
+          renderComponent({
+            topics: [
+              {
+                ...topicsPayload[1],
+                cleanUpPolicy: CleanUpPolicy.COMPACT,
+              },
+            ],
+          });
+          expectDropdownExists();
+          const actionBtn = screen.getAllByRole('menuitem');
+          expect(actionBtn[0]).toHaveTextContent('Clear Messages');
+          expect(actionBtn[0]).toHaveAttribute('aria-disabled');
+        });
+        it('works as expected', async () => {
+          renderComponent({
+            topics: [
+              {
+                ...topicsPayload[1],
+                cleanUpPolicy: CleanUpPolicy.DELETE,
+              },
+            ],
+          });
+          expectDropdownExists();
+          userEvent.click(screen.getByText('Clear Messages'));
+          expect(
+            screen.getByText('Are you sure want to clear topic messages?')
+          ).toBeInTheDocument();
+          await act(() =>
+            userEvent.click(screen.getByRole('button', { name: 'Confirm' }))
+          );
+          expect(mockUnwrap).toHaveBeenCalled();
+        });
+      });
+
+      describe('and remove topic action', () => {
+        it('is visible only when topic deletion allowed for cluster', async () => {
+          renderComponent({ topics: [topicsPayload[1]] });
+          expectDropdownExists();
+          const actionBtn = screen.getAllByRole('menuitem');
+          expect(actionBtn[2]).toHaveTextContent('Remove Topic');
+          expect(actionBtn[2]).not.toHaveAttribute('aria-disabled');
+        });
+        it('is disabled when topic deletion is not allowed for cluster', async () => {
+          renderComponent({ topics: [topicsPayload[1]] }, false, false);
+          expectDropdownExists();
+          const actionBtn = screen.getAllByRole('menuitem');
+          expect(actionBtn[2]).toHaveTextContent('Remove Topic');
+          expect(actionBtn[2]).toHaveAttribute('aria-disabled');
+        });
+        it('works as expected', async () => {
+          renderComponent({ topics: [topicsPayload[1]] });
+          expectDropdownExists();
+          userEvent.click(screen.getByText('Remove Topic'));
+          expect(screen.getByText('Confirm the action')).toBeInTheDocument();
+          await waitFor(() =>
+            userEvent.click(screen.getByRole('button', { name: 'Confirm' }))
+          );
+          await waitFor(() => expect(deleteTopicMock).toHaveBeenCalled());
+        });
+      });
+      describe('and recreate topic action', () => {
+        it('works as expected', async () => {
+          renderComponent({ topics: [topicsPayload[1]] });
+          expectDropdownExists();
+          userEvent.click(screen.getByText('Recreate Topic'));
+          expect(screen.getByText('Confirm the action')).toBeInTheDocument();
+          await waitFor(() =>
+            userEvent.click(screen.getByRole('button', { name: 'Confirm' }))
+          );
+          await waitFor(() => expect(recreateTopicMock).toHaveBeenCalled());
+        });
+      });
+    });
+  });
+});

+ 0 - 168
kafka-ui-react-app/src/components/Topics/List/__tests__/TopicsTable.spec.tsx

@@ -1,168 +0,0 @@
-import React from 'react';
-import { render, WithRoute } from 'lib/testHelpers';
-import { act, screen, within } from '@testing-library/react';
-import { externalTopicPayload } from 'lib/fixtures/topics';
-import ClusterContext from 'components/contexts/ClusterContext';
-import userEvent from '@testing-library/user-event';
-import { useDeleteTopic, useTopics } from 'lib/hooks/api/topics';
-import TopicsTable from 'components/Topics/List/TopicsTable';
-import { clusterTopicsPath } from 'lib/paths';
-
-const mockUnwrap = jest.fn();
-const useDispatchMock = () => jest.fn(() => ({ unwrap: mockUnwrap }));
-
-jest.mock('lib/hooks/redux', () => ({
-  ...jest.requireActual('lib/hooks/redux'),
-  useAppDispatch: useDispatchMock,
-}));
-
-jest.mock('lib/hooks/api/topics', () => ({
-  ...jest.requireActual('lib/hooks/api/topics'),
-  useDeleteTopic: jest.fn(),
-  useTopics: jest.fn(),
-}));
-
-const deleteTopicMock = jest.fn();
-const refetchMock = jest.fn();
-
-const clusterName = 'test-cluster';
-
-const getCheckboxInput = (at: number) => {
-  const rows = screen.getAllByRole('row');
-  return within(rows[at + 1]).getByRole('checkbox');
-};
-
-describe('TopicsTable Component', () => {
-  beforeEach(() => {
-    (useDeleteTopic as jest.Mock).mockImplementation(() => ({
-      mutateAsync: deleteTopicMock,
-    }));
-    (useTopics as jest.Mock).mockImplementation(() => ({
-      data: {
-        topics: [
-          externalTopicPayload,
-          { ...externalTopicPayload, name: 'test-topic' },
-        ],
-        totalPages: 1,
-      },
-      refetch: refetchMock,
-    }));
-  });
-
-  const renderComponent = () => {
-    return render(
-      <ClusterContext.Provider
-        value={{
-          isReadOnly: false,
-          hasKafkaConnectConfigured: true,
-          hasSchemaRegistryConfigured: true,
-          isTopicDeletionAllowed: true,
-        }}
-      >
-        <WithRoute path={clusterTopicsPath()}>
-          <TopicsTable />
-        </WithRoute>
-      </ClusterContext.Provider>,
-      { initialEntries: [clusterTopicsPath(clusterName)] }
-    );
-  };
-
-  beforeEach(() => {
-    renderComponent();
-  });
-
-  const getButtonByName = (name: string) =>
-    screen.getByRole('button', { name });
-
-  const queryButtonByName = (name: string) =>
-    screen.queryByRole('button', { name });
-
-  it('renders the table', () => {
-    expect(screen.getByRole('table')).toBeInTheDocument();
-  });
-
-  it('renders batch actions bar', () => {
-    expect(screen.getByRole('table')).toBeInTheDocument();
-
-    // check batch actions bar is hidden
-    const firstCheckbox = getCheckboxInput(0);
-    expect(firstCheckbox).not.toBeChecked();
-    expect(queryButtonByName('Delete selected topics')).not.toBeInTheDocument();
-
-    // select firsr row
-    userEvent.click(firstCheckbox);
-    expect(firstCheckbox).toBeChecked();
-
-    // check batch actions bar is shown
-    expect(getButtonByName('Delete selected topics')).toBeInTheDocument();
-    expect(getButtonByName('Copy selected topic')).toBeInTheDocument();
-    expect(
-      getButtonByName('Purge messages of selected topics')
-    ).toBeInTheDocument();
-
-    // select second row
-    const secondCheckbox = getCheckboxInput(1);
-    expect(secondCheckbox).not.toBeChecked();
-    userEvent.click(secondCheckbox);
-    expect(secondCheckbox).toBeChecked();
-
-    // check batch actions bar is still shown
-    expect(getButtonByName('Delete selected topics')).toBeInTheDocument();
-    expect(
-      getButtonByName('Purge messages of selected topics')
-    ).toBeInTheDocument();
-
-    // check Copy button is hidden
-    expect(queryButtonByName('Copy selected topic')).not.toBeInTheDocument();
-  });
-
-  describe('', () => {
-    beforeEach(() => {
-      userEvent.click(getCheckboxInput(0));
-      userEvent.click(getCheckboxInput(1));
-    });
-
-    it('handels delete button click', async () => {
-      const button = getButtonByName('Delete selected topics');
-      expect(button).toBeInTheDocument();
-
-      await act(() => userEvent.click(button));
-
-      expect(
-        screen.getByText('Are you sure you want to remove selected topics?')
-      ).toBeInTheDocument();
-
-      const confirmBtn = getButtonByName('Confirm');
-      expect(confirmBtn).toBeInTheDocument();
-      expect(deleteTopicMock).not.toHaveBeenCalled();
-      await act(() => userEvent.click(confirmBtn));
-
-      expect(deleteTopicMock).toHaveBeenCalledTimes(2);
-
-      expect(getCheckboxInput(0)).not.toBeChecked();
-      expect(getCheckboxInput(1)).not.toBeChecked();
-    });
-
-    it('handels purge messages button click', async () => {
-      const button = getButtonByName('Purge messages of selected topics');
-      expect(button).toBeInTheDocument();
-
-      await act(() => userEvent.click(button));
-
-      expect(
-        screen.getByText(
-          'Are you sure you want to purge messages of selected topics?'
-        )
-      ).toBeInTheDocument();
-
-      const confirmBtn = getButtonByName('Confirm');
-      expect(confirmBtn).toBeInTheDocument();
-      expect(mockUnwrap).not.toHaveBeenCalled();
-      await act(() => userEvent.click(confirmBtn));
-      expect(mockUnwrap).toHaveBeenCalledTimes(2);
-
-      expect(getCheckboxInput(0)).not.toBeChecked();
-      expect(getCheckboxInput(1)).not.toBeChecked();
-    });
-  });
-});

+ 0 - 189
kafka-ui-react-app/src/components/Topics/List/__tests__/TopicsTableCells.spec.tsx

@@ -1,189 +0,0 @@
-import React from 'react';
-import { render } from 'lib/testHelpers';
-import {
-  MessagesCell,
-  OutOfSyncReplicasCell,
-  TitleCell,
-  TopicSizeCell,
-} from 'components/Topics/List/TopicsTableCells';
-import { TableState } from 'lib/hooks/useTableState';
-import { screen } from '@testing-library/react';
-import { Topic } from 'generated-sources';
-import { topicsPayload } from 'lib/fixtures/topics';
-
-describe('TopicsTableCells Components', () => {
-  const mockTableState: TableState<Topic, string> = {
-    data: topicsPayload,
-    selectedIds: new Set([]),
-    idSelector: jest.fn(),
-    isRowSelectable: jest.fn(),
-    selectedCount: 0,
-    setRowsSelection: jest.fn(),
-    toggleSelection: jest.fn(),
-  };
-
-  describe('TitleCell Component', () => {
-    it('should check the TitleCell component Render without the internal option', () => {
-      const currentData = topicsPayload[1];
-      render(
-        <TitleCell
-          rowIndex={1}
-          dataItem={currentData}
-          tableState={mockTableState}
-        />
-      );
-      expect(screen.queryByText('IN')).not.toBeInTheDocument();
-      expect(screen.getByText(currentData.name)).toBeInTheDocument();
-    });
-
-    it('should check the TitleCell component Render without the internal option', () => {
-      const currentData = topicsPayload[0];
-      render(
-        <TitleCell
-          rowIndex={1}
-          dataItem={currentData}
-          tableState={mockTableState}
-        />
-      );
-      expect(screen.getByText('IN')).toBeInTheDocument();
-      expect(screen.getByText(currentData.name)).toBeInTheDocument();
-    });
-  });
-
-  describe('TopicSizeCell Component', () => {
-    const currentData = topicsPayload[1];
-    it('should check the TopicSizeCell component Render', () => {
-      render(
-        <TopicSizeCell
-          rowIndex={1}
-          dataItem={currentData}
-          tableState={mockTableState}
-        />
-      );
-      expect(screen.getByText('1KB')).toBeInTheDocument();
-    });
-  });
-
-  describe('OutOfSyncReplicasCell Component', () => {
-    it('returns 0 if no partition is empty array', () => {
-      const currentData = topicsPayload[0];
-      currentData.partitions = [];
-      render(
-        <OutOfSyncReplicasCell
-          rowIndex={1}
-          dataItem={currentData}
-          tableState={mockTableState}
-        />
-      );
-      expect(screen.getByText('0')).toBeInTheDocument();
-    });
-
-    it('returns 0 if no partition is found', () => {
-      const currentData = topicsPayload[1];
-      currentData.partitions = undefined;
-      render(
-        <OutOfSyncReplicasCell
-          rowIndex={1}
-          dataItem={currentData}
-          tableState={mockTableState}
-        />
-      );
-      expect(screen.getByText('0')).toBeInTheDocument();
-    });
-
-    it('returns number of out of sync partitions', () => {
-      const currentData = {
-        ...topicsPayload[1],
-        partitions: [
-          {
-            partition: 0,
-            leader: 1,
-            replicas: [{ broker: 1, leader: false, inSync: false }],
-            offsetMax: 0,
-            offsetMin: 0,
-          },
-        ],
-      };
-      render(
-        <OutOfSyncReplicasCell
-          rowIndex={1}
-          dataItem={currentData}
-          tableState={mockTableState}
-        />
-      );
-      expect(screen.getByText('1')).toBeInTheDocument();
-    });
-
-    it('should check the content of the OutOfSyncReplicasCell with the correct partition number', () => {
-      const currentData = topicsPayload[0];
-      const partitionNumber = currentData.partitions?.reduce(
-        (memo, { replicas }) => {
-          const outOfSync = replicas?.filter(({ inSync }) => !inSync);
-          return memo + (outOfSync?.length || 0);
-        },
-        0
-      );
-
-      render(
-        <OutOfSyncReplicasCell
-          rowIndex={1}
-          dataItem={currentData}
-          tableState={mockTableState}
-        />
-      );
-      expect(
-        screen.getByText(partitionNumber ? partitionNumber.toString() : '0')
-      ).toBeInTheDocument();
-    });
-  });
-
-  describe('MessagesCell Component', () => {
-    it('returns 0 if partition is empty array ', () => {
-      render(
-        <MessagesCell
-          rowIndex={1}
-          dataItem={{ ...topicsPayload[0], partitions: [] }}
-          tableState={mockTableState}
-        />
-      );
-      expect(screen.getByText('0')).toBeInTheDocument();
-    });
-
-    it('returns 0 if no partition is found', () => {
-      render(
-        <MessagesCell
-          rowIndex={1}
-          dataItem={{ ...topicsPayload[0], partitions: undefined }}
-          tableState={mockTableState}
-        />
-      );
-      expect(screen.getByText('0')).toBeInTheDocument();
-    });
-
-    it('returns the correct messages number', () => {
-      const offsetMax = 10034;
-      const offsetMin = 345;
-      const currentData = {
-        ...topicsPayload[0],
-        partitions: [
-          {
-            partition: 0,
-            leader: 1,
-            replicas: [{ broker: 1, leader: false, inSync: false }],
-            offsetMax,
-            offsetMin,
-          },
-        ],
-      };
-      render(
-        <MessagesCell
-          rowIndex={1}
-          dataItem={currentData}
-          tableState={mockTableState}
-        />
-      );
-      expect(offsetMax - offsetMin).toEqual(9689);
-      expect(screen.getByText(offsetMax - offsetMin)).toBeInTheDocument();
-    });
-  });
-});

+ 58 - 60
kafka-ui-react-app/src/components/Topics/Topic/Details/Overview/Overview.tsx

@@ -90,68 +90,66 @@ const Overview: React.FC = () => {
           </Metrics.Indicator>
         </Metrics.Section>
       </Metrics.Wrapper>
-      <div>
-        <Table isFullwidth>
-          <thead>
+      <Table isFullwidth>
+        <thead>
+          <tr>
+            <TableHeaderCell title="Partition ID" />
+            <TableHeaderCell title="Replicas" />
+            <TableHeaderCell title="First Offset" />
+            <TableHeaderCell title="Next Offset" />
+            <TableHeaderCell title="Message Count" />
+            <TableHeaderCell title=" " />
+          </tr>
+        </thead>
+        <tbody>
+          {data?.partitions?.map((partition: Partition) => (
+            <tr key={`partition-list-item-key-${partition.partition}`}>
+              <td>{partition.partition}</td>
+              <td>
+                {partition.replicas?.map(({ broker, leader }: Replica) => (
+                  <S.Replica
+                    leader={leader}
+                    key={broker}
+                    title={leader ? 'Leader' : ''}
+                  >
+                    {broker}
+                  </S.Replica>
+                ))}
+              </td>
+              <td>{partition.offsetMin}</td>
+              <td>{partition.offsetMax}</td>
+              <td>{partition.offsetMax - partition.offsetMin}</td>
+              <td style={{ width: '5%' }}>
+                {!data?.internal &&
+                !isReadOnly &&
+                data?.cleanUpPolicy === 'DELETE' ? (
+                  <Dropdown>
+                    <DropdownItem
+                      onClick={() =>
+                        dispatch(
+                          clearTopicMessages({
+                            clusterName,
+                            topicName,
+                            partitions: [partition.partition],
+                          })
+                        ).unwrap()
+                      }
+                      danger
+                    >
+                      Clear Messages
+                    </DropdownItem>
+                  </Dropdown>
+                ) : null}
+              </td>
+            </tr>
+          ))}
+          {data?.partitions?.length === 0 && (
             <tr>
-              <TableHeaderCell title="Partition ID" />
-              <TableHeaderCell title="Replicas" />
-              <TableHeaderCell title="First Offset" />
-              <TableHeaderCell title="Next Offset" />
-              <TableHeaderCell title="Message Count" />
-              <TableHeaderCell title=" " />
+              <td colSpan={10}>No Partitions found</td>
             </tr>
-          </thead>
-          <tbody>
-            {data?.partitions?.map((partition: Partition) => (
-              <tr key={`partition-list-item-key-${partition.partition}`}>
-                <td>{partition.partition}</td>
-                <td>
-                  {partition.replicas?.map(({ broker, leader }: Replica) => (
-                    <S.Replica
-                      leader={leader}
-                      key={broker}
-                      title={leader ? 'Leader' : ''}
-                    >
-                      {broker}
-                    </S.Replica>
-                  ))}
-                </td>
-                <td>{partition.offsetMin}</td>
-                <td>{partition.offsetMax}</td>
-                <td>{partition.offsetMax - partition.offsetMin}</td>
-                <td style={{ width: '5%' }}>
-                  {!data?.internal &&
-                  !isReadOnly &&
-                  data?.cleanUpPolicy === 'DELETE' ? (
-                    <Dropdown>
-                      <DropdownItem
-                        onClick={() =>
-                          dispatch(
-                            clearTopicMessages({
-                              clusterName,
-                              topicName,
-                              partitions: [partition.partition],
-                            })
-                          ).unwrap()
-                        }
-                        danger
-                      >
-                        Clear Messages
-                      </DropdownItem>
-                    </Dropdown>
-                  ) : null}
-                </td>
-              </tr>
-            ))}
-            {data?.partitions?.length === 0 && (
-              <tr>
-                <td colSpan={10}>No Partitions found</td>
-              </tr>
-            )}
-          </tbody>
-        </Table>
-      </div>
+          )}
+        </tbody>
+      </Table>
     </>
   );
 };

+ 0 - 1
kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/PartitionTable.tsx

@@ -1,4 +1,3 @@
-/* eslint-disable react/no-unstable-nested-components */
 import React from 'react';
 import { TopicAnalysisStats } from 'generated-sources';
 import { ColumnDef } from '@tanstack/react-table';

+ 7 - 0
kafka-ui-react-app/src/components/common/Dropdown/Dropdown.styled.ts

@@ -51,6 +51,7 @@ export const Dropdown = styled(ControlledMenu)(
 
     ${menuItemSelector.disabled} {
       cursor: not-allowed;
+      opacity: 0.5;
     }
   `
 );
@@ -61,6 +62,12 @@ export const DropdownButton = styled.button`
   display: flex;
   cursor: pointer;
   align-self: center;
+  float: right;
+
+  &:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
 `;
 
 export const DangerItem = styled.div`

+ 3 - 1
kafka-ui-react-app/src/components/common/Dropdown/Dropdown.tsx

@@ -7,9 +7,10 @@ import * as S from './Dropdown.styled';
 
 interface DropdownProps extends PropsWithChildren<Partial<MenuProps>> {
   label?: React.ReactNode;
+  disabled?: boolean;
 }
 
-const Dropdown: React.FC<DropdownProps> = ({ label, children }) => {
+const Dropdown: React.FC<DropdownProps> = ({ label, disabled, children }) => {
   const ref = useRef(null);
   const { isOpen, setClose, setOpen } = useModal(false);
 
@@ -25,6 +26,7 @@ const Dropdown: React.FC<DropdownProps> = ({ label, children }) => {
         onClick={handleClick}
         ref={ref}
         aria-label="Dropdown Toggle"
+        disabled={disabled}
       >
         {label || <VerticalElipsisIcon />}
       </S.DropdownButton>

+ 24 - 0
kafka-ui-react-app/src/components/common/IndeterminateCheckbox/IndeterminateCheckbox.tsx

@@ -0,0 +1,24 @@
+import React, { HTMLProps } from 'react';
+import styled from 'styled-components';
+
+interface IndeterminateCheckboxProps extends HTMLProps<HTMLInputElement> {
+  indeterminate?: boolean;
+}
+
+const IndeterminateCheckbox: React.FC<IndeterminateCheckboxProps> = ({
+  indeterminate,
+  ...rest
+}) => {
+  const ref = React.useRef<HTMLInputElement>(null);
+  React.useEffect(() => {
+    if (typeof indeterminate === 'boolean' && ref.current) {
+      ref.current.indeterminate = !rest.checked && indeterminate;
+    }
+  }, [ref, indeterminate]);
+
+  return <input type="checkbox" ref={ref} {...rest} />;
+};
+
+export default styled(IndeterminateCheckbox)`
+  cursor: pointer;
+`;

+ 15 - 0
kafka-ui-react-app/src/components/common/NewTable/SelectRowCell.tsx

@@ -0,0 +1,15 @@
+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 }) => (
+  <IndeterminateCheckbox
+    checked={row.getIsSelected()}
+    disabled={!row.getCanSelect()}
+    indeterminate={row.getIsSomeSelected()}
+    onChange={row.getToggleSelectedHandler()}
+  />
+);
+
+export default SelectRowCell;

+ 14 - 0
kafka-ui-react-app/src/components/common/NewTable/SelectRowHeader.tsx

@@ -0,0 +1,14 @@
+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 }) => (
+  <IndeterminateCheckbox
+    checked={table.getIsAllPageRowsSelected()}
+    indeterminate={table.getIsSomePageRowsSelected()}
+    onChange={table.getToggleAllPageRowsSelectedHandler()}
+  />
+);
+
+export default SelectRowHeader;

+ 10 - 0
kafka-ui-react-app/src/components/common/NewTable/SizeCell.tsx

@@ -0,0 +1,10 @@
+import React from 'react';
+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()} />
+);
+
+export default SizeCell;

+ 46 - 16
kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts

@@ -19,7 +19,7 @@ interface ThProps {
 
 const sortableMixin = (normalColor: string, hoverColor: string) => `
   cursor: pointer;
-  padding-right: 18px;
+  padding-left: 14px;
   position: relative;
 
   &::before,
@@ -28,7 +28,7 @@ const sortableMixin = (normalColor: string, hoverColor: string) => `
     content: '';
     display: block;
     height: 0;
-    right: 5px;
+    left: 0px;
     top: 50%;
     position: absolute;
   }
@@ -51,13 +51,13 @@ const ASCMixin = (color: string) => `
     border-bottom-color: ${color};
   }
   &:after {
-    border-top-color: rgba(0, 0, 0, 0.2);
+    border-top-color: rgba(0, 0, 0, 0.1);
   }
 `;
 const DESCMixin = (color: string) => `
   color: ${color};
   &:before {
-    border-bottom-color: rgba(0, 0, 0, 0.2);
+    border-bottom-color: rgba(0, 0, 0, 0.1);
   }
   &:after {
     border-top-color: ${color};
@@ -65,8 +65,15 @@ const DESCMixin = (color: string) => `
 `;
 
 export const Th = styled.th<ThProps>(
-  ({ theme: { table }, sortable, sortOrder, expander }) => `
-  padding: 4px 0 4px 24px;
+  ({
+    theme: {
+      table: { th },
+    },
+    sortable,
+    sortOrder,
+    expander,
+  }) => `
+  padding: 8px 0 8px 24px;
   border-bottom-width: 1px;
   vertical-align: middle;
   text-align: left;
@@ -77,17 +84,16 @@ export const Th = styled.th<ThProps>(
   line-height: 16px;
   letter-spacing: 0em;
   text-align: left;
-  background: ${table.th.backgroundColor.normal};
+  background: ${th.backgroundColor.normal};
   width: ${expander ? '5px' : 'auto'};
+  white-space: nowrap;
 
   & > div {
     cursor: default;
-    color: ${table.th.color.normal};
-    ${
-      sortable ? sortableMixin(table.th.color.normal, table.th.color.hover) : ''
-    }
-    ${sortable && sortOrder === 'asc' && ASCMixin(table.th.color.active)}
-    ${sortable && sortOrder === 'desc' && DESCMixin(table.th.color.active)}
+    color: ${th.color.normal};
+    ${sortable ? sortableMixin(th.color.sortable, th.color.hover) : ''}
+    ${sortable && sortOrder === 'asc' && ASCMixin(th.color.active)}
+    ${sortable && sortOrder === 'desc' && DESCMixin(th.color.active)}
   }
 `
 );
@@ -118,6 +124,14 @@ export const Nowrap = styled.div`
   white-space: nowrap;
 `;
 
+export const TableActionsBar = styled.div`
+  padding: 8px;
+  background-color: ${({ theme }) => theme.table.actionBar.backgroundColor};
+  margin: 16px 0;
+  display: flex;
+  gap: 8px;
+`;
+
 export const Table = styled.table(
   ({ theme: { table } }) => `
   width: 100%;
@@ -129,18 +143,34 @@ export const Table = styled.table(
     padding: 8px 8px 8px 24px;
     color: ${table.td.color.normal};
     vertical-align: middle;
-    max-width: 350px;
     word-wrap: break-word;
 
-    & > a {
-      color: ${table.link.color};
+    & a {
+      color: ${table.link.color.normal};
       font-weight: 500;
+      max-width: 450px;
+      white-space: nowrap;
+      overflow: hidden;
       text-overflow: ellipsis;
+      display: block;
+
+      &:hover {
+        color: ${table.link.color.hover};
+      }
+
+      &:active {
+        color: ${table.link.color.active};
+      }
     }
   }
 `
 );
 
+export const EmptyTableMessageCell = styled.td`
+  padding: 16px;
+  text-align: center;
+`;
+
 export const Pagination = styled.div`
   display: flex;
   justify-content: space-between;

+ 116 - 52
kafka-ui-react-app/src/components/common/NewTable/Table.tsx

@@ -21,8 +21,10 @@ import * as S from './Table.styled';
 import updateSortingState from './utils/updateSortingState';
 import updatePaginationState from './utils/updatePaginationState';
 import ExpanderCell from './ExpanderCell';
+import SelectRowCell from './SelectRowCell';
+import SelectRowHeader from './SelectRowHeader';
 
-interface TableProps<TData> {
+export interface TableProps<TData> {
   data: TData[];
   pageCount?: number;
   columns: ColumnDef<TData>[];
@@ -30,10 +32,69 @@ interface TableProps<TData> {
   getRowCanExpand?: (row: Row<TData>) => boolean;
   serverSideProcessing?: boolean;
   enableSorting?: boolean;
+  enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
+  batchActionsBar?: React.FC<{ rows: Row<TData>[]; resetRowSelection(): void }>;
+  emptyMessage?: string;
 }
 
 type UpdaterFn<T> = (previousState: T) => T;
 
+const getPaginationFromSearchParams = (searchParams: URLSearchParams) => {
+  const page = searchParams.get('page');
+  const perPage = searchParams.get('perPage');
+  const pageIndex = page ? Number(page) - 1 : 0;
+  return {
+    pageIndex,
+    pageSize: Number(perPage || PER_PAGE),
+  };
+};
+
+const getSortingFromSearchParams = (searchParams: URLSearchParams) => {
+  const sortBy = searchParams.get('sortBy');
+  const sortDirection = searchParams.get('sortDirection');
+  if (!sortBy) return [];
+  return [{ id: sortBy, desc: sortDirection === 'desc' }];
+};
+
+/**
+ * Table component that uses the react-table library to render a table.
+ * https://tanstack.com/table/v8
+ *
+ * The most important props are:
+ *  - `data`: the data to render in the table
+ *  - `columns`: ColumnsDef. You can finde more info about it on https://tanstack.com/table/v8/docs/guide/column-defs
+ *  - `emptyMessage`: the message to show when there is no data to render
+ *
+ * Usecases:
+ * 1. Sortable table
+ *    - set `enableSorting` property of component to true. It will enable sorting for all columns.
+ *      If you want to disable sorting for some particular columns you can pass
+ *     `enableSorting = false` to the column def.
+ *    - table component stores the sorting state in URLSearchParams. Use `sortBy` and `sortDirection`
+ *      search param to set default sortings.
+ *
+ * 2. Pagination
+ *    - pagination enabled by default.
+ *    - use `perPage` search param to manage default page size.
+ *    - use `page` search param to manage default page index.
+ *    - use `pageCount` prop to set the total number of pages only in case of server side processing.
+ *
+ * 3. Expandable rows
+ *    - use `getRowCanExpand` prop to set a function that returns true if the row can be expanded.
+ *    - use `renderSubComponent` prop to provide a sub component for each expanded row.
+ *
+ * 4. Row selection
+ *    - use `enableRowSelection` prop to enable row selection. This prop can be a boolean or
+ *      a function that returns true if the particular row can be selected.
+ *    - use `batchActionsBar` prop to provide a component that will be rendered at the top of the table
+ *      when row selection is enabled and there are selected rows.
+ *
+ * 5. Server side processing:
+ *    - set `serverSideProcessing` to true
+ *    - set `pageCount` to the total number of pages
+ *    - use URLSearchParams to get the pagination and sorting state from the url for your server side processing.
+ */
+
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 const Table: React.FC<TableProps<any>> = ({
   data,
@@ -43,77 +104,45 @@ const Table: React.FC<TableProps<any>> = ({
   renderSubComponent,
   serverSideProcessing = false,
   enableSorting = false,
+  enableRowSelection = false,
+  batchActionsBar,
+  emptyMessage,
 }) => {
   const [searchParams, setSearchParams] = useSearchParams();
-  const [sorting, setSorting] = React.useState<SortingState>([]);
-  const [{ pageIndex, pageSize }, setPagination] =
-    React.useState<PaginationState>({
-      pageIndex: 0,
-      pageSize: PER_PAGE,
-    });
-
+  const [rowSelection, setRowSelection] = React.useState({});
   const onSortingChange = React.useCallback(
     (updater: UpdaterFn<SortingState>) => {
       const newState = updateSortingState(updater, searchParams);
       setSearchParams(searchParams);
-      setSorting(newState);
       return newState;
     },
     [searchParams]
   );
-
   const onPaginationChange = React.useCallback(
     (updater: UpdaterFn<PaginationState>) => {
       const newState = updatePaginationState(updater, searchParams);
       setSearchParams(searchParams);
-      setPagination(newState);
       return newState;
     },
     [searchParams]
   );
 
   React.useEffect(() => {
-    const sortBy = searchParams.get('sortBy');
-    const sortDirection = searchParams.get('sortDirection');
-    const page = searchParams.get('page');
-    const perPage = searchParams.get('perPage');
-
-    if (sortBy) {
-      setSorting([
-        {
-          id: sortBy,
-          desc: sortDirection === 'desc',
-        },
-      ]);
-    } else {
-      setSorting([]);
-    }
-    if (page || perPage) {
-      setPagination({
-        pageIndex: Number(page || 0),
-        pageSize: Number(perPage || PER_PAGE),
-      });
-    }
-  }, []);
-
-  const pagination = React.useMemo(
-    () => ({
-      pageIndex,
-      pageSize,
-    }),
-    [pageIndex, pageSize]
-  );
+    setRowSelection({});
+  }, [searchParams]);
 
   const table = useReactTable({
     data,
     pageCount,
     columns,
     state: {
-      sorting,
-      pagination,
+      sorting: getSortingFromSearchParams(searchParams),
+      pagination: getPaginationFromSearchParams(searchParams),
+      rowSelection,
     },
     onSortingChange: onSortingChange as OnChangeFn<SortingState>,
     onPaginationChange: onPaginationChange as OnChangeFn<PaginationState>,
+    onRowSelectionChange: setRowSelection,
     getRowCanExpand,
     getCoreRowModel: getCoreRowModel(),
     getExpandedRowModel: getExpandedRowModel(),
@@ -122,14 +151,34 @@ const Table: React.FC<TableProps<any>> = ({
     manualSorting: serverSideProcessing,
     manualPagination: serverSideProcessing,
     enableSorting,
+    autoResetPageIndex: false,
+    enableRowSelection,
   });
 
+  const Bar = batchActionsBar;
+
   return (
     <>
+      {table.getSelectedRowModel().flatRows.length > 0 && Bar && (
+        <S.TableActionsBar>
+          <Bar
+            rows={table.getSelectedRowModel().flatRows}
+            resetRowSelection={table.resetRowSelection}
+          />
+        </S.TableActionsBar>
+      )}
       <S.Table>
         <thead>
           {table.getHeaderGroups().map((headerGroup) => (
             <tr key={headerGroup.id}>
+              {!!enableRowSelection && (
+                <S.Th key={`${headerGroup.id}-select`}>
+                  {flexRender(
+                    SelectRowHeader,
+                    headerGroup.headers[0].getContext()
+                  )}
+                </S.Th>
+              )}
               {table.getCanSomeRowsExpand() && (
                 <S.Th expander key={`${headerGroup.id}-expander`} />
               )}
@@ -160,6 +209,14 @@ const Table: React.FC<TableProps<any>> = ({
                 expanded={row.getIsExpanded()}
                 onClick={() => row.getCanExpand() && row.toggleExpanded()}
               >
+                {!!enableRowSelection && (
+                  <td key={`${row.id}-select`}>
+                    {flexRender(
+                      SelectRowCell,
+                      row.getVisibleCells()[0].getContext()
+                    )}
+                  </td>
+                )}
                 {row.getCanExpand() && (
                   <td key={`${row.id}-expander`}>
                     {flexRender(
@@ -168,15 +225,15 @@ const Table: React.FC<TableProps<any>> = ({
                     )}
                   </td>
                 )}
-                {row.getVisibleCells().map((cell) => (
-                  <td key={cell.id}>
-                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
-                  </td>
-                ))}
+                {row
+                  .getVisibleCells()
+                  .map(({ id, getContext, column: { columnDef } }) => (
+                    <td key={id}>{flexRender(columnDef.cell, getContext())}</td>
+                  ))}
               </S.Row>
               {row.getIsExpanded() && renderSubComponent && (
                 <S.Row expanded>
-                  <td colSpan={row.getVisibleCells().length + 1}>
+                  <td colSpan={row.getVisibleCells().length + 2}>
                     <S.ExpandedRowInfo>
                       {renderSubComponent({ row })}
                     </S.ExpandedRowInfo>
@@ -185,6 +242,13 @@ const Table: React.FC<TableProps<any>> = ({
               )}
             </React.Fragment>
           ))}
+          {table.getRowModel().rows.length === 0 && (
+            <S.Row>
+              <S.EmptyTableMessageCell colSpan={100}>
+                {emptyMessage || 'No rows found'}
+              </S.EmptyTableMessageCell>
+            </S.Row>
+          )}
         </tbody>
       </S.Table>
       {table.getPageCount() > 1 && (
@@ -231,9 +295,9 @@ const Table: React.FC<TableProps<any>> = ({
                 inputSize="M"
                 max={table.getPageCount()}
                 min={1}
-                onChange={(e) => {
-                  const page = e.target.value ? Number(e.target.value) - 1 : 0;
-                  table.setPageIndex(page);
+                onChange={({ target: { value } }) => {
+                  const index = value ? Number(value) - 1 : 0;
+                  table.setPageIndex(index);
                 }}
               />
             </S.GoToPage>

+ 78 - 16
kafka-ui-react-app/src/components/common/NewTable/__test__/Table.spec.tsx

@@ -1,19 +1,24 @@
 import React from 'react';
 import { render, WithRoute } from 'lib/testHelpers';
-import Table, { TimestampCell } from 'components/common/NewTable';
+import Table, {
+  TableProps,
+  TimestampCell,
+  SizeCell,
+} from 'components/common/NewTable';
 import { screen, waitFor } from '@testing-library/dom';
-import { ColumnDef } from '@tanstack/react-table';
+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';
 
+type Datum = typeof data[0];
+
 const data = [
-  { timestamp: 1660034383725, text: 'lorem' },
-  { timestamp: 1660034399999, text: 'ipsum' },
-  { timestamp: 1660034399922, text: 'dolor' },
-  { timestamp: 1660034199922, text: 'sit' },
+  { 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 },
 ];
-type Datum = typeof data[0];
 
 const columns: ColumnDef<Datum>[] = [
   {
@@ -25,27 +30,30 @@ const columns: ColumnDef<Datum>[] = [
     header: 'Text',
     accessorKey: 'text',
   },
+  {
+    header: 'Size',
+    accessorKey: 'size',
+    cell: SizeCell,
+  },
 ];
 
 const ExpandedRow: React.FC = () => <div>I am expanded row</div>;
 
-interface Props {
+interface Props extends TableProps<Datum> {
   path?: string;
-  canExpand?: boolean;
 }
 
-const renderComponent = ({ path, canExpand }: Props = {}) => {
+const renderComponent = (props: Partial<Props> = {}) => {
   render(
     <WithRoute path="/">
       <Table
         columns={columns}
         data={data}
         renderSubComponent={ExpandedRow}
-        getRowCanExpand={() => !!canExpand}
-        enableSorting
+        {...props}
       />
     </WithRoute>,
-    { initialEntries: [path || ''] }
+    { initialEntries: [props.path || ''] }
   );
 };
 
@@ -53,6 +61,30 @@ describe('Table', () => {
   it('renders table', () => {
     renderComponent();
     expect(screen.getByRole('table')).toBeInTheDocument();
+    expect(screen.getAllByRole('row').length).toEqual(data.length + 1);
+  });
+
+  it('renders empty table', () => {
+    renderComponent({ data: [] });
+    expect(screen.getByRole('table')).toBeInTheDocument();
+    expect(screen.getAllByRole('row').length).toEqual(2);
+    expect(screen.getByText('No rows found')).toBeInTheDocument();
+  });
+
+  it('renders empty table with custom message', () => {
+    const emptyMessage = 'Super custom message';
+    renderComponent({ data: [], emptyMessage });
+    expect(screen.getByRole('table')).toBeInTheDocument();
+    expect(screen.getAllByRole('row').length).toEqual(2);
+    expect(screen.getByText(emptyMessage)).toBeInTheDocument();
+  });
+
+  it('renders SizeCell', () => {
+    renderComponent();
+    expect(screen.getByText('1KB')).toBeInTheDocument();
+    expect(screen.getByText('3Bytes')).toBeInTheDocument();
+    expect(screen.getByText('49KB')).toBeInTheDocument();
+    expect(screen.getByText('1MB')).toBeInTheDocument();
   });
 
   it('renders TimestampCell', () => {
@@ -64,7 +96,7 @@ describe('Table', () => {
 
   describe('ExpanderCell', () => {
     it('renders button', () => {
-      renderComponent({ canExpand: true });
+      renderComponent({ getRowCanExpand: () => true });
       const btns = screen.getAllByRole('button', { name: 'Expand row' });
       expect(btns.length).toEqual(data.length);
 
@@ -76,7 +108,7 @@ describe('Table', () => {
     });
 
     it('does not render button', () => {
-      renderComponent({ canExpand: false });
+      renderComponent({ getRowCanExpand: () => false });
       expect(
         screen.queryByRole('button', { name: 'Expand row' })
       ).not.toBeInTheDocument();
@@ -147,7 +179,10 @@ describe('Table', () => {
   describe('Sorting', () => {
     it('sort rows', async () => {
       await act(() =>
-        renderComponent({ path: '/?sortBy=text&&sortDirection=desc' })
+        renderComponent({
+          path: '/?sortBy=text&&sortDirection=desc',
+          enableSorting: true,
+        })
       );
       expect(screen.getAllByRole('row').length).toEqual(data.length + 1);
       const th = screen.getByRole('columnheader', { name: 'Text' });
@@ -178,4 +213,31 @@ describe('Table', () => {
       expect(rows[4].textContent?.indexOf('sit')).toBeGreaterThan(-1);
     });
   });
+
+  describe('Row Selecting', () => {
+    beforeEach(() => {
+      renderComponent({
+        enableRowSelection: (row: Row<Datum>) => row.original.selectable,
+        batchActionsBar: () => <div>I am Action Bar</div>,
+      });
+    });
+    it('renders selectable rows', () => {
+      expect(screen.getAllByRole('row').length).toEqual(data.length + 1);
+      const checkboxes = screen.getAllByRole('checkbox');
+      expect(checkboxes.length).toEqual(data.length + 1);
+      expect(checkboxes[1]).toBeDisabled();
+      expect(checkboxes[2]).toBeEnabled();
+      expect(checkboxes[3]).toBeEnabled();
+      expect(checkboxes[4]).toBeDisabled();
+    });
+
+    it('renders action bar', () => {
+      expect(screen.getAllByRole('row').length).toEqual(data.length + 1);
+      expect(screen.queryByText('I am Action Bar')).not.toBeInTheDocument();
+      const checkboxes = screen.getAllByRole('checkbox');
+      expect(checkboxes.length).toEqual(data.length + 1);
+      userEvent.click(checkboxes[2]);
+      expect(screen.getByText('I am Action Bar')).toBeInTheDocument();
+    });
+  });
 });

+ 5 - 3
kafka-ui-react-app/src/components/common/NewTable/index.ts

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

+ 6 - 13
kafka-ui-react-app/src/components/common/NewTable/utils/updatePaginationState.ts

@@ -7,21 +7,14 @@ export default (
   updater: UpdaterFn<PaginationState>,
   searchParams: URLSearchParams
 ) => {
+  const page = searchParams.get('page');
   const previousState: PaginationState = {
-    pageIndex: Number(searchParams.get('page') || 0),
+    // Page number starts at 1, but the pageIndex starts at 0
+    pageIndex: page ? Number(page) - 1 : 0,
     pageSize: Number(searchParams.get('perPage') || PER_PAGE),
   };
   const newState = updater(previousState);
-  if (newState.pageIndex !== 0) {
-    searchParams.set('page', newState.pageIndex.toString());
-  } else {
-    searchParams.delete('page');
-  }
-
-  if (newState.pageSize !== PER_PAGE) {
-    searchParams.set('perPage', newState.pageSize.toString());
-  } else {
-    searchParams.delete('perPage');
-  }
-  return newState;
+  searchParams.set('page', String(newState.pageIndex + 1));
+  searchParams.set('perPage', newState.pageSize.toString());
+  return previousState;
 };

+ 9 - 1
kafka-ui-react-app/src/components/common/table/Table/TableKeyLink.styled.ts

@@ -3,9 +3,17 @@ import styled, { css } from 'styled-components';
 const tableLinkMixin = css(
   ({ theme }) => `
  & > a {
-    color: ${theme.table.link.color};
+    color: ${theme.table.link.color.normal};
     font-weight: 500;
     text-overflow: ellipsis;
+
+    &:hover {
+      color: ${theme.table.link.color.hover};
+    }
+
+    &:active {
+      color: ${theme.table.link.color.active};
+    }
   }
 `
 );

+ 4 - 0
kafka-ui-react-app/src/lib/fixtures/brokers.ts

@@ -32,4 +32,8 @@ export const brokerLogDirsPayload: BrokersLogdirs[] = [
       },
     ],
   },
+  {
+    error: 'NONE',
+    name: '/opt/kafka/data-1/logs',
+  },
 ];

+ 7 - 1
kafka-ui-react-app/src/theme/theme.ts

@@ -299,12 +299,16 @@ const theme = {
     deletionTextColor: Colors.neutral[70],
   },
   table: {
+    actionBar: {
+      backgroundColor: Colors.neutral[0],
+    },
     th: {
       backgroundColor: {
         normal: Colors.neutral[0],
       },
       color: {
-        normal: Colors.neutral[50],
+        sortable: Colors.neutral[30],
+        normal: Colors.neutral[60],
         hover: Colors.brand[50],
         active: Colors.brand[50],
       },
@@ -326,6 +330,8 @@ const theme = {
     link: {
       color: {
         normal: Colors.neutral[90],
+        hover: Colors.neutral[50],
+        active: Colors.neutral[90],
       },
     },
     expander: {