Pārlūkot izejas kodu

[ui-storagebrowser] adds trash functionality (#4024)

Ram Prasad Agarwal 8 mēneši atpakaļ
vecāks
revīzija
ff0039b51a
19 mainītis faili ar 650 papildinājumiem un 37 dzēšanām
  1. 1 1
      desktop/core/src/desktop/api_public.py
  2. 52 5
      desktop/core/src/desktop/js/apps/storageBrowser/StorageBrowserTab/StorageBrowserTab.tsx
  3. 0 0
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/FileAndFolderActions.scss
  4. 16 5
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/FileAndFolderActions.test.tsx
  5. 4 4
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/FileAndFolderActions.tsx
  6. 0 0
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/FileAndFolderActions.util.ts
  7. 1 1
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/MoveCopyModal/MoveCopyModal.test.tsx
  8. 1 1
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/MoveCopyModal/MoveCopyModal.tsx
  9. 109 0
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/StorageDirectoryActions.test.tsx
  10. 19 2
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/StorageDirectoryActions.tsx
  11. 244 0
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/Trash/TrashActions.test.tsx
  12. 132 0
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/Trash/TrashActions.tsx
  13. 4 0
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.tsx
  14. 3 0
      desktop/core/src/desktop/js/apps/storageBrowser/api.ts
  15. 4 0
      desktop/core/src/desktop/js/apps/storageBrowser/types.ts
  16. 6 6
      desktop/core/src/desktop/js/utils/hooks/useLoadData/useLoadData.test.tsx
  17. 2 1
      desktop/core/src/desktop/js/utils/hooks/useLoadData/useLoadData.ts
  18. 21 1
      desktop/core/src/desktop/js/utils/storageBrowserUtils.test.ts
  19. 31 10
      desktop/core/src/desktop/js/utils/storageBrowserUtils.ts

+ 1 - 1
desktop/core/src/desktop/api_public.py

@@ -371,7 +371,7 @@ def storage_trash_bulk_restore(request):
   return filebrowser_api.bulk_op(django_request, filebrowser_api.trash_restore)
 
 
-@api_view(["DELETE"])
+@api_view(["POST"])
 def storage_trash_purge(request):
   django_request = get_django_request(request)
   return filebrowser_api.trash_purge(django_request)

+ 52 - 5
desktop/core/src/desktop/js/apps/storageBrowser/StorageBrowserTab/StorageBrowserTab.tsx

@@ -21,8 +21,8 @@ import BucketIcon from '@cloudera/cuix-core/icons/react/BucketIcon';
 
 import PathBrowser from '../../../reactComponents/PathBrowser/PathBrowser';
 import StorageDirectoryPage from '../StorageDirectoryPage/StorageDirectoryPage';
-import { FILE_STATS_API_URL } from '../api';
-import { BrowserViewType, FileStats, FileSystem } from '../types';
+import { FILE_STATS_API_URL, TRASH_PATH } from '../api';
+import { BrowserViewType, FileStats, FileSystem, TrashPath } from '../types';
 import useLoadData from '../../../utils/hooks/useLoadData/useLoadData';
 import { BorderlessButton } from 'cuix/dist/components/Button';
 
@@ -33,6 +33,9 @@ import LoadingErrorWrapper from '../../../reactComponents/LoadingErrorWrapper/Lo
 import { getFileSystemAndPath } from '../../../reactComponents/PathBrowser/PathBrowser.util';
 import RefreshIcon from '@cloudera/cuix-core/icons/react/RefreshIcon';
 import HomeIcon from '@cloudera/cuix-core/icons/react/HomeIcon';
+import DeleteIcon from '@cloudera/cuix-core/icons/react/DeleteIcon';
+import { inTrash } from '../../../utils/storageBrowserUtils';
+import { Alert } from 'antd';
 
 interface StorageBrowserTabProps {
   fileSystem: FileSystem;
@@ -57,6 +60,27 @@ const StorageBrowserTab = ({ fileSystem, testId }: StorageBrowserTabProps): JSX.
 
   const { t } = i18nReact.useTranslation();
 
+  const {
+    data: trashPath,
+    loading: trashLoading,
+    reloadData: onTrashPathReload
+  } = useLoadData<TrashPath>(TRASH_PATH, {
+    params: { path: fileSystem.user_home_directory },
+    skip: !fileSystem.config?.is_trash_enabled || !fileSystem.user_home_directory
+  });
+
+  const onTrashClick = async () => {
+    const latestTrashData = await onTrashPathReload();
+    setFilePath(latestTrashData?.trash_path ?? '');
+  };
+
+  const reloadTrashPath = () => {
+    if (trashPath?.trash_path) {
+      return;
+    }
+    onTrashPathReload();
+  };
+
   const {
     data: fileStats,
     loading,
@@ -96,8 +120,10 @@ const StorageBrowserTab = ({ fileSystem, testId }: StorageBrowserTabProps): JSX.
     }
   ];
 
+  const isLoading = loading || trashLoading;
+
   return (
-    <LoadingErrorWrapper loading={loading} errors={errorConfig}>
+    <LoadingErrorWrapper loading={isLoading} errors={errorConfig}>
       <div className="hue-storage-browser-tab-content" data-testid={testId}>
         <div className="hue-storage-browser__title-bar" data-testid={`${testId}-title-bar`}>
           <div className="hue-storage-browser__title">
@@ -118,6 +144,18 @@ const StorageBrowserTab = ({ fileSystem, testId }: StorageBrowserTabProps): JSX.
             >
               {t('Home')}
             </BorderlessButton>
+            {fileSystem.config?.is_trash_enabled && (
+              <BorderlessButton
+                onClick={onTrashClick}
+                className="hue-path-browser__home-btn"
+                data-event=""
+                title={t('Trash')}
+                icon={<DeleteIcon />}
+                disabled={!trashPath?.trash_path}
+              >
+                {t('Trash')}
+              </BorderlessButton>
+            )}
             <BorderlessButton
               onClick={() => reloadData()}
               className="hue-storage-browser__home-bar-btns"
@@ -129,6 +167,14 @@ const StorageBrowserTab = ({ fileSystem, testId }: StorageBrowserTabProps): JSX.
             </BorderlessButton>
           </div>
         </div>
+        {!!inTrash(filePath) && fileSystem.file_system === 'hdfs' && (
+          <Alert
+            type="warning"
+            message={t(
+              'This is Hadoop trash. Files will be under a checkpoint, or timestamp named, directory.'
+            )}
+          />
+        )}
         <div
           className="hue-storage-browser__path-browser-panel"
           data-testid={`${testId}-path-browser-panel`}
@@ -140,14 +186,15 @@ const StorageBrowserTab = ({ fileSystem, testId }: StorageBrowserTabProps): JSX.
             showIcon={false}
           />
         </div>
-        {fileStats?.type === BrowserViewType.dir && !loading && (
+        {fileStats?.type === BrowserViewType.dir && !isLoading && (
           <StorageDirectoryPage
             fileStats={fileStats}
             onFilePathChange={setFilePath}
             fileSystem={fileSystem}
+            reloadTrashPath={reloadTrashPath}
           />
         )}
-        {fileStats?.type === BrowserViewType.file && !loading && (
+        {fileStats?.type === BrowserViewType.file && !isLoading && (
           <StorageFilePage fileName={fileName} fileStats={fileStats} onReload={reloadData} />
         )}
       </div>

+ 0 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/StorageBrowserActions.scss → desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/FileAndFolderActions.scss


+ 16 - 5
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/StorageBrowserActions.test.tsx → desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/FileAndFolderActions.test.tsx

@@ -19,7 +19,7 @@ import { render, screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import '@testing-library/jest-dom';
 
-import StorageBrowserActions from './StorageBrowserActions';
+import FileAndFolderActions from './FileAndFolderActions';
 import { StorageDirectoryTableData } from '../../../types';
 import { get } from '../../../../../api/utils';
 import huePubSub from '../../../../../utils/huePubSub';
@@ -44,7 +44,7 @@ jest.mock('config/hueConfig', () => ({
 
 const mockGet = get as jest.MockedFunction<typeof get>;
 
-describe('StorageBrowserRowActions', () => {
+describe('FileAndFolderActions', () => {
   //View summary option is enabled and added to the actions menu when the row data is either hdfs/ofs and a single file
   const mockTwoRecords: StorageDirectoryTableData[] = [
     {
@@ -74,7 +74,16 @@ describe('StorageBrowserRowActions', () => {
   const mockRecord: StorageDirectoryTableData = mockTwoRecords[0];
 
   const setLoadingFiles = jest.fn();
-  const onSuccessfulAction = jest.fn();
+  const mockOnActionSuccess = jest.fn();
+  const mockOnActionError = jest.fn();
+  const mockConfig = {
+    is_trash_enabled: true,
+    is_hdfs_superuser: true,
+    groups: ['hue'],
+    users: ['hue'],
+    superuser: 'hue',
+    supergroup: 'hue'
+  };
 
   const setUpActionMenu = async (
     records: StorageDirectoryTableData[],
@@ -93,9 +102,11 @@ describe('StorageBrowserRowActions', () => {
           ]
         : records;
     const { getByRole } = render(
-      <StorageBrowserActions
+      <FileAndFolderActions
+        config={mockConfig}
+        onActionError={mockOnActionError}
         setLoadingFiles={setLoadingFiles}
-        onSuccessfulAction={onSuccessfulAction}
+        onActionSuccess={mockOnActionSuccess}
         selectedFiles={selectedFiles}
         currentPath="/path/to/folder"
       />

+ 4 - 4
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/StorageBrowserActions.tsx → desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/FileAndFolderActions.tsx

@@ -34,9 +34,9 @@ import ConfigureIcon from '@cloudera/cuix-core/icons/react/ConfigureIcon';
 
 import { i18nReact } from '../../../../../utils/i18nReact';
 import huePubSub from '../../../../../utils/huePubSub';
-import './StorageBrowserActions.scss';
+import './FileAndFolderActions.scss';
 import { FileStats, FileSystem, StorageDirectoryTableData } from '../../../types';
-import { ActionType, getEnabledActions } from './StorageBrowserActions.util';
+import { ActionType, getEnabledActions } from './FileAndFolderActions.util';
 import MoveCopyModal from './MoveCopyModal/MoveCopyModal';
 import RenameModal from './RenameModal/RenameModal';
 import ReplicationModal from './ReplicationModal/ReplicationModal';
@@ -71,7 +71,7 @@ const iconsMap: Record<ActionType, JSX.Element> = {
   [ActionType.ChangePermission]: <ConfigureIcon />
 };
 
-const StorageBrowserActions = ({
+const FileAndFolderActions = ({
   config,
   currentPath,
   selectedFiles,
@@ -217,4 +217,4 @@ const StorageBrowserActions = ({
   );
 };
 
-export default StorageBrowserActions;
+export default FileAndFolderActions;

+ 0 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/StorageBrowserActions.util.ts → desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/FileAndFolderActions.util.ts


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

@@ -18,7 +18,7 @@ import React from 'react';
 import { render, fireEvent } from '@testing-library/react';
 import '@testing-library/jest-dom';
 import MoveCopyModal from './MoveCopyModal';
-import { ActionType } from '../StorageBrowserActions.util';
+import { ActionType } from '../FileAndFolderActions.util';
 import { BULK_COPY_API_URL, BULK_MOVE_API_URL } from '../../../../api';
 import { StorageDirectoryTableData } from '../../../../types';
 

+ 1 - 1
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/FileAndFolder/MoveCopyModal/MoveCopyModal.tsx

@@ -17,7 +17,7 @@
 import React from 'react';
 import { i18nReact } from '../../../../../../utils/i18nReact';
 import useSaveData from '../../../../../../utils/hooks/useSaveData/useSaveData';
-import { ActionType } from '../StorageBrowserActions.util';
+import { ActionType } from '../FileAndFolderActions.util';
 import { BULK_COPY_API_URL, BULK_MOVE_API_URL } from '../../../../api';
 import FileChooserModal from '../../../../FileChooserModal/FileChooserModal';
 import { FileStats, StorageDirectoryTableData } from '../../../../types';

+ 109 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/StorageDirectoryActions.test.tsx

@@ -0,0 +1,109 @@
+// 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 } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import StorageDirectoryActions from './StorageDirectoryActions';
+
+const mockOnActionSuccess = jest.fn();
+const mockSetLoadingFiles = jest.fn();
+const mockOnFilesDrop = jest.fn();
+const mockOnFilePathChange = jest.fn();
+
+describe('StorageDirectoryActions Component', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  const mockSelectedFiles = [
+    {
+      name: 'file1.txt',
+      size: '0 Byte',
+      type: 'file',
+      permission: 'rwxrwxrwx',
+      mtime: '2021-01-01 00:00:00',
+      path: '/user/path/.Trash/Current/file1.txt',
+      user: 'test',
+      group: 'test',
+      replication: 1
+    }
+  ];
+
+  const mockFileStats = {
+    name: 'file1.txt',
+    type: 'file',
+    permission: 'rwxrwxrwx',
+    mtime: 123,
+    path: '/user/path/file1.txt',
+    user: 'test',
+    group: 'test',
+    replication: 1,
+    atime: 123,
+    blockSize: 123,
+    mode: 123,
+    rwx: 'rwxrwxrwx',
+    size: 123
+  };
+
+  const mockFileSystem = {
+    file_system: 'hdfs',
+    user_home_directory: '/user/hue'
+  };
+
+  const mockTrashPath = '/user/path/.Trash/Current';
+  const mockIsTrashEmpty = !mockSelectedFiles.length;
+
+  it('should render the Trash actions when path is in Trash', () => {
+    const { getByRole, queryByRole } = render(
+      <StorageDirectoryActions
+        selectedFiles={mockSelectedFiles}
+        isFolderEmpty={mockIsTrashEmpty}
+        onActionSuccess={mockOnActionSuccess}
+        setLoadingFiles={mockSetLoadingFiles}
+        fileStats={{ ...mockFileStats, path: mockTrashPath }}
+        fileSystem={mockFileSystem}
+        onFilePathChange={mockOnFilePathChange}
+        onFilesDrop={mockOnFilesDrop}
+      />
+    );
+
+    expect(getByRole('button', { name: 'Restore' })).toBeInTheDocument();
+    expect(getByRole('button', { name: 'Empty trash' })).toBeInTheDocument();
+    expect(queryByRole('button', { name: 'Actions' })).not.toBeInTheDocument();
+    expect(queryByRole('button', { name: 'New' })).not.toBeInTheDocument();
+  });
+
+  it('should render the regular actions when path is not in Trash', () => {
+    const { getByRole, queryByRole } = render(
+      <StorageDirectoryActions
+        selectedFiles={mockSelectedFiles}
+        isFolderEmpty={mockIsTrashEmpty}
+        onActionSuccess={mockOnActionSuccess}
+        setLoadingFiles={mockSetLoadingFiles}
+        fileStats={mockFileStats}
+        fileSystem={mockFileSystem}
+        onFilePathChange={mockOnFilePathChange}
+        onFilesDrop={mockOnFilesDrop}
+      />
+    );
+
+    expect(getByRole('button', { name: 'Actions' })).toBeInTheDocument();
+    expect(getByRole('button', { name: 'New' })).toBeInTheDocument();
+    expect(queryByRole('button', { name: 'Restore' })).not.toBeInTheDocument();
+    expect(queryByRole('button', { name: 'Empty trash' })).not.toBeInTheDocument();
+  });
+});

+ 19 - 2
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/StorageDirectoryActions.tsx

@@ -15,14 +15,17 @@
 // limitations under the License.
 
 import React from 'react';
+import { inTrash } from '../../../../utils/storageBrowserUtils';
 import CreateAndUploadAction from './CreateAndUpload/CreateAndUploadAction';
-import StorageBrowserActions from './FileAndFolder/StorageBrowserActions';
+import TrashActions from './Trash/TrashActions';
+import FileAndFolderActions from './FileAndFolder/FileAndFolderActions';
 import { FileStats, FileSystem, StorageDirectoryTableData } from '../../types';
 import huePubSub from '../../../../utils/huePubSub';
 
 interface StorageDirectoryActionsProps {
   fileStats: FileStats;
   fileSystem: FileSystem;
+  onFilePathChange: (path: string) => void;
   onActionSuccess: () => void;
   setLoadingFiles: (value: boolean) => void;
   selectedFiles: StorageDirectoryTableData[];
@@ -32,6 +35,7 @@ interface StorageDirectoryActionsProps {
 const StorageDirectoryActions = ({
   fileStats,
   fileSystem,
+  onFilePathChange,
   onActionSuccess,
   setLoadingFiles,
   selectedFiles,
@@ -47,9 +51,22 @@ const StorageDirectoryActions = ({
     huePubSub.publish('hue.error', error);
   };
 
+  if (inTrash(fileStats.path)) {
+    return (
+      <TrashActions
+        selectedFiles={selectedFiles}
+        currentPath={fileStats.path}
+        setLoadingFiles={setLoadingFiles}
+        onActionSuccess={onApiSuccess}
+        onActionError={onApiError}
+        onTrashEmptySuccess={() => onFilePathChange(fileSystem.user_home_directory)}
+      />
+    );
+  }
+
   return (
     <>
-      <StorageBrowserActions
+      <FileAndFolderActions
         config={fileSystem.config}
         currentPath={fileStats.path}
         selectedFiles={selectedFiles}

+ 244 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/Trash/TrashActions.test.tsx

@@ -0,0 +1,244 @@
+// 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 '@testing-library/jest-dom';
+import TrashActions from './TrashActions';
+
+const mockSave = jest.fn();
+jest.mock('../../../../../utils/hooks/useSaveData/useSaveData', () => ({
+  __esModule: true,
+  default: jest.fn(() => ({
+    save: mockSave
+  }))
+}));
+
+const mockOnActionSuccess = jest.fn();
+const mockOnActionError = jest.fn();
+const mockOnTrashEmptySuccess = jest.fn();
+const mockSetLoadingFiles = jest.fn();
+
+describe('TrashActions Component', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  const mockSelectedFiles = [
+    {
+      name: 'file1.txt',
+      size: '0 Byte',
+      type: 'file',
+      permission: 'rwxrwxrwx',
+      mtime: '2021-01-01 00:00:00',
+      path: '/user/path/.Trash/Current/file1.txt',
+      user: 'test',
+      group: 'test',
+      replication: 1
+    }
+  ];
+
+  const mockCurrentPath = '/user/path/.Trash/Current';
+
+  it('should render the Restore and Empty trash buttons', () => {
+    const { getByRole, queryByRole } = render(
+      <TrashActions
+        selectedFiles={mockSelectedFiles}
+        currentPath={mockCurrentPath}
+        onActionSuccess={mockOnActionSuccess}
+        onActionError={mockOnActionError}
+        setLoadingFiles={mockSetLoadingFiles}
+        onTrashEmptySuccess={mockOnTrashEmptySuccess}
+      />
+    );
+
+    expect(getByRole('button', { name: 'Restore' })).toBeInTheDocument();
+    expect(getByRole('button', { name: 'Empty trash' })).toBeInTheDocument();
+    expect(queryByRole('dialog')).not.toBeInTheDocument();
+  });
+
+  it('should open modal when "Restore" button is clicked', () => {
+    const { getByText, getByRole } = render(
+      <TrashActions
+        selectedFiles={mockSelectedFiles}
+        currentPath={mockCurrentPath}
+        onActionSuccess={mockOnActionSuccess}
+        onActionError={mockOnActionError}
+        setLoadingFiles={mockSetLoadingFiles}
+        onTrashEmptySuccess={mockOnTrashEmptySuccess}
+      />
+    );
+
+    fireEvent.click(getByRole('button', { name: 'Restore' }));
+
+    expect(getByRole('dialog')).toBeInTheDocument();
+    expect(getByText('Are you sure you want to restore these files?')).toBeInTheDocument();
+    expect(getByRole('button', { name: 'Yes' })).toBeInTheDocument();
+    expect(getByRole('button', { name: 'No' })).toBeInTheDocument();
+  });
+
+  it('should open modal when "Empty trash" button is clicked', () => {
+    const { getByText, getByRole } = render(
+      <TrashActions
+        selectedFiles={mockSelectedFiles}
+        currentPath={mockCurrentPath}
+        onActionSuccess={mockOnActionSuccess}
+        onActionError={mockOnActionError}
+        setLoadingFiles={mockSetLoadingFiles}
+        onTrashEmptySuccess={mockOnTrashEmptySuccess}
+      />
+    );
+
+    fireEvent.click(getByRole('button', { name: 'Empty trash' }));
+
+    expect(getByRole('dialog')).toBeInTheDocument();
+    expect(
+      getByText('Are you sure you want to permanently delete all your trash?')
+    ).toBeInTheDocument();
+    expect(getByRole('button', { name: 'Yes' })).toBeInTheDocument();
+    expect(getByRole('button', { name: 'No' })).toBeInTheDocument();
+  });
+
+  it('should restore files when "Yes" is clicked in the modal for restore', () => {
+    const { getByRole } = render(
+      <TrashActions
+        selectedFiles={mockSelectedFiles}
+        currentPath={mockCurrentPath}
+        onActionSuccess={mockOnActionSuccess}
+        onActionError={mockOnActionError}
+        setLoadingFiles={mockSetLoadingFiles}
+        onTrashEmptySuccess={mockOnTrashEmptySuccess}
+      />
+    );
+
+    fireEvent.click(getByRole('button', { name: 'Restore' }));
+    fireEvent.click(getByRole('button', { name: 'Yes' }));
+
+    expect(mockSetLoadingFiles).toHaveBeenCalledWith(true);
+    expect(mockSave).toHaveBeenCalled();
+  });
+
+  it('should call onTrashEmpty when "Yes" is clicked in the modal for empty trash', () => {
+    const { getByRole } = render(
+      <TrashActions
+        selectedFiles={mockSelectedFiles}
+        currentPath={mockCurrentPath}
+        onActionSuccess={mockOnActionSuccess}
+        onActionError={mockOnActionError}
+        setLoadingFiles={mockSetLoadingFiles}
+        onTrashEmptySuccess={mockOnTrashEmptySuccess}
+      />
+    );
+
+    fireEvent.click(getByRole('button', { name: 'Empty trash' }));
+    fireEvent.click(getByRole('button', { name: 'Yes' }));
+
+    expect(mockSetLoadingFiles).toHaveBeenCalledWith(true);
+    expect(mockSave).toHaveBeenCalled();
+  });
+
+  it('should close modal when "No" is clicked', () => {
+    const { getByRole, queryByRole } = render(
+      <TrashActions
+        selectedFiles={mockSelectedFiles}
+        currentPath={mockCurrentPath}
+        onActionSuccess={mockOnActionSuccess}
+        onActionError={mockOnActionError}
+        setLoadingFiles={mockSetLoadingFiles}
+        onTrashEmptySuccess={mockOnTrashEmptySuccess}
+      />
+    );
+
+    fireEvent.click(getByRole('button', { name: 'Restore' }));
+    fireEvent.click(getByRole('button', { name: 'No' }));
+
+    expect(queryByRole('dialog')).not.toBeInTheDocument();
+  });
+
+  it('should disable "Restore" button if no files are selected', () => {
+    const { getByRole } = render(
+      <TrashActions
+        selectedFiles={[]}
+        currentPath={mockCurrentPath}
+        onActionSuccess={mockOnActionSuccess}
+        onActionError={mockOnActionError}
+        setLoadingFiles={mockSetLoadingFiles}
+        onTrashEmptySuccess={mockOnTrashEmptySuccess}
+      />
+    );
+
+    expect(getByRole('button', { name: 'Restore' })).toBeDisabled();
+  });
+
+  it('should disable "Restore" button if current path is not restorable', () => {
+    const { getByRole } = render(
+      <TrashActions
+        selectedFiles={mockSelectedFiles}
+        currentPath={'path/not/restorable'}
+        onActionSuccess={mockOnActionSuccess}
+        onActionError={mockOnActionError}
+        setLoadingFiles={mockSetLoadingFiles}
+        onTrashEmptySuccess={mockOnTrashEmptySuccess}
+      />
+    );
+
+    expect(getByRole('button', { name: 'Restore' })).toBeDisabled();
+  });
+
+  it('should call onActionError when restore fails', () => {
+    mockSave.mockImplementationOnce(() => {
+      mockOnActionError(new Error('Restore failed'));
+    });
+
+    const { getByRole } = render(
+      <TrashActions
+        selectedFiles={mockSelectedFiles}
+        currentPath={mockCurrentPath}
+        onActionSuccess={mockOnActionSuccess}
+        onActionError={mockOnActionError}
+        setLoadingFiles={mockSetLoadingFiles}
+        onTrashEmptySuccess={mockOnTrashEmptySuccess}
+      />
+    );
+
+    fireEvent.click(getByRole('button', { name: 'Restore' }));
+    fireEvent.click(getByRole('button', { name: 'Yes' }));
+
+    expect(mockOnActionError).toHaveBeenCalledWith(new Error('Restore failed'));
+  });
+
+  it('should call onTrashEmptySuccess when trash is emptied successfully', () => {
+    mockSave.mockImplementationOnce(() => {
+      mockOnTrashEmptySuccess();
+    });
+
+    const { getByRole } = render(
+      <TrashActions
+        selectedFiles={mockSelectedFiles}
+        currentPath={mockCurrentPath}
+        onActionSuccess={mockOnActionSuccess}
+        onActionError={mockOnActionError}
+        setLoadingFiles={mockSetLoadingFiles}
+        onTrashEmptySuccess={mockOnTrashEmptySuccess}
+      />
+    );
+
+    fireEvent.click(getByRole('button', { name: 'Empty trash' }));
+    fireEvent.click(getByRole('button', { name: 'Yes' }));
+
+    expect(mockOnTrashEmptySuccess).toHaveBeenCalledTimes(1);
+  });
+});

+ 132 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/Trash/TrashActions.tsx

@@ -0,0 +1,132 @@
+// 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, { useState } from 'react';
+import { BorderlessButton } from 'cuix/dist/components/Button';
+import Modal from 'cuix/dist/components/Modal';
+
+import { i18nReact } from '../../../../../utils/i18nReact';
+import { FileStats, StorageDirectoryTableData } from '../../../types';
+import useSaveData from '../../../../../utils/hooks/useSaveData/useSaveData';
+import { TRASH_PURGE, TRASH_RESTORE_BULK } from '../../../api';
+import { inRestorableTrash } from '../../../../../utils/storageBrowserUtils';
+
+interface TrashActionsProps {
+  selectedFiles: StorageDirectoryTableData[];
+  currentPath: FileStats['path'];
+  onActionSuccess: () => void;
+  onActionError: (error: Error) => void;
+  setLoadingFiles: (value: boolean) => void;
+  onTrashEmptySuccess: () => void;
+}
+
+enum Actions {
+  restore = 'restore',
+  trashEmpty = 'trashEmpty'
+}
+
+const TrashActions = ({
+  selectedFiles,
+  currentPath,
+  onActionSuccess,
+  onActionError,
+  setLoadingFiles,
+  onTrashEmptySuccess
+}: TrashActionsProps): JSX.Element => {
+  const { t } = i18nReact.useTranslation();
+  const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
+  const [selectedAction, setSelectedAction] = useState<Actions | undefined>();
+
+  const { save, loading } = useSaveData(undefined, {
+    postOptions: {
+      qsEncodeData: false
+    },
+    onSuccess: () => {
+      setIsModalOpen(false);
+    },
+    onError: onActionError
+  });
+
+  const onRestoreFiles = () => {
+    const formData = new FormData();
+    selectedFiles.forEach(selectedFile => {
+      formData.append('path', selectedFile.path);
+    });
+    setLoadingFiles(true);
+
+    save(formData, { url: TRASH_RESTORE_BULK, onSuccess: onActionSuccess });
+  };
+
+  const onTrashEmpty = () => {
+    setLoadingFiles(true);
+
+    save(undefined, {
+      url: TRASH_PURGE,
+      onSuccess: onTrashEmptySuccess
+    });
+  };
+
+  const handleActionClick = (clickedAction: Actions) => {
+    setIsModalOpen(true);
+    setSelectedAction(clickedAction);
+  };
+
+  const handleModalConfirm = () => {
+    if (selectedAction === Actions.restore) {
+      return onRestoreFiles();
+    }
+    if (selectedAction === Actions.trashEmpty) {
+      return onTrashEmpty();
+    }
+  };
+
+  const handleModalCancel = () => {
+    setIsModalOpen(false);
+    setSelectedAction(undefined);
+  };
+
+  return (
+    <>
+      <BorderlessButton
+        data-event=""
+        disabled={!selectedFiles.length || !inRestorableTrash(currentPath)}
+        onClick={() => handleActionClick(Actions.restore)}
+      >
+        {t('Restore')}
+      </BorderlessButton>
+      <BorderlessButton data-event="" onClick={() => handleActionClick(Actions.trashEmpty)}>
+        {t('Empty trash')}
+      </BorderlessButton>
+      <Modal
+        cancelText={t('No')}
+        className="cuix antd"
+        okText={t('Yes')}
+        onCancel={handleModalCancel}
+        onOk={handleModalConfirm}
+        open={isModalOpen}
+        title={selectedAction === Actions.restore ? t('Restore') : t('Empty trash')}
+        okButtonProps={{ disabled: loading }}
+        cancelButtonProps={{ disabled: loading }}
+      >
+        {selectedAction === Actions.restore
+          ? t('Are you sure you want to restore these files?')
+          : t('Are you sure you want to permanently delete all your trash?')}
+      </Modal>
+    </>
+  );
+};
+
+export default TrashActions;

+ 4 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.tsx

@@ -62,6 +62,7 @@ interface StorageDirectoryPageProps {
   className?: string;
   rowClassName?: string;
   testId?: string;
+  reloadTrashPath: () => void;
 }
 
 const defaultProps = {
@@ -77,6 +78,7 @@ const StorageDirectoryPage = ({
   className,
   rowClassName,
   testId,
+  reloadTrashPath,
   ...restProps
 }: StorageDirectoryPageProps): JSX.Element => {
   const [loadingFiles, setLoadingFiles] = useState<boolean>(false);
@@ -288,8 +290,10 @@ const StorageDirectoryPage = ({
             fileStats={fileStats}
             fileSystem={fileSystem}
             selectedFiles={selectedFiles}
+            onFilePathChange={onFilePathChange}
             onActionSuccess={() => {
               reloadFilesData();
+              reloadTrashPath();
             }}
             setLoadingFiles={setLoadingFiles}
             onFilesDrop={onFilesDrop}

+ 3 - 0
desktop/core/src/desktop/js/apps/storageBrowser/api.ts

@@ -39,3 +39,6 @@ export const BULK_MOVE_API_URL = '/api/v1/storage/move/bulk';
 export const BULK_CHANGE_OWNER_API_URL = '/api/v1/storage/chown/bulk';
 export const BULK_CHANGE_PERMISSION_API_URL = '/api/v1/storage/chmod/bulk';
 export const UPLOAD_AVAILABLE_SPACE_URL = '/api/v1/taskserver/upload/available_space';
+export const TRASH_PATH = '/api/v1/storage/trash/path';
+export const TRASH_RESTORE_BULK = '/api/v1/storage/trash/restore/bulk';
+export const TRASH_PURGE = '/api/v1/storage/trash/purge';

+ 4 - 0
desktop/core/src/desktop/js/apps/storageBrowser/types.ts

@@ -81,6 +81,10 @@ export interface ContentSummary {
   replication: number;
 }
 
+export interface TrashPath {
+  trash_path: string;
+}
+
 export enum SortOrder {
   ASC = 'ascending',
   DSC = 'descending',

+ 6 - 6
desktop/core/src/desktop/js/utils/hooks/useLoadData/useLoadData.test.tsx

@@ -14,7 +14,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import { renderHook, act, waitFor } from '@testing-library/react';
+import { renderHook, waitFor } from '@testing-library/react';
 import useLoadData from './useLoadData';
 import { get } from '../../../api/utils';
 
@@ -115,16 +115,16 @@ describe('useLoadData', () => {
       expect(result.current.loading).toBe(false);
     });
 
-    mockGet.mockResolvedValueOnce({ ...mockData, product: 'Hue 2' });
+    const updatedMockResult = { ...mockData, product: 'Hue 2' };
+    mockGet.mockResolvedValueOnce(updatedMockResult);
 
-    act(() => {
-      result.current.reloadData();
-    });
+    const reloadResult = await result.current.reloadData();
 
     await waitFor(() => {
       expect(mockGet).toHaveBeenCalledTimes(2);
       expect(mockGet).toHaveBeenCalledWith(mockUrl, undefined, expect.any(Object));
-      expect(result.current.data).toEqual({ ...mockData, product: 'Hue 2' });
+      expect(result.current.data).toEqual(updatedMockResult);
+      expect(reloadResult).toEqual(updatedMockResult);
       expect(result.current.error).toBeUndefined();
       expect(result.current.loading).toBe(false);
     });

+ 2 - 1
desktop/core/src/desktop/js/utils/hooks/useLoadData/useLoadData.ts

@@ -31,7 +31,7 @@ interface UseLoadDataProps<T> {
   data?: T;
   loading: boolean;
   error?: AxiosError;
-  reloadData: () => void;
+  reloadData: () => Promise<T | undefined>;
 }
 
 const useLoadData = <T, U = unknown>(
@@ -70,6 +70,7 @@ const useLoadData = <T, U = unknown>(
         if (localOptions?.onSuccess) {
           localOptions.onSuccess(response);
         }
+        return response;
       } catch (error) {
         setError(error as AxiosError);
         if (localOptions?.onError) {

+ 21 - 1
desktop/core/src/desktop/js/utils/storageBrowserUtils.test.ts

@@ -26,7 +26,8 @@ import {
   isOFSRoot,
   isOFSServiceID,
   isOFSVol,
-  inTrash
+  inTrash,
+  inRestorableTrash
 } from './storageBrowserUtils';
 
 describe('isHDFS function', () => {
@@ -203,3 +204,22 @@ describe('inTrash function', () => {
     expect(inTrash('ofs://path/to/file')).toBe(false);
   });
 });
+
+describe('inRestorableTrash function', () => {
+  test('returns false if path is not in trash"', () => {
+    expect(inRestorableTrash('hdfs://path/to/file')).toBe(false);
+    expect(inRestorableTrash('hdfs://path/to/file')).toBe(false);
+    expect(inRestorableTrash('ofs://path/to/file')).toBe(false);
+  });
+
+  test('returns false if path is trash root folder"', () => {
+    expect(inRestorableTrash('/user/path/.Trash')).toBe(false);
+    expect(inRestorableTrash('/user/path/.Trash/')).toBe(false);
+  });
+
+  test('returns true if path is trash but not trash root folder', () => {
+    expect(inRestorableTrash('/user/path/.Trash/Current')).toBe(true);
+    expect(inRestorableTrash('/user/path/.Trash/Current/user')).toBe(true);
+    expect(inRestorableTrash('/user/path/.Trash/Current/user/path')).toBe(true);
+  });
+});

+ 31 - 10
desktop/core/src/desktop/js/utils/storageBrowserUtils.ts

@@ -14,41 +14,58 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+const fileSysteRoot = {
+  hdfs: ['/', 'hdfs'],
+  ofs: ['ofs://'],
+  s3: ['s3a://'],
+  gs: ['gs://'],
+  abfs: ['abfs://']
+};
+
+const checkFileSystem = (path: string, allowedRoots: string[]): boolean => {
+  const formattedPath = path.toLowerCase();
+  return allowedRoots.some(root => formattedPath.startsWith(root));
+};
+
+const checkFileSystemRoot = (path: string, allowedRoots: string[]): boolean => {
+  const formattedPath = path.toLowerCase();
+  return allowedRoots.some(root => formattedPath === root);
+};
+
 export const isHDFS = (path: string): boolean => {
-  const currentPath = path.toLowerCase();
-  return currentPath.indexOf('/') === 0 || currentPath.indexOf('hdfs') === 0;
+  return checkFileSystem(path, fileSysteRoot.hdfs);
 };
 
 export const isOFS = (path: string): boolean => {
-  return path.toLowerCase().indexOf('ofs://') === 0;
+  return checkFileSystem(path, fileSysteRoot.ofs);
 };
 
 export const isS3 = (path: string): boolean => {
-  return path.toLowerCase().indexOf('s3a://') === 0;
+  return checkFileSystem(path, fileSysteRoot.s3);
 };
 
 export const isGS = (path: string): boolean => {
-  return path.toLowerCase().indexOf('gs://') === 0;
+  return checkFileSystem(path, fileSysteRoot.gs);
 };
 
 export const isABFS = (path: string): boolean => {
-  return path.toLowerCase().indexOf('abfs://') === 0;
+  return checkFileSystem(path, fileSysteRoot.abfs);
 };
 
 export const isS3Root = (path: string): boolean => {
-  return isS3(path) && path.toLowerCase() === 's3a://';
+  return checkFileSystemRoot(path, fileSysteRoot.s3);
 };
 
 export const isGSRoot = (path: string): boolean => {
-  return isGS(path) && path.toLowerCase() === 'gs://';
+  return checkFileSystemRoot(path, fileSysteRoot.gs);
 };
 
 export const isABFSRoot = (path: string): boolean => {
-  return isABFS(path) && path.toLowerCase() === 'abfs://';
+  return checkFileSystemRoot(path, fileSysteRoot.abfs);
 };
 
 export const isOFSRoot = (path: string): boolean => {
-  return isOFS(path) && path.toLowerCase() === 'ofs://';
+  return checkFileSystemRoot(path, fileSysteRoot.ofs);
 };
 
 export const isOFSServiceID = (path: string): boolean => {
@@ -62,3 +79,7 @@ export const isOFSVol = (path: string): boolean => {
 export const inTrash = (path: string): boolean => {
   return path.match(/^\/user\/.+?\/\.Trash/) !== null;
 };
+
+export const inRestorableTrash = (path: string): boolean => {
+  return path.match(/^\/user\/.+?\/\.Trash\/.+?/) !== null;
+};