فهرست منبع

[ui-storagebrowser] adds compress action (#3938)

* [ui-storagebrowser] adds compress action

* fixes the naming convention
Ram Prasad Agarwal 10 ماه پیش
والد
کامیت
7ecdbe4d49

+ 1 - 1
apps/filebrowser/src/filebrowser/api.py

@@ -790,7 +790,7 @@ def compress_files_using_batch_job(request):
 
   upload_path = request.fs.netnormpath(request.POST.get('upload_path'))
   archive_name = request.POST.get('archive_name')
-  file_names = request.POST.getlist('files[]')  # TODO: Check if this param is correct? Need to improve it?
+  file_names = request.POST.getlist('file_name')
 
   if upload_path and file_names and archive_name:
     try:

+ 8 - 1
desktop/core/src/desktop/api2.py

@@ -78,7 +78,13 @@ from desktop.models import (
   uuid_default,
 )
 from desktop.views import get_banner_message, serve_403_error
-from filebrowser.conf import CONCURRENT_MAX_CONNECTIONS, FILE_UPLOAD_CHUNK_SIZE, RESTRICT_FILE_EXTENSIONS, SHOW_DOWNLOAD_BUTTON
+from filebrowser.conf import (
+  CONCURRENT_MAX_CONNECTIONS,
+  ENABLE_EXTRACT_UPLOADED_ARCHIVE,
+  FILE_UPLOAD_CHUNK_SIZE,
+  RESTRICT_FILE_EXTENSIONS,
+  SHOW_DOWNLOAD_BUTTON,
+)
 from filebrowser.tasks import check_disk_usage_and_clean_task, document_cleanup_task
 from filebrowser.views import MAX_FILEEDITOR_SIZE
 from hadoop.cluster import is_yarn
@@ -133,6 +139,7 @@ def get_config(request):
   config['storage_browser']['file_upload_chunk_size'] = FILE_UPLOAD_CHUNK_SIZE.get()
   config['storage_browser']['enable_file_download_button'] = SHOW_DOWNLOAD_BUTTON.get()
   config['storage_browser']['max_file_editor_size'] = MAX_FILEEDITOR_SIZE
+  config['storage_browser']['enable_extract_uploaded_archive'] = ENABLE_EXTRACT_UPLOADED_ARCHIVE.get()
   config['clusters'] = list(get_clusters(request.user).values())
   config['documents'] = {
     'types': list(Document2.objects.documents(user=request.user).order_by().values_list('type', flat=True).distinct())

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

@@ -125,7 +125,7 @@ urlpatterns += [
   re_path(
     r'^storage/extract_archive/?$', api_public.storage_extract_archive_using_batch_job, name='storage_extract_archive_using_batch_job'
   ),
-  re_path(r'^storage/compress_files/?$', api_public.storage_compress_files_using_batch_job, name='storage_compress_files_using_batch_job'),
+  re_path(r'^storage/compress/?$', api_public.storage_compress_files_using_batch_job, name='storage_compress_files_using_batch_job'),
   re_path(r'^storage/move/bulk/?$', api_public.storage_bulk_move, name='storage_bulk_move'),
   re_path(r'^storage/copy/bulk/?$', api_public.storage_bulk_copy, name='storage_bulk_copy'),
   re_path(r'^storage/delete/bulk/?$', api_public.storage_bulk_rmtree, name='storage_bulk_rmtree'),

+ 38 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Compress/Compress.scss

@@ -0,0 +1,38 @@
+// 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.
+
+@use 'variables' as vars;
+
+.antd.cuix {
+  .compress-action {
+    display: flex;
+    flex-direction: column;
+    margin-top: 16px;
+
+    &__list {
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      grid-gap: 8px 16px;
+      margin: 0;
+      padding: 0;
+
+      li {
+        margin-left: 16px;
+      }
+    }
+
+  }
+}

+ 186 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Compress/Compress.test.tsx

@@ -0,0 +1,186 @@
+// 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, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import CompressAction from './Compress';
+import { StorageDirectoryTableData } from '../../../../../reactComponents/FileChooser/types';
+import { COMPRESS_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('CompressAction Component', () => {
+  const mockOnSuccess = jest.fn();
+  const mockOnError = jest.fn();
+  const mockOnClose = jest.fn();
+  const setLoading = jest.fn();
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('should render the Compress modal with the correct title and buttons', () => {
+    const { getByText, getByRole } = render(
+      <CompressAction
+        isOpen={true}
+        files={mockFiles}
+        setLoading={setLoading}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+        currentPath="test/path"
+      />
+    );
+
+    expect(getByText('Compress files and folders')).toBeInTheDocument();
+    expect(getByText('Compressed file name')).toBeInTheDocument();
+    expect(getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+    expect(getByRole('button', { name: 'Compress' })).toBeInTheDocument();
+  });
+
+  it('should display the correct list of files to be compressed', () => {
+    const { getByText } = render(
+      <CompressAction
+        isOpen={true}
+        files={mockFiles}
+        setLoading={setLoading}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+        currentPath="test/path"
+      />
+    );
+
+    expect(getByText('Following files and folders will be compressed:')).toBeInTheDocument();
+    expect(getByText('file1.txt')).toBeInTheDocument();
+    expect(getByText('file2.txt')).toBeInTheDocument();
+  });
+
+  it('should call handleCompress with the correct data when "Compress" is clicked', async () => {
+    const { getByText } = render(
+      <CompressAction
+        isOpen={true}
+        files={mockFiles}
+        setLoading={setLoading}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+        currentPath="test/path"
+      />
+    );
+
+    const formData = new FormData();
+    mockFiles.forEach(file => {
+      formData.append('file_name', file.name);
+    });
+    formData.append('upload_path', 'test/path');
+    formData.append('archive_name', 'path.zip');
+
+    fireEvent.click(getByText('Compress'));
+
+    expect(mockSave).toHaveBeenCalledWith(formData, { url: COMPRESS_API_URL });
+  });
+
+  it('should update the compressed file name when input value changes', () => {
+    const { getByRole } = render(
+      <CompressAction
+        isOpen={true}
+        files={mockFiles}
+        setLoading={setLoading}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+        currentPath="test/path"
+      />
+    );
+
+    const input = getByRole('textbox');
+    fireEvent.change(input, { target: { value: 'new-compressed-file.zip' } });
+
+    expect(input).toHaveValue('new-compressed-file.zip');
+  });
+
+  it('should call onClose when the modal is closed', () => {
+    const { getByText } = render(
+      <CompressAction
+        isOpen={true}
+        files={mockFiles}
+        setLoading={setLoading}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+        currentPath="test/path"
+      />
+    );
+
+    fireEvent.click(getByText('Cancel'));
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('should call onError when the compress request fails', async () => {
+    mockSave.mockImplementationOnce(() => {
+      mockOnError(new Error('Compression failed'));
+    });
+
+    const { getByText } = render(
+      <CompressAction
+        isOpen={true}
+        files={mockFiles}
+        setLoading={setLoading}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+        currentPath="test/path"
+      />
+    );
+
+    fireEvent.click(getByText('Compress'));
+    await waitFor(() => expect(mockOnError).toHaveBeenCalledWith(new Error('Compression failed')));
+  });
+});

+ 109 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Compress/Compress.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, { useState } 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 { COMPRESS_API_URL } from '../../../../../reactComponents/FileChooser/api';
+import { Input } from 'antd';
+
+import './Compress.scss';
+
+interface CompressActionProps {
+  currentPath: string;
+  isOpen?: boolean;
+  files: StorageDirectoryTableData[];
+  setLoading: (value: boolean) => void;
+  onSuccess: () => void;
+  onError: (error: Error) => void;
+  onClose: () => void;
+}
+
+const CompressAction = ({
+  currentPath,
+  isOpen = true,
+  files,
+  setLoading,
+  onSuccess,
+  onError,
+  onClose
+}: CompressActionProps): JSX.Element => {
+  const initialName = currentPath.split('/').pop() + '.zip';
+  const [value, setValue] = useState<string>(initialName);
+  const { t } = i18nReact.useTranslation();
+
+  const { save: saveForm, loading } = useSaveData(undefined, {
+    postOptions: {
+      qsEncodeData: false,
+      headers: {
+        'Content-Type': 'multipart/form-data'
+      }
+    },
+    skip: !files.length,
+    onSuccess,
+    onError
+  });
+
+  const handleCompress = () => {
+    setLoading(true);
+
+    const formData = new FormData();
+    files.forEach(selectedFile => {
+      formData.append('file_name', selectedFile.name);
+    });
+    formData.append('upload_path', currentPath);
+    formData.append('archive_name', value);
+
+    saveForm(formData, { url: COMPRESS_API_URL });
+  };
+
+  return (
+    <Modal
+      cancelText={t('Cancel')}
+      className="cuix antd"
+      okText={t('Compress')}
+      onCancel={onClose}
+      onOk={() => handleCompress()}
+      open={isOpen}
+      title={t('Compress files and folders')}
+      okButtonProps={{ disabled: loading }}
+      cancelButtonProps={{ disabled: loading }}
+    >
+      {t('Compressed file name')}
+      <Input
+        value={value}
+        type="text"
+        onPressEnter={() => handleCompress()}
+        onChange={e => {
+          setValue(e.target.value);
+        }}
+      />
+
+      <div className="compress-action">
+        {t('Following files and folders will be compressed:')}
+        <ul className="compress-action__list">
+          {files.map(file => (
+            <li key={file.path}>{file.name}</li>
+          ))}
+        </ul>
+      </div>
+    </Modal>
+  );
+};
+
+export default CompressAction;

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

@@ -20,12 +20,13 @@ import { MenuItemType } from 'antd/lib/menu/hooks/useItems';
 
 import Button from 'cuix/dist/components/Button';
 import DropDownIcon from '@cloudera/cuix-core/icons/react/DropdownIcon';
-import InfoIcon from '@cloudera/cuix-core/icons/react/InfoIcon';
+import SummaryIcon from '@cloudera/cuix-core/icons/react/SummaryIcon';
 import EditIcon from '@cloudera/cuix-core/icons/react/EditIcon';
 import DuplicateIcon from '@cloudera/cuix-core/icons/react/DuplicateIcon';
 import CopyClipboardIcon from '@cloudera/cuix-core/icons/react/CopyClipboardIcon';
 import DataMovementIcon from '@cloudera/cuix-core/icons/react/DataMovementIcon';
 import DeleteIcon from '@cloudera/cuix-core/icons/react/DeleteIcon';
+import CollapseIcon from '@cloudera/cuix-core/icons/react/CollapseViewIcon';
 
 import { i18nReact } from '../../../../utils/i18nReact';
 import huePubSub from '../../../../utils/huePubSub';
@@ -40,6 +41,7 @@ import RenameAction from './Rename/Rename';
 import ReplicationAction from './Replication/Replication';
 import ViewSummary from './ViewSummary/ViewSummary';
 import DeleteAction from './Delete/Delete';
+import CompressAction from './Compress/Compress';
 
 interface StorageBrowserRowActionsProps {
   isTrashEnabled?: boolean;
@@ -55,7 +57,8 @@ const iconsMap: Record<ActionType, JSX.Element> = {
   [ActionType.Rename]: <EditIcon />,
   [ActionType.Replication]: <DuplicateIcon />,
   [ActionType.Delete]: <DeleteIcon />,
-  [ActionType.Summary]: <InfoIcon />
+  [ActionType.Summary]: <SummaryIcon />,
+  [ActionType.Compress]: <CollapseIcon />
 };
 
 const StorageBrowserActions = ({
@@ -150,6 +153,16 @@ const StorageBrowserActions = ({
           setLoading={setLoadingFiles}
         />
       )}
+      {selectedAction === ActionType.Compress && (
+        <CompressAction
+          currentPath={currentPath}
+          files={selectedFiles}
+          onSuccess={onApiSuccess}
+          onError={onApiError}
+          onClose={closeModal}
+          setLoading={setLoadingFiles}
+        />
+      )}
     </>
   );
 };

+ 29 - 1
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.util.ts

@@ -1,3 +1,20 @@
+// 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 { getLastKnownConfig } from 'config/hueConfig';
 import {
   BrowserViewType,
   StorageDirectoryTableData
@@ -23,7 +40,8 @@ export enum ActionType {
   Summary = 'summary',
   Rename = 'rename',
   Replication = 'replication',
-  Delete = 'delete'
+  Delete = 'delete',
+  Compress = 'compress'
 }
 
 const isValidFileOrFolder = (filePath: string): boolean => {
@@ -47,6 +65,8 @@ const isActionEnabled = (file: StorageDirectoryTableData, action: ActionType): b
     case ActionType.Delete:
     case ActionType.Move:
       return isValidFileOrFolder(file.path);
+    case ActionType.Compress:
+      return isHDFS(file.path) && isValidFileOrFolder(file.path);
     default:
       return false;
   }
@@ -73,6 +93,7 @@ export const getEnabledActions = (
   type: ActionType;
   label: string;
 }[] => {
+  const config = getLastKnownConfig();
   const isAnyFileInTrash = files.some(file => inTrash(file.path));
   const isNoFileSelected = files && files.length === 0;
   if (isAnyFileInTrash || isNoFileSelected) {
@@ -110,6 +131,13 @@ export const getEnabledActions = (
       enabled: isSingleFileActionEnabled(files, ActionType.Replication),
       type: ActionType.Replication,
       label: 'Set Replication'
+    },
+    {
+      enabled:
+        !!config?.storage_browser.enable_extract_uploaded_archive &&
+        isMultipleFileActionEnabled(files, ActionType.Compress),
+      type: ActionType.Compress,
+      label: 'Compress'
     }
   ].filter(e => e.enabled);
 

+ 1 - 1
desktop/core/src/desktop/js/components/sidebar/HueSidebar.vue

@@ -485,7 +485,7 @@
           if (appConfig.browser && appConfig.browser.interpreters) {
             // Replace old file browser entries with the new storage browser if the feature flag is enabled.
             let browserInterpreters: BrowserInterpreter[] = [];
-            if (clusterConfig.hue_config.enable_new_storage_browser) {
+            if (clusterConfig.storage_browser.enable_new_storage_browser) {
               let firstFileBrowserFound = false;
               appConfig.browser.interpreters.forEach(browser => {
                 const isFileBrowser = /\/filebrowser/.test(browser.page);

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

@@ -63,6 +63,7 @@ export enum AppType {
 interface StorageBrowserConfig {
   concurrent_max_connection: number;
   enable_chunked_file_upload: boolean;
+  enable_extract_uploaded_archive: boolean;
   enable_file_download_button: boolean;
   enable_new_storage_browser: boolean;
   file_upload_chunk_size: number;

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

@@ -30,6 +30,7 @@ export const RENAME_API_URL = '/api/v1/storage/rename/';
 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 COMPRESS_API_URL = '/api/v1/storage/compress';
 export const COPY_API_URL = '/api/v1/storage/copy/';
 export const BULK_COPY_API_URL = '/api/v1/storage/copy/bulk/';
 export const MOVE_API_URL = '/api/v1/storage/move/';