Browse Source

[ui-storagebrowser] adds change permission action to new storage browser (#3975)

Ram Prasad Agarwal 10 months ago
parent
commit
9c5906b66e
13 changed files with 680 additions and 55 deletions
  1. 1 1
      apps/filebrowser/src/filebrowser/api.py
  2. 10 9
      desktop/core/src/desktop/js/apps/storageBrowser/FileChooserModal/FileChooserModal.scss
  3. 44 0
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangePermissionModal/ChangePermissionModal.scss
  4. 198 0
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangePermissionModal/ChangePermissionModal.test.tsx
  5. 154 0
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangePermissionModal/ChangePermissionModal.tsx
  6. 110 0
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangePermissionModal/ChangePermissionModal.util.test.ts
  7. 95 0
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangePermissionModal/ChangePermissionModal.util.ts
  8. 10 10
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.test.tsx
  9. 14 2
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.tsx
  10. 38 30
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.util.ts
  11. 2 0
      desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.scss
  12. 1 0
      desktop/core/src/desktop/js/reactComponents/FileChooser/api.ts
  13. 3 3
      desktop/core/src/desktop/models.py

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

@@ -763,7 +763,7 @@ def chmod(request):
 
 
   mode = compress_mode([coerce_bool(permission.get(p)) for p in perm_names])
   mode = compress_mode([coerce_bool(permission.get(p)) for p in perm_names])
 
 
-  request.fs.chmod(path, mode, recursive=permission.get('recursive', False))
+  request.fs.chmod(path, mode, recursive=coerce_bool(permission.get('recursive', False)))
 
 
   return HttpResponse(status=200)
   return HttpResponse(status=200)
 
 

+ 10 - 9
desktop/core/src/desktop/js/apps/storageBrowser/FileChooserModal/FileChooserModal.scss

@@ -13,6 +13,7 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // See the License for the specific language governing permissions and
 // limitations under the License.
 // limitations under the License.
+
 @use 'variables' as vars;
 @use 'variables' as vars;
 @use 'mixins';
 @use 'mixins';
 
 
@@ -21,8 +22,8 @@ $table-placeholder-height: 100px;
 $icon-margin: 5px;
 $icon-margin: 5px;
 
 
 .antd.cuix {
 .antd.cuix {
-  .hue-filechooser-modal__body{
-    height:350px;
+  .hue-filechooser-modal__body {
+    height: 350px;
   }
   }
 
 
   .hue-filechooser-modal__path-browser-panel {
   .hue-filechooser-modal__path-browser-panel {
@@ -33,7 +34,7 @@ $icon-margin: 5px;
 
 
   .hue-filechooser-modal__destPath {
   .hue-filechooser-modal__destPath {
     flex: 0 0 auto;
     flex: 0 0 auto;
-     font-weight: 600;
+    font-weight: 600;
   }
   }
 
 
   .hue-filechooser-modal__search {
   .hue-filechooser-modal__search {
@@ -55,6 +56,10 @@ $icon-margin: 5px;
       height: $cell-height;
       height: $cell-height;
       border: none;
       border: none;
     }
     }
+
+    .ant-table-tbody>tr>td {
+      border: none
+    }
   }
   }
 
 
   .hue-filechooser-modal__table-cell-icon {
   .hue-filechooser-modal__table-cell-icon {
@@ -71,18 +76,14 @@ $icon-margin: 5px;
       height: $cell-height;
       height: $cell-height;
       padding: 2px;
       padding: 2px;
       @include mixins.nowrap-ellipsis;
       @include mixins.nowrap-ellipsis;
-    }  
+    }
   }
   }
 
 
   .disabled-row {
   .disabled-row {
     pointer-events: none;
     pointer-events: none;
-  }  
-
-  .ant-table-tbody > tr > td {
-    border: none
   }
   }
 
 
   .hue-filechooser-modal__table-cell-name {
   .hue-filechooser-modal__table-cell-name {
     color: vars.$fluidx-blue-700;
     color: vars.$fluidx-blue-700;
   }
   }
-}
+}

+ 44 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangePermissionModal/ChangePermissionModal.scss

@@ -0,0 +1,44 @@
+// 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 {
+  .hue-change-permission {
+    &__table {
+      border: 1px solid vars.$fluidx-gray-200;
+      border-bottom: none;
+
+      .ant-table-placeholder {
+        text-align: center;
+      }
+
+      th.ant-table-cell {
+        background-color: vars.$fluidx-gray-040;
+      }
+
+      .ant-table-cell {
+        text-align: center;
+      }
+
+      .ant-table-cell:first-child {
+        font-weight: bold;
+        text-align: left;
+        text-transform: capitalize;
+      }
+    }
+  }
+}

+ 198 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangePermissionModal/ChangePermissionModal.test.tsx

@@ -0,0 +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, fireEvent, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import ChangePermissionModal from './ChangePermissionModal';
+import { StorageDirectoryTableData } from '../../../../../reactComponents/FileChooser/types';
+
+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: 'user1',
+    group: 'group1',
+    replication: 1
+  }
+];
+
+const mockSave = jest.fn();
+jest.mock('../../../../../utils/hooks/useSaveData/useSaveData', () => ({
+  __esModule: true,
+  default: jest.fn(() => ({
+    save: mockSave,
+    loading: false
+  }))
+}));
+
+const mockOnSuccess = jest.fn();
+const mockOnError = jest.fn();
+const mockOnClose = jest.fn();
+
+describe('ChangePermissionModal Component', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('should render the modal with initial permissions', () => {
+    const { getByText } = render(
+      <ChangePermissionModal
+        isOpen={true}
+        files={mockFiles}
+        setLoading={jest.fn()}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+      />
+    );
+
+    expect(getByText('Change Permissions')).toBeInTheDocument();
+    expect(getByText('Submit')).toBeInTheDocument();
+    expect(getByText('Cancel')).toBeInTheDocument();
+    expect(getByText('read')).toBeInTheDocument();
+    expect(getByText('write')).toBeInTheDocument();
+    expect(getByText('execute')).toBeInTheDocument();
+    expect(getByText('sticky')).toBeInTheDocument();
+    expect(getByText('recursive')).toBeInTheDocument();
+  });
+
+  it('should toggle permission checkboxes correctly for user, group', () => {
+    const { getAllByRole } = render(
+      <ChangePermissionModal
+        isOpen={true}
+        files={mockFiles}
+        setLoading={jest.fn()}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+      />
+    );
+
+    const checkboxes = getAllByRole('checkbox');
+    const userReadCheckbox = checkboxes[0];
+    const groupWriteCheckbox = checkboxes[1];
+
+    waitFor(() => expect(userReadCheckbox).toBeChecked());
+    fireEvent.click(userReadCheckbox);
+    waitFor(() => expect(userReadCheckbox).not.toBeChecked());
+
+    fireEvent.click(groupWriteCheckbox);
+    waitFor(() => expect(groupWriteCheckbox).toBeChecked());
+  });
+
+  it('should submit the form when the Submit button is clicked', () => {
+    const { getByText } = render(
+      <ChangePermissionModal
+        isOpen={true}
+        files={mockFiles}
+        setLoading={jest.fn()}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+      />
+    );
+
+    fireEvent.click(getByText('Submit'));
+
+    waitFor(() => {
+      expect(mockSave).toHaveBeenCalledTimes(1);
+      expect(mockSave).toHaveBeenCalledWith(expect.any(FormData));
+    });
+  });
+
+  it('should call onSuccess when the request is successful', () => {
+    mockSave.mockImplementationOnce(() => {
+      mockOnSuccess();
+    });
+
+    const { getByText } = render(
+      <ChangePermissionModal
+        isOpen={true}
+        files={mockFiles}
+        setLoading={jest.fn()}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+      />
+    );
+
+    fireEvent.click(getByText('Submit'));
+
+    waitFor(() => expect(mockOnSuccess).toHaveBeenCalledTimes(1));
+  });
+
+  it('should call onError when the request fails', () => {
+    mockSave.mockImplementationOnce(() => {
+      mockOnError(new Error('Something went wrong'));
+    });
+
+    const { getByText } = render(
+      <ChangePermissionModal
+        isOpen={true}
+        files={mockFiles}
+        setLoading={jest.fn()}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+      />
+    );
+
+    fireEvent.click(getByText('Submit'));
+
+    waitFor(() => expect(mockOnError).toHaveBeenCalledTimes(1));
+  });
+
+  it('should call onClose when the modal is closed', () => {
+    const { getByText } = render(
+      <ChangePermissionModal
+        isOpen={true}
+        files={mockFiles}
+        setLoading={jest.fn()}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+      />
+    );
+
+    fireEvent.click(getByText('Cancel'));
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('should disable Submit button when the permissions have not been modified', () => {
+    const { getByText, getAllByRole } = render(
+      <ChangePermissionModal
+        isOpen={true}
+        files={mockFiles}
+        setLoading={jest.fn()}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+      />
+    );
+
+    const submitButton = getByText('Submit');
+    expect(submitButton).not.toBeDisabled();
+
+    const checkboxes = getAllByRole('checkbox');
+    fireEvent.click(checkboxes[0]);
+    waitFor(() => expect(submitButton).toBeDisabled());
+  });
+});

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

@@ -0,0 +1,154 @@
+// 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/useSaveData';
+import { Checkbox, Table } from 'antd';
+import { StorageDirectoryTableData } from '../../../../../reactComponents/FileChooser/types';
+import { BULK_CHANGE_PERMISSION_API_URL } from '../../../../../reactComponents/FileChooser/api';
+import { getInitialPermissions, Permission } from './ChangePermissionModal.util';
+
+import './ChangePermissionModal.scss';
+
+interface ChangePermissionModalProps {
+  isOpen?: boolean;
+  files: StorageDirectoryTableData[];
+  setLoading: (value: boolean) => void;
+  onSuccess: () => void;
+  onError: (error: Error) => void;
+  onClose: () => void;
+}
+
+const ChangePermissionModal = ({
+  isOpen = true,
+  files,
+  setLoading,
+  onSuccess,
+  onError,
+  onClose
+}: ChangePermissionModalProps): JSX.Element => {
+  const { t } = i18nReact.useTranslation();
+
+  const initialPermissions = getInitialPermissions(files);
+  const [permissions, setPermissions] = useState<Permission[]>(initialPermissions);
+
+  const { save, loading } = useSaveData(BULK_CHANGE_PERMISSION_API_URL, {
+    postOptions: {
+      qsEncodeData: false
+    },
+    skip: !files.length,
+    onSuccess,
+    onError
+  });
+
+  const handleChangeOwner = () => {
+    setLoading(true);
+
+    const formData = new FormData();
+    const perm = permissions.reduce((acc, { key, user, group, other, common }) => {
+      if (user) {
+        acc[`user_${key}`] = user;
+      }
+      if (group) {
+        acc[`group_${key}`] = group;
+      }
+      if (other) {
+        acc[`other_${key}`] = other;
+      }
+      if (common) {
+        acc[key] = common;
+      }
+      return acc;
+    }, {});
+
+    formData.append('permission', JSON.stringify(perm));
+
+    files.forEach(file => {
+      formData.append('path', file.path);
+    });
+
+    save(formData);
+  };
+
+  const handleCheckboxChange = (key: string, column: keyof Permission) => {
+    setPermissions(prev =>
+      prev.map(item => (item.key === key ? { ...item, [column]: !item[column] } : item))
+    );
+  };
+
+  const renderTableCheckbox = (key: keyof Permission) => (value: boolean, record: Permission) => {
+    if (value !== undefined) {
+      return <Checkbox checked={value} onChange={() => handleCheckboxChange(record.key, key)} />;
+    }
+  };
+
+  const columns = [
+    {
+      title: '',
+      dataIndex: 'key',
+      key: 'key'
+    },
+    {
+      title: 'User',
+      dataIndex: 'user',
+      key: 'user',
+      render: renderTableCheckbox('user')
+    },
+    {
+      title: 'Group',
+      dataIndex: 'group',
+      key: 'group',
+      render: renderTableCheckbox('group')
+    },
+    {
+      title: 'Other',
+      dataIndex: 'other',
+      key: 'other',
+      render: renderTableCheckbox('other')
+    },
+    {
+      title: '',
+      dataIndex: 'common',
+      key: 'common',
+      render: renderTableCheckbox('common')
+    }
+  ];
+
+  return (
+    <Modal
+      cancelText={t('Cancel')}
+      className="cuix antd"
+      okText={t('Submit')}
+      onCancel={onClose}
+      onOk={handleChangeOwner}
+      open={isOpen}
+      title={t('Change Permissions')}
+      okButtonProps={{ disabled: loading }}
+      cancelButtonProps={{ disabled: loading }}
+    >
+      <Table
+        dataSource={permissions}
+        columns={columns}
+        pagination={false}
+        className="hue-change-permission__table"
+      />
+    </Modal>
+  );
+};
+
+export default ChangePermissionModal;

+ 110 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangePermissionModal/ChangePermissionModal.util.test.ts

@@ -0,0 +1,110 @@
+// 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 { getInitialPermissions } from './ChangePermissionModal.util';
+import { StorageDirectoryTableData } from '../../../../../reactComponents/FileChooser/types';
+
+describe('getInitialPermissions', () => {
+  const mockFiles: StorageDirectoryTableData[] = [
+    {
+      name: 'file1.txt',
+      size: '0 Byte',
+      type: 'file',
+      permission: '-rwxr-xr--',
+      mtime: '2021-01-01 00:00:00',
+      path: 'test/path/file1.txt',
+      user: 'user1',
+      group: 'group1',
+      replication: 1
+    },
+    {
+      name: 'file2.txt',
+      size: '0 Byte',
+      type: 'file',
+      permission: '-r-xr-xr--',
+      mtime: '2021-01-01 00:00:00',
+      path: 'test/path/file2.txt',
+      user: 'user1',
+      group: 'group1',
+      replication: 1
+    }
+  ];
+
+  it('should correctly initialize permissions for multiple files', () => {
+    const result = getInitialPermissions(mockFiles);
+
+    const expectedPermissions = [
+      { key: 'read', user: true, group: true, other: true },
+      { key: 'write', user: true, group: false, other: false },
+      { key: 'execute', user: true, group: true, other: false },
+      { key: 'sticky', common: false },
+      { key: 'recursive', common: false }
+    ];
+
+    expect(result).toEqual(expectedPermissions);
+  });
+
+  it('should correctly initialize permissions for a single file', () => {
+    const result = getInitialPermissions([mockFiles[0]]);
+
+    const expectedPermissions = [
+      { key: 'read', user: true, group: true, other: true },
+      { key: 'write', user: true, group: false, other: false },
+      { key: 'execute', user: true, group: true, other: false },
+      { key: 'sticky', common: false },
+      { key: 'recursive', common: false }
+    ];
+
+    expect(result).toEqual(expectedPermissions);
+  });
+
+  it('should correctly set initialize sticky to true', () => {
+    const expectedPermissions = [
+      { key: 'read', user: true, group: true, other: true },
+      { key: 'write', user: true, group: true, other: true },
+      { key: 'execute', user: true, group: true, other: true },
+      { key: 'sticky', common: true },
+      { key: 'recursive', common: false }
+    ];
+
+    const result = getInitialPermissions([
+      {
+        ...mockFiles[0],
+        permission: '-rwxrwxrwxt'
+      }
+    ]);
+
+    expect(result).toEqual(expectedPermissions);
+  });
+
+  it('should correctly set initialize sticky to false', () => {
+    const expectedPermissions = [
+      { key: 'read', user: true, group: true, other: true },
+      { key: 'write', user: true, group: true, other: true },
+      { key: 'execute', user: true, group: true, other: true },
+      { key: 'sticky', common: false },
+      { key: 'recursive', common: false }
+    ];
+
+    const result = getInitialPermissions([
+      {
+        ...mockFiles[0],
+        permission: '-rwxrwxrwx'
+      }
+    ]);
+    expect(result).toEqual(expectedPermissions);
+  });
+});

+ 95 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangePermissionModal/ChangePermissionModal.util.ts

@@ -0,0 +1,95 @@
+// 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 { StorageDirectoryTableData } from '../../../../../reactComponents/FileChooser/types';
+
+interface entityPermission {
+  read: boolean;
+  write: boolean;
+  execute: boolean;
+}
+
+interface RWXPermissions {
+  user: entityPermission;
+  group: entityPermission;
+  other: entityPermission;
+  sticky: boolean;
+  recursive: boolean;
+}
+
+export interface Permission {
+  key: string;
+  user?: boolean;
+  group?: boolean;
+  other?: boolean;
+  common?: boolean;
+}
+
+// Helper function to parse the rwx string into boolean values
+const parseRWX = (rwx: string): RWXPermissions => ({
+  user: {
+    read: rwx[1] === 'r',
+    write: rwx[2] === 'w',
+    execute: rwx[3] === 'x'
+  },
+  group: {
+    read: rwx[4] === 'r',
+    write: rwx[5] === 'w',
+    execute: rwx[6] === 'x'
+  },
+  other: {
+    read: rwx[7] === 'r',
+    write: rwx[8] === 'w',
+    execute: rwx[9] === 'x'
+  },
+  sticky: rwx[10] === 't',
+  recursive: false
+});
+
+export const getInitialPermissions = (files: StorageDirectoryTableData[]): Permission[] => {
+  if (files.length === 0) {
+    return [];
+  }
+
+  const defaultPermissions: Permission[] = [
+    { key: 'read' },
+    { key: 'write' },
+    { key: 'execute' },
+    { key: 'sticky' },
+    { key: 'recursive' }
+  ];
+
+  const filesPermissions = files.map(({ permission }) => parseRWX(permission));
+
+  return defaultPermissions.reduce((acc: Permission[], permission) => {
+    const { key } = permission;
+
+    filesPermissions.forEach(parsedPermission => {
+      if (['read', 'write', 'execute'].includes(key)) {
+        permission.user = permission.user || parsedPermission.user[key];
+        permission.group = permission.group || parsedPermission.group[key];
+        permission.other = permission.other || parsedPermission.other[key];
+      }
+      if (['sticky', 'recursive'].includes(key)) {
+        permission.common = permission.common || parsedPermission[key];
+      }
+    });
+
+    acc.push(permission);
+
+    return acc;
+  }, []);
+};

+ 10 - 10
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.test.tsx

@@ -130,29 +130,29 @@ describe('StorageBrowserRowActions', () => {
       }
       }
     };
     };
 
 
-    it('should not render view summary option when there are multiple records selected', async () => {
+    it('should not render summary option when there are multiple records selected', async () => {
       await setUpActionMenu(mockTwoRecords);
       await setUpActionMenu(mockTwoRecords);
-      expect(screen.queryByRole('menuitem', { name: 'View Summary' })).toBeNull();
+      expect(screen.queryByRole('menuitem', { name: 'Summary' })).toBeNull();
     });
     });
 
 
-    it('should render view summary option when record is a hdfs file', async () => {
+    it('should render summary option when record is a hdfs file', async () => {
       await setUpActionMenu([mockRecord], '/user/demo/test', 'file');
       await setUpActionMenu([mockRecord], '/user/demo/test', 'file');
-      expect(screen.queryByRole('menuitem', { name: 'View Summary' })).not.toBeNull();
+      expect(screen.queryByRole('menuitem', { name: 'Summary' })).not.toBeNull();
     });
     });
 
 
-    it('should render view summary option when record is a ofs file', async () => {
+    it('should render summary option when record is a ofs file', async () => {
       await setUpActionMenu([mockRecord], 'ofs://demo/test', 'file');
       await setUpActionMenu([mockRecord], 'ofs://demo/test', 'file');
-      expect(screen.queryByRole('menuitem', { name: 'View Summary' })).not.toBeNull();
+      expect(screen.queryByRole('menuitem', { name: 'Summary' })).not.toBeNull();
     });
     });
 
 
-    it('should not render view summary option when record is a hdfs folder', async () => {
+    it('should not render summary option when record is a hdfs folder', async () => {
       await setUpActionMenu([mockRecord], '/user/demo/test', 'dir');
       await setUpActionMenu([mockRecord], '/user/demo/test', 'dir');
-      expect(screen.queryByRole('menuitem', { name: 'View Summary' })).toBeNull();
+      expect(screen.queryByRole('menuitem', { name: 'Summary' })).toBeNull();
     });
     });
 
 
-    it('should not render view summary option when record is a an abfs file', async () => {
+    it('should not render summary option when record is a an abfs file', async () => {
       await setUpActionMenu([mockRecord], 'abfs://demo/test', 'file');
       await setUpActionMenu([mockRecord], 'abfs://demo/test', 'file');
-      expect(screen.queryByRole('menuitem', { name: 'View Summary' })).toBeNull();
+      expect(screen.queryByRole('menuitem', { name: 'Summary' })).toBeNull();
     });
     });
   });
   });
 
 

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

@@ -30,6 +30,7 @@ import CollapseIcon from '@cloudera/cuix-core/icons/react/CollapseViewIcon';
 import ExpandIcon from '@cloudera/cuix-core/icons/react/ExpandViewIcon';
 import ExpandIcon from '@cloudera/cuix-core/icons/react/ExpandViewIcon';
 import DownloadIcon from '@cloudera/cuix-core/icons/react/DownloadIcon';
 import DownloadIcon from '@cloudera/cuix-core/icons/react/DownloadIcon';
 import GroupsIcon from '@cloudera/cuix-core/icons/react/GroupsIcon';
 import GroupsIcon from '@cloudera/cuix-core/icons/react/GroupsIcon';
+import ConfigureIcon from '@cloudera/cuix-core/icons/react/ConfigureIcon';
 
 
 import { i18nReact } from '../../../../utils/i18nReact';
 import { i18nReact } from '../../../../utils/i18nReact';
 import huePubSub from '../../../../utils/huePubSub';
 import huePubSub from '../../../../utils/huePubSub';
@@ -49,6 +50,7 @@ import CompressionModal from './CompressionModal/CompressionModal';
 import ExtractionModal from './ExtractionModal/ExtractionModal';
 import ExtractionModal from './ExtractionModal/ExtractionModal';
 import { DOWNLOAD_API_URL } from '../../../../reactComponents/FileChooser/api';
 import { DOWNLOAD_API_URL } from '../../../../reactComponents/FileChooser/api';
 import ChangeOwnerAndGroupModal from './ChangeOwnerAndGroupModal/ChangeOwnerAndGroupModal';
 import ChangeOwnerAndGroupModal from './ChangeOwnerAndGroupModal/ChangeOwnerAndGroupModal';
+import ChangePermissionModal from './ChangePermissionModal/ChangePermissionModal';
 
 
 interface StorageBrowserRowActionsProps {
 interface StorageBrowserRowActionsProps {
   // TODO: move relevant keys to hue_config
   // TODO: move relevant keys to hue_config
@@ -74,7 +76,8 @@ const iconsMap: Record<ActionType, JSX.Element> = {
   [ActionType.Compress]: <CollapseIcon />,
   [ActionType.Compress]: <CollapseIcon />,
   [ActionType.Extract]: <ExpandIcon />,
   [ActionType.Extract]: <ExpandIcon />,
   [ActionType.Download]: <DownloadIcon />,
   [ActionType.Download]: <DownloadIcon />,
-  [ActionType.ChangeOwnerAndGroup]: <GroupsIcon />
+  [ActionType.ChangeOwnerAndGroup]: <GroupsIcon />,
+  [ActionType.ChangePermission]: <ConfigureIcon />
 };
 };
 
 
 const StorageBrowserActions = ({
 const StorageBrowserActions = ({
@@ -121,7 +124,7 @@ const StorageBrowserActions = ({
   };
   };
 
 
   const actionItems: MenuItemType[] = useMemo(() => {
   const actionItems: MenuItemType[] = useMemo(() => {
-    const enabledActions = getEnabledActions(selectedFiles, isFsSuperUser);
+    const enabledActions = getEnabledActions(t, selectedFiles, isFsSuperUser);
     return enabledActions.map(action => ({
     return enabledActions.map(action => ({
       key: String(action.type),
       key: String(action.type),
       label: t(action.label),
       label: t(action.label),
@@ -219,6 +222,15 @@ const StorageBrowserActions = ({
           setLoading={setLoadingFiles}
           setLoading={setLoadingFiles}
         />
         />
       )}
       )}
+      {selectedAction === ActionType.ChangePermission && (
+        <ChangePermissionModal
+          files={selectedFiles}
+          onSuccess={onApiSuccess}
+          onError={onApiError}
+          onClose={closeModal}
+          setLoading={setLoadingFiles}
+        />
+      )}
     </>
     </>
   );
   );
 };
 };

+ 38 - 30
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.util.ts

@@ -34,6 +34,7 @@ import {
   inTrash
   inTrash
 } from '../../../../utils/storageBrowserUtils';
 } from '../../../../utils/storageBrowserUtils';
 import { SUPPORTED_COMPRESSED_FILE_EXTENTION } from '../../../../utils/constants/storageBrowser';
 import { SUPPORTED_COMPRESSED_FILE_EXTENTION } from '../../../../utils/constants/storageBrowser';
+import { TFunction } from 'i18next';
 
 
 export enum ActionType {
 export enum ActionType {
   Copy = 'copy',
   Copy = 'copy',
@@ -45,7 +46,8 @@ export enum ActionType {
   Compress = 'compress',
   Compress = 'compress',
   Extract = 'extract',
   Extract = 'extract',
   Download = 'download',
   Download = 'download',
-  ChangeOwnerAndGroup = 'changeOwnerAndGroup'
+  ChangeOwnerAndGroup = 'changeOwnerAndGroup',
+  ChangePermission = 'changePermission'
 }
 }
 
 
 const isValidFileOrFolder = (filePath: string): boolean => {
 const isValidFileOrFolder = (filePath: string): boolean => {
@@ -73,6 +75,8 @@ const isActionEnabled = (file: StorageDirectoryTableData, action: ActionType): b
     case ActionType.Copy:
     case ActionType.Copy:
     case ActionType.Delete:
     case ActionType.Delete:
     case ActionType.Move:
     case ActionType.Move:
+    case ActionType.ChangeOwnerAndGroup:
+    case ActionType.ChangePermission:
       return isValidFileOrFolder(file.path);
       return isValidFileOrFolder(file.path);
     case ActionType.Extract:
     case ActionType.Extract:
       return (
       return (
@@ -84,8 +88,6 @@ const isActionEnabled = (file: StorageDirectoryTableData, action: ActionType): b
       return !!config?.enable_extract_uploaded_archive && isHDFS(file.path);
       return !!config?.enable_extract_uploaded_archive && isHDFS(file.path);
     case ActionType.Download:
     case ActionType.Download:
       return !!config?.enable_file_download_button && file.type === BrowserViewType.file;
       return !!config?.enable_file_download_button && file.type === BrowserViewType.file;
-    case ActionType.ChangeOwnerAndGroup:
-      return isValidFileOrFolder(file.path);
     default:
     default:
       return false;
       return false;
   }
   }
@@ -106,6 +108,7 @@ const isMultipleFileActionEnabled = (
 };
 };
 
 
 export const getEnabledActions = (
 export const getEnabledActions = (
+  t: TFunction,
   files: StorageDirectoryTableData[],
   files: StorageDirectoryTableData[],
   isFsSuperUser?: boolean
   isFsSuperUser?: boolean
 ): {
 ): {
@@ -122,55 +125,60 @@ export const getEnabledActions = (
   // order of the elements will be the order of the action menu
   // order of the elements will be the order of the action menu
   const actions = [
   const actions = [
     {
     {
-      enabled: isMultipleFileActionEnabled(files, ActionType.Copy),
-      type: ActionType.Copy,
-      label: 'Copy'
+      enabled: isSingleFileActionEnabled(files, ActionType.Rename),
+      type: ActionType.Rename,
+      label: t('Rename')
     },
     },
     {
     {
       enabled: isMultipleFileActionEnabled(files, ActionType.Move),
       enabled: isMultipleFileActionEnabled(files, ActionType.Move),
       type: ActionType.Move,
       type: ActionType.Move,
-      label: 'Move'
+      label: t('Move')
     },
     },
     {
     {
-      enabled: isSingleFileActionEnabled(files, ActionType.Summary),
-      type: ActionType.Summary,
-      label: 'View Summary'
+      enabled: isMultipleFileActionEnabled(files, ActionType.Copy),
+      type: ActionType.Copy,
+      label: t('Copy')
     },
     },
     {
     {
-      enabled: isSingleFileActionEnabled(files, ActionType.Rename),
-      type: ActionType.Rename,
-      label: 'Rename'
+      enabled: isSingleFileActionEnabled(files, ActionType.Download),
+      type: ActionType.Download,
+      label: t('Download')
     },
     },
     {
     {
-      enabled: isMultipleFileActionEnabled(files, ActionType.Delete),
-      type: ActionType.Delete,
-      label: 'Delete'
+      enabled:
+        !!isFsSuperUser && isMultipleFileActionEnabled(files, ActionType.ChangeOwnerAndGroup),
+      type: ActionType.ChangeOwnerAndGroup,
+      label: t('Change Owner / Group')
+    },
+    {
+      enabled: !!isFsSuperUser && isMultipleFileActionEnabled(files, ActionType.ChangePermission),
+      type: ActionType.ChangePermission,
+      label: t('Change Permission')
+    },
+    {
+      enabled: isSingleFileActionEnabled(files, ActionType.Summary),
+      type: ActionType.Summary,
+      label: t('Summary')
     },
     },
     {
     {
       enabled: isSingleFileActionEnabled(files, ActionType.Replication),
       enabled: isSingleFileActionEnabled(files, ActionType.Replication),
       type: ActionType.Replication,
       type: ActionType.Replication,
-      label: 'Set Replication'
+      label: t('Set Replication')
+    },
+    {
+      enabled: isMultipleFileActionEnabled(files, ActionType.Delete),
+      type: ActionType.Delete,
+      label: t('Delete')
     },
     },
     {
     {
       enabled: isMultipleFileActionEnabled(files, ActionType.Compress),
       enabled: isMultipleFileActionEnabled(files, ActionType.Compress),
       type: ActionType.Compress,
       type: ActionType.Compress,
-      label: 'Compress'
+      label: t('Compress')
     },
     },
     {
     {
       enabled: isSingleFileActionEnabled(files, ActionType.Extract),
       enabled: isSingleFileActionEnabled(files, ActionType.Extract),
       type: ActionType.Extract,
       type: ActionType.Extract,
-      label: 'Extract'
-    },
-    {
-      enabled: isSingleFileActionEnabled(files, ActionType.Download),
-      type: ActionType.Download,
-      label: 'Download'
-    },
-    {
-      enabled:
-        !!isFsSuperUser && isMultipleFileActionEnabled(files, ActionType.ChangeOwnerAndGroup),
-      type: ActionType.ChangeOwnerAndGroup,
-      label: 'Change Owner / Group'
+      label: t('Extract')
     }
     }
   ].filter(e => e.enabled);
   ].filter(e => e.enabled);
 
 

+ 2 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.scss

@@ -63,6 +63,8 @@ $icon-margin: 5px;
   }
   }
 
 
   .hue-storage-browser__table-row {
   .hue-storage-browser__table-row {
+    border-bottom: 1px solid vars.$fluidx-gray-040;
+
     :hover {
     :hover {
       cursor: pointer;
       cursor: pointer;
     }
     }

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

@@ -37,6 +37,7 @@ 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';
 export const BULK_MOVE_API_URL = '/api/v1/storage/move/bulk';
 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_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 UPLOAD_AVAILABLE_SPACE_URL = '/api/v1/taskserver/upload/available_space';
 
 
 export interface ApiFileSystem {
 export interface ApiFileSystem {

+ 3 - 3
desktop/core/src/desktop/models.py

@@ -2015,9 +2015,9 @@ class ClusterConfig(object):
     if ENABLE_NEW_STORAGE_BROWSER.get():
     if ENABLE_NEW_STORAGE_BROWSER.get():
       interpreters.append({
       interpreters.append({
         'type': 'newfilebrowser',
         'type': 'newfilebrowser',
-        'displayName': _('New File Browser'),
-        'buttonName': _('New File Browser'),
-        'tooltip': _('New File Browser'),
+        'displayName': _('Storage Browser'),
+        'buttonName': _('Storage Browser'),
+        'tooltip': _('Storage Browser'),
         'page': '/filebrowser/new'
         'page': '/filebrowser/new'
       })
       })
     else:
     else: