Quellcode durchsuchen

Reusable tables (#1703)

* Table and TableColumn components, TableState and DataSource

* Table: Migrate topics table to new Table component

* fix module paths

* test for propertyLookup

* improve useTableState code

* fix folder name

* improve table ordering

* fix selected count for table

Co-authored-by: Roman Zabaluev <rzabaluev@provectus.com>
Sasha Stepanyan vor 3 Jahren
Ursprung
Commit
97b6e2593a

+ 134 - 85
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 usePagination from 'lib/hooks/usePagination';
 import ClusterContext from 'components/contexts/ClusterContext';
 import ClusterContext from 'components/contexts/ClusterContext';
 import PageLoader from 'components/common/PageLoader/PageLoader';
 import PageLoader from 'components/common/PageLoader/PageLoader';
-import Pagination from 'components/common/Pagination/Pagination';
 import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
 import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
 import {
 import {
+  CleanUpPolicy,
   GetTopicsRequest,
   GetTopicsRequest,
   SortOrder,
   SortOrder,
   TopicColumnsToSort,
   TopicColumnsToSort,
 } from 'generated-sources';
 } from 'generated-sources';
-import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
 import Search from 'components/common/Search/Search';
 import Search from 'components/common/Search/Search';
 import { PER_PAGE } from 'lib/constants';
 import { PER_PAGE } from 'lib/constants';
-import { Table } from 'components/common/table/Table/Table.styled';
 import { Button } from 'components/common/Button/Button';
 import { Button } from 'components/common/Button/Button';
 import PageHeading from 'components/common/PageHeading/PageHeading';
 import PageHeading from 'components/common/PageHeading/PageHeading';
 import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
 import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
 import Switch from 'components/common/Switch/Switch';
 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 {
 export interface TopicsListProps {
   areTopicsFetching: boolean;
   areTopicsFetching: boolean;
@@ -63,7 +75,8 @@ const List: React.FC<TopicsListProps> = ({
   setTopicsSearch,
   setTopicsSearch,
   setTopicsOrderBy,
   setTopicsOrderBy,
 }) => {
 }) => {
-  const { isReadOnly } = React.useContext(ClusterContext);
+  const { isReadOnly, isTopicDeletionAllowed } =
+    React.useContext(ClusterContext);
   const { clusterName } = useParams<{ clusterName: ClusterName }>();
   const { clusterName } = useParams<{ clusterName: ClusterName }>();
   const { page, perPage, pathname } = usePagination();
   const { page, perPage, pathname } = usePagination();
   const [showInternal, setShowInternal] = React.useState<boolean>(true);
   const [showInternal, setShowInternal] = React.useState<boolean>(true);
@@ -90,6 +103,24 @@ const List: React.FC<TopicsListProps> = ({
     showInternal,
     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(() => {
   const handleSwitch = React.useCallback(() => {
     setShowInternal(!showInternal);
     setShowInternal(!showInternal);
     history.push(`${pathname}?page=1&perPage=${perPage || PER_PAGE}`);
     history.push(`${pathname}?page=1&perPage=${perPage || PER_PAGE}`);
@@ -103,36 +134,26 @@ const List: React.FC<TopicsListProps> = ({
     setConfirmationModal('');
     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(() => {
   const deleteTopicsHandler = React.useCallback(() => {
-    deleteTopics(clusterName, Array.from(selectedTopics));
+    deleteTopics(clusterName, Array.from(tableState.selectedIds));
     closeConfirmationModal();
     closeConfirmationModal();
     clearSelectedTopics();
     clearSelectedTopics();
-  }, [clusterName, deleteTopics, selectedTopics]);
+  }, [clearSelectedTopics, clusterName, deleteTopics, tableState.selectedIds]);
   const purgeMessagesHandler = React.useCallback(() => {
   const purgeMessagesHandler = React.useCallback(() => {
-    clearTopicsMessages(clusterName, Array.from(selectedTopics));
+    clearTopicsMessages(clusterName, Array.from(tableState.selectedIds));
     closeConfirmationModal();
     closeConfirmationModal();
     clearSelectedTopics();
     clearSelectedTopics();
-  }, [clearTopicsMessages, clusterName, selectedTopics]);
+  }, [
+    clearSelectedTopics,
+    clearTopicsMessages,
+    clusterName,
+    tableState.selectedIds,
+  ]);
+
   const searchHandler = React.useCallback(
   const searchHandler = React.useCallback(
     (searchString: string) => {
     (searchString: string) => {
       setTopicsSearch(searchString);
       setTopicsSearch(searchString);
@@ -141,6 +162,53 @@ const List: React.FC<TopicsListProps> = ({
     [setTopicsSearch, history, pathname, perPage]
     [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 (
   return (
     <div>
     <div>
       <div>
       <div>
@@ -178,7 +246,7 @@ const List: React.FC<TopicsListProps> = ({
         <PageLoader />
         <PageLoader />
       ) : (
       ) : (
         <div>
         <div>
-          {selectedTopics.size > 0 && (
+          {tableState.selectedCount > 0 && (
             <>
             <>
               <ControlPanelWrapper data-testid="delete-buttons">
               <ControlPanelWrapper data-testid="delete-buttons">
                 <Button
                 <Button
@@ -215,62 +283,43 @@ const List: React.FC<TopicsListProps> = ({
               </ConfirmationModal>
               </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>
       )}
       )}
     </div>
     </div>

+ 59 - 0
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>;
+};

+ 9 - 9
kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx

@@ -155,7 +155,7 @@ describe('List', () => {
       </StaticRouter>
       </StaticRouter>
     );
     );
     const getCheckboxInput = (at: number) =>
     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 = () =>
     const getConfirmationModal = () =>
       component.find('mock-ConfirmationModal').at(0);
       component.find('mock-ConfirmationModal').at(0);
@@ -166,12 +166,12 @@ describe('List', () => {
       expect(component.find('.buttons').length).toEqual(0);
       expect(component.find('.buttons').length).toEqual(0);
 
 
       // check first item
       // check first item
-      getCheckboxInput(0).simulate('change');
+      getCheckboxInput(0).simulate('change', { target: { checked: true } });
       expect(getCheckboxInput(0).props().checked).toBeTruthy();
       expect(getCheckboxInput(0).props().checked).toBeTruthy();
       expect(getCheckboxInput(1).props().checked).toBeFalsy();
       expect(getCheckboxInput(1).props().checked).toBeFalsy();
 
 
       // check second item
       // check second item
-      getCheckboxInput(1).simulate('change');
+      getCheckboxInput(1).simulate('change', { target: { checked: true } });
       expect(getCheckboxInput(0).props().checked).toBeTruthy();
       expect(getCheckboxInput(0).props().checked).toBeTruthy();
       expect(getCheckboxInput(1).props().checked).toBeTruthy();
       expect(getCheckboxInput(1).props().checked).toBeTruthy();
       expect(
       expect(
@@ -179,7 +179,7 @@ describe('List', () => {
       ).toEqual(1);
       ).toEqual(1);
 
 
       // uncheck second item
       // uncheck second item
-      getCheckboxInput(1).simulate('change');
+      getCheckboxInput(1).simulate('change', { target: { checked: false } });
       expect(getCheckboxInput(0).props().checked).toBeTruthy();
       expect(getCheckboxInput(0).props().checked).toBeTruthy();
       expect(getCheckboxInput(1).props().checked).toBeFalsy();
       expect(getCheckboxInput(1).props().checked).toBeFalsy();
       expect(
       expect(
@@ -187,7 +187,7 @@ describe('List', () => {
       ).toEqual(1);
       ).toEqual(1);
 
 
       // uncheck first item
       // uncheck first item
-      getCheckboxInput(0).simulate('change');
+      getCheckboxInput(0).simulate('change', { target: { checked: false } });
       expect(getCheckboxInput(0).props().checked).toBeFalsy();
       expect(getCheckboxInput(0).props().checked).toBeFalsy();
       expect(getCheckboxInput(1).props().checked).toBeFalsy();
       expect(getCheckboxInput(1).props().checked).toBeFalsy();
       expect(
       expect(
@@ -203,8 +203,8 @@ describe('List', () => {
           : 'Are you sure you want to purge messages of selected topics?';
           : 'Are you sure you want to purge messages of selected topics?';
       const mockFn =
       const mockFn =
         action === 'deleteTopics' ? mockDeleteTopics : mockClearTopicsMessages;
         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();
       let modal = getConfirmationModal();
       expect(modal.prop('isOpen')).toBeFalsy();
       expect(modal.prop('isOpen')).toBeFalsy();
       component
       component
@@ -240,8 +240,8 @@ describe('List', () => {
     });
     });
 
 
     it('closes ConfirmationModal when clicked on the cancel button', async () => {
     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();
       let modal = getConfirmationModal();
       expect(modal.prop('isOpen')).toBeFalsy();
       expect(modal.prop('isOpen')).toBeFalsy();
       component
       component

+ 134 - 0
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} />
+      )}
+    </>
+  );
+};

+ 94 - 0
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>
+  );
+};

+ 85 - 0
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>
+  );
+};

+ 1 - 0
kafka-ui-react-app/src/custom.d.ts

@@ -1 +1,2 @@
 type Dictionary<T> = Record<string, T>;
 type Dictionary<T> = Record<string, T>;
+type IdType = string | number;

+ 18 - 0
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);
+  });
+});

+ 78 - 0
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,
+  ]);
+};

+ 9 - 0
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);
+}