Selaa lähdekoodia

[ui-sb] feat: disable drag and drop for root paths (#4240)

Ram Prasad Agarwal 2 kuukautta sitten
vanhempi
commit
67b902e3d5

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

@@ -36,7 +36,7 @@ import {
   getLastDirOrFileNameFromPath,
   getFileSystemAndPath
 } from '../../../reactComponents/PathBrowser/PathBrowser.util';
-import { inTrash } from '../../../utils/storageBrowserUtils';
+import { inTrash } from '../utils/utils';
 
 import './StorageBrowserTab.scss';
 

+ 1 - 1
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/CreateAndUpload/CreateAndUploadAction.test.tsx

@@ -20,7 +20,7 @@ import userEvent from '@testing-library/user-event';
 import '@testing-library/jest-dom';
 import CreateAndUploadAction from './CreateAndUploadAction';
 import { CREATE_DIRECTORY_API_URL, CREATE_FILE_API_URL } from '../../../api';
-import * as storageUtils from '../../../../../utils/storageBrowserUtils';
+import * as storageUtils from '../../../utils/utils';
 
 const mockSave = jest.fn();
 jest.mock('../../../../../utils/hooks/useSaveData/useSaveData', () => ({

+ 5 - 5
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryActions/CreateAndUpload/CreateAndUploadAction.tsx

@@ -34,8 +34,8 @@ import {
   isOFSServiceID,
   isOFSVol,
   isS3Root,
-  isFileSystemRoot
-} from '../../../../../utils/storageBrowserUtils';
+  isFileSystemNonRoot
+} from '../../../utils/utils';
 import { FileStats } from '../../../types';
 import useSaveData from '../../../../../utils/hooks/useSaveData/useSaveData';
 import InputModal from '../../../../../reactComponents/InputModal/InputModal';
@@ -78,7 +78,7 @@ const getActionConfig = (t: TFunction): Record<ActionType, ActionItem> => ({
     modal: { title: t('Create File'), label: t('File name') },
     api: CREATE_FILE_API_URL,
     group: 'create',
-    visible: path => isFileSystemRoot(path)
+    visible: path => isFileSystemNonRoot(path)
   },
   [ActionType.createFolder]: {
     icon: <FolderIcon />,
@@ -86,7 +86,7 @@ const getActionConfig = (t: TFunction): Record<ActionType, ActionItem> => ({
     modal: { title: t('Create Folder'), label: t('Folder name') },
     api: CREATE_DIRECTORY_API_URL,
     group: 'create',
-    visible: path => isFileSystemRoot(path)
+    visible: path => isFileSystemNonRoot(path)
   },
   [ActionType.createBucket]: {
     icon: <BucketIcon />,
@@ -116,7 +116,7 @@ const getActionConfig = (t: TFunction): Record<ActionType, ActionItem> => ({
     icon: <ImportIcon />,
     label: t('Upload File'),
     group: 'upload',
-    visible: path => isFileSystemRoot(path)
+    visible: path => isFileSystemNonRoot(path)
   }
 });
 

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

@@ -29,7 +29,7 @@ import {
   isS3,
   isOFSRoot,
   inTrash
-} from '../../../../../utils/storageBrowserUtils';
+} from '../../../utils/utils';
 import { SupportedFileTypes } from '../../../../../utils/constants/storageBrowser';
 import { TFunction } from 'i18next';
 import { getFileType } from '../../../StorageFilePage/StorageFilePage.util';

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

@@ -15,7 +15,7 @@
 // limitations under the License.
 
 import React from 'react';
-import { inTrash } from '../../../../utils/storageBrowserUtils';
+import { inTrash } from '../../utils/utils';
 import CreateAndUploadAction from './CreateAndUpload/CreateAndUploadAction';
 import TrashActions from './Trash/TrashActions';
 import FileAndFolderActions from './FileAndFolder/FileAndFolderActions';

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

@@ -22,7 +22,7 @@ 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';
+import { inRestorableTrash } from '../../../utils/utils';
 
 interface TrashActionsProps {
   selectedFiles: StorageDirectoryTableData[];

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

@@ -51,6 +51,7 @@ import {
   FILE_UPLOAD_START_EVENT,
   FILE_UPLOAD_SUCCESS_EVENT
 } from '../../../reactComponents/FileUploadQueue/event';
+import { isFileSystemNonRoot } from '../utils/utils';
 
 import './StorageDirectoryPage.scss';
 
@@ -127,7 +128,16 @@ const StorageDirectoryPage = ({
 
   const onRowClicked = (record: StorageDirectoryTableData) => {
     return {
-      onClick: () => {
+      onClick: (event: React.MouseEvent) => {
+        // Handle CTRL+click to open in new tab
+        if (event.ctrlKey || event.metaKey) {
+          const currentUrl = new URL(window.location.href);
+          currentUrl.searchParams.set('path', record.path);
+          window.open(currentUrl.toString(), '_blank');
+          return;
+        }
+
+        // Normal click behavior
         if (selectedFiles.length === 0) {
           onFilePathChange(record.path);
           if (record.type === 'dir') {
@@ -258,7 +268,7 @@ const StorageDirectoryPage = ({
         </div>
       </div>
 
-      <DragAndDrop onDrop={onFilesDrop}>
+      <DragAndDrop onDrop={onFilesDrop} disabled={!isFileSystemNonRoot(fileStats.path)}>
         <LoadingErrorWrapper errors={errorConfig}>
           <PaginatedTable<StorageDirectoryTableData>
             loading={listDirectoryLoading && !polling}

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

@@ -32,7 +32,7 @@ import {
 import useLoadData from '../../../utils/hooks/useLoadData/useLoadData';
 import { getLastKnownConfig } from '../../../config/hueConfig';
 import LoadingErrorWrapper from '../../../reactComponents/LoadingErrorWrapper/LoadingErrorWrapper';
-import { inTrash } from '../../../utils/storageBrowserUtils';
+import { inTrash } from '../utils/utils';
 import { getLastDirOrFileNameFromPath } from '../../../reactComponents/PathBrowser/PathBrowser.util';
 
 interface StorageFilePageProps {

+ 14 - 14
desktop/core/src/desktop/js/utils/storageBrowserUtils.test.ts → desktop/core/src/desktop/js/apps/storageBrowser/utils/utils.test.ts

@@ -28,8 +28,8 @@ import {
   isOFSVol,
   inTrash,
   inRestorableTrash,
-  isFileSystemRoot
-} from './storageBrowserUtils';
+  isFileSystemNonRoot
+} from './utils';
 
 describe('isHDFS function', () => {
   test('returns true for paths starting with "/"', () => {
@@ -225,49 +225,49 @@ describe('inRestorableTrash function', () => {
   });
 });
 
-describe('isFileSystemRoot', () => {
+describe('isFileSystemNonRoot', () => {
   beforeEach(() => {
     jest.resetAllMocks();
   });
 
   it('returns true for non-root S3 path', () => {
-    expect(isFileSystemRoot('s3a://bucket/folder')).toBe(true);
+    expect(isFileSystemNonRoot('s3a://bucket/folder')).toBe(true);
   });
 
   it('returns false for S3 root path', () => {
-    expect(isFileSystemRoot('s3a://')).toBe(false);
+    expect(isFileSystemNonRoot('s3a://')).toBe(false);
   });
 
   it('returns true for non-root GS path', () => {
-    expect(isFileSystemRoot('gs://folder')).toBe(true);
+    expect(isFileSystemNonRoot('gs://folder')).toBe(true);
   });
 
   it('returns false for GS root path', () => {
-    expect(isFileSystemRoot('gs://')).toBe(false);
+    expect(isFileSystemNonRoot('gs://')).toBe(false);
   });
 
   it('returns true for non-root ABFS path', () => {
-    expect(isFileSystemRoot('abfs://folder')).toBe(true);
+    expect(isFileSystemNonRoot('abfs://folder')).toBe(true);
   });
 
   it('returns false for ABFS root path', () => {
-    expect(isFileSystemRoot('abfs://')).toBe(false);
+    expect(isFileSystemNonRoot('abfs://')).toBe(false);
   });
 
   it('returns true for OFS when not serviceID or volume', () => {
-    expect(isFileSystemRoot('ofs://service/volume/folder')).toBe(true);
+    expect(isFileSystemNonRoot('ofs://service/volume/folder')).toBe(true);
   });
 
   it('returns false for OFS serviceID', () => {
-    expect(isFileSystemRoot('ofs://service')).toBe(false);
+    expect(isFileSystemNonRoot('ofs://service')).toBe(false);
   });
 
   it('returns false for OFS volume', () => {
-    expect(isFileSystemRoot('ofs://service/vol')).toBe(false);
+    expect(isFileSystemNonRoot('ofs://service/vol')).toBe(false);
   });
 
   it('returns true for HDFS (default case)', () => {
-    expect(isFileSystemRoot('/hdfs')).toBe(true);
-    expect(isFileSystemRoot('/')).toBe(true);
+    expect(isFileSystemNonRoot('/hdfs')).toBe(true);
+    expect(isFileSystemNonRoot('/')).toBe(true);
   });
 });

+ 1 - 1
desktop/core/src/desktop/js/utils/storageBrowserUtils.ts → desktop/core/src/desktop/js/apps/storageBrowser/utils/utils.ts

@@ -84,7 +84,7 @@ export const inRestorableTrash = (path: string): boolean => {
   return path.match(/^\/user\/.+?\/\.Trash\/.+?/) !== null;
 };
 
-export const isFileSystemRoot = (path: string): boolean => {
+export const isFileSystemNonRoot = (path: string): boolean => {
   if (isS3(path)) {
     return !isS3Root(path);
   }

+ 145 - 37
desktop/core/src/desktop/js/reactComponents/DragAndDrop/DragAndDrop.test.tsx

@@ -15,77 +15,185 @@
 // limitations under the License.
 
 import React from 'react';
-import { render, fireEvent, waitFor, act } from '@testing-library/react';
+import { render, fireEvent, waitFor, act, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
 import '@testing-library/jest-dom';
 import DragAndDrop from './DragAndDrop';
 
+const createMockFile = (name = 'test.txt', content = 'file content', type = 'text/plain') => {
+  const file = new File([content], name, { type });
+  Object.defineProperty(file, 'size', { value: content.length });
+  return file;
+};
+
+const createDragEvent = (files: File[]) => ({
+  dataTransfer: {
+    files,
+    items: files.map(file => ({
+      kind: 'file',
+      type: file.type,
+      getAsFile: () => file
+    })),
+    types: ['Files']
+  }
+});
+
 describe('DragAndDrop', () => {
-  const mockOnFilesDrop = jest.fn();
+  const mockOnDrop = jest.fn();
 
   beforeEach(() => {
     jest.clearAllMocks();
   });
 
-  it('should render the initial message when not dragging and children not present', () => {
-    const { getByText } = render(<DragAndDrop onDrop={mockOnFilesDrop} />);
+  it('should render the default message when no children provided', () => {
+    render(<DragAndDrop onDrop={mockOnDrop} />);
 
-    expect(getByText('Browse files or drag and drop files')).toBeInTheDocument();
+    expect(screen.getByText('Select file')).toBeInTheDocument();
+    expect(screen.getByText('Browse files or drag and drop files')).toBeInTheDocument();
   });
 
   it('should render children when provided and not dragging', () => {
-    const { getByText } = render(
-      <DragAndDrop onDrop={mockOnFilesDrop}>
+    render(
+      <DragAndDrop onDrop={mockOnDrop}>
         <div>Custom Child Element</div>
       </DragAndDrop>
     );
 
-    expect(getByText('Custom Child Element')).toBeInTheDocument();
+    expect(screen.getByText('Custom Child Element')).toBeInTheDocument();
+    expect(screen.queryByText('Browse files or drag and drop files')).not.toBeInTheDocument();
   });
 
-  it('should not display the default message when children are passed', () => {
-    const { queryByText, getByText } = render(
-      <DragAndDrop onDrop={mockOnFilesDrop}>
-        <div>Custom Child Element</div>
-      </DragAndDrop>
-    );
+  it('should handle single file selection', async () => {
+    const user = userEvent.setup();
+    render(<DragAndDrop onDrop={mockOnDrop} />);
+
+    const fileInput = screen.getByTestId('drag-drop__input');
+    const file = createMockFile();
+
+    await user.upload(fileInput, file);
+
+    // react-dropzone calls onDrop with (acceptedFiles, fileRejections, event)
+    expect(mockOnDrop).toHaveBeenCalledWith(expect.arrayContaining([file]), [], expect.any(Object));
+  });
+
+  it('should handle multiple file selection', async () => {
+    const user = userEvent.setup();
+    render(<DragAndDrop onDrop={mockOnDrop} />);
+
+    const fileInput = screen.getByTestId('drag-drop__input');
+    const files = [
+      createMockFile('file1.txt'),
+      createMockFile('file2.txt'),
+      createMockFile('file3.jpg', 'image content', 'image/jpeg')
+    ];
+
+    await user.upload(fileInput, files);
+
+    // react-dropzone calls onDrop with (acceptedFiles, fileRejections, event)
+    expect(mockOnDrop).toHaveBeenCalledWith(files, [], expect.any(Object));
+  });
+
+  it('should display drop message when dragging files over the component', async () => {
+    render(<DragAndDrop onDrop={mockOnDrop} />);
+
+    const dropzone = screen.getByTestId('drag-drop__input');
+
+    await act(async () => {
+      fireEvent.dragEnter(dropzone);
+    });
+
+    await waitFor(() => {
+      expect(screen.getByText('Drop files here')).toBeInTheDocument();
+    });
+  });
+
+  it('should call onDrop with correct files when files are dropped', async () => {
+    const files = [createMockFile('dropped.txt', 'dropped content')];
+    render(<DragAndDrop onDrop={mockOnDrop} />);
 
-    expect(queryByText('Browse files or drag and drop files')).not.toBeInTheDocument();
-    expect(getByText('Custom Child Element')).toBeInTheDocument();
+    const dropzone = screen.getByTestId('drag-drop__input');
+    const dropEvent = createDragEvent(files);
+
+    await act(async () => {
+      fireEvent.drop(dropzone, dropEvent);
+    });
+
+    await waitFor(() => {
+      // react-dropzone calls onDrop with (acceptedFiles, fileRejections, event)
+      expect(mockOnDrop).toHaveBeenCalledWith(files, [], expect.any(Object));
+      expect(mockOnDrop).toHaveBeenCalledTimes(1);
+    });
   });
 
-  it('should display the dragging message when dragging files', async () => {
-    const { getByText, getByTestId } = render(<DragAndDrop onDrop={mockOnFilesDrop} />);
+  it('should handle multiple files being dropped', async () => {
+    const files = [
+      createMockFile('file1.pdf', 'pdf content', 'application/pdf'),
+      createMockFile('file2.jpg', 'image content', 'image/jpeg'),
+      createMockFile('file3.txt', 'text content', 'text/plain')
+    ];
+    render(<DragAndDrop onDrop={mockOnDrop} />);
+
+    const dropzone = screen.getByTestId('drag-drop__input');
+    const dropEvent = createDragEvent(files);
 
-    await act(async () => fireEvent.dragEnter(getByTestId('drag-drop__input')));
+    await act(async () => {
+      fireEvent.drop(dropzone, dropEvent);
+    });
 
     await waitFor(() => {
-      expect(getByText('Drop files here')).toBeInTheDocument();
+      // react-dropzone calls onDrop with (acceptedFiles, fileRejections, event)
+      expect(mockOnDrop).toHaveBeenCalledWith(files, [], expect.any(Object));
+      expect(mockOnDrop).toHaveBeenCalledTimes(1);
     });
   });
 
-  it('should call onDrop when files are dropped', async () => {
-    const files = [new File(['fileContents'], 'test.txt', { type: 'text/plain' })];
+  it('should return to normal state after drag ends', async () => {
+    render(<DragAndDrop onDrop={mockOnDrop} />);
 
-    const { getByTestId } = render(<DragAndDrop onDrop={mockOnFilesDrop} />);
+    const dropzone = screen.getByTestId('drag-drop__input');
 
-    const dropzone = getByTestId('drag-drop__input');
+    await act(async () => {
+      fireEvent.dragEnter(dropzone);
+    });
 
-    const dropEvent = {
-      dataTransfer: {
-        files,
-        items: files.map(file => ({
-          kind: 'file',
-          type: file.type,
-          getAsFile: () => file
-        })),
-        types: ['Files']
-      }
-    };
+    expect(screen.getByText('Drop files here')).toBeInTheDocument();
 
-    await act(async () => fireEvent.drop(dropzone, dropEvent));
+    await act(async () => {
+      fireEvent.dragLeave(dropzone);
+    });
 
     await waitFor(() => {
-      expect(mockOnFilesDrop).toHaveBeenCalledTimes(1);
+      expect(screen.queryByText('Drop files here')).not.toBeInTheDocument();
+      expect(screen.getByText('Browse files or drag and drop files')).toBeInTheDocument();
     });
   });
+
+  it('should not call onDrop when disabled and files are dropped', async () => {
+    const files = [createMockFile()];
+    render(<DragAndDrop onDrop={mockOnDrop} disabled />);
+
+    const dropzone = screen.getByTestId('drag-drop__input');
+    const dropEvent = createDragEvent(files);
+
+    await act(async () => {
+      fireEvent.drop(dropzone, dropEvent);
+    });
+
+    await waitFor(() => {
+      expect(mockOnDrop).not.toHaveBeenCalled();
+    });
+  });
+
+  it('should not show drag state when disabled', async () => {
+    render(<DragAndDrop onDrop={mockOnDrop} disabled />);
+
+    const dropzone = screen.getByTestId('drag-drop__input');
+
+    await act(async () => {
+      fireEvent.dragEnter(dropzone);
+    });
+
+    // Should not show drag message when disabled
+    expect(screen.queryByText('Drop files here')).not.toBeInTheDocument();
+  });
 });

+ 4 - 2
desktop/core/src/desktop/js/reactComponents/DragAndDrop/DragAndDrop.tsx

@@ -23,14 +23,16 @@ import { i18nReact } from '../../utils/i18nReact';
 interface DragAndDropProps {
   onDrop: (files: File[]) => void;
   children?: JSX.Element;
+  disabled?: boolean;
 }
 
-const DragAndDrop = ({ children, onDrop }: DragAndDropProps): JSX.Element => {
+const DragAndDrop = ({ children, onDrop, disabled = false }: DragAndDropProps): JSX.Element => {
   const { t } = i18nReact.useTranslation();
 
   const { getRootProps, getInputProps, isDragActive } = useDropzone({
     onDrop,
-    noClick: !!children
+    noClick: !!children,
+    disabled
   });
 
   return (