Kaynağa Gözat

[ui-core] adds Table component (#4052)

Ram Prasad Agarwal 8 ay önce
ebeveyn
işleme
cdafdb6536

+ 4 - 7
desktop/core/src/desktop/js/apps/admin/Metrics/Metrics.scss

@@ -26,12 +26,9 @@
     color: $fluidx-gray-900;
   }
 
-  .metrics-table {
-    th {
-      width: 30%;
-      background-color: $fluidx-gray-040;
-    }
-
-    margin-bottom: $font-size-base;
+  .metrics-component__table-group {
+    display: flex;
+    flex-direction: column;
+    gap: $font-size-base;
   }
 }

+ 14 - 14
desktop/core/src/desktop/js/apps/admin/Metrics/MetricsTab.test.tsx

@@ -54,29 +54,29 @@ jest.mock('api/utils', () => ({
 }));
 
 describe('Metrics', () => {
-  test('Filtering metrics based on name column value', async () => {
+  it('should filter metrics based on name column value', async () => {
     render(<Metrics />);
 
     const filterInput = screen.getByPlaceholderText('Filter metrics...');
     fireEvent.change(filterInput, { target: { value: 'value' } });
 
     await waitFor(() => {
-      expect(screen.getByText('queries.number')).toBeInTheDocument();
-      expect(screen.getByText('threads.daemon')).toBeInTheDocument();
-      expect(screen.getByText('threads.total')).toBeInTheDocument();
-      expect(screen.getByText('users')).toBeInTheDocument();
-      expect(screen.getByText('users.active')).toBeInTheDocument();
-      expect(screen.getByText('users.active.total')).toBeInTheDocument();
-      expect(screen.queryByText('requests.active')).not.toBeInTheDocument();
-      expect(screen.queryByText('requests.exceptions')).toBeNull();
-      expect(screen.queryByText('requests.response-time')).toBeNull();
+      expect(screen.getByText('Number of Queries')).toBeInTheDocument();
+      expect(screen.getByText('Daemon Threads')).toBeInTheDocument();
+      expect(screen.getByText('Total Threads')).toBeInTheDocument();
+      expect(screen.getByText('Users')).toBeInTheDocument();
+      expect(screen.getByText('Active Users')).toBeInTheDocument();
+      expect(screen.getByText('Total Active Users')).toBeInTheDocument();
+      expect(screen.queryByText('Active Requests')).not.toBeInTheDocument();
+      expect(screen.queryByText('Request Exceptions')).toBeNull();
+      expect(screen.queryByText('Request Response Time')).toBeNull();
     });
   });
 
-  test('Selecting a specific metric from the dropdown filters the data using click events', async () => {
+  it('should select a specific metric from the dropdown filters the data using click events', async () => {
     render(<Metrics />);
 
-    await waitFor(() => screen.getByText('queries.number'));
+    await waitFor(() => screen.getByText('Number of Queries'));
 
     const select = screen.getByTestId('admin-header--select').firstElementChild;
     if (select) {
@@ -98,7 +98,7 @@ describe('Metrics', () => {
     }
   });
 
-  test('Ensuring metrics starting with auth, multiprocessing and python.gc are not displayed', async () => {
+  it('should ensure metrics starting with auth, multiprocessing and python.gc are not displayed', async () => {
     jest.clearAllMocks();
     jest.mock('api/utils', () => ({
       get: jest.fn(() =>
@@ -133,7 +133,7 @@ describe('Metrics', () => {
       expect(screen.queryByText('auth.ldap.auth-time')).not.toBeInTheDocument();
       expect(screen.queryByText('multiprocessing.processes.total')).not.toBeInTheDocument();
       expect(screen.queryByText('python.gc.objects')).not.toBeInTheDocument();
-      expect(screen.queryByText('users')).toBeInTheDocument();
+      expect(screen.queryByText('Users')).toBeInTheDocument();
     });
   });
 });

+ 10 - 8
desktop/core/src/desktop/js/apps/admin/Metrics/MetricsTab.tsx

@@ -114,14 +114,16 @@ const Metrics: React.FC = (): JSX.Element => {
           />
         )}
 
-        {!error &&
-          filteredMetricsData.map((tableData, index) => (
-            <div key={index}>
-              {(showAllTables || selectedMetric === tableData.caption) && (
-                <MetricsTable caption={tableData.caption} dataSource={tableData.dataSource} />
-              )}
-            </div>
-          ))}
+        <div className="metrics-component__table-group">
+          {!error &&
+            filteredMetricsData.map((tableData, index) => (
+              <div key={index}>
+                {(showAllTables || selectedMetric === tableData.caption) && (
+                  <MetricsTable caption={tableData.caption} dataSource={tableData.dataSource} />
+                )}
+              </div>
+            ))}
+        </div>
       </Spin>
     </div>
   );

+ 21 - 21
desktop/core/src/desktop/js/apps/admin/Metrics/MetricsTable.tsx

@@ -15,8 +15,9 @@
 // limitations under the License.
 
 import React, { useMemo } from 'react';
-import Table from 'cuix/dist/components/Table/Table';
-import type { ColumnType } from 'antd/es/table';
+import PaginatedTable, {
+  ColumnProps
+} from '../../../reactComponents/PaginatedTable/PaginatedTable';
 import './Metrics.scss';
 import { i18nReact } from '../../../utils/i18nReact';
 
@@ -91,23 +92,24 @@ const metricLabels: { [key: string]: string } = {
   'users.active.total': 'Total Active Users'
 };
 
-const metricsColumns: ColumnType<DataSourceItem>[] = [
-  {
-    title: 'Name',
-    dataIndex: 'name',
-    key: 'name'
-  },
-  {
-    title: 'Value',
-    dataIndex: 'value',
-    key: 'value',
-    render: value => (typeof value === 'number' ? value : JSON.stringify(value))
-  }
-];
-
 const MetricsTable: React.FC<MetricsTableProps> = ({ caption, dataSource }) => {
   const { t } = i18nReact.useTranslation();
 
+  const metricsColumns: ColumnProps<DataSourceItem>[] = [
+    {
+      title: t('Name'),
+      dataIndex: 'name',
+      key: 'name',
+      width: '30%'
+    },
+    {
+      title: t('Value'),
+      dataIndex: 'value',
+      key: 'value',
+      render: value => (typeof value === 'number' ? value : JSON.stringify(value))
+    }
+  ];
+
   const transformedDataSource: DataSourceItem[] = useMemo(
     () =>
       dataSource.map(item => ({
@@ -118,13 +120,11 @@ const MetricsTable: React.FC<MetricsTableProps> = ({ caption, dataSource }) => {
   );
 
   return (
-    <Table
-      className="metrics-table"
-      dataSource={transformedDataSource}
+    <PaginatedTable<DataSourceItem>
+      data={transformedDataSource}
       rowKey="name"
       columns={metricsColumns}
-      pagination={false}
-      title={() => <span className="metrics-heading">{caption}</span>}
+      title={() => <span className="metrics-heading">{metricLabels[caption] ?? caption}</span>}
     />
   );
 };

+ 9 - 11
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/ChangePermissionModal/ChangePermissionModal.tsx

@@ -15,10 +15,13 @@
 // limitations under the License.
 
 import React, { useState } from 'react';
+import { Checkbox } from 'antd';
 import Modal from 'cuix/dist/components/Modal';
 import { i18nReact } from '../../../../../../utils/i18nReact';
 import useSaveData from '../../../../../../utils/hooks/useSaveData/useSaveData';
-import { Checkbox, Table } from 'antd';
+import PaginatedTable, {
+  ColumnProps
+} from '../../../../../../reactComponents/PaginatedTable/PaginatedTable';
 import { StorageDirectoryTableData } from '../../../../types';
 import { BULK_CHANGE_PERMISSION_API_URL } from '../../../../api';
 import { getInitialPermissions, Permission } from './ChangePermissionModal.util';
@@ -97,26 +100,26 @@ const ChangePermissionModal = ({
     }
   };
 
-  const columns = [
+  const columns: ColumnProps<Permission>[] = [
     {
       title: '',
       dataIndex: 'key',
       key: 'key'
     },
     {
-      title: 'User',
+      title: t('User'),
       dataIndex: 'user',
       key: 'user',
       render: renderTableCheckbox('user')
     },
     {
-      title: 'Group',
+      title: t('Group'),
       dataIndex: 'group',
       key: 'group',
       render: renderTableCheckbox('group')
     },
     {
-      title: 'Other',
+      title: t('Other'),
       dataIndex: 'other',
       key: 'other',
       render: renderTableCheckbox('other')
@@ -141,12 +144,7 @@ const ChangePermissionModal = ({
       okButtonProps={{ disabled: loading }}
       cancelButtonProps={{ disabled: loading }}
     >
-      <Table
-        dataSource={permissions}
-        columns={columns}
-        pagination={false}
-        className="hue-change-permission__table"
-      />
+      <PaginatedTable<Permission> data={permissions} columns={columns} rowKey="key" />
     </Modal>
   );
 };

+ 10 - 45
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.scss

@@ -17,8 +17,6 @@
 @use 'variables' as vars;
 @use 'mixins';
 
-$cell-height: 40px;
-$table-placeholder-height: 100px;
 $icon-margin: 5px;
 
 .antd.cuix {
@@ -54,53 +52,20 @@ $icon-margin: 5px;
       flex: 1;
       display: flex;
       flex-direction: column;
-    }
-
-    .hue-storage-browser__table {
-      .ant-table-placeholder {
-        height: $table-placeholder-height;
-        text-align: center;
-      }
-
-      th.ant-table-cell {
-        height: $cell-height;
-        background-color: vars.$fluidx-gray-040;
-      }
-    }
 
-    .hue-storage-browser__table-cell-icon {
-      color: vars.$fluidx-blue-700;
-      margin-right: 6px;
-      vertical-align: middle;
-    }
-
-    .hue-storage-browser__table-row {
-      border-bottom: 1px solid vars.$fluidx-gray-040;
-
-      :hover {
-        cursor: pointer;
-      }
+      &__name-column {
+        display: flex;
+        gap: 6px;
+        color: vars.$fluidx-blue-700;
 
-      td.ant-table-cell {
-        height: $cell-height;
-        @include mixins.nowrap-ellipsis;
-      }
+        &__icon {
+          display: flex;
+        }
 
-      td.ant-table-cell:first-child {
-        text-overflow: initial;
+        &__label {
+          @include mixins.nowrap-ellipsis;
+        }
       }
     }
-
-    .hue-storage-browser__table-cell-name {
-      color: vars.$fluidx-blue-700;
-    }
-
-    .hue-storage-browser__table-column-header {
-      display: flex;
-    }
-
-    .hue-storage-browser__table-column-title {
-      text-transform: capitalize;
-    }
   }
 }

+ 87 - 121
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.tsx

@@ -15,29 +15,22 @@
 // limitations under the License.
 
 import React, { useMemo, useState, useCallback } from 'react';
-import { ColumnProps } from 'antd/lib/table';
 import { Input, Tooltip } from 'antd';
 
 import FolderIcon from '@cloudera/cuix-core/icons/react/ProjectIcon';
 import FileIcon from '@cloudera/cuix-core/icons/react/DocumentationIcon';
-import SortAscending from '@cloudera/cuix-core/icons/react/SortAscendingIcon';
-import SortDescending from '@cloudera/cuix-core/icons/react/SortDescendingIcon';
-
-import Table from 'cuix/dist/components/Table';
 
 import { i18nReact } from '../../../utils/i18nReact';
 import useDebounce from '../../../utils/useDebounce';
 
 import { LIST_DIRECTORY_API_URL } from '../api';
 import {
-  SortOrder,
   ListDirectory,
   FileStats,
   BrowserViewType,
   StorageDirectoryTableData,
   FileSystem
 } from '../types';
-import Pagination from '../../../reactComponents/Pagination/Pagination';
 import formatBytes from '../../../utils/formatBytes';
 
 import './StorageDirectoryPage.scss';
@@ -55,20 +48,20 @@ import FileUploadQueue from '../../../reactComponents/FileUploadQueue/FileUpload
 import { useWindowSize } from '../../../utils/hooks/useWindowSize/useWindowSize';
 import LoadingErrorWrapper from '../../../reactComponents/LoadingErrorWrapper/LoadingErrorWrapper';
 import StorageDirectoryActions from './StorageDirectoryActions/StorageDirectoryActions';
+import PaginatedTable, {
+  SortOrder,
+  ColumnProps
+} from '../../../reactComponents/PaginatedTable/PaginatedTable';
 
 interface StorageDirectoryPageProps {
   fileStats: FileStats;
   fileSystem: FileSystem;
   onFilePathChange: (path: string) => void;
-  className?: string;
-  rowClassName?: string;
   testId?: string;
   reloadTrashPath: () => void;
 }
 
 const defaultProps = {
-  className: 'hue-storage-browser__table',
-  rowClassName: 'hue-storage-browser__table-row',
   testId: 'hue-storage-browser__table'
 };
 
@@ -76,11 +69,8 @@ const StorageDirectoryPage = ({
   fileStats,
   fileSystem,
   onFilePathChange,
-  className,
-  rowClassName,
   testId,
-  reloadTrashPath,
-  ...restProps
+  reloadTrashPath
 }: StorageDirectoryPageProps): JSX.Element => {
   const [loadingFiles, setLoadingFiles] = useState<boolean>(false);
   const [selectedFiles, setSelectedFiles] = useState<StorageDirectoryTableData[]>([]);
@@ -89,8 +79,9 @@ const StorageDirectoryPage = ({
 
   const [pageSize, setPageSize] = useState<number>(DEFAULT_PAGE_SIZE);
   const [pageNumber, setPageNumber] = useState<number>(1);
-  const [sortByColumn, setSortByColumn] = useState<string>('');
-  const [sortOrder, setSortOrder] = useState<SortOrder>(SortOrder.NONE);
+  const [sortByColumn, setSortByColumn] =
+    useState<ColumnProps<StorageDirectoryTableData>['dataIndex']>();
+  const [sortOrder, setSortOrder] = useState<SortOrder>(null);
   const [searchTerm, setSearchTerm] = useState<string>('');
 
   const { t } = i18nReact.useTranslation();
@@ -105,17 +96,15 @@ const StorageDirectoryPage = ({
       path: fileStats.path,
       pagesize: pageSize.toString(),
       pagenum: pageNumber.toString(),
-      filter: searchTerm,
+      filter: searchTerm !== '' ? searchTerm : undefined,
       sortby: sortByColumn,
-      descending: sortOrder === SortOrder.DSC ? 'true' : 'false'
+      descending: sortOrder !== null ? sortOrder === 'descend' : undefined
     },
     skip:
       fileStats.path === '' ||
       fileStats.path === undefined ||
       fileStats.type !== BrowserViewType.dir,
-    onSuccess: () => {
-      setSelectedFiles([]);
-    },
+    onSuccess: () => setSelectedFiles([]),
     pollInterval: polling ? DEFAULT_POLLING_TIME : undefined
   });
 
@@ -137,67 +126,6 @@ const StorageDirectoryPage = ({
     }));
   }, [filesData]);
 
-  const onColumnTitleClicked = (columnClicked: string) => {
-    if (columnClicked === sortByColumn) {
-      if (sortOrder === SortOrder.NONE) {
-        setSortOrder(SortOrder.ASC);
-      } else if (sortOrder === SortOrder.ASC) {
-        setSortOrder(SortOrder.DSC);
-      } else {
-        setSortOrder(SortOrder.NONE);
-      }
-    } else {
-      setSortByColumn(columnClicked);
-      setSortOrder(SortOrder.ASC);
-    }
-  };
-
-  const getColumns = (file: StorageDirectoryTableData) => {
-    const columns: ColumnProps<StorageDirectoryTableData>[] = [];
-    for (const key of Object.keys(file)) {
-      const column: ColumnProps<StorageDirectoryTableData> = {
-        dataIndex: key,
-        title: (
-          <div
-            className="hue-storage-browser__table-column-header"
-            onClick={() => onColumnTitleClicked(key)}
-          >
-            <div className="hue-storage-browser__table-column-title">
-              {key === 'mtime' ? t('Last Updated') : t(key)}
-            </div>
-            {key === sortByColumn ? (
-              sortOrder === SortOrder.DSC ? (
-                <SortDescending />
-              ) : sortOrder === SortOrder.ASC ? (
-                <SortAscending />
-              ) : null
-            ) : null}
-          </div>
-        ),
-        key: `${key}`
-      };
-      if (key === 'name') {
-        column.width = '40%';
-        column.render = (_, record: StorageDirectoryTableData) => (
-          <Tooltip title={record.name} mouseEnterDelay={1.5}>
-            <span className="hue-storage-browser__table-cell-icon">
-              {record.type === 'dir' ? <FolderIcon /> : <FileIcon />}
-            </span>
-            <span className="hue-storage-browser__table-cell-name">{record.name}</span>
-          </Tooltip>
-        );
-      } else if (key === 'mtime') {
-        column.width = '20%';
-      } else if (key === 'permission') {
-        column.width = '12%';
-      }
-      columns.push(column);
-    }
-    return columns.filter(
-      col => col.dataIndex !== 'type' && col.dataIndex !== 'path' && col.dataIndex !== 'replication'
-    );
-  };
-
   const onRowClicked = (record: StorageDirectoryTableData) => {
     return {
       onClick: () => {
@@ -211,12 +139,6 @@ const StorageDirectoryPage = ({
     };
   };
 
-  const rowSelection = {
-    onChange: (_: React.Key[], selectedRows: StorageDirectoryTableData[]) => {
-      setSelectedFiles(selectedRows);
-    }
-  };
-
   const handleSearch = useCallback(
     useDebounce(searchTerm => {
       setSearchTerm(encodeURIComponent(searchTerm));
@@ -241,10 +163,6 @@ const StorageDirectoryPage = ({
   // 40px for table header, 50px for pagination
   const tableBodyHeight = Math.max(rect.height - 90, 100);
 
-  const locale = {
-    emptyText: t('Folder is empty')
-  };
-
   const errorConfig = [
     {
       enabled: !!listDirectoryError,
@@ -254,6 +172,64 @@ const StorageDirectoryPage = ({
     }
   ];
 
+  const columnsConfig: ColumnProps<StorageDirectoryTableData>[] = [
+    {
+      title: t('Name'),
+      dataIndex: 'name',
+      key: 'name',
+      sorter: true,
+      width: '40%',
+      render: (_, record) => (
+        <Tooltip title={record.name} mouseEnterDelay={1.5}>
+          <div className="hue-storage-browser-directory__table-container__name-column">
+            <div className="hue-storage-browser-directory__table-container__name-column__icon">
+              {record.type === BrowserViewType.dir ? (
+                <FolderIcon height={18} width={18} />
+              ) : (
+                <FileIcon height={18} width={18} />
+              )}
+            </div>
+            <div className="hue-storage-browser-directory__table-container__name-column__label">
+              {record.name}
+            </div>
+          </div>
+        </Tooltip>
+      )
+    },
+    {
+      title: t('Size'),
+      dataIndex: 'size',
+      key: 'size',
+      width: '10%',
+      sorter: true
+    },
+    {
+      title: t('User'),
+      dataIndex: 'user',
+      key: 'user',
+      width: '10%'
+    },
+    {
+      title: t('Group'),
+      dataIndex: 'group',
+      key: 'group',
+      width: '10%'
+    },
+    {
+      title: t('Permission'),
+      dataIndex: 'permission',
+      key: 'permission',
+      width: '10%'
+    },
+    {
+      title: t('Last Updated'),
+      dataIndex: 'mtime',
+      key: 'mtime',
+      width: '20%',
+      sorter: true
+    }
+  ];
+
   return (
     <div className="hue-storage-browser-directory">
       <div className="hue-storage-browser-directory__actions-bar">
@@ -261,9 +237,7 @@ const StorageDirectoryPage = ({
           className="hue-storage-browser-directory__actions-bar__search"
           placeholder={t('Search')}
           allowClear={true}
-          onChange={event => {
-            handleSearch(event.target.value);
-          }}
+          onChange={event => handleSearch(event.target.value)}
           disabled={!tableData.length && !searchTerm.length}
         />
         <div className="hue-storage-browser-directory__actions-bar__actions">
@@ -288,34 +262,26 @@ const StorageDirectoryPage = ({
             loading={(loadingFiles || listDirectoryLoading) && !polling}
             errors={errorConfig}
           >
-            <Table
-              className={className}
-              columns={getColumns(tableData[0] ?? {})}
-              dataSource={tableData}
-              onRow={onRowClicked}
-              pagination={false}
-              rowClassName={rowClassName}
+            <PaginatedTable<StorageDirectoryTableData>
+              data={tableData}
+              columns={columnsConfig}
               rowKey={r => `${r.path}_${r.type}_${r.mtime}`}
-              rowSelection={{
-                hideSelectAll: !tableData.length,
-                columnWidth: 36,
-                type: 'checkbox',
-                ...rowSelection
-              }}
+              onRowClick={onRowClicked}
+              onRowSelect={setSelectedFiles}
+              sortByColumn={sortByColumn}
+              setSortByColumn={setSortByColumn}
+              sortOrder={sortOrder}
+              setSortOrder={setSortOrder}
+              locale={{ emptyText: t('Folder is empty') }}
               scroll={{ y: tableBodyHeight }}
-              data-testid={testId}
-              locale={locale}
-              {...restProps}
+              testId={testId}
+              pagination={{
+                pageSize,
+                setPageSize,
+                setPageNumber,
+                pageStats: filesData?.page
+              }}
             />
-
-            {filesData?.page && filesData?.page?.totalPages > 0 && (
-              <Pagination
-                setPageSize={setPageSize}
-                pageSize={pageSize}
-                setPageNumber={setPageNumber}
-                pageStats={filesData?.page}
-              />
-            )}
           </LoadingErrorWrapper>
         </DragAndDrop>
       </div>

+ 2 - 13
desktop/core/src/desktop/js/apps/storageBrowser/types.ts

@@ -14,6 +14,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import { type PageStats } from '../../reactComponents/Pagination/Pagination';
+
 export interface HDFSFileSystemConfig {
   isTrashEnabled: boolean;
   isHdfsSuperuser: boolean;
@@ -51,13 +53,6 @@ export interface StorageDirectoryTableData
   mtime: string;
 }
 
-export interface PageStats {
-  pageNumber: number;
-  totalPages: number;
-  pageSize: number;
-  totalSize: number;
-}
-
 export interface FilePreview {
   contents: string;
   end: number;
@@ -87,12 +82,6 @@ export interface TrashData {
   trashPath: string;
 }
 
-export enum SortOrder {
-  ASC = 'ascending',
-  DSC = 'descending',
-  NONE = 'none'
-}
-
 export enum BrowserViewType {
   dir = 'dir',
   file = 'file'

+ 34 - 0
desktop/core/src/desktop/js/reactComponents/PaginatedTable/PaginatedTable.scss

@@ -0,0 +1,34 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+@use 'variables' as vars;
+@use 'mixins';
+
+.hue-table {
+  .ant-table-cell {
+    @include mixins.nowrap-ellipsis;
+  }
+
+  .ant-table-selection-column {
+    // This prevents the eplipses from being applied to the selection column
+    text-overflow: initial !important;
+  }
+
+  .ant-table-placeholder {
+    height: 100px;
+    text-align: center;
+  }
+}

+ 138 - 0
desktop/core/src/desktop/js/reactComponents/PaginatedTable/PaginatedTable.test.tsx

@@ -0,0 +1,138 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react';
+import Table from './PaginatedTable';
+import { ColumnProps } from 'antd/lib/table';
+import '@testing-library/jest-dom';
+
+interface TestData {
+  id: string;
+  name: string;
+  age: number;
+}
+
+describe('Table', () => {
+  const mockData: TestData[] = [
+    { id: '1', name: 'John', age: 25 },
+    { id: '2', name: 'Jane', age: 30 }
+  ];
+
+  const mockColumns: ColumnProps<TestData>[] = [
+    {
+      title: 'Name',
+      dataIndex: 'name',
+      key: 'name',
+      sorter: true
+    },
+    {
+      title: 'Age',
+      dataIndex: 'age',
+      key: 'age',
+      sorter: false
+    }
+  ];
+
+  const defaultProps = {
+    data: mockData,
+    columns: mockColumns,
+    rowKey: (record: TestData) => record.id,
+    testId: 'test-table'
+  };
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('renders table with data', () => {
+    const { getByTestId, getByText } = render(<Table {...defaultProps} />);
+
+    expect(getByTestId('test-table')).toBeInTheDocument();
+    expect(getByText('John')).toBeInTheDocument();
+    expect(getByText('30')).toBeInTheDocument();
+  });
+
+  it('handles row selection', () => {
+    const onRowSelect = jest.fn();
+    const { container } = render(<Table {...defaultProps} onRowSelect={onRowSelect} />);
+
+    const checkboxes = container.querySelectorAll('input[type="checkbox"]');
+    fireEvent.click(checkboxes[1]); // First data row checkbox
+
+    expect(onRowSelect).toHaveBeenCalledWith([mockData[0]]);
+  });
+
+  it('handles row click', () => {
+    const mockClick = jest.fn();
+    const handleRowClick = jest.fn().mockReturnValue({
+      onClick: mockClick
+    });
+
+    const { getByText } = render(<Table {...defaultProps} onRowClick={handleRowClick} />);
+
+    const row = getByText('John').closest('tr');
+    fireEvent.click(row as HTMLElement);
+
+    expect(handleRowClick).toHaveBeenCalled();
+    expect(handleRowClick.mock.calls[0][0]).toEqual(mockData[0]);
+    expect(mockClick).toHaveBeenCalled();
+  });
+
+  it('renders pagination when provided with valid stats', () => {
+    const { container } = render(
+      <Table
+        {...defaultProps}
+        pagination={{
+          pageSize: 10,
+          setPageSize: jest.fn(),
+          setPageNumber: jest.fn(),
+          pageStats: {
+            totalPages: 5,
+            pageNumber: 1,
+            pageSize: 10,
+            totalSize: 50
+          }
+        }}
+      />
+    );
+
+    const pagination = container.querySelector('.hue-pagination');
+    expect(pagination).toBeInTheDocument();
+  });
+
+  it('does not render pagination when totalPages is 0', () => {
+    const { container } = render(
+      <Table
+        {...defaultProps}
+        pagination={{
+          pageSize: 10,
+          setPageSize: jest.fn(),
+          setPageNumber: jest.fn(),
+          pageStats: {
+            totalPages: 0,
+            pageNumber: 0,
+            pageSize: 1,
+            totalSize: 0
+          }
+        }}
+      />
+    );
+
+    const pagination = container.querySelector('.hue-pagination');
+    expect(pagination).not.toBeInTheDocument();
+  });
+});

+ 136 - 0
desktop/core/src/desktop/js/reactComponents/PaginatedTable/PaginatedTable.tsx

@@ -0,0 +1,136 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React, { HTMLAttributes } from 'react';
+import Table, { type ColumnProps } from 'cuix/dist/components/Table';
+import {
+  TableLocale,
+  RowSelectionType,
+  FilterValue,
+  SorterResult,
+  SortOrder
+} from 'antd/lib/table/interface';
+import { PanelRender } from 'rc-table/lib/interface';
+import type { TableProps } from 'rc-table/lib/Table';
+import Pagination, { PaginationProps } from '../Pagination/Pagination';
+
+import './PaginatedTable.scss';
+import { TablePaginationConfig } from 'antd/es/table';
+
+export interface PaginatedTableProps<T> {
+  title?: PanelRender<T>;
+  data: T[];
+  columns: ColumnProps<T>[];
+  onRowClick?: (record: T) => HTMLAttributes<HTMLElement>;
+  locale?: TableLocale;
+  onRowSelect?: (selectedRows: T[]) => void;
+  scroll?: TableProps<T>['scroll'];
+  sortByColumn?: ColumnProps<T>['dataIndex'];
+  sortOrder?: SortOrder;
+  setSortByColumn?: (column: ColumnProps<T>['dataIndex']) => void;
+  setSortOrder?: (order: SortOrder) => void;
+  testId?: string;
+  rowKey: ((record: T) => string) | string;
+  pagination?: Partial<PaginationProps>;
+  rowClassName?: ((record: T) => string) | string;
+}
+
+const PaginatedTable = <T extends object>({
+  title,
+  data,
+  columns,
+  onRowClick,
+  onRowSelect,
+  scroll,
+  sortByColumn,
+  sortOrder,
+  setSortByColumn,
+  setSortOrder,
+  pagination,
+  testId,
+  locale,
+  rowKey,
+  rowClassName
+}: PaginatedTableProps<T>): JSX.Element => {
+  const rowSelection = onRowSelect
+    ? {
+        hideSelectAll: !data.length,
+        columnWidth: 36,
+        type: 'checkbox' as RowSelectionType,
+        onChange: (_: React.Key[], selectedRows: T[]) => {
+          onRowSelect(selectedRows);
+        }
+      }
+    : undefined;
+
+  const onColumnClick = (
+    _: TablePaginationConfig,
+    __: Record<string, FilterValue | null>,
+    sorter: SorterResult<T> | SorterResult<T>[]
+  ) => {
+    if (setSortOrder && setSortByColumn) {
+      const sorterObj = sorter && Array.isArray(sorter) ? sorter[0] : sorter;
+      if (sorterObj?.order) {
+        setSortOrder(sorterObj.order);
+        setSortByColumn(sorterObj.columnKey);
+      } else {
+        setSortOrder(null);
+        setSortByColumn(undefined);
+      }
+    }
+  };
+
+  const getColumnsFromConfig = (columnsConfig: ColumnProps<T>[]) => {
+    return columnsConfig.map(col => ({
+      ...col,
+      defaultSortOrder: sortByColumn === col.dataIndex ? sortOrder : undefined
+    }));
+  };
+
+  return (
+    <>
+      <Table
+        title={title}
+        className="hue-table"
+        onChange={onColumnClick}
+        columns={getColumnsFromConfig(columns)}
+        dataSource={data}
+        onRow={onRowClick}
+        pagination={false}
+        rowClassName={rowClassName}
+        rowKey={rowKey}
+        rowSelection={rowSelection}
+        scroll={scroll}
+        data-testid={testId}
+        locale={locale}
+        sticky
+      />
+      {pagination?.pageStats &&
+        pagination?.pageStats?.totalPages > 0 &&
+        pagination.setPageNumber && (
+          <Pagination
+            setPageSize={pagination.setPageSize}
+            pageSize={pagination.pageSize}
+            setPageNumber={pagination.setPageNumber}
+            pageStats={pagination?.pageStats}
+          />
+        )}
+    </>
+  );
+};
+
+export default PaginatedTable;
+export { ColumnProps, TableLocale, RowSelectionType, SortOrder };

+ 10 - 8
desktop/core/src/desktop/js/reactComponents/Pagination/Pagination.tsx

@@ -13,6 +13,7 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
+
 import React from 'react';
 import { Dropdown } from 'antd';
 import type { MenuProps } from 'antd';
@@ -24,10 +25,16 @@ import PageNextIcon from '@cloudera/cuix-core/icons/react/PageNextIcon';
 import PageLastIcon from '@cloudera/cuix-core/icons/react/PageLastIcon';
 import DropdownIcon from '@cloudera/cuix-core/icons/react/DropdownIcon';
 
-import { PageStats } from '../../apps/storageBrowser/types';
 import './Pagination.scss';
 
-interface PaginationProps {
+export interface PageStats {
+  pageNumber: number;
+  totalPages: number;
+  pageSize: number;
+  totalSize: number;
+}
+
+export interface PaginationProps {
   setPageNumber: (pageNumber: number) => void;
   setPageSize?: (pageSize: number) => void;
   pageSize?: number;
@@ -36,14 +43,10 @@ interface PaginationProps {
   showIndexes?: boolean;
 }
 
-const defaultProps = {
-  pageSizeOptions: [10, 50, 500, 1000]
-};
-
 const Pagination = ({
   setPageNumber,
   setPageSize,
-  pageSizeOptions = [],
+  pageSizeOptions = [10, 50, 500, 1000],
   pageStats,
   showIndexes = false
 }: PaginationProps): JSX.Element => {
@@ -130,5 +133,4 @@ const Pagination = ({
   );
 };
 
-Pagination.defaultProps = defaultProps;
 export default Pagination;