Repository: provectus/kafka-ui
Issue: Reusable tables
Labels: scope/frontend
type/refactoring
status/accepted

### Is your proposal related to a problem?

<!--
  Provide a clear and concise description of what the problem is.
  For example, "I'm always frustrated when..."
-->
The purpose of this feature request is to have a **Table** component more reusable and handle _select row, pagination, emty state_ inside it.  

### Describe the solution you'd like

<!--
  Provide a clear and concise description of what you want to happen.
-->
With the current implementation we must handle rendering rows, selecting rows, empty state, pagination in each page where we have a table. With this Feature Request we will have **Table** and **TableColumn** components and describe table structure with them. Also there is possibility for providing custom component for particular column cell and header cell. With that we can define which column is sortable etc. for example:
```
<Table selectable tableState={tableState}>
    <TableColumn sortable title="Topic Name" field="name" />
    <TableColumn title="Total Partitions" field="partitions.length" />
    <TableColumn
      title="Out of sync replicas"
      cell={OutOfSyncReplicasCell}
    />
    <TableColumn title="Replication Factor" field="replicationFactor" />
    <TableColumn
      title="Number of messages"
      cell={NumberOfMessagesCell}
    />
    <TableColumn title="Size" cell={TopicSizeCell} />
</Table>
```
### Describe alternatives you've considered

<!--
  Let us know about other solutions you've tried or researched.
-->

### Additional context
Also in this feature request I have added **TableState** and **DataSource** features. **TableState** will handle row selection, pagination and will provide table state, and the **DataSource** will have responsibility to handle data manipulations, later we can have also **AsyncDataSource** which will handle requesting to API and handle incoming data.
 <!--
  Is there anything else you can add about the proposal?
  You might want to link to related issues here, if you haven't already.
-->



Issue comments:
Hello there sasunprov! 👋

Thank you and congratulations 🎉 for opening your very first issue in this project! 💖

In case you want to claim this issue, please comment down below! We will try to get back to you as soon as we can. 👀

Pull request review comments:
@sasunprov should we remove ListItem component?
pls use `SmartTable` instead of `smart-table`
Yes 
Why is this a function rather than a variable that gets returned in the function ? just Curios , if it was variable we can just return it renamed without applying useCallback on it
because this logic is moved to TableColumn components and custom components for cells

one liner function which is not heavy do we have to wrap it  with useCallback ? 
I think here we have extra addition sign , and why do we have to show the placeholders if no data is found ?
Here also during every re-render it have to check to variable in order no to run a minimal small function , can we ignore it , if not why ?
good catch
This will help us later when we memoize child components
There is no extra addition operator, it will increment the colspan if table is selectable and there will be added one more column for selection
The same justification as in previous comment
okay got it.
okay got it.
okay it makes sense now.

Patch that resolved the issue:
diff --git a/kafka-ui-react-app/src/components/Topics/List/List.tsx b/kafka-ui-react-app/src/components/Topics/List/List.tsx
index fd75c87a919..4458761f7e4 100644
--- a/kafka-ui-react-app/src/components/Topics/List/List.tsx
+++ b/kafka-ui-react-app/src/components/Topics/List/List.tsx
@@ -10,23 +10,35 @@ import { clusterTopicNewPath } from 'lib/paths';
 import usePagination from 'lib/hooks/usePagination';
 import ClusterContext from 'components/contexts/ClusterContext';
 import PageLoader from 'components/common/PageLoader/PageLoader';
-import Pagination from 'components/common/Pagination/Pagination';
 import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
 import {
+  CleanUpPolicy,
   GetTopicsRequest,
   SortOrder,
   TopicColumnsToSort,
 } from 'generated-sources';
-import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
 import Search from 'components/common/Search/Search';
 import { PER_PAGE } from 'lib/constants';
-import { Table } from 'components/common/table/Table/Table.styled';
 import { Button } from 'components/common/Button/Button';
 import PageHeading from 'components/common/PageHeading/PageHeading';
 import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
 import Switch from 'components/common/Switch/Switch';
+import { SmartTable } from 'components/common/SmartTable/SmartTable';
+import {
+  TableCellProps,
+  TableColumn,
+} from 'components/common/SmartTable/TableColumn';
+import { useTableState } from 'lib/hooks/useTableState';
+import Dropdown from 'components/common/Dropdown/Dropdown';
+import VerticalElipsisIcon from 'components/common/Icons/VerticalElipsisIcon';
+import DropdownItem from 'components/common/Dropdown/DropdownItem';
 
-import ListItem from './ListItem';
+import {
+  MessagesCell,
+  OutOfSyncReplicasCell,
+  TitleCell,
+  TopicSizeCell,
+} from './TopicsTableCells';
 
 export interface TopicsListProps {
   areTopicsFetching: boolean;
@@ -63,7 +75,8 @@ const List: React.FC<TopicsListProps> = ({
   setTopicsSearch,
   setTopicsOrderBy,
 }) => {
-  const { isReadOnly } = React.useContext(ClusterContext);
+  const { isReadOnly, isTopicDeletionAllowed } =
+    React.useContext(ClusterContext);
   const { clusterName } = useParams<{ clusterName: ClusterName }>();
   const { page, perPage, pathname } = usePagination();
   const [showInternal, setShowInternal] = React.useState<boolean>(true);
@@ -90,6 +103,24 @@ const List: React.FC<TopicsListProps> = ({
     showInternal,
   ]);
 
+  const tableState = useTableState<
+    TopicWithDetailedInfo,
+    string,
+    TopicColumnsToSort
+  >(
+    topics,
+    {
+      idSelector: (topic) => topic.name,
+      totalPages,
+      isRowSelectable: (topic) => !topic.internal,
+    },
+    {
+      handleOrderBy: setTopicsOrderBy,
+      orderBy,
+      sortOrder,
+    }
+  );
+
   const handleSwitch = React.useCallback(() => {
     setShowInternal(!showInternal);
     history.push(`${pathname}?page=1&perPage=${perPage || PER_PAGE}`);
@@ -103,36 +134,26 @@ const List: React.FC<TopicsListProps> = ({
     setConfirmationModal('');
   };
 
-  const [selectedTopics, setSelectedTopics] = React.useState<Set<string>>(
-    new Set()
-  );
-
-  const clearSelectedTopics = () => {
-    setSelectedTopics(new Set());
-  };
-
-  const toggleTopicSelected = (topicName: string) => {
-    setSelectedTopics((prevState) => {
-      const newState = new Set(prevState);
-      if (newState.has(topicName)) {
-        newState.delete(topicName);
-      } else {
-        newState.add(topicName);
-      }
-      return newState;
-    });
-  };
+  const clearSelectedTopics = React.useCallback(() => {
+    tableState.toggleSelection(false);
+  }, [tableState]);
 
   const deleteTopicsHandler = React.useCallback(() => {
-    deleteTopics(clusterName, Array.from(selectedTopics));
+    deleteTopics(clusterName, Array.from(tableState.selectedIds));
     closeConfirmationModal();
     clearSelectedTopics();
-  }, [clusterName, deleteTopics, selectedTopics]);
+  }, [clearSelectedTopics, clusterName, deleteTopics, tableState.selectedIds]);
   const purgeMessagesHandler = React.useCallback(() => {
-    clearTopicsMessages(clusterName, Array.from(selectedTopics));
+    clearTopicsMessages(clusterName, Array.from(tableState.selectedIds));
     closeConfirmationModal();
     clearSelectedTopics();
-  }, [clearTopicsMessages, clusterName, selectedTopics]);
+  }, [
+    clearSelectedTopics,
+    clearTopicsMessages,
+    clusterName,
+    tableState.selectedIds,
+  ]);
+
   const searchHandler = React.useCallback(
     (searchString: string) => {
       setTopicsSearch(searchString);
@@ -141,6 +162,53 @@ const List: React.FC<TopicsListProps> = ({
     [setTopicsSearch, history, pathname, perPage]
   );
 
+  const ActionsCell = React.memo<TableCellProps<TopicWithDetailedInfo, string>>(
+    ({ hovered, dataItem: { internal, cleanUpPolicy, name } }) => {
+      const [
+        isDeleteTopicConfirmationVisible,
+        setDeleteTopicConfirmationVisible,
+      ] = React.useState(false);
+
+      const deleteTopicHandler = React.useCallback(() => {
+        deleteTopic(clusterName, name);
+      }, [name]);
+
+      const clearTopicMessagesHandler = React.useCallback(() => {
+        clearTopicMessages(clusterName, name);
+      }, [name]);
+      return (
+        <>
+          {!internal && !isReadOnly && hovered ? (
+            <div className="has-text-right">
+              <Dropdown label={<VerticalElipsisIcon />} right>
+                {cleanUpPolicy === CleanUpPolicy.DELETE && (
+                  <DropdownItem onClick={clearTopicMessagesHandler} danger>
+                    Clear Messages
+                  </DropdownItem>
+                )}
+                {isTopicDeletionAllowed && (
+                  <DropdownItem
+                    onClick={() => setDeleteTopicConfirmationVisible(true)}
+                    danger
+                  >
+                    Remove Topic
+                  </DropdownItem>
+                )}
+              </Dropdown>
+            </div>
+          ) : null}
+          <ConfirmationModal
+            isOpen={isDeleteTopicConfirmationVisible}
+            onCancel={() => setDeleteTopicConfirmationVisible(false)}
+            onConfirm={deleteTopicHandler}
+          >
+            Are you sure want to remove <b>{name}</b> topic?
+          </ConfirmationModal>
+        </>
+      );
+    }
+  );
+
   return (
     <div>
       <div>
@@ -178,7 +246,7 @@ const List: React.FC<TopicsListProps> = ({
         <PageLoader />
       ) : (
         <div>
-          {selectedTopics.size > 0 && (
+          {tableState.selectedCount > 0 && (
             <>
               <ControlPanelWrapper data-testid="delete-buttons">
                 <Button
@@ -215,62 +283,43 @@ const List: React.FC<TopicsListProps> = ({
               </ConfirmationModal>
             </>
           )}
-          <Table isFullwidth>
-            <thead>
-              <tr>
-                {!isReadOnly && <TableHeaderCell />}
-                <TableHeaderCell
-                  title="Topic Name"
-                  orderValue={TopicColumnsToSort.NAME}
-                  orderBy={orderBy}
-                  sortOrder={sortOrder}
-                  handleOrderBy={setTopicsOrderBy}
-                />
-                <TableHeaderCell
-                  title="Total Partitions"
-                  orderValue={TopicColumnsToSort.TOTAL_PARTITIONS}
-                  orderBy={orderBy}
-                  sortOrder={sortOrder}
-                  handleOrderBy={setTopicsOrderBy}
-                />
-                <TableHeaderCell
-                  title="Out of sync replicas"
-                  orderValue={TopicColumnsToSort.OUT_OF_SYNC_REPLICAS}
-                  orderBy={orderBy}
-                  sortOrder={sortOrder}
-                  handleOrderBy={setTopicsOrderBy}
-                />
-                <TableHeaderCell title="Replication Factor" />
-                <TableHeaderCell title="Number of messages" />
-                <TableHeaderCell
-                  title="Size"
-                  orderValue={TopicColumnsToSort.SIZE}
-                  orderBy={orderBy}
-                  sortOrder={sortOrder}
-                  handleOrderBy={setTopicsOrderBy}
-                />
-              </tr>
-            </thead>
-            <tbody>
-              {topics.map((topic) => (
-                <ListItem
-                  clusterName={clusterName}
-                  key={topic.name}
-                  topic={topic}
-                  selected={selectedTopics.has(topic.name)}
-                  toggleTopicSelected={toggleTopicSelected}
-                  deleteTopic={deleteTopic}
-                  clearTopicMessages={clearTopicMessages}
-                />
-              ))}
-              {topics.length === 0 && (
-                <tr>
-                  <td colSpan={10}>No topics found</td>
-                </tr>
-              )}
-            </tbody>
-          </Table>
-          <Pagination totalPages={totalPages} />
+          <SmartTable
+            selectable={!isReadOnly}
+            tableState={tableState}
+            placeholder="No topics found"
+            isFullwidth
+            paginated
+            hoverable
+          >
+            <TableColumn
+              width="44%"
+              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
+              width="4%"
+              className="topic-action-block"
+              cell={ActionsCell}
+            />
+          </SmartTable>
         </div>
       )}
     </div>
diff --git a/kafka-ui-react-app/src/components/Topics/List/TopicsTableCells.tsx b/kafka-ui-react-app/src/components/Topics/List/TopicsTableCells.tsx
new file mode 100644
index 00000000000..024bf45d735
--- /dev/null
+++ b/kafka-ui-react-app/src/components/Topics/List/TopicsTableCells.tsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import { TopicWithDetailedInfo } from 'redux/interfaces';
+import { TableCellProps } from 'components/common/SmartTable/TableColumn';
+import { Tag } from 'components/common/Tag/Tag.styled';
+import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
+
+import * as S from './List.styled';
+
+export const TitleCell: React.FC<
+  TableCellProps<TopicWithDetailedInfo, string>
+> = ({ dataItem: { internal, name } }) => {
+  return (
+    <>
+      {internal && <Tag color="gray">IN</Tag>}
+      <S.Link exact to={`topics/${name}`} $isInternal={internal}>
+        {name}
+      </S.Link>
+    </>
+  );
+};
+
+export const TopicSizeCell: React.FC<
+  TableCellProps<TopicWithDetailedInfo, string>
+> = ({ dataItem: { segmentSize } }) => {
+  return <BytesFormatted value={segmentSize} />;
+};
+
+export const OutOfSyncReplicasCell: React.FC<
+  TableCellProps<TopicWithDetailedInfo, 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<TopicWithDetailedInfo, 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>;
+};
diff --git a/kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx b/kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx
index d11b08fa26d..5aacb2543eb 100644
--- a/kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx
+++ b/kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx
@@ -155,7 +155,7 @@ describe('List', () => {
       </StaticRouter>
     );
     const getCheckboxInput = (at: number) =>
-      component.find('ListItem').at(at).find('input[type="checkbox"]').at(0);
+      component.find('TableRow').at(at).find('input[type="checkbox"]').at(0);
 
     const getConfirmationModal = () =>
       component.find('mock-ConfirmationModal').at(0);
@@ -166,12 +166,12 @@ describe('List', () => {
       expect(component.find('.buttons').length).toEqual(0);
 
       // check first item
-      getCheckboxInput(0).simulate('change');
+      getCheckboxInput(0).simulate('change', { target: { checked: true } });
       expect(getCheckboxInput(0).props().checked).toBeTruthy();
       expect(getCheckboxInput(1).props().checked).toBeFalsy();
 
       // check second item
-      getCheckboxInput(1).simulate('change');
+      getCheckboxInput(1).simulate('change', { target: { checked: true } });
       expect(getCheckboxInput(0).props().checked).toBeTruthy();
       expect(getCheckboxInput(1).props().checked).toBeTruthy();
       expect(
@@ -179,7 +179,7 @@ describe('List', () => {
       ).toEqual(1);
 
       // uncheck second item
-      getCheckboxInput(1).simulate('change');
+      getCheckboxInput(1).simulate('change', { target: { checked: false } });
       expect(getCheckboxInput(0).props().checked).toBeTruthy();
       expect(getCheckboxInput(1).props().checked).toBeFalsy();
       expect(
@@ -187,7 +187,7 @@ describe('List', () => {
       ).toEqual(1);
 
       // uncheck first item
-      getCheckboxInput(0).simulate('change');
+      getCheckboxInput(0).simulate('change', { target: { checked: false } });
       expect(getCheckboxInput(0).props().checked).toBeFalsy();
       expect(getCheckboxInput(1).props().checked).toBeFalsy();
       expect(
@@ -203,8 +203,8 @@ describe('List', () => {
           : 'Are you sure you want to purge messages of selected topics?';
       const mockFn =
         action === 'deleteTopics' ? mockDeleteTopics : mockClearTopicsMessages;
-      getCheckboxInput(0).simulate('change');
-      getCheckboxInput(1).simulate('change');
+      getCheckboxInput(0).simulate('change', { target: { checked: true } });
+      getCheckboxInput(1).simulate('change', { target: { checked: true } });
       let modal = getConfirmationModal();
       expect(modal.prop('isOpen')).toBeFalsy();
       component
@@ -240,8 +240,8 @@ describe('List', () => {
     });
 
     it('closes ConfirmationModal when clicked on the cancel button', async () => {
-      getCheckboxInput(0).simulate('change');
-      getCheckboxInput(1).simulate('change');
+      getCheckboxInput(0).simulate('change', { target: { checked: true } });
+      getCheckboxInput(1).simulate('change', { target: { checked: true } });
       let modal = getConfirmationModal();
       expect(modal.prop('isOpen')).toBeFalsy();
       component
diff --git a/kafka-ui-react-app/src/components/common/SmartTable/SmartTable.tsx b/kafka-ui-react-app/src/components/common/SmartTable/SmartTable.tsx
new file mode 100644
index 00000000000..301ee671a36
--- /dev/null
+++ b/kafka-ui-react-app/src/components/common/SmartTable/SmartTable.tsx
@@ -0,0 +1,134 @@
+import React from 'react';
+import Pagination from 'components/common/Pagination/Pagination';
+import { Table } from 'components/common/table/Table/Table.styled';
+import * as S from 'components/common/table/TableHeaderCell/TableHeaderCell.styled';
+import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
+import { TableState } from 'lib/hooks/useTableState';
+
+import {
+  isColumnElement,
+  SelectCell,
+  TableHeaderCellProps,
+} from './TableColumn';
+import { TableRow } from './TableRow';
+
+interface SmartTableProps<T, TId extends IdType, OT = never> {
+  tableState: TableState<T, TId, OT>;
+  allSelectable?: boolean;
+  selectable?: boolean;
+  className?: string;
+  placeholder?: string;
+  isFullwidth?: boolean;
+  paginated?: boolean;
+  hoverable?: boolean;
+}
+
+export const SmartTable = <T, TId extends IdType, OT = never>({
+  children,
+  tableState,
+  selectable = false,
+  allSelectable = false,
+  placeholder = 'No Data Found',
+  isFullwidth = false,
+  paginated = false,
+  hoverable = false,
+}: React.PropsWithChildren<SmartTableProps<T, TId, OT>>) => {
+  const handleRowSelection = React.useCallback(
+    (row: T, checked: boolean) => {
+      tableState.setRowsSelection([row], checked);
+    },
+    [tableState]
+  );
+
+  const headerRow = React.useMemo(() => {
+    const headerCells = React.Children.map(children, (child) => {
+      if (!isColumnElement<T, TId, OT>(child)) {
+        return child;
+      }
+
+      const { headerCell, title, orderValue } = child.props;
+
+      const HeaderCell = headerCell as
+        | React.FC<TableHeaderCellProps<T, TId, OT>>
+        | undefined;
+
+      return HeaderCell ? (
+        <S.TableHeaderCell>
+          <HeaderCell
+            orderValue={orderValue}
+            orderable={tableState.orderable}
+            tableState={tableState}
+          />
+        </S.TableHeaderCell>
+      ) : (
+        // TODO types will be changed after fixing TableHeaderCell
+        <TableHeaderCell
+          {...(tableState.orderable as never)}
+          orderValue={orderValue as never}
+          title={title}
+        />
+      );
+    });
+    return (
+      <tr>
+        {allSelectable ? (
+          <SelectCell
+            rowIndex={-1}
+            el="th"
+            selectable
+            selected={tableState.selectedCount === tableState.data.length}
+            onChange={tableState.toggleSelection}
+          />
+        ) : (
+          <S.TableHeaderCell />
+        )}
+        {headerCells}
+      </tr>
+    );
+  }, [children, allSelectable, tableState]);
+
+  const bodyRows = React.useMemo(() => {
+    if (tableState.data.length === 0) {
+      const colspan = React.Children.count(children) + +selectable;
+      return (
+        <tr>
+          <td colSpan={colspan}>{placeholder}</td>
+        </tr>
+      );
+    }
+    return tableState.data.map((dataItem, index) => {
+      return (
+        <TableRow
+          key={tableState.idSelector(dataItem)}
+          index={index}
+          hoverable={hoverable}
+          dataItem={dataItem}
+          tableState={tableState}
+          selectable={selectable}
+          onSelectChange={handleRowSelection}
+        >
+          {children}
+        </TableRow>
+      );
+    });
+  }, [
+    children,
+    handleRowSelection,
+    hoverable,
+    placeholder,
+    selectable,
+    tableState,
+  ]);
+
+  return (
+    <>
+      <Table isFullwidth={isFullwidth}>
+        <thead>{headerRow}</thead>
+        <tbody>{bodyRows}</tbody>
+      </Table>
+      {paginated && tableState.totalPages !== undefined && (
+        <Pagination totalPages={tableState.totalPages} />
+      )}
+    </>
+  );
+};
diff --git a/kafka-ui-react-app/src/components/common/SmartTable/TableColumn.tsx b/kafka-ui-react-app/src/components/common/SmartTable/TableColumn.tsx
new file mode 100644
index 00000000000..971aacf7bde
--- /dev/null
+++ b/kafka-ui-react-app/src/components/common/SmartTable/TableColumn.tsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import { TableState } from 'lib/hooks/useTableState';
+import { SortOrder } from 'generated-sources';
+
+export interface OrderableProps<OT> {
+  orderBy: OT | null;
+  sortOrder: SortOrder;
+  handleOrderBy: (orderBy: OT | null) => void;
+}
+
+interface TableCellPropsBase<T, TId extends IdType, OT = never> {
+  tableState: TableState<T, TId, OT>;
+}
+
+export interface TableHeaderCellProps<T, TId extends IdType, OT = never>
+  extends TableCellPropsBase<T, TId, OT> {
+  orderable?: OrderableProps<OT>;
+  orderValue?: OT;
+}
+
+export interface TableCellProps<T, TId extends IdType, OT = never>
+  extends TableCellPropsBase<T, TId, OT> {
+  rowIndex: number;
+  dataItem: T;
+  hovered?: boolean;
+}
+
+interface TableColumnProps<T, TId extends IdType, OT = never> {
+  cell?: React.FC<TableCellProps<T, TId>>;
+  children?: React.ReactElement;
+  headerCell?: React.FC<TableHeaderCellProps<T, TId, OT>>;
+  field?: string;
+  title?: string;
+  width?: string;
+  className?: string;
+  orderValue?: OT;
+}
+
+export const TableColumn = <T, TId extends IdType, OT = never>(
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  props: React.PropsWithChildren<TableColumnProps<T, TId, OT>>
+): React.ReactElement => {
+  return <td />;
+};
+
+export function isColumnElement<T, TId extends IdType, OT = never>(
+  element: React.ReactNode
+): element is React.ReactElement<TableColumnProps<T, TId, OT>> {
+  if (!React.isValidElement(element)) {
+    return false;
+  }
+
+  const elementType = (element as React.ReactElement).type;
+  return (
+    elementType === TableColumn ||
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    (elementType as any).originalType === TableColumn
+  );
+}
+
+interface SelectCellProps {
+  selected: boolean;
+  selectable: boolean;
+  el: 'td' | 'th';
+  rowIndex: number;
+  onChange: (checked: boolean) => void;
+}
+
+export const SelectCell: React.FC<SelectCellProps> = ({
+  selected,
+  selectable,
+  rowIndex,
+  onChange,
+  el,
+}) => {
+  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    onChange(e.target.checked);
+  };
+
+  const El = el;
+
+  return (
+    <El>
+      {selectable && (
+        <input
+          data-row={rowIndex}
+          onChange={handleChange}
+          type="checkbox"
+          checked={selected}
+        />
+      )}
+    </El>
+  );
+};
diff --git a/kafka-ui-react-app/src/components/common/SmartTable/TableRow.tsx b/kafka-ui-react-app/src/components/common/SmartTable/TableRow.tsx
new file mode 100644
index 00000000000..14a89797278
--- /dev/null
+++ b/kafka-ui-react-app/src/components/common/SmartTable/TableRow.tsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import { propertyLookup } from 'lib/propertyLookup';
+import { TableState } from 'lib/hooks/useTableState';
+
+import { isColumnElement, SelectCell, TableCellProps } from './TableColumn';
+
+interface TableRowProps<T, TId extends IdType = never, OT = never> {
+  index: number;
+  id?: TId;
+  hoverable?: boolean;
+  tableState: TableState<T, TId, OT>;
+  dataItem: T;
+  selectable: boolean;
+  onSelectChange?: (row: T, checked: boolean) => void;
+}
+
+export const TableRow = <T, TId extends IdType, OT = never>({
+  children,
+  hoverable = false,
+  id,
+  index,
+  dataItem,
+  selectable,
+  tableState,
+  onSelectChange,
+}: React.PropsWithChildren<TableRowProps<T, TId, OT>>): React.ReactElement => {
+  const [hovered, setHovered] = React.useState(false);
+
+  const handleMouseEnter = React.useCallback(() => {
+    setHovered(true);
+  }, []);
+
+  const handleMouseLeave = React.useCallback(() => {
+    setHovered(false);
+  }, []);
+
+  const handleSelectChange = React.useCallback(
+    (checked: boolean) => {
+      onSelectChange?.(dataItem, checked);
+    },
+    [dataItem, onSelectChange]
+  );
+
+  return (
+    <tr
+      tabIndex={index}
+      id={id as string}
+      onMouseEnter={hoverable ? handleMouseEnter : undefined}
+      onMouseLeave={hoverable ? handleMouseLeave : undefined}
+    >
+      {selectable && (
+        <SelectCell
+          rowIndex={index}
+          el="td"
+          selectable={tableState.isRowSelectable(dataItem)}
+          selected={tableState.selectedIds.has(tableState.idSelector(dataItem))}
+          onChange={handleSelectChange}
+        />
+      )}
+      {React.Children.map(children, (child) => {
+        if (!isColumnElement<T, TId>(child)) {
+          return child;
+        }
+        const { cell, field, width, className } = child.props;
+
+        const Cell = cell as React.FC<TableCellProps<T, TId, OT>> | undefined;
+
+        return Cell ? (
+          <td className={className} style={{ width }}>
+            <Cell
+              tableState={tableState}
+              hovered={hovered}
+              rowIndex={index}
+              dataItem={dataItem}
+            />
+          </td>
+        ) : (
+          <td className={className} style={{ width }}>
+            {field && propertyLookup(field, dataItem)}
+          </td>
+        );
+      })}
+    </tr>
+  );
+};
diff --git a/kafka-ui-react-app/src/custom.d.ts b/kafka-ui-react-app/src/custom.d.ts
index 990f04c63a8..9c2d0f2d6ea 100644
--- a/kafka-ui-react-app/src/custom.d.ts
+++ b/kafka-ui-react-app/src/custom.d.ts
@@ -1,1 +1,2 @@
 type Dictionary<T> = Record<string, T>;
+type IdType = string | number;
diff --git a/kafka-ui-react-app/src/lib/__test__/propertyLookup.spec.ts b/kafka-ui-react-app/src/lib/__test__/propertyLookup.spec.ts
new file mode 100644
index 00000000000..45f89b0a08f
--- /dev/null
+++ b/kafka-ui-react-app/src/lib/__test__/propertyLookup.spec.ts
@@ -0,0 +1,18 @@
+import { propertyLookup } from 'lib/propertyLookup';
+
+describe('Property Lookup', () => {
+  const entityObject = {
+    prop: {
+      nestedProp: 1,
+    },
+  };
+  it('returns undefined if property not found', () => {
+    expect(
+      propertyLookup('prop.nonExistingProp', entityObject)
+    ).toBeUndefined();
+  });
+
+  it('returns value of nested property if it exists', () => {
+    expect(propertyLookup('prop.nestedProp', entityObject)).toBe(1);
+  });
+});
diff --git a/kafka-ui-react-app/src/lib/hooks/useTableState.ts b/kafka-ui-react-app/src/lib/hooks/useTableState.ts
new file mode 100644
index 00000000000..b5fa6ed79c1
--- /dev/null
+++ b/kafka-ui-react-app/src/lib/hooks/useTableState.ts
@@ -0,0 +1,78 @@
+import React, { useCallback } from 'react';
+import { OrderableProps } from 'components/common/SmartTable/TableColumn';
+
+export interface TableState<T, TId extends IdType, OT = never> {
+  data: T[];
+  selectedIds: Set<TId>;
+  totalPages?: number;
+  idSelector: (row: T) => TId;
+  isRowSelectable: (row: T) => boolean;
+  selectedCount: number;
+  setRowsSelection: (rows: T[], selected: boolean) => void;
+  toggleSelection: (selected: boolean) => void;
+  orderable?: OrderableProps<OT>;
+}
+
+export const useTableState = <T, TId extends IdType, OT = never>(
+  data: T[],
+  options: {
+    totalPages: number;
+    isRowSelectable?: (row: T) => boolean;
+    idSelector: (row: T) => TId;
+  },
+  orderable?: OrderableProps<OT>
+): TableState<T, TId, OT> => {
+  const [selectedIds, setSelectedIds] = React.useState(new Set<TId>());
+
+  const { idSelector, totalPages, isRowSelectable = () => true } = options;
+
+  const selectedCount = selectedIds.size;
+
+  const setRowsSelection = useCallback(
+    (rows: T[], selected: boolean) => {
+      rows.forEach((row) => {
+        const id = idSelector(row);
+        const newSet = new Set(selectedIds);
+        if (selected) {
+          newSet.add(id);
+        } else {
+          newSet.delete(id);
+        }
+        setSelectedIds(newSet);
+      });
+    },
+    [idSelector, selectedIds]
+  );
+
+  const toggleSelection = useCallback(
+    (selected: boolean) => {
+      const newSet = new Set(selected ? data.map((r) => idSelector(r)) : []);
+      setSelectedIds(newSet);
+    },
+    [data, idSelector]
+  );
+
+  return React.useMemo<TableState<T, TId, OT>>(() => {
+    return {
+      data,
+      totalPages,
+      selectedIds,
+      orderable,
+      selectedCount,
+      idSelector,
+      isRowSelectable,
+      setRowsSelection,
+      toggleSelection,
+    };
+  }, [
+    data,
+    orderable,
+    selectedIds,
+    totalPages,
+    selectedCount,
+    idSelector,
+    isRowSelectable,
+    setRowsSelection,
+    toggleSelection,
+  ]);
+};
diff --git a/kafka-ui-react-app/src/lib/propertyLookup.ts b/kafka-ui-react-app/src/lib/propertyLookup.ts
new file mode 100644
index 00000000000..6a563ca623d
--- /dev/null
+++ b/kafka-ui-react-app/src/lib/propertyLookup.ts
@@ -0,0 +1,9 @@
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function propertyLookup<T extends { [key: string]: any }>(
+  path: string,
+  obj: T
+) {
+  return path.split('.').reduce((prev, curr) => {
+    return prev ? prev[curr] : null;
+  }, obj);
+}


Test patch: