浏览代码

[ui-importer] Add configured filesystems in importer (#4131)

* [ui-importer] Add configured filesystems in importer

* [ui-importer] Added Bottom slide panel,tests and improvements

* [ui-importer] Updated tests and improvements

* [ui-importer] Update File chooser modal with Paginated table

* [ui-importer] Added tests for filechooser and improvements
Nidhi Bhat G 3 月之前
父节点
当前提交
e4605d3a05
共有 15 个文件被更改,包括 1086 次插入187 次删除
  1. 1 0
      desktop/core/src/desktop/js/apps/newimporter/ImporterSourceSelector/ImporterSourceSelector.scss
  2. 111 0
      desktop/core/src/desktop/js/apps/newimporter/ImporterSourceSelector/ImporterSourceSelector.test.tsx
  3. 111 74
      desktop/core/src/desktop/js/apps/newimporter/ImporterSourceSelector/ImporterSourceSelector.tsx
  4. 157 0
      desktop/core/src/desktop/js/apps/newimporter/ImporterSourceSelector/LocalFileUploadOption.test.tsx
  5. 101 0
      desktop/core/src/desktop/js/apps/newimporter/ImporterSourceSelector/LocalFileUploadOption.tsx
  6. 3 0
      desktop/core/src/desktop/js/apps/newimporter/constants.ts
  7. 17 7
      desktop/core/src/desktop/js/apps/storageBrowser/FileChooserModal/FileChooserModal.scss
  8. 78 39
      desktop/core/src/desktop/js/apps/storageBrowser/FileChooserModal/FileChooserModal.test.tsx
  9. 205 55
      desktop/core/src/desktop/js/apps/storageBrowser/FileChooserModal/FileChooserModal.tsx
  10. 15 8
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/MoveCopyModal/MoveCopyModal.test.tsx
  11. 6 0
      desktop/core/src/desktop/js/config/types.ts
  12. 83 0
      desktop/core/src/desktop/js/reactComponents/BottomSlidePanel/BottomSlidePanel.scss
  13. 95 0
      desktop/core/src/desktop/js/reactComponents/BottomSlidePanel/BottomSlidePanel.test.tsx
  14. 96 0
      desktop/core/src/desktop/js/reactComponents/BottomSlidePanel/BottomSlidePanel.tsx
  15. 7 4
      desktop/core/src/desktop/js/reactComponents/PaginatedTable/PaginatedTable.tsx

+ 1 - 0
desktop/core/src/desktop/js/apps/newimporter/ImporterSourceSelector/ImporterSourceSelector.scss

@@ -42,6 +42,7 @@ $option-size: 80px;
       margin-top: 70px;
       max-width: 80%;
       gap: $option-size;
+      align-items: baseline;
     }
 
     &-option {

+ 111 - 0
desktop/core/src/desktop/js/apps/newimporter/ImporterSourceSelector/ImporterSourceSelector.test.tsx

@@ -0,0 +1,111 @@
+// 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, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import ImporterSourceSelector from './ImporterSourceSelector';
+
+import { hueWindow } from 'types/types';
+
+import useLoadData from '../../../utils/hooks/useLoadData/useLoadData';
+import FileChooserModal from '../../storageBrowser/FileChooserModal/FileChooserModal';
+
+jest.mock('../../../utils/hooks/useLoadData/useLoadData');
+jest.mock('../../storageBrowser/FileChooserModal/FileChooserModal', () => jest.fn(() => null));
+jest.mock('./LocalFileUploadOption', () => () => <div data-testid="local-upload">LocalUpload</div>);
+
+const mockSetFileMetaData = jest.fn();
+
+describe('ImporterSourceSelector', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+    (useLoadData as jest.Mock).mockReturnValue({
+      data: [
+        { name: 's3a', userHomeDirectory: '/user/test/s3' },
+        { name: 'hdfs', userHomeDirectory: '/user/test/hdfs' },
+        { name: 'abfs', userHomeDirectory: '/user/test/abfs' },
+        { name: 'ofs', userHomeDirectory: '/user/test/ozone' },
+        { name: 'gs', userHomeDirectory: '/user/test/gs' }
+      ],
+      loading: false,
+      error: null,
+      reloadData: jest.fn()
+    });
+    // Ensure the ENABLE_DIRECT_UPLOAD is enabled
+    (window as hueWindow).ENABLE_DIRECT_UPLOAD = true;
+  });
+
+  it('should render local upload and filesystem options', () => {
+    render(<ImporterSourceSelector setFileMetaData={mockSetFileMetaData} />);
+
+    expect(screen.getByText('Select a source to import from')).toBeInTheDocument();
+    expect(screen.queryByText('LocalUpload')).toBeInTheDocument();
+    expect(screen.getByText('Amazon S3')).toBeInTheDocument();
+    expect(screen.getByText('HDFS')).toBeInTheDocument();
+    expect(screen.getByText('Azure Storage')).toBeInTheDocument();
+    expect(screen.getByText('Ozone')).toBeInTheDocument();
+    expect(screen.getByText('Google Storage')).toBeInTheDocument();
+  });
+
+  it('should not render local upload option if ENABLE_DIRECT_UPLOAD is false', () => {
+    (window as hueWindow).ENABLE_DIRECT_UPLOAD = false;
+    render(<ImporterSourceSelector setFileMetaData={mockSetFileMetaData} />);
+
+    expect(screen.getByText('Select a source to import from')).toBeInTheDocument();
+    expect(screen.queryByText('LocalUpload')).not.toBeInTheDocument();
+  });
+
+  it('should open FileChooserModal when filesystem button is clicked', () => {
+    (FileChooserModal as jest.Mock).mockImplementation(({ showModal }) => {
+      return showModal ? <div data-testid="file-chooser">FileChooserModal</div> : null;
+    });
+
+    render(<ImporterSourceSelector setFileMetaData={mockSetFileMetaData} />);
+
+    const s3Button = screen.getByRole('button', { name: /Amazon S3/i }); // second button (first is local upload)
+    fireEvent.click(s3Button);
+
+    expect(screen.getByTestId('file-chooser')).toBeInTheDocument();
+  });
+
+  it('should handle loading state', () => {
+    (useLoadData as jest.Mock).mockReturnValue({
+      data: null,
+      loading: true,
+      error: null,
+      reloadData: jest.fn()
+    });
+
+    render(<ImporterSourceSelector setFileMetaData={mockSetFileMetaData} />);
+
+    expect(screen.getAllByTestId('loading-error-wrapper__spinner')).toHaveLength(2);
+  });
+
+  it('should display error message on fetch failure', () => {
+    const mockRetry = jest.fn();
+    (useLoadData as jest.Mock).mockReturnValue({
+      data: null,
+      loading: false,
+      error: true,
+      reloadData: mockRetry
+    });
+
+    render(<ImporterSourceSelector setFileMetaData={mockSetFileMetaData} />);
+
+    expect(screen.getByText('An error occurred while fetching the filesystem')).toBeInTheDocument();
+  });
+});

+ 111 - 74
desktop/core/src/desktop/js/apps/newimporter/ImporterSourceSelector/ImporterSourceSelector.tsx

@@ -14,102 +14,139 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import React, { useRef } from 'react';
+import React, { useState } from 'react';
 import Button from 'cuix/dist/components/Button';
-import DocumentationIcon from '@cloudera/cuix-core/icons/react/DocumentationIcon';
+import S3Icon from '@cloudera/cuix-core/icons/react/S3Icon';
+import HDFSIcon from '@cloudera/cuix-core/icons/react/HdfsIcon';
+import OzoneIcon from '@cloudera/cuix-core/icons/react/OzoneIcon';
+import GoogleCloudIcon from '@cloudera/cuix-core/icons/react/GoogleCloudIcon';
+import AdlsIcon from '../../../components/icons/AdlsIcon';
+
 import { hueWindow } from 'types/types';
 
-import { FileMetaData, ImporterFileSource, LocalFileUploadResponse } from '../types';
+import LoadingErrorWrapper from '../../../reactComponents/LoadingErrorWrapper/LoadingErrorWrapper';
+import FileChooserModal from '../../storageBrowser/FileChooserModal/FileChooserModal';
+import LocalFileUploadOption from './LocalFileUploadOption';
+import { FILESYSTEMS_API_URL } from '../../storageBrowser/api';
+import { FileMetaData, ImporterFileSource } from '../types';
+import { FileSystem } from '../../storageBrowser/types';
 import { i18nReact } from '../../../utils/i18nReact';
-import { UPLOAD_LOCAL_FILE_API_URL } from '../api';
-import useSaveData from '../../../utils/hooks/useSaveData/useSaveData';
-import huePubSub from '../../../utils/huePubSub';
+import useLoadData from '../../../utils/hooks/useLoadData/useLoadData';
 
 import './ImporterSourceSelector.scss';
 
+const getFileSystems = t => {
+  return {
+    s3a: {
+      icon: <S3Icon />,
+      title: t('Amazon S3')
+    },
+    hdfs: {
+      icon: <HDFSIcon />,
+      title: t('HDFS')
+    },
+    abfs: {
+      icon: <AdlsIcon />,
+      title: t('Azure Storage')
+    },
+    ofs: {
+      icon: <OzoneIcon />,
+      title: t('Ozone')
+    },
+    adls: {
+      icon: <AdlsIcon />,
+      title: t('Azure Storage')
+    },
+    gs: {
+      icon: <GoogleCloudIcon />,
+      title: t('Google Storage')
+    }
+  };
+};
 interface ImporterSourceSelectorProps {
   setFileMetaData: (fileMetaData: FileMetaData) => void;
 }
 
 const ImporterSourceSelector = ({ setFileMetaData }: ImporterSourceSelectorProps): JSX.Element => {
+  const [selectedUserHomeDirectory, setSelectedUserHomeDirectory] = useState<string | undefined>(
+    undefined
+  );
+  const [uploadError, setUploadError] = useState<string | undefined>(undefined);
   const { t } = i18nReact.useTranslation();
-  const uploadRef = useRef<HTMLInputElement>(null);
-
-  const { save: upload } = useSaveData<LocalFileUploadResponse>(UPLOAD_LOCAL_FILE_API_URL);
+  const fileSystems = getFileSystems(t);
 
-  const handleUploadClick = () => {
-    if (!uploadRef || !uploadRef.current) {
-      return;
-    }
-    uploadRef.current.click();
-  };
+  const {
+    data: fileSystemsData,
+    loading,
+    error,
+    reloadData
+  } = useLoadData<FileSystem[]>(FILESYSTEMS_API_URL);
 
-  const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
-    const files = e.target.files;
-    if (!files) {
-      return;
+  const errorConfig = [
+    {
+      enabled: !!error,
+      message: t('An error occurred while fetching the filesystem'),
+      action: t('Retry'),
+      onClick: reloadData
+    },
+    {
+      enabled: !!uploadError,
+      message: uploadError
     }
+  ];
 
-    const file = files[0];
-
-    const payload = new FormData();
-    payload.append('file', file);
-
-    const file_size = file.size;
-    if (file_size === 0) {
-      huePubSub.publish('hue.global.warning', {
-        message: t('This file is empty, please select another file.')
-      });
-    } else if (file_size > 200 * 1000) {
-      huePubSub.publish('hue.global.warning', {
-        message: t(
-          'File size exceeds the supported size (200 KB). Please use the S3, ABFS or HDFS browser to upload files.'
-        )
-      });
-    } else {
-      upload(payload, {
-        onSuccess: data => {
-          setFileMetaData({
-            path: data.file_path,
-            fileName: file.name,
-            source: ImporterFileSource.LOCAL
-          });
-        },
-        onError: error => {
-          huePubSub.publish('hue.error', error);
-        }
-      });
-    }
+  const handleFileSelection = async (destinationPath: string) => {
+    setFileMetaData({
+      path: destinationPath,
+      source: ImporterFileSource.REMOTE
+    });
   };
 
   return (
-    <div className="hue-importer__source-selector cuix antd">
-      <div className="hue-importer__source-selector-title">
-        {t('Select a source to import from')}
-      </div>
-      <div className="hue-importer__source-selector-options">
-        {(window as hueWindow).ENABLE_DIRECT_UPLOAD && (
-          <div className="hue-importer__source-selector-option">
-            <Button
-              className="hue-importer__source-selector-option-button"
-              size="large"
-              icon={<DocumentationIcon />}
-              onClick={handleUploadClick}
-            ></Button>
-            <span className="hue-importer__source-selector-option-btn-title">
-              {t('Upload from File')}
-            </span>
-            <input
-              ref={uploadRef}
-              type="file"
-              className="hue-importer__source-selector-option-upload"
-              onChange={handleFileUpload}
-              accept=".csv, .xlsx, .xls"
+    <LoadingErrorWrapper loading={loading} errors={errorConfig}>
+      <div className="hue-importer__source-selector cuix antd">
+        <div className="hue-importer__source-selector-title">
+          {t('Select a source to import from')}
+        </div>
+        <div className="hue-importer__source-selector-options">
+          {(window as hueWindow).ENABLE_DIRECT_UPLOAD && (
+            <LocalFileUploadOption
+              setFileMetaData={setFileMetaData}
+              setUploadError={setUploadError}
             />
-          </div>
-        )}
+          )}
+          {fileSystemsData?.map(filesystem => (
+            <div className="hue-importer__source-selector-option" key={filesystem.name}>
+              <Button
+                className="hue-importer__source-selector-option-button"
+                aria-label={t(fileSystems[filesystem.name].title)}
+                size="large"
+                icon={fileSystems[filesystem.name].icon}
+                onClick={() => {
+                  setSelectedUserHomeDirectory(filesystem.userHomeDirectory);
+                }}
+              ></Button>
+              <span className="hue-importer__source-selector-option-btn-title">
+                {t(fileSystems[filesystem.name].title)}
+              </span>
+            </div>
+          ))}
+        </div>
       </div>
-    </div>
+      {selectedUserHomeDirectory && (
+        <FileChooserModal
+          onClose={() => {
+            setSelectedUserHomeDirectory(undefined);
+          }}
+          onSubmit={handleFileSelection}
+          showModal={true}
+          title={t('Import file')}
+          sourcePath={selectedUserHomeDirectory}
+          isFileSelectionAllowed={true}
+          isUploadEnabled={true}
+        />
+      )}
+    </LoadingErrorWrapper>
   );
 };
 

+ 157 - 0
desktop/core/src/desktop/js/apps/newimporter/ImporterSourceSelector/LocalFileUploadOption.test.tsx

@@ -0,0 +1,157 @@
+// 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 { fireEvent, render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import LocalFileUploadOption from './LocalFileUploadOption';
+
+import { ImporterFileSource } from '../types';
+
+const mockSave = jest.fn();
+
+jest.mock('../../../utils/hooks/useSaveData/useSaveData', () => ({
+  __esModule: true,
+  default: jest.fn(() => ({
+    save: mockSave
+  }))
+}));
+
+jest.mock('../constants', () => ({
+  ...jest.requireActual('../constants'),
+  SUPPORTED_UPLOAD_TYPES: '.csv, .xlsx, .xls',
+  DEFAULT_UPLOAD_LIMIT: 150 * 1000 * 1000
+}));
+
+const mockSetFileMetaData = jest.fn();
+const mockSetUploadError = jest.fn();
+
+describe('LocalFileUploadOption', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('should render upload button and label', () => {
+    render(
+      <LocalFileUploadOption
+        setFileMetaData={mockSetFileMetaData}
+        setUploadError={mockSetUploadError}
+      />
+    );
+    expect(screen.getByRole('button')).toBeInTheDocument();
+    expect(screen.getByText('Upload from File')).toBeInTheDocument();
+  });
+
+  it('should trigger input click on button click', () => {
+    render(
+      <LocalFileUploadOption
+        setFileMetaData={mockSetFileMetaData}
+        setUploadError={mockSetUploadError}
+      />
+    );
+    const fileInput = screen.getByTestId('hue-file-input');
+    const clickSpy = jest.spyOn(fileInput, 'click');
+    fireEvent.click(screen.getByRole('button'));
+    expect(clickSpy).toHaveBeenCalled();
+  });
+
+  it('should show warning for empty file', () => {
+    render(
+      <LocalFileUploadOption
+        setFileMetaData={mockSetFileMetaData}
+        setUploadError={mockSetUploadError}
+      />
+    );
+
+    const file = new File([], 'empty.csv', { type: 'text/csv' });
+
+    fireEvent.change(screen.getByTestId('hue-file-input'), {
+      target: { files: [file] }
+    });
+
+    expect(mockSetUploadError).toHaveBeenCalledWith(
+      'This file is empty, please select another file.'
+    );
+  });
+
+  it('should show error for file > 150MB', () => {
+    render(
+      <LocalFileUploadOption
+        setFileMetaData={mockSetFileMetaData}
+        setUploadError={mockSetUploadError}
+      />
+    );
+
+    const largeFile = new File([new ArrayBuffer(200000000)], 'big.csv', { type: 'text/csv' });
+
+    fireEvent.change(screen.getByTestId('hue-file-input'), {
+      target: { files: [largeFile] }
+    });
+
+    expect(mockSetUploadError).toHaveBeenCalledWith(
+      'File size exceeds the supported size. Please use any file browser to upload files.'
+    );
+  });
+
+  it('should upload file successfully and sets metadata', () => {
+    mockSave.mockImplementation((_data, { onSuccess }) => {
+      onSuccess({
+        file_path: '/tmp/data.csv',
+        fileName: 'data.csv',
+        source: ImporterFileSource.LOCAL
+      });
+    });
+
+    render(
+      <LocalFileUploadOption
+        setFileMetaData={mockSetFileMetaData}
+        setUploadError={mockSetUploadError}
+      />
+    );
+
+    const file = new File(['dummy data'], 'data.csv', { type: 'text/csv' });
+
+    fireEvent.change(screen.getByTestId('hue-file-input'), {
+      target: { files: [file] }
+    });
+    expect(mockSetFileMetaData).toHaveBeenCalledWith({
+      path: '/tmp/data.csv',
+      fileName: 'data.csv',
+      source: ImporterFileSource.LOCAL
+    });
+  });
+
+  it('should handle upload error', () => {
+    const error = new Error('upload failed');
+    mockSave.mockImplementation((_data, { onError }) => {
+      onError(error);
+    });
+
+    render(
+      <LocalFileUploadOption
+        setFileMetaData={mockSetFileMetaData}
+        setUploadError={mockSetUploadError}
+      />
+    );
+
+    const file = new File(['data'], 'fail.csv', { type: 'text/csv' });
+
+    fireEvent.change(screen.getByTestId('hue-file-input'), {
+      target: { files: [file] }
+    });
+    expect(mockSetUploadError).toHaveBeenCalledWith(error.message);
+  });
+});

+ 101 - 0
desktop/core/src/desktop/js/apps/newimporter/ImporterSourceSelector/LocalFileUploadOption.tsx

@@ -0,0 +1,101 @@
+// 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, { useRef } from 'react';
+import DocumentationIcon from '@cloudera/cuix-core/icons/react/DocumentationIcon';
+import Button from 'cuix/dist/components/Button';
+
+import { FileMetaData, ImporterFileSource, LocalFileUploadResponse } from '../types';
+import { UPLOAD_LOCAL_FILE_API_URL } from '../api';
+import useSaveData from '../../../utils/hooks/useSaveData/useSaveData';
+import { i18nReact } from '../../../utils/i18nReact';
+import { SUPPORTED_UPLOAD_TYPES, DEFAULT_UPLOAD_LIMIT } from '../constants';
+import { getLastKnownConfig } from '../../../config/hueConfig';
+
+interface LocalFileUploadOptionProps {
+  setFileMetaData: (fileMetaData: FileMetaData) => void;
+  setUploadError: (error: string) => void;
+}
+
+const LocalFileUploadOption = ({
+  setFileMetaData,
+  setUploadError
+}: LocalFileUploadOptionProps): JSX.Element => {
+  const { t } = i18nReact.useTranslation();
+  const uploadRef = useRef<HTMLInputElement>(null);
+  const config = getLastKnownConfig()?.importer;
+
+  const { save: upload } = useSaveData<LocalFileUploadResponse>(UPLOAD_LOCAL_FILE_API_URL);
+
+  const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const files = e.target.files;
+    if (!files) {
+      return;
+    }
+
+    const file = files[0];
+
+    const payload = new FormData();
+    payload.append('file', file);
+
+    const fileSize = file.size;
+    if (fileSize === 0) {
+      setUploadError(t('This file is empty, please select another file.'));
+    } else if (fileSize > (config?.max_local_file_size_upload_limit ?? DEFAULT_UPLOAD_LIMIT)) {
+      setUploadError(
+        t('File size exceeds the supported size. Please use any file browser to upload files.')
+      );
+    } else {
+      upload(payload, {
+        onSuccess: data => {
+          setFileMetaData({
+            path: data.file_path,
+            fileName: file.name,
+            source: ImporterFileSource.LOCAL
+          });
+        },
+        onError: error => {
+          setUploadError(error.message);
+        }
+      });
+    }
+  };
+
+  //TODO: Add loader or extend fileUploadQueue to accept local file upload
+  return (
+    <div className="hue-importer__source-selector-option">
+      <Button
+        className="hue-importer__source-selector-option-button"
+        size="large"
+        icon={<DocumentationIcon />}
+        onClick={() => uploadRef?.current?.click()}
+      />
+      <span className="hue-importer__source-selector-option-btn-title">
+        {t('Upload from File')}
+      </span>
+      <input
+        ref={uploadRef}
+        type="file"
+        data-testid="hue-file-input"
+        className="hue-importer__source-selector-option-upload"
+        onChange={handleFileUpload}
+        accept={SUPPORTED_UPLOAD_TYPES}
+      />
+    </div>
+  );
+};
+
+export default LocalFileUploadOption;

+ 3 - 0
desktop/core/src/desktop/js/apps/newimporter/constants.ts

@@ -80,3 +80,6 @@ export const sourceConfigs: {
     options: []
   }
 ];
+
+export const SUPPORTED_UPLOAD_TYPES = '.csv, .xlsx, .xls';
+export const DEFAULT_UPLOAD_LIMIT = 150 * 1000 * 1000;

+ 17 - 7
desktop/core/src/desktop/js/apps/storageBrowser/FileChooserModal/FileChooserModal.scss

@@ -24,6 +24,9 @@ $icon-margin: 5px;
 .antd.cuix {
   .hue-filechooser-modal__body {
     height: 350px;
+    position: relative;
+    display: flex;
+    flex-direction: column;
   }
 
   .hue-filechooser-modal__path-browser-panel {
@@ -41,14 +44,8 @@ $icon-margin: 5px;
     }
   }
 
-  .hue-filechooser-modal__table {
-    .ant-table-placeholder {
-      height: $table-placeholder-height;
-      text-align: center;
-    }
-
+  .hue-table {
     th.ant-table-cell {
-      height: $cell-height;
       border: none;
     }
 
@@ -64,6 +61,8 @@ $icon-margin: 5px;
   }
 
   .hue-filechooser-modal__table-row {
+    height: $cell-height;
+
     :hover {
       cursor: pointer;
     }
@@ -83,3 +82,14 @@ $icon-margin: 5px;
     color: vars.$fluidx-blue-700;
   }
 }
+
+.hue-file-upload-modal {
+  .ant-modal-body {
+    cursor: pointer;
+    height: 150px;
+  }
+
+  .ant-modal-footer {
+    display: none;
+  }
+}

+ 78 - 39
desktop/core/src/desktop/js/apps/storageBrowser/FileChooserModal/FileChooserModal.test.tsx

@@ -14,39 +14,65 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 import React from 'react';
-import { render, screen, waitFor } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
 import '@testing-library/jest-dom';
 
 import FileChooserModal from './FileChooserModal';
-import { get } from '../../../api/utils';
+import useLoadData from '../../../utils/hooks/useLoadData/useLoadData';
+import useSaveData from '../../../utils/hooks/useSaveData/useSaveData';
 
-// Mock the `get` function
-jest.mock('../../../api/utils', () => ({
-  get: jest.fn()
+jest.mock('../../../utils/hooks/useLoadData/useLoadData', () => ({
+  __esModule: true,
+  default: jest.fn()
 }));
 
-const mockGet = get as jest.MockedFunction<typeof get>;
+jest.mock('../../../utils/hooks/useSaveData/useSaveData', () => ({
+  __esModule: true,
+  default: jest.fn()
+}));
+
+const mockReloadData = jest.fn();
 
 const mockData = [
   {
-    name: 'test',
-    path: 'user/test',
+    name: 'testFile',
+    path: '/user/testFile',
     type: 'file'
   },
   {
     name: 'testFolder',
-    path: 'user/testFolder',
+    path: '/user/testFolder',
     type: 'dir'
   }
 ];
 
 describe('FileChooserModal', () => {
+  const defaultProps = {
+    onClose: jest.fn(),
+    onSubmit: jest.fn(() => Promise.resolve()),
+    title: 'Select File',
+    sourcePath: '/user',
+    submitText: 'Submit',
+    cancelText: 'Cancel'
+  };
+
   beforeAll(() => {
     jest.clearAllMocks();
   });
 
   beforeEach(() => {
-    mockGet.mockResolvedValue(mockData);
+    (useLoadData as jest.Mock).mockReturnValue({
+      data: { files: mockData },
+      loading: false,
+      error: null,
+      reloadData: mockReloadData
+    });
+
+    (useSaveData as jest.Mock).mockReturnValue({
+      save: jest.fn((_data, { onSuccess }) => onSuccess()),
+      loading: false,
+      error: null
+    });
   });
 
   afterEach(() => {
@@ -54,53 +80,66 @@ describe('FileChooserModal', () => {
   });
 
   test('renders the modal with basic props', async () => {
-    render(
-      <FileChooserModal
-        showModal={true}
-        onClose={() => {}}
-        onSubmit={() => {}}
-        title="Select File"
-        sourcePath="/path/to/source"
-      />
-    );
+    render(<FileChooserModal {...defaultProps} showModal />);
     await waitFor(() => {
       expect(screen.getByText('Select File')).toBeInTheDocument();
+      expect(screen.getByRole('row', { name: 'testFile' })).toBeInTheDocument();
       expect(screen.getByText('Cancel')).toBeInTheDocument();
       expect(screen.getByText('Submit')).toBeInTheDocument();
     });
   });
 
+  test('does not render modal when showModal is false', () => {
+    render(<FileChooserModal showModal={false} {...defaultProps} />);
+    expect(screen.queryByText('Select File')).not.toBeInTheDocument();
+  });
+
   test('displays empty message if there are no files in the directory', async () => {
-    mockGet.mockResolvedValue([]);
-    render(
-      <FileChooserModal
-        showModal={true}
-        onClose={() => {}}
-        onSubmit={() => {}}
-        title="Select File"
-        sourcePath="/path/to/source"
-      />
-    );
+    (useLoadData as jest.Mock).mockReturnValueOnce({
+      data: { files: [] }, // Empty files array
+      loading: false,
+      error: null,
+      reloadData: mockReloadData
+    });
+    render(<FileChooserModal showModal {...defaultProps} />);
     await waitFor(() => {
       expect(screen.getByText('Folder is empty')).toBeInTheDocument();
     });
   });
 
   test('Submit button is disabled if destination path is same as source path', async () => {
-    const mockOnSubmit = jest.fn();
-    render(
-      <FileChooserModal
-        showModal={true}
-        onClose={() => {}}
-        onSubmit={mockOnSubmit}
-        title="Select File"
-        sourcePath="/path/to/source"
-      />
-    );
+    render(<FileChooserModal showModal {...defaultProps} />);
 
     const submitButton = screen.getByRole('button', { name: 'Submit' });
     await waitFor(() => {
       expect(submitButton).toBeDisabled();
     });
   });
+
+  test('Upload button is visible instead of submit if upload is enabled', async () => {
+    render(<FileChooserModal showModal {...defaultProps} isUploadEnabled={true} />);
+
+    const submitButton = screen.getByRole('button', { name: 'Upload file' });
+    await waitFor(() => {
+      expect(submitButton).toBeVisible();
+    });
+  });
+
+  test('Secondary button create folder is visible', async () => {
+    render(<FileChooserModal showModal {...defaultProps} isUploadEnabled={true} />);
+
+    const createFolderButton = screen.getByRole('button', { name: 'Create folder' });
+    await waitFor(() => {
+      expect(createFolderButton).toBeVisible();
+    });
+  });
+
+  test('Clicking on create folder opens bottom panel modal', async () => {
+    render(<FileChooserModal showModal {...defaultProps} isUploadEnabled={true} />);
+    const createFolderButton = screen.getByRole('button', { name: 'Create folder' });
+    await waitFor(() => {
+      fireEvent.click(createFolderButton);
+      expect(screen.getByRole('button', { name: 'Create' })).toBeVisible();
+    });
+  });
 });

+ 205 - 55
desktop/core/src/desktop/js/apps/storageBrowser/FileChooserModal/FileChooserModal.tsx

@@ -14,10 +14,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import React, { useCallback, useMemo, useRef, useState } from 'react';
 import Modal from 'cuix/dist/components/Modal';
-import { Input, Tooltip } from 'antd';
-import Table, { ColumnProps } from 'cuix/dist/components/Table';
+import { Input, InputRef, Tooltip } from 'antd';
+import { ColumnProps } from 'cuix/dist/components/Table';
 import classNames from 'classnames';
 
 import FolderIcon from '@cloudera/cuix-core/icons/react/ProjectIcon';
@@ -26,22 +26,38 @@ import FileIcon from '@cloudera/cuix-core/icons/react/DocumentationIcon';
 import { i18nReact } from '../../../utils/i18nReact';
 import useDebounce from '../../../utils/useDebounce';
 import useLoadData from '../../../utils/hooks/useLoadData/useLoadData';
+import useSaveData from '../../../utils/hooks/useSaveData/useSaveData';
+import huePubSub from '../../../utils/huePubSub';
+import { useHuePubSub } from '../../../utils/hooks/useHuePubSub/useHuePubSub';
+import { FileStatus } from '../../../utils/hooks/useFileUpload/types';
+import UUID from '../../../utils/string/UUID';
+import { DEFAULT_POLLING_TIME } from '../../../utils/constants/storageBrowser';
 
 import { BrowserViewType, ListDirectory } from '../types';
-import { LIST_DIRECTORY_API_URL } from '../api';
+import { LIST_DIRECTORY_API_URL, CREATE_DIRECTORY_API_URL } from '../api';
+
 import PathBrowser from '../../../reactComponents/PathBrowser/PathBrowser';
 import LoadingErrorWrapper from '../../../reactComponents/LoadingErrorWrapper/LoadingErrorWrapper';
+import DragAndDrop from '../../../reactComponents/DragAndDrop/DragAndDrop';
+import BottomSlidePanel from '../../../reactComponents/BottomSlidePanel/BottomSlidePanel';
+import PaginatedTable from '../../../reactComponents/PaginatedTable/PaginatedTable';
+import {
+  FILE_UPLOAD_START_EVENT,
+  FILE_UPLOAD_SUCCESS_EVENT
+} from '../../../reactComponents/FileUploadQueue/event';
 
 import './FileChooserModal.scss';
 
 interface FileChooserModalProps {
   onClose: () => void;
-  onSubmit: (destination_path: string) => Promise<void>;
+  onSubmit: (destinationPath: string) => Promise<void>;
   showModal: boolean;
   title: string;
   sourcePath: string;
   submitText?: string;
   cancelText?: string;
+  isFileSelectionAllowed?: boolean;
+  isUploadEnabled?: boolean;
 }
 
 interface FileChooserTableData {
@@ -56,6 +72,8 @@ const FileChooserModal = ({
   onSubmit,
   title,
   sourcePath,
+  isFileSelectionAllowed = false,
+  isUploadEnabled = false,
   ...i18n
 }: FileChooserModalProps): JSX.Element => {
   const { t } = i18nReact.useTranslation();
@@ -63,18 +81,25 @@ const FileChooserModal = ({
   const [destPath, setDestPath] = useState<string>(sourcePath);
   const [submitLoading, setSubmitLoading] = useState<boolean>(false);
   const [searchTerm, setSearchTerm] = useState<string>('');
+  const [showCreateFolderModal, setShowCreateFolderModal] = useState<boolean>(false);
+  const [polling, setPolling] = useState<boolean>(false);
+  const uploadRef = useRef<HTMLInputElement>(null);
+  const createFolderInputRef = useRef<InputRef>(null);
+  const [createFolderValue, setCreateFolderValue] = useState<string>('');
 
-  useEffect(() => {
-    setDestPath(sourcePath);
-  }, [sourcePath]);
-
-  const { data: filesData, loading } = useLoadData<ListDirectory>(LIST_DIRECTORY_API_URL, {
+  const {
+    data: filesData,
+    loading,
+    error,
+    reloadData
+  } = useLoadData<ListDirectory>(LIST_DIRECTORY_API_URL, {
     params: {
       path: destPath,
       pagesize: '1000',
       filter: searchTerm
     },
-    skip: destPath === '' || destPath === undefined || !showModal
+    skip: destPath === '' || destPath === undefined || !showModal,
+    pollInterval: polling ? DEFAULT_POLLING_TIME : undefined
   });
 
   const tableData: FileChooserTableData[] = useMemo(() => {
@@ -87,7 +112,7 @@ const FileChooserModal = ({
       path: file.path,
       type: file.type
     }));
-  }, [filesData]);
+  }, [filesData, loading, error]);
 
   const handleSearch = useCallback(
     useDebounce(searchTerm => {
@@ -109,7 +134,12 @@ const FileChooserModal = ({
             <span className="hue-filechooser-modal__table-cell-icon">
               {record.type === BrowserViewType.dir ? <FolderIcon /> : <FileIcon />}
             </span>
-            <span className="hue-filechooser-modal__table-cell-name">{record.name}</span>
+            <span
+              className="hue-filechooser-modal__table-cell-name"
+              data-testid="hue-filechooser-modal__table-cell-name"
+            >
+              {record.name}
+            </span>
           </Tooltip>
         );
       }
@@ -124,10 +154,68 @@ const FileChooserModal = ({
         if (record.type === BrowserViewType.dir) {
           setDestPath(record.path);
         }
+        if (isFileSelectionAllowed && record.type === BrowserViewType.file) {
+          onSubmit(record.path);
+          onClose();
+        }
       }
     };
   };
 
+  const onFilesDrop = (newFiles: File[]) => {
+    const newUploadItems = newFiles.map(file => {
+      return {
+        file,
+        filePath: destPath,
+        uuid: UUID(),
+        status: FileStatus.Pending
+      };
+    });
+    setPolling(true);
+    huePubSub.publish(FILE_UPLOAD_START_EVENT, {
+      files: newUploadItems
+    });
+  };
+
+  useHuePubSub({
+    topic: FILE_UPLOAD_SUCCESS_EVENT,
+    callback: () => {
+      setPolling(false);
+    }
+  });
+
+  const handleUploadClick = () => {
+    if (!uploadRef || !uploadRef.current) {
+      return;
+    }
+    uploadRef.current.click();
+  };
+
+  const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const files = Array.from(e.target.files as ArrayLike<File>);
+    onFilesDrop(files);
+  };
+
+  const {
+    save: createFolder,
+    loading: createFolderLoading,
+    error: createFolderError
+  } = useSaveData(CREATE_DIRECTORY_API_URL, {
+    postOptions: { qsEncodeData: true } // TODO: Remove once API supports RAW JSON payload
+  });
+
+  const handleCreate = () => {
+    createFolder(
+      { path: destPath, name: createFolderValue },
+      {
+        onSuccess: () => {
+          setShowCreateFolderModal(false);
+          setDestPath(prev => `${prev}/${createFolderValue}`);
+        }
+      }
+    );
+  };
+
   const locale = {
     emptyText: t('Folder is empty')
   };
@@ -139,50 +227,112 @@ const FileChooserModal = ({
     onClose();
   };
 
+  const errorConfig = [
+    {
+      enabled: !!error,
+      message: t('An error occurred while fetching the filesystem'),
+      action: t('Retry'),
+      onClick: reloadData
+    }
+  ];
+
+  const createFolderErrorConfig = [
+    {
+      enabled: !!createFolderError,
+      message: createFolderError
+    }
+  ];
+
+  const TableContent = (
+    <LoadingErrorWrapper errors={errorConfig}>
+      <PaginatedTable<FileChooserTableData>
+        loading={loading && !polling}
+        data={tableData}
+        isDynamicHeight
+        rowKey={r => `${r.path}__${r.type}__${r.name}`}
+        locale={locale}
+        columns={getColumns(tableData[0] ?? {})}
+        rowClassName={record =>
+          record.type === BrowserViewType.file && !isFileSelectionAllowed
+            ? classNames('hue-filechooser-modal__table-row', 'disabled-row')
+            : 'hue-filechooser-modal__table-row'
+        }
+        onRowClick={onRowClicked}
+        showHeader={false}
+      />
+    </LoadingErrorWrapper>
+  );
+
   return (
-    <Modal
-      open={showModal}
-      title={title}
-      className="hue-filechooser-modal cuix antd"
-      okText={submitText}
-      onOk={handleOk}
-      okButtonProps={{ disabled: sourcePath === destPath, loading: submitLoading }}
-      cancelText={cancelText}
-      onCancel={onClose}
-    >
-      <div className="hue-filechooser-modal__body">
-        <div className="hue-filechooser-modal__path-browser-panel">
-          <PathBrowser filePath={destPath} onFilepathChange={setDestPath} showIcon={false} />
-        </div>
-        <Input
-          className="hue-filechooser-modal__search"
-          placeholder={t('Search')}
-          allowClear={true}
-          onChange={event => {
-            handleSearch(event.target.value);
-          }}
-        />
-        <LoadingErrorWrapper loading={loading}>
-          <Table
-            className="hue-filechooser-modal__table"
-            data-testid="hue-filechooser-modal__table"
-            dataSource={tableData}
-            pagination={false}
-            columns={getColumns(tableData[0] ?? {})}
-            rowKey={r => `${r.path}__${r.type}__${r.name}`}
-            scroll={{ y: '250px' }}
-            rowClassName={record =>
-              record.type === BrowserViewType.file
-                ? classNames('hue-filechooser-modal__table-row', 'disabled-row')
-                : 'hue-filechooser-modal__table-row'
-            }
-            onRow={onRowClicked}
-            locale={locale}
-            showHeader={false}
+    <>
+      {showModal && (
+        <Modal
+          open={showModal}
+          title={title}
+          className="hue-filechooser-modal cuix antd"
+          onOk={isUploadEnabled ? handleUploadClick : handleOk}
+          okText={isUploadEnabled ? t('Upload file') : submitText}
+          okButtonProps={
+            !isUploadEnabled ? { disabled: sourcePath === destPath, loading: submitLoading } : {}
+          }
+          secondaryButtonText={t('Create folder')}
+          onSecondary={() => setShowCreateFolderModal(true)}
+          cancelText={cancelText}
+          onCancel={onClose}
+        >
+          <div className="hue-filechooser-modal__body">
+            <div className="hue-filechooser-modal__path-browser-panel">
+              <PathBrowser filePath={destPath} onFilepathChange={setDestPath} showIcon={false} />
+            </div>
+            <Input
+              className="hue-filechooser-modal__search"
+              placeholder={t('Search')}
+              allowClear={true}
+              onChange={event => {
+                handleSearch(event.target.value);
+              }}
+            />
+            {isUploadEnabled ? (
+              <DragAndDrop onDrop={onFilesDrop}>{TableContent}</DragAndDrop>
+            ) : (
+              TableContent
+            )}
+          </div>
+          <input
+            ref={uploadRef}
+            type="file"
+            className="hue-importer__source-selector-option-upload"
+            onChange={handleFileUpload}
           />
-        </LoadingErrorWrapper>
-      </div>
-    </Modal>
+          {showCreateFolderModal && (
+            <BottomSlidePanel
+              isOpen={showCreateFolderModal}
+              title={t('Create Folder')}
+              cancelText="Cancel"
+              primaryText={t('Create')}
+              onClose={() => {
+                setShowCreateFolderModal(false);
+                setCreateFolderValue('');
+              }}
+              onPrimaryClick={handleCreate}
+            >
+              {/* TODO: Refactor CreateAndUpload to reuse */}
+              <LoadingErrorWrapper errors={createFolderErrorConfig}>
+                <Input
+                  defaultValue={createFolderValue}
+                  disabled={createFolderLoading}
+                  onPressEnter={() => handleCreate()}
+                  ref={createFolderInputRef}
+                  onChange={e => {
+                    setCreateFolderValue(e.target.value);
+                  }}
+                />
+              </LoadingErrorWrapper>
+            </BottomSlidePanel>
+          )}
+        </Modal>
+      )}
+    </>
   );
 };
 

+ 15 - 8
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/MoveCopyModal/MoveCopyModal.test.tsx

@@ -46,13 +46,17 @@ const mockFiles: StorageDirectoryTableData[] = [
     replication: 1
   }
 ];
+
+const mockReloadData = jest.fn();
 jest.mock('../../../../../../utils/hooks/useLoadData/useLoadData', () => ({
   __esModule: true,
   default: jest.fn(() => ({
     data: {
       files: mockFiles
     },
-    loading: false
+    loading: false,
+    error: null,
+    reloadData: mockReloadData
   }))
 }));
 
@@ -76,7 +80,7 @@ describe('MoveCopy Action Component', () => {
   });
 
   describe('Copy Actions', () => {
-    it('should render correctly and open the modal', () => {
+    it('should render correctly and open the modal', async () => {
       const { getByText } = render(
         <MoveCopyModal
           isOpen={true}
@@ -89,8 +93,10 @@ describe('MoveCopy Action Component', () => {
         />
       );
 
-      expect(getByText('Copy to')).toBeInTheDocument();
-      expect(getByText('Copy')).toBeInTheDocument();
+      await waitFor(() => {
+        expect(getByText('Copy to')).toBeInTheDocument();
+        expect(getByText('Copy')).toBeInTheDocument();
+      });
     });
 
     it('should call handleCopyOrMove with the correct data when the form is submitted', async () => {
@@ -191,7 +197,7 @@ describe('MoveCopy Action Component', () => {
   });
 
   describe('Move Actions', () => {
-    it('should render correctly and open the modal', () => {
+    it('should render correctly and open the modal', async () => {
       const { getByText } = render(
         <MoveCopyModal
           isOpen={true}
@@ -203,9 +209,10 @@ describe('MoveCopy Action Component', () => {
           onClose={mockOnClose}
         />
       );
-
-      expect(getByText('Move to')).toBeInTheDocument();
-      expect(getByText('Move')).toBeInTheDocument();
+      await waitFor(() => {
+        expect(getByText('Move to')).toBeInTheDocument();
+        expect(getByText('Move')).toBeInTheDocument();
+      });
     });
 
     it('should call handleCopyOrMove with the correct data when the form is submitted', async () => {

+ 6 - 0
desktop/core/src/desktop/js/config/types.ts

@@ -70,6 +70,11 @@ interface StorageBrowserConfig {
   max_file_editor_size: number;
 }
 
+interface ImporterConfig {
+  is_enabled: boolean;
+  restrict_local_file_extensions: [];
+  max_local_file_size_upload_limit: number;
+}
 export interface HueConfig extends GenericApiResponse {
   app_config: {
     [AppType.browser]?: AppConfig<BrowserInterpreter>;
@@ -99,6 +104,7 @@ export interface HueConfig extends GenericApiResponse {
     allow_sample_data_from_views: boolean;
   };
   storage_browser: StorageBrowserConfig;
+  importer: ImporterConfig;
   hue_version?: string;
   img_version?: string;
   vw_name?: string;

+ 83 - 0
desktop/core/src/desktop/js/reactComponents/BottomSlidePanel/BottomSlidePanel.scss

@@ -0,0 +1,83 @@
+// 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.
+
+.hue-bottom-slide-mask {
+  position: absolute;
+  inset: 0 0 0 0;
+  background: rgba(0, 0, 0, 0.35);
+  z-index: 9;
+  transition: opacity 0.3s ease;
+}
+
+.fade-in {
+  opacity: 1;
+}
+
+.fade-out {
+  opacity: 0;
+}
+
+.hue-bottom-slide-panel {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background: #fff;
+  box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
+  padding: 16px;
+  z-index: 10;
+  border-top: 1px solid #eee;
+  transition:
+    transform 0.3s ease,
+    opacity 0.3s ease;
+
+  .hue-bottom-slide-panel__title {
+    font-weight: 300;
+    margin-bottom: 12px;
+    font-size: 16px;
+    padding: 16px 0 0 24px;
+  }
+
+  /* Content section */
+  .hue-bottom-slide-panel__content {
+    padding: 16px 24px 24px 24px;
+  }
+
+  /* Footer with buttons */
+  .hue-bottom-slide-panel__footer {
+    display: flex;
+    justify-content: space-between;
+    padding: 16px 24px;
+  }
+
+  .hue-bottom-slide-panel__btn {
+    padding: 6px 16px;
+    border: none;
+    border-radius: 4px;
+    font-weight: 500;
+    cursor: pointer;
+  }
+}
+
+/* Animation classes */
+.hue-bottom-slide-in {
+  transform: translateY(0%);
+  opacity: 1;
+}
+
+.hue-bottom-slide-out {
+  opacity: 0;
+}

+ 95 - 0
desktop/core/src/desktop/js/reactComponents/BottomSlidePanel/BottomSlidePanel.test.tsx

@@ -0,0 +1,95 @@
+// 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, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import BottomSlidePanel from './BottomSlidePanel';
+
+describe('BottomSlidePanel', () => {
+  const defaultProps = {
+    isOpen: true,
+    onClose: jest.fn(),
+    onPrimaryClick: jest.fn(),
+    primaryText: 'Create',
+    cancelText: 'Cancel',
+    title: 'Create Folder',
+    children: <div>Panel Content</div>
+  };
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('should render when isOpen is true', () => {
+    render(<BottomSlidePanel {...defaultProps} />);
+    expect(screen.getByText('Create Folder')).toBeInTheDocument();
+    expect(screen.getByText('Panel Content')).toBeInTheDocument();
+    expect(screen.getByText('Create')).toBeInTheDocument();
+    expect(screen.getByText('Cancel')).toBeInTheDocument();
+  });
+
+  it('should not render when isOpen is false', () => {
+    const { queryByText } = render(<BottomSlidePanel {...defaultProps} isOpen={false} />);
+    expect(queryByText('Create Folder')).not.toBeInTheDocument();
+    expect(queryByText('Panel Content')).not.toBeInTheDocument();
+  });
+
+  it('should call onPrimaryClick when primary button is clicked', () => {
+    render(<BottomSlidePanel {...defaultProps} />);
+    fireEvent.click(screen.getByText('Create'));
+    expect(defaultProps.onPrimaryClick).toHaveBeenCalled();
+  });
+
+  it('should call onClose when cancel button is clicked', () => {
+    render(<BottomSlidePanel {...defaultProps} />);
+    fireEvent.click(screen.getByText('Cancel'));
+    expect(defaultProps.onClose).toHaveBeenCalled();
+  });
+
+  it('should call onClose when mask is clicked and maskClosable is true', () => {
+    render(<BottomSlidePanel {...defaultProps} />);
+    const mask = screen.getByTestId('mask');
+    fireEvent.click(mask);
+    expect(defaultProps.onClose).toHaveBeenCalled();
+  });
+
+  it('should not call onClose when mask is clicked and maskClosable is false', () => {
+    render(<BottomSlidePanel {...defaultProps} maskClosable={false} />);
+    const mask = screen.getByTestId('mask');
+    fireEvent.click(mask);
+    expect(defaultProps.onClose).not.toHaveBeenCalled();
+  });
+
+  it('should call onClose on Enter or Space keydown on mask', () => {
+    render(<BottomSlidePanel {...defaultProps} />);
+    const mask = screen.getByTestId('mask');
+    fireEvent.keyDown(mask, { key: 'Enter' });
+    fireEvent.keyDown(mask, { key: ' ' });
+    expect(defaultProps.onClose).toHaveBeenCalledTimes(2);
+  });
+
+  it('should apply custom className to panel', () => {
+    const className = 'custom-class';
+    const { container } = render(<BottomSlidePanel {...defaultProps} className={className} />);
+    expect(container.querySelector('.hue-bottom-slide-panel')).toHaveClass(className);
+  });
+
+  it('should not render footer if buttons are not provided', () => {
+    render(<BottomSlidePanel {...defaultProps} primaryText={undefined} cancelText={undefined} />);
+    expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument();
+  });
+});

+ 96 - 0
desktop/core/src/desktop/js/reactComponents/BottomSlidePanel/BottomSlidePanel.tsx

@@ -0,0 +1,96 @@
+// 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 { BorderlessButton, PrimaryButton } from 'cuix/dist/components/Button';
+import React, { useEffect, useState } from 'react';
+import './BottomSlidePanel.scss';
+
+interface BottomSlidePanelProps {
+  isOpen: boolean;
+  onClose: () => void;
+  onPrimaryClick?: (unknown) => void;
+  primaryText?: string;
+  cancelText?: string;
+  title?: string;
+  children: React.ReactNode;
+  className?: string;
+  showMask?: boolean;
+  maskClosable?: boolean;
+}
+
+const BottomSlidePanel: React.FC<BottomSlidePanelProps> = ({
+  isOpen,
+  onClose,
+  onPrimaryClick,
+  primaryText,
+  cancelText,
+  title,
+  children,
+  className = '',
+  showMask = true,
+  maskClosable = true
+}) => {
+  const [shouldRender, setShouldRender] = useState<boolean>(isOpen);
+
+  useEffect(() => {
+    if (isOpen) {
+      setShouldRender(true);
+    } else {
+      const timeout = setTimeout(() => setShouldRender(false), 300); // match CSS duration
+      return () => clearTimeout(timeout);
+    }
+  }, [isOpen]);
+
+  if (!shouldRender) {
+    return null;
+  }
+
+  return (
+    <>
+      {showMask && (
+        <div
+          className={`hue-bottom-slide-mask ${isOpen ? 'fade-in' : 'fade-out'}`}
+          role="button"
+          tabIndex={0}
+          onClick={maskClosable ? onClose : undefined}
+          onKeyDown={e => {
+            if (maskClosable && (e.key === 'Enter' || e.key === ' ')) {
+              e.preventDefault();
+              onClose();
+            }
+          }}
+          data-testid="mask"
+        />
+      )}
+      <div
+        className={`hue-bottom-slide-panel ${isOpen ? 'hue-bottom-slide-in' : 'hue-bottom-slide-out'} ${className}`}
+      >
+        {title && <div className="hue-bottom-slide-panel__title">{title}</div>}
+
+        <div className="hue-bottom-slide-panel__content">{children}</div>
+
+        {(primaryText || cancelText) && (
+          <div className="hue-bottom-slide-panel__footer">
+            {primaryText && <PrimaryButton onClick={onPrimaryClick}>{primaryText}</PrimaryButton>}
+            {cancelText && <BorderlessButton onClick={onClose}>{cancelText}</BorderlessButton>}
+          </div>
+        )}
+      </div>
+    </>
+  );
+};
+
+export default BottomSlidePanel;

+ 7 - 4
desktop/core/src/desktop/js/reactComponents/PaginatedTable/PaginatedTable.tsx

@@ -37,6 +37,7 @@ export interface PaginatedTableProps<T> {
   onRowClick?: (record: T) => HTMLAttributes<HTMLElement>;
   locale?: TableLocale;
   onRowSelect?: (selectedRows: T[]) => void;
+  showHeader?: boolean;
   isDynamicHeight?: boolean;
   sortByColumn?: ColumnProps<T>['dataIndex'];
   sortOrder?: SortOrder;
@@ -68,7 +69,8 @@ const PaginatedTable = <T extends object>({
   testId,
   locale,
   rowKey,
-  rowClassName
+  rowClassName,
+  showHeader = true
 }: PaginatedTableProps<T>): JSX.Element => {
   const rowSelection = onRowSelect
     ? {
@@ -111,9 +113,9 @@ const PaginatedTable = <T extends object>({
 
   const [tableRef, rect] = useResizeObserver();
 
-  const tableOffset = isPaginationEnabled
-    ? PAGINATION_HEIGHT + TABLE_HEADER_HEIGHT
-    : TABLE_HEADER_HEIGHT;
+  const headerHeight = showHeader ? TABLE_HEADER_HEIGHT : 0;
+  const paginationHeight = isPaginationEnabled ? PAGINATION_HEIGHT : 0;
+  const tableOffset = headerHeight + paginationHeight;
   const tableBodyHeight = Math.max(rect.height - tableOffset, 100);
 
   const tableScroll = isDynamicHeight ? { y: tableBodyHeight } : undefined;
@@ -139,6 +141,7 @@ const PaginatedTable = <T extends object>({
         locale={locale}
         loading={loading}
         sticky
+        showHeader={showHeader}
       />
       {isPaginationEnabled && (
         <Pagination