Browse Source

[ui-storagebrowser] adds delete action for files and folder (#3926)

* [ui-storagebrowser] adds delete action for files and folder
Ram Prasad Agarwal 10 tháng trước cách đây
mục cha
commit
28e761c6d2

+ 216 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Delete/Delete.test.tsx

@@ -0,0 +1,216 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import DeleteAction from './Delete';
+import { StorageDirectoryTableData } from '../../../../../reactComponents/FileChooser/types';
+import {
+  BULK_DELETION_API_URL,
+  DELETION_API_URL
+} from '../../../../../reactComponents/FileChooser/api';
+
+const mockFiles: StorageDirectoryTableData[] = [
+  {
+    name: 'file1.txt',
+    size: '0 Byte',
+    type: 'file',
+    permission: 'rwxrwxrwx',
+    mtime: '2021-01-01 00:00:00',
+    path: 'test/path/file1.txt',
+    user: 'test',
+    group: 'test',
+    replication: 1
+  },
+  {
+    name: 'file2.txt',
+    size: '0 Byte',
+    type: 'file',
+    permission: 'rwxrwxrwx',
+    mtime: '2021-01-01 00:00:00',
+    path: 'test/path/file2.txt',
+    user: 'test',
+    group: 'test',
+    replication: 1
+  }
+];
+
+const mockSave = jest.fn();
+jest.mock('../../../../../utils/hooks/useSaveData', () => ({
+  __esModule: true,
+  default: jest.fn(() => ({
+    save: mockSave,
+    loading: false
+  }))
+}));
+
+describe('DeleteAction Component', () => {
+  const mockOnSuccess = jest.fn();
+  const mockOnError = jest.fn();
+  const mockOnClose = jest.fn();
+  const setLoading = jest.fn();
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('should render the Delete modal with the correct title and buttons', () => {
+    const { getByText, getByRole } = render(
+      <DeleteAction
+        isOpen={true}
+        files={mockFiles}
+        setLoading={setLoading}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+        isTrashEnabled={true}
+      />
+    );
+
+    expect(getByText('Delete file')).toBeInTheDocument();
+    expect(getByText('Move to Trash')).toBeInTheDocument();
+    expect(getByText('Delete Permanently')).toBeInTheDocument();
+    expect(getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+  });
+
+  it('should render the Delete modal with the correct title and buttons when trash is not enabled', () => {
+    const { getByText, queryByText, getByRole } = render(
+      <DeleteAction
+        isOpen={true}
+        files={mockFiles}
+        setLoading={setLoading}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+        isTrashEnabled={false}
+      />
+    );
+
+    expect(getByText('Delete file')).toBeInTheDocument();
+    expect(queryByText('Move to Trash')).not.toBeInTheDocument();
+    expect(getByText('Delete Permanently')).toBeInTheDocument();
+    expect(getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+  });
+
+  it('should call handleDeletion with the correct data for single delete when "Delete Permanently" is clicked', async () => {
+    const { getByText } = render(
+      <DeleteAction
+        isOpen={true}
+        files={[mockFiles[0]]}
+        setLoading={setLoading}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+        isTrashEnabled={false}
+      />
+    );
+
+    fireEvent.click(getByText('Delete Permanently'));
+
+    const payload = { path: mockFiles[0].path, skip_trash: true };
+    expect(mockSave).toHaveBeenCalledWith(payload, { url: DELETION_API_URL });
+  });
+
+  it('should call handleDeletion with the correct data for bulk delete when "Delete Permanently" is clicked', async () => {
+    const { getByText } = render(
+      <DeleteAction
+        isOpen={true}
+        files={mockFiles}
+        setLoading={setLoading}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+        isTrashEnabled={false}
+      />
+    );
+
+    fireEvent.click(getByText('Delete Permanently'));
+
+    const formData = new FormData();
+    mockFiles.forEach(file => {
+      formData.append('path', file.path);
+    });
+    formData.append('skip_trash', 'true');
+
+    expect(mockSave).toHaveBeenCalledWith(formData, { url: BULK_DELETION_API_URL });
+  });
+
+  it('should call handleDeletion with the correct data for trash delete when "Move to Trash" is clicked', async () => {
+    const { getByText } = render(
+      <DeleteAction
+        isOpen={true}
+        files={[mockFiles[0]]}
+        setLoading={setLoading}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+        isTrashEnabled={true}
+      />
+    );
+
+    fireEvent.click(getByText('Move to Trash'));
+
+    const payload = { path: mockFiles[0].path };
+    expect(mockSave).toHaveBeenCalledWith(payload, { url: DELETION_API_URL });
+  });
+
+  it('should call handleDeletion with the correct data for bulk trash delete when "Move to Trash" is clicked', async () => {
+    const { getByText } = render(
+      <DeleteAction
+        isOpen={true}
+        files={mockFiles}
+        setLoading={setLoading}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+        isTrashEnabled={true}
+      />
+    );
+
+    fireEvent.click(getByText('Move to Trash'));
+
+    const formData = new FormData();
+    mockFiles.forEach(file => {
+      formData.append('path', file.path);
+    });
+
+    expect(mockSave).toHaveBeenCalledWith(formData, { url: BULK_DELETION_API_URL });
+  });
+
+  it('should call onError when the delete request fails', async () => {
+    mockSave.mockImplementationOnce(() => {
+      mockOnError(new Error());
+    });
+    const { getByText } = render(
+      <DeleteAction
+        isOpen={true}
+        files={mockFiles}
+        setLoading={setLoading}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+        isTrashEnabled={true}
+      />
+    );
+
+    fireEvent.click(getByText('Move to Trash'));
+
+    expect(mockOnError).toHaveBeenCalledTimes(1);
+  });
+
+  it('should call onClose when the modal is closed', () => {
+    const { getByText } = render(
+      <DeleteAction
+        isOpen={true}
+        files={mockFiles}
+        setLoading={setLoading}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+        isTrashEnabled={true}
+      />
+    );
+
+    fireEvent.click(getByText('Cancel'));
+
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+});

+ 115 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Delete/Delete.tsx

@@ -0,0 +1,115 @@
+// 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 Modal from 'cuix/dist/components/Modal';
+import { i18nReact } from '../../../../../utils/i18nReact';
+import useSaveData from '../../../../../utils/hooks/useSaveData';
+import { StorageDirectoryTableData } from '../../../../../reactComponents/FileChooser/types';
+import {
+  BULK_DELETION_API_URL,
+  DELETION_API_URL
+} from '../../../../../reactComponents/FileChooser/api';
+
+interface DeleteActionProps {
+  isOpen?: boolean;
+  isTrashEnabled?: boolean;
+  files: StorageDirectoryTableData[];
+  setLoading: (value: boolean) => void;
+  onSuccess: () => void;
+  onError: (error: Error) => void;
+  onClose: () => void;
+}
+
+const DeleteAction = ({
+  isOpen = true,
+  isTrashEnabled = false,
+  files,
+  setLoading,
+  onSuccess,
+  onError,
+  onClose
+}: DeleteActionProps): JSX.Element => {
+  const { t } = i18nReact.useTranslation();
+
+  const { save, loading: saveLoading } = useSaveData(undefined, {
+    skip: !files.length,
+    onSuccess,
+    onError
+  });
+
+  const { save: saveForm, loading: saveFormLoading } = useSaveData(undefined, {
+    postOptions: {
+      qsEncodeData: false,
+      headers: {
+        'Content-Type': 'multipart/form-data'
+      }
+    },
+    skip: !files.length,
+    onSuccess,
+    onError
+  });
+
+  const handleDeletion = (isForedSkipTrash: boolean = false) => {
+    const isSkipTrash = !isTrashEnabled || isForedSkipTrash;
+    setLoading(true);
+
+    const isBulkDelete = files.length > 1;
+    if (isBulkDelete) {
+      const formData = new FormData();
+      files.forEach(selectedFile => {
+        formData.append('path', selectedFile.path);
+      });
+      if (isSkipTrash) {
+        formData.append('skip_trash', String(isSkipTrash));
+      }
+
+      saveForm(formData, { url: BULK_DELETION_API_URL });
+    } else {
+      const payload = { path: files[0].path, skip_trash: isSkipTrash ? true : undefined };
+      save(payload, { url: DELETION_API_URL });
+    }
+  };
+
+  const loading = saveFormLoading || saveLoading;
+
+  return (
+    <Modal
+      cancelText={t('Cancel')}
+      className="hue-input-modal cuix antd"
+      okText={isTrashEnabled ? t('Move to Trash') : t('Delete Permanently')}
+      onCancel={onClose}
+      onOk={() => handleDeletion()}
+      open={isOpen}
+      title={t('Delete file')}
+      secondaryButtonText={isTrashEnabled ? t('Delete Permanently') : undefined}
+      onSecondary={() => handleDeletion(true)}
+      secondaryButtonProps={{ disabled: loading }}
+      okButtonProps={{ disabled: loading }}
+      cancelButtonProps={{ disabled: loading }}
+    >
+      {isTrashEnabled
+        ? files.length > 1
+          ? t('Do you want to move {{count}} items to trash?', { count: files.length })
+          : t('Do you want to move "{{name}}" to trash?', { name: files[0]?.name })
+        : files.length > 1
+          ? t('Do you want to delete {{count}} items permanently?', { count: files.length })
+          : t('Do you want to delete "{{name}}" permanently?', { name: files[0]?.name })}
+    </Modal>
+  );
+};
+
+export default DeleteAction;

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

@@ -39,8 +39,10 @@ import MoveCopyAction from './MoveCopy/MoveCopy';
 import RenameAction from './Rename/Rename';
 import RenameAction from './Rename/Rename';
 import ReplicationAction from './Replication/Replication';
 import ReplicationAction from './Replication/Replication';
 import ViewSummary from './ViewSummary/ViewSummary';
 import ViewSummary from './ViewSummary/ViewSummary';
+import DeleteAction from './Delete/Delete';
 
 
 interface StorageBrowserRowActionsProps {
 interface StorageBrowserRowActionsProps {
+  isTrashEnabled?: boolean;
   currentPath: FileStats['path'];
   currentPath: FileStats['path'];
   selectedFiles: StorageDirectoryTableData[];
   selectedFiles: StorageDirectoryTableData[];
   onSuccessfulAction: () => void;
   onSuccessfulAction: () => void;
@@ -57,6 +59,7 @@ const iconsMap: Record<ActionType, JSX.Element> = {
 };
 };
 
 
 const StorageBrowserActions = ({
 const StorageBrowserActions = ({
+  isTrashEnabled,
   currentPath,
   currentPath,
   selectedFiles,
   selectedFiles,
   onSuccessfulAction,
   onSuccessfulAction,
@@ -137,6 +140,16 @@ const StorageBrowserActions = ({
           setLoadingFiles={setLoadingFiles}
           setLoadingFiles={setLoadingFiles}
         />
         />
       )}
       )}
+      {selectedAction === ActionType.Delete && (
+        <DeleteAction
+          isTrashEnabled={isTrashEnabled}
+          files={selectedFiles}
+          onSuccess={onApiSuccess}
+          onError={onApiError}
+          onClose={closeModal}
+          setLoading={setLoadingFiles}
+        />
+      )}
     </>
     </>
   );
   );
 };
 };

+ 5 - 1
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.tsx

@@ -101,7 +101,10 @@ const StorageDirectoryPage = ({
     skip:
     skip:
       fileStats.path === '' ||
       fileStats.path === '' ||
       fileStats.path === undefined ||
       fileStats.path === undefined ||
-      fileStats.type !== BrowserViewType.dir
+      fileStats.type !== BrowserViewType.dir,
+    onSuccess: () => {
+      setSelectedFiles([]);
+    }
   });
   });
 
 
   const tableData: StorageDirectoryTableData[] = useMemo(() => {
   const tableData: StorageDirectoryTableData[] = useMemo(() => {
@@ -273,6 +276,7 @@ const StorageDirectoryPage = ({
         <div className="hue-storage-browser__actions-bar-right">
         <div className="hue-storage-browser__actions-bar-right">
           <StorageBrowserActions
           <StorageBrowserActions
             currentPath={fileStats.path}
             currentPath={fileStats.path}
+            isTrashEnabled={filesData?.is_trash_enabled}
             selectedFiles={selectedFiles}
             selectedFiles={selectedFiles}
             setLoadingFiles={setLoadingFiles}
             setLoadingFiles={setLoadingFiles}
             onSuccessfulAction={reloadData}
             onSuccessfulAction={reloadData}

+ 2 - 0
desktop/core/src/desktop/js/reactComponents/FileChooser/api.ts

@@ -28,6 +28,8 @@ export const CREATE_FILE_API_URL = '/api/v1/storage/create/file/';
 export const CREATE_DIRECTORY_API_URL = '/api/v1/storage/create/directory/';
 export const CREATE_DIRECTORY_API_URL = '/api/v1/storage/create/directory/';
 export const RENAME_API_URL = '/api/v1/storage/rename/';
 export const RENAME_API_URL = '/api/v1/storage/rename/';
 export const SET_REPLICATION_API_URL = '/api/v1/storage/replication/';
 export const SET_REPLICATION_API_URL = '/api/v1/storage/replication/';
+export const DELETION_API_URL = '/api/v1/storage/delete/';
+export const BULK_DELETION_API_URL = '/api/v1/storage/delete/bulk';
 export const COPY_API_URL = '/api/v1/storage/copy/';
 export const COPY_API_URL = '/api/v1/storage/copy/';
 export const BULK_COPY_API_URL = '/api/v1/storage/copy/bulk/';
 export const BULK_COPY_API_URL = '/api/v1/storage/copy/bulk/';
 export const MOVE_API_URL = '/api/v1/storage/move/';
 export const MOVE_API_URL = '/api/v1/storage/move/';