ソースを参照

[ui-storageBrowser] Add create bucket/volume/filesystem functionality (#4234)

* [ui-storageBrowser] Add create bucket/volume/filesystem functionality
Nidhi Bhat G 3 ヶ月 前
コミット
9011d72e7d

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

@@ -1,133 +1,198 @@
+// 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, waitFor, act } from '@testing-library/react';
+import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
+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';
 
 const mockSave = jest.fn();
 jest.mock('../../../../../utils/hooks/useSaveData/useSaveData', () => ({
   __esModule: true,
   default: jest.fn(() => ({
-    save: mockSave
+    save: mockSave,
+    loading: false,
+    error: undefined
   }))
 }));
 
-jest.mock('../../../../../utils/huePubSub', () => ({
-  __esModule: true,
-  publish: jest.fn()
-}));
-
 describe('CreateAndUploadAction', () => {
-  const currentPath = '/some/path';
+  const defaultPath = '/some/path';
   const onActionSuccess = jest.fn();
   const onActionError = jest.fn();
   const mockFilesUpload = jest.fn();
 
-  beforeEach(() => {
-    jest.clearAllMocks();
-
+  const setup = (path = defaultPath) =>
     render(
       <CreateAndUploadAction
-        currentPath={currentPath}
+        currentPath={path}
         onActionSuccess={onActionSuccess}
         onFilesUpload={mockFilesUpload}
         onActionError={onActionError}
       />
     );
+
+  const openDropdown = async user => {
+    await user.click(screen.getByRole('button', { name: 'New' }));
+  };
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+    jest.spyOn(storageUtils, 'isS3').mockReturnValue(false);
+    jest.spyOn(storageUtils, 'isGS').mockReturnValue(false);
+    jest.spyOn(storageUtils, 'isABFS').mockReturnValue(false);
+    jest.spyOn(storageUtils, 'isOFS').mockReturnValue(false);
+  });
+
+  const clickMenuOption = async (label: string, user) => {
+    await openDropdown(user);
+    await user.click(screen.getByRole('menuitem', { name: label }));
+  };
+
+  afterEach(() => {
+    cleanup();
   });
 
-  it('should render the dropdown with actions', async () => {
+  it('renders the New button', () => {
+    setup();
     const newButton = screen.getByRole('button', { name: 'New' });
     expect(newButton).toBeInTheDocument();
+  });
 
-    await act(async () => fireEvent.click(newButton));
-
+  it('should render the dropdown with CREATE and UPLOAD group actions', async () => {
+    const user = userEvent.setup();
+    setup();
+    await openDropdown(user);
     // Check that the "Create" and "Upload" groups are in the dropdown
     expect(screen.getByText('CREATE')).toBeInTheDocument();
     expect(screen.getByText('UPLOAD')).toBeInTheDocument();
   });
 
-  it('should open the folder creation modal when "New Folder" is clicked', async () => {
-    const newButton = screen.getByRole('button', { name: 'New' });
-    await act(async () => fireEvent.click(newButton));
-
-    const newFolderButton = screen.getByRole('menuitem', { name: 'New Folder' });
-    await act(async () => fireEvent.click(newFolderButton));
+  describe('create actions', () => {
+    it.each([
+      { label: 'New Folder', modalTitle: 'Create Folder', api: CREATE_DIRECTORY_API_URL },
+      { label: 'New File', modalTitle: 'Create File', api: CREATE_FILE_API_URL }
+    ])('opens ${label} modal and calls correct API', async ({ label, modalTitle, api }) => {
+      const user = userEvent.setup();
+      setup();
+      await clickMenuOption(label, user);
 
-    expect(screen.getByRole('dialog', { name: 'Create Folder' })).toBeInTheDocument();
-  });
+      expect(screen.getByText(modalTitle)).toBeInTheDocument();
 
-  it('should open the file creation modal when "New File" is clicked', async () => {
-    const newButton = screen.getByRole('button', { name: 'New' });
-    await act(async () => fireEvent.click(newButton));
+      const input = screen.getByRole('textbox');
+      fireEvent.change(input, { target: { value: `Test ${label}` } });
 
-    const newFileButton = screen.getByRole('menuitem', { name: 'New File' });
-    await act(async () => fireEvent.click(newFileButton));
+      fireEvent.click(screen.getByRole('button', { name: 'Create' }));
 
-    expect(screen.getByRole('dialog', { name: 'Create File' })).toBeInTheDocument();
+      await waitFor(() => {
+        expect(mockSave).toHaveBeenCalledWith(
+          { path: defaultPath, name: `Test ${label}` },
+          { url: api }
+        );
+      });
+    });
   });
 
-  it('should render hidden file input for upload functionality', async () => {
-    const fileInput = document.querySelector('input[type="file"]');
-    expect(fileInput).toBeInTheDocument();
-    expect(fileInput).toHaveAttribute('hidden');
-    expect(fileInput).toHaveAttribute('multiple');
-  });
+  describe('upload actions', () => {
+    it('should render hidden file input for upload functionality', async () => {
+      setup();
+      const fileInput = document.querySelector('input[type="file"]');
+      expect(fileInput).toBeInTheDocument();
+      expect(fileInput).toHaveAttribute('hidden');
+      expect(fileInput).toHaveAttribute('multiple');
+    });
 
-  it('should handle file selection and call onFilesUpload', async () => {
-    const fileInput = document.querySelector('input[type="file"]');
-    expect(fileInput).toBeInTheDocument();
+    it('should handle file selection and call onFilesUpload', async () => {
+      setup();
+      const fileInput = document.querySelector('input[type="file"]');
+      expect(fileInput).toBeInTheDocument();
 
-    const file1 = new File(['test content 1'], 'test1.txt', { type: 'text/plain' });
-    const file2 = new File(['test content 2'], 'test2.txt', { type: 'text/plain' });
+      const file1 = new File(['test content 1'], 'test1.txt', { type: 'text/plain' });
+      const file2 = new File(['test content 2'], 'test2.txt', { type: 'text/plain' });
 
-    fireEvent.change(fileInput!, {
-      target: { files: [file1, file2] }
-    });
+      fireEvent.change(fileInput!, {
+        target: { files: [file1, file2] }
+      });
 
-    expect(mockFilesUpload).toHaveBeenCalledWith([file1, file2]);
+      expect(mockFilesUpload).toHaveBeenCalledWith([file1, file2]);
+    });
   });
 
-  it('should call the correct API for creating a folder', async () => {
-    const newButton = screen.getByRole('button', { name: 'New' });
-    await act(async () => fireEvent.click(newButton));
+  describe('storage-specific actions', () => {
+    it('shows New Bucket when S3 root', async () => {
+      const user = userEvent.setup();
+      jest.spyOn(storageUtils, 'isS3').mockReturnValue(true);
+      jest.spyOn(storageUtils, 'isS3Root').mockReturnValue(true);
 
-    const newFolderButton = screen.getByRole('menuitem', { name: 'New Folder' });
-    await act(async () => fireEvent.click(newFolderButton));
+      setup('/');
+      await openDropdown(user);
+      expect(screen.getByRole('menuitem', { name: 'New Bucket' })).toBeInTheDocument();
+    });
+
+    it('shows New Bucket when OFS root', async () => {
+      const user = userEvent.setup();
+      jest.spyOn(storageUtils, 'isOFS').mockReturnValue(true);
+      jest.spyOn(storageUtils, 'isOFSRoot').mockReturnValue(true);
 
-    const input = screen.getByRole('textbox');
-    fireEvent.change(input, { target: { value: 'Test Folder' } });
+      setup('/');
+      await openDropdown(user);
+      expect(screen.getByRole('menuitem', { name: 'New Bucket' })).toBeInTheDocument();
+    });
 
-    const createButton = screen.getByRole('button', { name: 'Create' });
-    fireEvent.click(createButton);
+    it('does not show New Bucket when not in S3 root', async () => {
+      const user = userEvent.setup();
+      jest.spyOn(storageUtils, 'isS3').mockReturnValue(true);
+      jest.spyOn(storageUtils, 'isS3Root').mockReturnValue(false);
 
-    await waitFor(() => {
-      expect(mockSave).toHaveBeenCalledWith(
-        { path: currentPath, name: 'Test Folder' },
-        { url: CREATE_DIRECTORY_API_URL }
-      );
+      setup('s3://user');
+      await openDropdown(user);
+      expect(screen.queryByRole('menuitem', { name: 'New Bucket' })).not.toBeInTheDocument();
     });
-  });
 
-  it('should call the correct API for creating a file', async () => {
-    const newButton = screen.getByRole('button', { name: 'New' });
-    await act(async () => fireEvent.click(newButton));
+    it('shows New File System when ABFS root', async () => {
+      const user = userEvent.setup();
+      jest.spyOn(storageUtils, 'isABFS').mockReturnValue(true);
+      jest.spyOn(storageUtils, 'isABFSRoot').mockReturnValue(true);
+
+      setup('/');
+      await openDropdown(user);
+      expect(screen.getByRole('menuitem', { name: 'New File System' })).toBeInTheDocument();
+    });
 
-    const newFileButton = screen.getByRole('menuitem', { name: 'New File' });
-    await act(async () => fireEvent.click(newFileButton));
+    it('shows New Volume when OFS service ID', async () => {
+      const user = userEvent.setup();
+      jest.spyOn(storageUtils, 'isOFS').mockReturnValue(true);
+      jest.spyOn(storageUtils, 'isOFSServiceID').mockReturnValue(true);
 
-    // Simulate file name submission
-    const input = screen.getByRole('textbox');
-    fireEvent.change(input, { target: { value: 'Test File' } });
+      setup('/ofs-service');
+      await openDropdown(user);
+      expect(screen.getByRole('menuitem', { name: 'New Volume' })).toBeInTheDocument();
+    });
 
-    const createButton = screen.getByRole('button', { name: 'Create' });
-    fireEvent.click(createButton);
+    it('does not show New Volume when OFS service ID', async () => {
+      const user = userEvent.setup();
+      jest.spyOn(storageUtils, 'isOFS').mockReturnValue(true);
+      jest.spyOn(storageUtils, 'isOFSServiceID').mockReturnValue(false);
 
-    await waitFor(() => {
-      expect(mockSave).toHaveBeenCalledWith(
-        { path: currentPath, name: 'Test File' },
-        { url: CREATE_FILE_API_URL }
-      );
+      setup('/ofs-service');
+      await openDropdown(user);
+      expect(screen.queryByRole('menuitem', { name: 'New Volume' })).not.toBeInTheDocument();
     });
   });
 });

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

@@ -16,21 +16,32 @@
 
 import React, { useState, useRef } from 'react';
 import { Dropdown } from 'antd';
-import { MenuItemGroupType } from 'antd/lib/menu/hooks/useItems';
-
+import { ItemType, MenuItemGroupType } from 'antd/lib/menu/hooks/useItems';
 import FolderIcon from '@cloudera/cuix-core/icons/react/ProjectIcon';
 import FileIcon from '@cloudera/cuix-core/icons/react/DocumentationIcon';
 import DropDownIcon from '@cloudera/cuix-core/icons/react/DropdownIcon';
 import ImportIcon from '@cloudera/cuix-core/icons/react/ImportIcon';
+import BucketIcon from '@cloudera/cuix-core/icons/react/BucketIcon';
+import DatabaseIcon from '@cloudera/cuix-core/icons/react/DatabaseIcon';
+import DataBrowserIcon from '@cloudera/cuix-core/icons/react/DataBrowserIcon';
 import { PrimaryButton } from 'cuix/dist/components/Button';
 
 import { i18nReact } from '../../../../../utils/i18nReact';
 import { CREATE_DIRECTORY_API_URL, CREATE_FILE_API_URL } from '../../../api';
+import {
+  isABFSRoot,
+  isGSRoot,
+  isOFSServiceID,
+  isOFSVol,
+  isS3Root,
+  isFileSystemRoot
+} from '../../../../../utils/storageBrowserUtils';
 import { FileStats } from '../../../types';
 import useSaveData from '../../../../../utils/hooks/useSaveData/useSaveData';
 import InputModal from '../../../../../reactComponents/InputModal/InputModal';
 
 import './CreateAndUploadAction.scss';
+import { TFunction } from 'i18next';
 
 interface CreateAndUploadActionProps {
   currentPath: FileStats['path'];
@@ -42,9 +53,73 @@ interface CreateAndUploadActionProps {
 enum ActionType {
   createFile = 'createFile',
   createFolder = 'createFolder',
-  uploadFile = 'uploadFile'
+  uploadFile = 'uploadFile',
+  createBucket = 'createBucket',
+  createVolume = 'createVolume',
+  createFileSystem = 'createFileSystem'
 }
 
+type ActionItem = {
+  icon: React.ReactElement;
+  label: string;
+  modal?: {
+    title: string;
+    label: string;
+  };
+  api?: string;
+  visible: (path: string) => boolean;
+  group: string;
+};
+
+const getActionConfig = (t: TFunction): Record<ActionType, ActionItem> => ({
+  [ActionType.createFile]: {
+    icon: <FileIcon />,
+    label: t('New File'),
+    modal: { title: t('Create File'), label: t('File name') },
+    api: CREATE_FILE_API_URL,
+    group: 'create',
+    visible: path => isFileSystemRoot(path)
+  },
+  [ActionType.createFolder]: {
+    icon: <FolderIcon />,
+    label: t('New Folder'),
+    modal: { title: t('Create Folder'), label: t('Folder name') },
+    api: CREATE_DIRECTORY_API_URL,
+    group: 'create',
+    visible: path => isFileSystemRoot(path)
+  },
+  [ActionType.createBucket]: {
+    icon: <BucketIcon />,
+    label: t('New Bucket'),
+    modal: { title: t('Create Bucket'), label: t('Bucket name') },
+    api: CREATE_DIRECTORY_API_URL,
+    group: 'create',
+    visible: path => isS3Root(path) || isGSRoot(path) || isOFSVol(path)
+  },
+  [ActionType.createVolume]: {
+    icon: <DatabaseIcon />,
+    label: t('New Volume'),
+    modal: { title: t('Create Volume'), label: t('Volume name') },
+    api: CREATE_DIRECTORY_API_URL,
+    group: 'create',
+    visible: path => isOFSServiceID(path)
+  },
+  [ActionType.createFileSystem]: {
+    icon: <DataBrowserIcon />,
+    label: t('New File System'),
+    modal: { title: t('Create File System'), label: t(')File system name') },
+    api: CREATE_DIRECTORY_API_URL,
+    group: 'create',
+    visible: path => isABFSRoot(path)
+  },
+  [ActionType.uploadFile]: {
+    icon: <ImportIcon />,
+    label: t('Upload File'),
+    group: 'upload',
+    visible: path => isFileSystemRoot(path)
+  }
+});
+
 const CreateAndUploadAction = ({
   currentPath,
   onActionSuccess,
@@ -55,6 +130,7 @@ const CreateAndUploadAction = ({
   const fileInputRef = useRef<HTMLInputElement>(null);
 
   const [selectedAction, setSelectedAction] = useState<ActionType>();
+  const actionConfig = getActionConfig(t);
 
   const onModalClose = () => {
     setSelectedAction(undefined);
@@ -84,50 +160,48 @@ const CreateAndUploadAction = ({
     e.target.value = '';
   };
 
+  const getCreateActionChildren = (): ItemType[] => {
+    return (Object.entries(actionConfig) as [ActionType, (typeof actionConfig)[ActionType]][])
+      .filter(([, cfg]) => cfg.group !== 'upload' && cfg.visible(currentPath))
+      .map(([action, cfg]) => ({
+        icon: cfg.icon,
+        key: action,
+        label: cfg.label,
+        onClick: onActionClick(action)
+      }));
+  };
+
   const newActionsMenuItems: MenuItemGroupType[] = [
     {
       key: 'create',
-      type: 'group',
+      type: 'group' as const,
       label: t('CREATE'),
-      children: [
-        {
-          icon: <FileIcon />,
-          key: ActionType.createFile,
-          label: t('New File'),
-          onClick: onActionClick(ActionType.createFile)
-        },
-        {
-          icon: <FolderIcon />,
-          key: ActionType.createFolder,
-          label: t('New Folder'),
-          onClick: onActionClick(ActionType.createFolder)
-        }
-      ]
+      children: getCreateActionChildren()
     },
     {
       key: 'upload',
-      type: 'group',
+      type: 'group' as const,
       label: t('UPLOAD'),
-      children: [
-        {
-          icon: <ImportIcon />,
-          key: ActionType.uploadFile,
-          label: t('Upload File'),
-          onClick: () => fileInputRef.current?.click()
-        }
-      ]
+      children: actionConfig[ActionType.uploadFile].visible(currentPath)
+        ? [
+            {
+              icon: actionConfig[ActionType.uploadFile].icon,
+              key: ActionType.uploadFile,
+              label: actionConfig[ActionType.uploadFile].label,
+              onClick: () => fileInputRef.current?.click()
+            }
+          ]
+        : []
     }
-  ];
+  ].filter(group => group.children && group.children.length > 0);
 
   const handleCreate = (name: string | number) => {
-    const url = {
-      [ActionType.createFile]: CREATE_FILE_API_URL,
-      [ActionType.createFolder]: CREATE_DIRECTORY_API_URL
-    }[selectedAction ?? ''];
+    const url = selectedAction ? actionConfig[selectedAction]?.api : undefined;
 
     if (!url) {
       return;
     }
+
     save({ path: currentPath, name }, { url });
   };
 
@@ -146,13 +220,18 @@ const CreateAndUploadAction = ({
           <DropDownIcon />
         </PrimaryButton>
       </Dropdown>
-
-      {(selectedAction === ActionType.createFolder || selectedAction === ActionType.createFile) && (
+      {selectedAction !== undefined && selectedAction !== ActionType.uploadFile && (
         <InputModal
           showModal={true}
-          title={selectedAction === ActionType.createFolder ? t('Create Folder') : t('Create File')}
+          title={
+            actionConfig[selectedAction].modal
+              ? actionConfig[selectedAction].modal.title
+              : t('Create')
+          }
           inputLabel={
-            selectedAction === ActionType.createFolder ? t('Folder name') : t('File name')
+            actionConfig[selectedAction].modal
+              ? actionConfig[selectedAction].modal.label
+              : t('Name')
           }
           submitText={t('Create')}
           onSubmit={handleCreate}

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

@@ -27,7 +27,8 @@ import {
   isOFSServiceID,
   isOFSVol,
   inTrash,
-  inRestorableTrash
+  inRestorableTrash,
+  isFileSystemRoot
 } from './storageBrowserUtils';
 
 describe('isHDFS function', () => {
@@ -223,3 +224,50 @@ describe('inRestorableTrash function', () => {
     expect(inRestorableTrash('/user/path/.Trash/Current/user/path')).toBe(true);
   });
 });
+
+describe('isFileSystemRoot', () => {
+  beforeEach(() => {
+    jest.resetAllMocks();
+  });
+
+  it('returns true for non-root S3 path', () => {
+    expect(isFileSystemRoot('s3a://bucket/folder')).toBe(true);
+  });
+
+  it('returns false for S3 root path', () => {
+    expect(isFileSystemRoot('s3a://')).toBe(false);
+  });
+
+  it('returns true for non-root GS path', () => {
+    expect(isFileSystemRoot('gs://folder')).toBe(true);
+  });
+
+  it('returns false for GS root path', () => {
+    expect(isFileSystemRoot('gs://')).toBe(false);
+  });
+
+  it('returns true for non-root ABFS path', () => {
+    expect(isFileSystemRoot('abfs://folder')).toBe(true);
+  });
+
+  it('returns false for ABFS root path', () => {
+    expect(isFileSystemRoot('abfs://')).toBe(false);
+  });
+
+  it('returns true for OFS when not serviceID or volume', () => {
+    expect(isFileSystemRoot('ofs://service/volume/folder')).toBe(true);
+  });
+
+  it('returns false for OFS serviceID', () => {
+    expect(isFileSystemRoot('ofs://service')).toBe(false);
+  });
+
+  it('returns false for OFS volume', () => {
+    expect(isFileSystemRoot('ofs://service/vol')).toBe(false);
+  });
+
+  it('returns true for HDFS (default case)', () => {
+    expect(isFileSystemRoot('/hdfs')).toBe(true);
+    expect(isFileSystemRoot('/')).toBe(true);
+  });
+});

+ 17 - 0
desktop/core/src/desktop/js/utils/storageBrowserUtils.ts

@@ -83,3 +83,20 @@ export const inTrash = (path: string): boolean => {
 export const inRestorableTrash = (path: string): boolean => {
   return path.match(/^\/user\/.+?\/\.Trash\/.+?/) !== null;
 };
+
+export const isFileSystemRoot = (path: string): boolean => {
+  if (isS3(path)) {
+    return !isS3Root(path);
+  }
+  if (isGS(path)) {
+    return !isGSRoot(path);
+  }
+  if (isABFS(path)) {
+    return !isABFSRoot(path);
+  }
+  if (isOFS(path)) {
+    return !isOFSServiceID(path) && !isOFSVol(path);
+  }
+  //in case of HDFS root and non root have same level of access. hence no check
+  return true;
+};