diff --git a/apps/beeswax/src/beeswax/migrations/0004_alter_compute_is_ready.py b/apps/beeswax/src/beeswax/migrations/0004_alter_compute_is_ready.py new file mode 100644 index 00000000000..56557da1a43 --- /dev/null +++ b/apps/beeswax/src/beeswax/migrations/0004_alter_compute_is_ready.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2025-01-09 11:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('beeswax', '0003_compute_namespace'), + ] + + operations = [ + migrations.AlterField( + model_name='compute', + name='is_ready', + field=models.BooleanField(default=True, null=True), + ), + ] diff --git a/apps/beeswax/src/beeswax/models.py b/apps/beeswax/src/beeswax/models.py index 85f9e361d9b..8ae85d651ff 100644 --- a/apps/beeswax/src/beeswax/models.py +++ b/apps/beeswax/src/beeswax/models.py @@ -651,7 +651,7 @@ class Compute(models.Model): default='sqlalchemy' ) namespace = models.ForeignKey(Namespace, on_delete=models.CASCADE, null=True) - is_ready = models.BooleanField(default=True) + is_ready = models.BooleanField(default=True, null=True) external_id = models.CharField(max_length=255, null=True, db_index=True) ldap_groups_json = models.TextField(default='[]') settings = models.TextField(default='{}') diff --git a/apps/filebrowser/src/filebrowser/api.py b/apps/filebrowser/src/filebrowser/api.py index 5989f6926aa..5af002a5f16 100644 --- a/apps/filebrowser/src/filebrowser/api.py +++ b/apps/filebrowser/src/filebrowser/api.py @@ -734,7 +734,7 @@ def chown(request): path = request.POST.get('path') user = request.POST.get("user") group = request.POST.get("group") - recursive = request.POST.get('recursive', False) + recursive = coerce_bool(request.POST.get('recursive', False)) # TODO: Check if we need to explicitly handle encoding anywhere request.fs.chown(path, user, group, recursive=recursive) diff --git a/apps/oozie/src/oozie/conf.py b/apps/oozie/src/oozie/conf.py index ef15c28f7d1..913cd30d66e 100644 --- a/apps/oozie/src/oozie/conf.py +++ b/apps/oozie/src/oozie/conf.py @@ -61,6 +61,13 @@ ), ) +OOZIE_HS2_JDBC_URL = Config( + key="oozie_hs2_jdbc_url", + help="The JDBC URL for HiveServer2 action", + type=str, + default="" +) + def get_oozie_job_count(): '''Returns the maximum of jobs fetched by the API depending on the Hue version''' diff --git a/apps/oozie/src/oozie/models2.py b/apps/oozie/src/oozie/models2.py index ab7694558ef..0801f2cbfce 100644 --- a/apps/oozie/src/oozie/models2.py +++ b/apps/oozie/src/oozie/models2.py @@ -50,7 +50,7 @@ from liboozie.oozie_api import get_oozie from liboozie.submission2 import Submission, create_directories from notebook.models import Notebook -from oozie.conf import REMOTE_SAMPLE_DIR +from oozie.conf import OOZIE_HS2_JDBC_URL, REMOTE_SAMPLE_DIR from oozie.importlib.workflows import InvalidTagWithNamespaceException, MalformedWfDefException, generate_v2_graph_nodes from oozie.utils import UTC_TIME_FORMAT, convert_to_server_timezone, utc_datetime_format @@ -830,7 +830,7 @@ def to_xml(self, mapping=None, node_mapping=None, workflow_mapping=None): workflow_mapping = {} if self.data['type'] in ('hive2', 'hive-document') and not self.data['properties']['jdbc_url']: - self.data['properties']['jdbc_url'] = _get_hiveserver2_url() + self.data['properties']['jdbc_url'] = OOZIE_HS2_JDBC_URL.get() if OOZIE_HS2_JDBC_URL.get() else _get_hiveserver2_url() if self.data['type'] == 'fork': links = [link for link in self.data['children'] if link['to'] in node_mapping] diff --git a/desktop/conf.dist/hue.ini b/desktop/conf.dist/hue.ini index 2ea02ab95b4..b24a95d4dbd 100644 --- a/desktop/conf.dist/hue.ini +++ b/desktop/conf.dist/hue.ini @@ -1689,6 +1689,9 @@ submit_to=True # Parameters are $TIME and $USER, e.g. /user/$USER/hue/workspaces/workflow-$TIME ## remote_data_dir=/user/hue/oozie/workspaces +# JDBC URL for Hive2 action +## oozie_hs2_jdbc_url=jdbc:hive2://localhost:10000/default + # Maximum of Oozie workflows or coodinators to retrieve in one API call. ## oozie_jobs_count=100 diff --git a/desktop/conf/pseudo-distributed.ini.tmpl b/desktop/conf/pseudo-distributed.ini.tmpl index 207ee8020e3..add120073c9 100644 --- a/desktop/conf/pseudo-distributed.ini.tmpl +++ b/desktop/conf/pseudo-distributed.ini.tmpl @@ -1673,6 +1673,9 @@ # Parameters are $TIME and $USER, e.g. /user/$USER/hue/workspaces/workflow-$TIME ## remote_data_dir=/user/hue/oozie/workspaces + # JDBC URL for Hive2 action + ## oozie_hs2_jdbc_url=jdbc:hive2://localhost:10000/default + # Maximum of Oozie workflows or coodinators to retrieve in one API call. ## oozie_jobs_count=100 diff --git a/desktop/core/ext-py3/djangosaml2-0.18.0/djangosaml2/views.py b/desktop/core/ext-py3/djangosaml2-0.18.0/djangosaml2/views.py index 493e44876bc..47d5731a237 100644 --- a/desktop/core/ext-py3/djangosaml2-0.18.0/djangosaml2/views.py +++ b/desktop/core/ext-py3/djangosaml2-0.18.0/djangosaml2/views.py @@ -232,6 +232,7 @@ def login(request, saml_request = base64.b64encode(binary_type(request_xml)) http_response = render(request, post_binding_form_template, { + 'request': request, 'target_url': location, 'params': { 'SAMLRequest': saml_request, diff --git a/desktop/core/src/desktop/js/apps/editor/components/result/reactExample/ReactExample.tsx b/desktop/core/src/desktop/js/apps/editor/components/result/reactExample/ReactExample.tsx index ddd717e6066..8cb25be1f90 100644 --- a/desktop/core/src/desktop/js/apps/editor/components/result/reactExample/ReactExample.tsx +++ b/desktop/core/src/desktop/js/apps/editor/components/result/reactExample/ReactExample.tsx @@ -14,7 +14,7 @@ import { Ace } from '../../../../../ext/ace'; import { CURSOR_POSITION_CHANGED_EVENT } from '../../aceEditor/AceLocationHandler'; import ReactExampleGlobal from '../../../../../reactComponents/ReactExampleGlobal/ReactExampleGlobal'; -import { useHuePubSub } from '../../../../../utils/hooks/useHuePubSub'; +import { useHuePubSub } from '../../../../../utils/hooks/useHuePubSub/useHuePubSub'; import SqlExecutable from '../../../execution/sqlExecutable'; import './ReactExample.scss'; diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/FileChooserModal/FileChooserModal.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/FileChooserModal/FileChooserModal.tsx index 42ef9cd8239..5809021413b 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/FileChooserModal/FileChooserModal.tsx +++ b/desktop/core/src/desktop/js/apps/storageBrowser/FileChooserModal/FileChooserModal.tsx @@ -25,7 +25,7 @@ import FileIcon from '@cloudera/cuix-core/icons/react/DocumentationIcon'; import { i18nReact } from '../../../utils/i18nReact'; import useDebounce from '../../../utils/useDebounce'; -import useLoadData from '../../../utils/hooks/useLoadData'; +import useLoadData from '../../../utils/hooks/useLoadData/useLoadData'; import { BrowserViewType, ListDirectory } from '../../../reactComponents/FileChooser/types'; import { LIST_DIRECTORY_API_URL } from '../../../reactComponents/FileChooser/api'; diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageBrowserPage.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageBrowserPage.tsx index 619d8578943..a42de2556ba 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageBrowserPage.tsx +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageBrowserPage.tsx @@ -25,7 +25,7 @@ import StorageBrowserTab from './StorageBrowserTab/StorageBrowserTab'; import { ApiFileSystem, FILESYSTEMS_API_URL } from '../../reactComponents/FileChooser/api'; import './StorageBrowserPage.scss'; -import useLoadData from '../../utils/hooks/useLoadData'; +import useLoadData from '../../utils/hooks/useLoadData/useLoadData'; import LoadingErrorWrapper from '../../reactComponents/LoadingErrorWrapper/LoadingErrorWrapper'; const StorageBrowserPage = (): JSX.Element => { diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageBrowserTab/StorageBrowserTab.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageBrowserTab/StorageBrowserTab.tsx index 002ac71deca..37ab83f3812 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageBrowserTab/StorageBrowserTab.tsx +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageBrowserTab/StorageBrowserTab.tsx @@ -23,7 +23,7 @@ import PathBrowser from '../../../reactComponents/PathBrowser/PathBrowser'; import StorageDirectoryPage from '../StorageDirectoryPage/StorageDirectoryPage'; import { FILE_STATS_API_URL } from '../../../reactComponents/FileChooser/api'; import { BrowserViewType, FileStats } from '../../../reactComponents/FileChooser/types'; -import useLoadData from '../../../utils/hooks/useLoadData'; +import useLoadData from '../../../utils/hooks/useLoadData/useLoadData'; import './StorageBrowserTab.scss'; import StorageFilePage from '../StorageFilePage/StorageFilePage'; diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/CreateAndUploadAction/CreateAndUploadAction.test.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/CreateAndUploadAction/CreateAndUploadAction.test.tsx index 175c006507b..d1a1f653031 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/CreateAndUploadAction/CreateAndUploadAction.test.tsx +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/CreateAndUploadAction/CreateAndUploadAction.test.tsx @@ -8,7 +8,7 @@ import { } from '../../../../reactComponents/FileChooser/api'; const mockSave = jest.fn(); -jest.mock('../../../../utils/hooks/useSaveData', () => ({ +jest.mock('../../../../utils/hooks/useSaveData/useSaveData', () => ({ __esModule: true, default: jest.fn(() => ({ save: mockSave diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/CreateAndUploadAction/CreateAndUploadAction.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/CreateAndUploadAction/CreateAndUploadAction.tsx index cf41d868329..014e6685393 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/CreateAndUploadAction/CreateAndUploadAction.tsx +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/CreateAndUploadAction/CreateAndUploadAction.tsx @@ -31,7 +31,7 @@ import { CREATE_FILE_API_URL } from '../../../../reactComponents/FileChooser/api'; import { FileStats } from '../../../../reactComponents/FileChooser/types'; -import useSaveData from '../../../../utils/hooks/useSaveData'; +import useSaveData from '../../../../utils/hooks/useSaveData/useSaveData'; import InputModal from '../../InputModal/InputModal'; import './CreateAndUploadAction.scss'; import DragAndDrop from '../../../../reactComponents/DragAndDrop/DragAndDrop'; diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangeOwnerAndGroupModal/ChangeOwnerAndGroupModal.scss b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangeOwnerAndGroupModal/ChangeOwnerAndGroupModal.scss new file mode 100644 index 00000000000..5ace7a13888 --- /dev/null +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangeOwnerAndGroupModal/ChangeOwnerAndGroupModal.scss @@ -0,0 +1,59 @@ +// 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-owner-group { + display: flex; + flex-direction: column; + flex: 1; + gap: 8px; + + &__header-note { + padding: 8px; + background-color: vars.$fluidx-gray-200; + margin-bottom: 8px; + } + + &__form { + display: flex; + flex-direction: column; + flex: 1; + gap: 16px; + } + + &__entity { + display: flex; + flex-direction: column; + } + + &__dropdown { + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 8px; + } + + &__checkbox { + display: flex; + gap: 8px; + } + + &__label { + color: vars.$fluidx-gray-700; + } + } +} diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangeOwnerAndGroupModal/ChangeOwnerAndGroupModal.test.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangeOwnerAndGroupModal/ChangeOwnerAndGroupModal.test.tsx new file mode 100644 index 00000000000..f9fc50cf3d8 --- /dev/null +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangeOwnerAndGroupModal/ChangeOwnerAndGroupModal.test.tsx @@ -0,0 +1,297 @@ +// 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 userEvent from '@testing-library/user-event'; +import ChangeOwnerAndGroupModal from './ChangeOwnerAndGroupModal'; +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(); + +const users = ['user1', 'user2', 'user3']; +const groups = ['group1', 'group2', 'group3']; + +describe('ChangeOwnerAndGroupModal Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render correctly and show the modal', () => { + const { getByText } = render( + + ); + + expect(getByText('Change Onwer / Group')).toBeInTheDocument(); + expect(getByText('Submit')).toBeInTheDocument(); + expect(getByText('Cancel')).toBeInTheDocument(); + expect(getByText('User')).toBeInTheDocument(); + expect(getByText('Group')).toBeInTheDocument(); + expect(getByText('Recursive')).toBeInTheDocument(); + expect( + getByText( + 'Note: Only the Hadoop superuser, "{{superuser}}" or the HDFS supergroup, "{{supergroup}}" on this file system, may change the owner of a file.' + ) + ).toBeInTheDocument(); + }); + + it('should show input fields for custom user when "Others" is selected', async () => { + const { getAllByRole, getByText, getByPlaceholderText } = render( + + ); + + const [userSelect] = getAllByRole('combobox'); + + await userEvent.click(userSelect); + fireEvent.click(getByText('others')); + fireEvent.change(userSelect, { target: { value: 'others' } }); + + const userInput = getByPlaceholderText('Enter user'); + expect(userInput).toBeInTheDocument(); + + fireEvent.change(userInput, { target: { value: 'customUser' } }); + expect(userInput).toHaveValue('customUser'); + }); + + it('should show input fields for custom group when "Others" is selected', async () => { + const { getAllByRole, getByText, getByPlaceholderText } = render( + + ); + + const groupSelect = getAllByRole('combobox')[1]; + + await userEvent.click(groupSelect); + fireEvent.click(getByText('others')); + fireEvent.change(groupSelect, { target: { value: 'others' } }); + + const groupInput = getByPlaceholderText('Enter group'); + expect(groupInput).toBeInTheDocument(); + + fireEvent.change(groupInput, { target: { value: 'customGroup' } }); + expect(groupInput).toHaveValue('customGroup'); + }); + + it('should toggle the recursive checkbox', () => { + const { getByRole } = render( + + ); + + const recursiveCheckbox = getByRole('checkbox'); + expect(recursiveCheckbox).not.toBeChecked(); + fireEvent.click(recursiveCheckbox); + expect(recursiveCheckbox).toBeChecked(); + fireEvent.click(recursiveCheckbox); + expect(recursiveCheckbox).not.toBeChecked(); + }); + + it('should call handleChangeOwner when the form is submitted', async () => { + const { getByText } = render( + + ); + + fireEvent.click(getByText('Submit')); + + await waitFor(() => { + expect(mockSave).toHaveBeenCalledTimes(1); + expect(mockSave).toHaveBeenCalledWith(expect.any(FormData)); + }); + }); + + it('should call onSuccess when the request is successful', async () => { + mockSave.mockImplementationOnce(() => { + mockOnSuccess(); + }); + + const { getByText } = render( + + ); + + fireEvent.click(getByText('Submit')); + + await waitFor(() => { + expect(mockOnSuccess).toHaveBeenCalledTimes(1); + }); + }); + + it('should call onError when the request fails', async () => { + mockSave.mockImplementationOnce(() => { + mockOnError(new Error()); + }); + + const { getByText } = render( + + ); + + fireEvent.click(getByText('Submit')); + + await waitFor(() => { + expect(mockOnError).toHaveBeenCalledTimes(1); + }); + }); + + it('should call onClose when the modal is closed', () => { + const { getByText } = render( + + ); + + fireEvent.click(getByText('Cancel')); + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should disable submit button when other option is selected and input not provided', async () => { + const { getAllByRole, getAllByText, getByPlaceholderText, getByRole } = render( + + ); + + const [userSelect] = getAllByRole('combobox'); + + await userEvent.click(userSelect); + fireEvent.click(getAllByText('others')[0]); + fireEvent.change(userSelect, { target: { value: 'others' } }); + + const submitButton = getByRole('button', { name: /submit/i }); + expect(submitButton).toBeDisabled(); + + const userInput = getByPlaceholderText('Enter user'); + fireEvent.change(userInput, { target: { value: 'customUser' } }); + expect(submitButton).toBeEnabled(); + }); +}); diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangeOwnerAndGroupModal/ChangeOwnerAndGroupModal.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangeOwnerAndGroupModal/ChangeOwnerAndGroupModal.tsx new file mode 100644 index 00000000000..28fa50ac936 --- /dev/null +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangeOwnerAndGroupModal/ChangeOwnerAndGroupModal.tsx @@ -0,0 +1,197 @@ +// 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, { useEffect, useMemo, useState } from 'react'; +import Modal from 'cuix/dist/components/Modal'; +import { i18nReact } from '../../../../../utils/i18nReact'; +import useSaveData from '../../../../../utils/hooks/useSaveData/useSaveData'; +import { Checkbox, Input, Select } from 'antd'; +import { + ListDirectory, + StorageDirectoryTableData +} from '../../../../../reactComponents/FileChooser/types'; +import { BULK_CHANGE_OWNER_API_URL } from '../../../../../reactComponents/FileChooser/api'; +import './ChangeOwnerAndGroupModal.scss'; + +interface ChangeOwnerAndGroupModalProps { + superUser?: ListDirectory['superuser']; + superGroup?: ListDirectory['supergroup']; + users?: ListDirectory['users']; + groups?: ListDirectory['groups']; + isOpen?: boolean; + files: StorageDirectoryTableData[]; + setLoading: (value: boolean) => void; + onSuccess: () => void; + onError: (error: Error) => void; + onClose: () => void; +} + +const OTHERS_KEY = 'others'; +const getDropdownOptions = (entity: ListDirectory['users'] | ListDirectory['groups']) => { + return [...entity, OTHERS_KEY].map(user => ({ + value: user, + label: user + })); +}; + +const ChangeOwnerAndGroupModal = ({ + superUser, + superGroup, + users = [], + groups = [], + isOpen = true, + files, + setLoading, + onSuccess, + onError, + onClose +}: ChangeOwnerAndGroupModalProps): JSX.Element => { + const { t } = i18nReact.useTranslation(); + + const [selectedUser, setSelectedUser] = useState(files[0].user); + const [selectedGroup, setSelectedGroup] = useState(files[0].group); + const [userOther, setUserOther] = useState(); + const [groupOther, setGroupOther] = useState(); + const [isRecursive, setIsRecursive] = useState(false); + + const { save, loading } = useSaveData(BULK_CHANGE_OWNER_API_URL, { + postOptions: { + qsEncodeData: false + }, + skip: !files.length, + onSuccess, + onError + }); + + const handleChangeOwner = () => { + setLoading(true); + + const formData = new FormData(); + if (selectedUser === OTHERS_KEY && userOther) { + formData.append('user', userOther); + } else { + formData.append('user', selectedUser); + } + if (selectedGroup === OTHERS_KEY && groupOther) { + formData.append('group', groupOther); + } else { + formData.append('group', selectedGroup); + } + if (isRecursive) { + formData.append('recursive', String(isRecursive)); + } + files.forEach(file => { + formData.append('path', file.path); + }); + + save(formData); + }; + + const usersOptions = getDropdownOptions(users); + const groupOptions = getDropdownOptions(groups); + + useEffect(() => { + const isOtherUserSelected = !users.includes(files[0].user); + if (isOtherUserSelected) { + setSelectedUser(OTHERS_KEY); + setUserOther(files[0].user); + } + + const isOtherGroupSelected = !groups.includes(files[0].group); + if (isOtherGroupSelected) { + setSelectedGroup(OTHERS_KEY); + setGroupOther(files[0].group); + } + }, []); + + const isSubmitEnabled = useMemo(() => { + return Boolean( + selectedUser && + selectedGroup && + !(selectedUser === OTHERS_KEY && !userOther) && + !(selectedGroup === OTHERS_KEY && !groupOther) + ); + }, [selectedUser, selectedGroup, userOther, groupOther]); + + return ( + +
+ + {t( + 'Note: Only the Hadoop superuser, "{{superuser}}" or the HDFS supergroup, "{{supergroup}}" on this file system, may change the owner of a file.', + { + superuser: superUser, + supergroup: superGroup + } + )} + + +
+
+
{t('User')}
+
+ setUserOther(e.target.value)} + required + /> + )} +
+
+ +
+
{t('Group')}
+
+ setGroupOther(e.target.value)} + required + /> + )} +
+
+ +
+ {t('Recursive')} + setIsRecursive(prev => !prev)} + name="recursive" + /> +
+
+
+
+ ); +}; + +export default ChangeOwnerAndGroupModal; diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Compress/Compress.scss b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/CompressionModal/CompressionModal.scss similarity index 100% rename from desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Compress/Compress.scss rename to desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/CompressionModal/CompressionModal.scss diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Compress/Compress.test.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/CompressionModal/CompressionModal.test.tsx similarity index 92% rename from desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Compress/Compress.test.tsx rename to desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/CompressionModal/CompressionModal.test.tsx index 953ec2e5bda..c09584834a1 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Compress/Compress.test.tsx +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/CompressionModal/CompressionModal.test.tsx @@ -17,9 +17,8 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; -import CompressAction from './Compress'; +import CompressionModal from './CompressionModal'; import { StorageDirectoryTableData } from '../../../../../reactComponents/FileChooser/types'; -import { COMPRESS_API_URL } from '../../../../../reactComponents/FileChooser/api'; const mockFiles: StorageDirectoryTableData[] = [ { @@ -47,7 +46,7 @@ const mockFiles: StorageDirectoryTableData[] = [ ]; const mockSave = jest.fn(); -jest.mock('../../../../../utils/hooks/useSaveData', () => ({ +jest.mock('../../../../../utils/hooks/useSaveData/useSaveData', () => ({ __esModule: true, default: jest.fn(() => ({ save: mockSave, @@ -55,7 +54,7 @@ jest.mock('../../../../../utils/hooks/useSaveData', () => ({ })) })); -describe('CompressAction Component', () => { +describe('CompressionModal Component', () => { const mockOnSuccess = jest.fn(); const mockOnError = jest.fn(); const mockOnClose = jest.fn(); @@ -67,7 +66,7 @@ describe('CompressAction Component', () => { it('should render the Compress modal with the correct title and buttons', () => { const { getByText, getByRole } = render( - { it('should display the correct list of files to be compressed', () => { const { getByText } = render( - { it('should call handleCompress with the correct data when "Compress" is clicked', async () => { const { getByText } = render( - { fireEvent.click(getByText('Compress')); - expect(mockSave).toHaveBeenCalledWith(formData, { url: COMPRESS_API_URL }); + expect(mockSave).toHaveBeenCalledWith(formData); }); it('should update the compressed file name when input value changes', () => { const { getByRole } = render( - { it('should call onClose when the modal is closed', () => { const { getByText } = render( - { }); const { getByText } = render( - void; } -const CompressAction = ({ +const CompressionModal = ({ currentPath, isOpen = true, files, @@ -42,17 +42,14 @@ const CompressAction = ({ onSuccess, onError, onClose -}: CompressActionProps): JSX.Element => { +}: CompressionModalProps): JSX.Element => { const initialName = currentPath.split('/').pop() + '.zip'; const [value, setValue] = useState(initialName); const { t } = i18nReact.useTranslation(); - const { save: saveForm, loading } = useSaveData(undefined, { + const { save: saveForm, loading } = useSaveData(COMPRESS_API_URL, { postOptions: { - qsEncodeData: false, - headers: { - 'Content-Type': 'multipart/form-data' - } + qsEncodeData: false }, skip: !files.length, onSuccess, @@ -69,7 +66,7 @@ const CompressAction = ({ formData.append('upload_path', currentPath); formData.append('archive_name', value); - saveForm(formData, { url: COMPRESS_API_URL }); + saveForm(formData); }; return ( @@ -106,4 +103,4 @@ const CompressAction = ({ ); }; -export default CompressAction; +export default CompressionModal; diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Delete/Delete.test.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/DeletionModal/DeletionModal.test.tsx similarity index 88% rename from desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Delete/Delete.test.tsx rename to desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/DeletionModal/DeletionModal.test.tsx index ec9af9deed4..d83ef36d8a2 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Delete/Delete.test.tsx +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/DeletionModal/DeletionModal.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; -import DeleteAction from './Delete'; +import DeletionModal from './DeletionModal'; import { StorageDirectoryTableData } from '../../../../../reactComponents/FileChooser/types'; import { BULK_DELETION_API_URL, @@ -34,7 +34,7 @@ const mockFiles: StorageDirectoryTableData[] = [ ]; const mockSave = jest.fn(); -jest.mock('../../../../../utils/hooks/useSaveData', () => ({ +jest.mock('../../../../../utils/hooks/useSaveData/useSaveData', () => ({ __esModule: true, default: jest.fn(() => ({ save: mockSave, @@ -42,7 +42,7 @@ jest.mock('../../../../../utils/hooks/useSaveData', () => ({ })) })); -describe('DeleteAction Component', () => { +describe('DeletionModal Component', () => { const mockOnSuccess = jest.fn(); const mockOnError = jest.fn(); const mockOnClose = jest.fn(); @@ -54,7 +54,7 @@ describe('DeleteAction Component', () => { it('should render the Delete modal with the correct title and buttons', () => { const { getByText, getByRole } = render( - { it('should render the Delete modal with the correct title and buttons when trash is not enabled', () => { const { getByText, queryByText, getByRole } = render( - { it('should call handleDeletion with the correct data for single delete when "Delete Permanently" is clicked', async () => { const { getByText } = render( - { fireEvent.click(getByText('Delete Permanently')); - const payload = { path: mockFiles[0].path, skip_trash: true }; - expect(mockSave).toHaveBeenCalledWith(payload, { url: DELETION_API_URL }); + const formData = new FormData(); + formData.append('path', mockFiles[0].path); + formData.append('skip_trash', 'true'); + + expect(mockSave).toHaveBeenCalledWith(formData, { url: DELETION_API_URL }); }); it('should call handleDeletion with the correct data for bulk delete when "Delete Permanently" is clicked', async () => { const { getByText } = render( - { it('should call handleDeletion with the correct data for trash delete when "Move to Trash" is clicked', async () => { const { getByText } = render( - { fireEvent.click(getByText('Move to Trash')); - const payload = { path: mockFiles[0].path }; - expect(mockSave).toHaveBeenCalledWith(payload, { url: DELETION_API_URL }); + const formData = new FormData(); + formData.append('path', mockFiles[0].path); + + expect(mockSave).toHaveBeenCalledWith(formData, { url: DELETION_API_URL }); }); it('should call handleDeletion with the correct data for bulk trash delete when "Move to Trash" is clicked', async () => { const { getByText } = render( - { mockOnError(new Error()); }); const { getByText } = render( - { it('should call onClose when the modal is closed', () => { const { getByText } = render( - void; } -const DeleteAction = ({ +const DeletionModal = ({ isOpen = true, isTrashEnabled = false, files, @@ -42,21 +42,12 @@ const DeleteAction = ({ onSuccess, onError, onClose -}: DeleteActionProps): JSX.Element => { +}: DeletionModalProps): JSX.Element => { const { t } = i18nReact.useTranslation(); - const { save, loading: saveLoading } = useSaveData(undefined, { - skip: !files.length, - onSuccess, - onError - }); - - const { save: saveForm, loading: saveFormLoading } = useSaveData(undefined, { + const { save, loading } = useSaveData(undefined, { postOptions: { - qsEncodeData: false, - headers: { - 'Content-Type': 'multipart/form-data' - } + qsEncodeData: false }, skip: !files.length, onSuccess, @@ -64,28 +55,24 @@ const DeleteAction = ({ }); const handleDeletion = (isForedSkipTrash: boolean = false) => { - const isSkipTrash = !isTrashEnabled || isForedSkipTrash; setLoading(true); + const isSkipTrash = !isTrashEnabled || isForedSkipTrash; - const isBulkDelete = files.length > 1; - if (isBulkDelete) { - const formData = new FormData(); - files.forEach(selectedFile => { - formData.append('path', selectedFile.path); - }); - if (isSkipTrash) { - formData.append('skip_trash', String(isSkipTrash)); - } + const formData = new FormData(); + files.forEach(selectedFile => { + formData.append('path', selectedFile.path); + }); + if (isSkipTrash) { + formData.append('skip_trash', String(isSkipTrash)); + } - saveForm(formData, { url: BULK_DELETION_API_URL }); + if (files.length > 1) { + save(formData, { url: BULK_DELETION_API_URL }); } else { - const payload = { path: files[0].path, skip_trash: isSkipTrash ? true : undefined }; - save(payload, { url: DELETION_API_URL }); + save(formData, { url: DELETION_API_URL }); } }; - const loading = saveFormLoading || saveLoading; - return ( ({ + __esModule: true, + default: jest.fn(() => ({ + save: mockSave, + loading: mockLoading + })) +})); + +describe('ExtractAction Component', () => { + const mockOnSuccess = jest.fn(); + const mockOnError = jest.fn(); + const mockOnClose = jest.fn(); + const setLoading = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the Extract modal with the correct title and buttons', () => { + const { getByText, getByRole } = render( + + ); + + expect(getByText('Extract Archive')).toBeInTheDocument(); + expect(getByText(`Are you sure you want to extract "{{fileName}}" file?`)).toBeInTheDocument(); + expect(getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Extract' })).toBeInTheDocument(); + }); + + it('should call handleExtract with the correct path and name when "Extract" is clicked', async () => { + const { getByText } = render( + + ); + + fireEvent.click(getByText('Extract')); + + expect(mockSave).toHaveBeenCalledWith({ + upload_path: 'test/path', + archive_name: mockFile.name + }); + }); + + it('should call onSuccess when the extract request is successful', async () => { + mockSave.mockImplementationOnce(() => { + mockOnSuccess(); + }); + + const { getByText } = render( + + ); + + fireEvent.click(getByText('Extract')); + await waitFor(() => expect(mockOnSuccess).toHaveBeenCalledTimes(1)); + }); + + it('should call onError when the extract request fails', async () => { + mockSave.mockImplementationOnce(() => { + mockOnError(new Error('Extraction failed')); + }); + + const { getByText } = render( + + ); + + fireEvent.click(getByText('Extract')); + await waitFor(() => expect(mockOnError).toHaveBeenCalledWith(new Error('Extraction failed'))); + }); + + it('should call onClose when the modal is closed', () => { + const { getByText } = render( + + ); + + fireEvent.click(getByText('Cancel')); + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should disable the "Extract" button while loading', () => { + mockLoading = true; + + const { getByRole } = render( + + ); + + expect(getByRole('button', { name: 'Extract' })).toBeDisabled(); + }); +}); diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ExtractionModal/ExtractionModal.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ExtractionModal/ExtractionModal.tsx new file mode 100644 index 00000000000..de4a3bf99c6 --- /dev/null +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ExtractionModal/ExtractionModal.tsx @@ -0,0 +1,77 @@ +// Licensed to Cloudera, Inc. under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. Cloudera, Inc. licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import Modal from 'cuix/dist/components/Modal'; +import { i18nReact } from '../../../../../utils/i18nReact'; +import useSaveData from '../../../../../utils/hooks/useSaveData/useSaveData'; +import { StorageDirectoryTableData } from '../../../../../reactComponents/FileChooser/types'; +import { EXTRACT_API_URL } from '../../../../../reactComponents/FileChooser/api'; + +interface ExtractActionProps { + currentPath: string; + isOpen?: boolean; + file: StorageDirectoryTableData; + setLoading: (value: boolean) => void; + onSuccess: () => void; + onError: (error: Error) => void; + onClose: () => void; +} + +const ExtractionModal = ({ + currentPath, + isOpen = true, + file, + setLoading, + onSuccess, + onError, + onClose +}: ExtractActionProps): JSX.Element => { + const { t } = i18nReact.useTranslation(); + + const { save, loading } = useSaveData(EXTRACT_API_URL, { + skip: !file, + onSuccess, + onError + }); + + const handleExtract = () => { + setLoading(true); + + save({ + upload_path: currentPath, + archive_name: file.name + }); + }; + + return ( + + {t('Are you sure you want to extract "{{fileName}}" file?', { fileName: file.name })} + + ); +}; + +export default ExtractionModal; diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/MoveCopy/MoveCopy.test.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/MoveCopyModal/MoveCopyModal.test.tsx similarity index 95% rename from desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/MoveCopy/MoveCopy.test.tsx rename to desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/MoveCopyModal/MoveCopyModal.test.tsx index b6020f8ff23..4b341c9dbad 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/MoveCopy/MoveCopy.test.tsx +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/MoveCopyModal/MoveCopyModal.test.tsx @@ -28,7 +28,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; -import MoveCopyAction from './MoveCopy'; +import MoveCopyModal from './MoveCopyModal'; import { ActionType } from '../StorageBrowserActions.util'; import { BULK_COPY_API_URL, @@ -60,7 +60,7 @@ const mockFiles: StorageDirectoryTableData[] = [ replication: 1 } ]; -jest.mock('../../../../../utils/hooks/useLoadData', () => ({ +jest.mock('../../../../../utils/hooks/useLoadData/useLoadData', () => ({ __esModule: true, default: jest.fn(() => ({ data: { @@ -71,7 +71,7 @@ jest.mock('../../../../../utils/hooks/useLoadData', () => ({ })); const mockSave = jest.fn(); -jest.mock('../../../../../utils/hooks/useSaveData', () => ({ +jest.mock('../../../../../utils/hooks/useSaveData/useSaveData', () => ({ __esModule: true, default: jest.fn(() => ({ save: mockSave @@ -92,7 +92,7 @@ describe('MoveCopy Action Component', () => { describe('Copy Actions', () => { it('should render correctly and open the modal', () => { const { getByText } = render( - { const newDestPath = 'test/path/folder1'; const { getByText } = render( - { it('should call onSuccess when the request succeeds', async () => { mockSave.mockImplementationOnce(mockOnSuccess); const { getByText } = render( - { mockOnError(new Error()); }); const { getByText } = render( - { it('should call onClose when the modal is closed', () => { const { getByText } = render( - { describe('Move Actions', () => { it('should render correctly and open the modal', () => { const { getByText } = render( - { const newDestPath = 'test/path/folder1'; const { getByText } = render( - void; } -const MoveCopyAction = ({ +const MoveCopyModal = ({ isOpen = true, action, currentPath, @@ -48,15 +48,12 @@ const MoveCopyAction = ({ onSuccess, onError, onClose -}: MoveCopyActionProps): JSX.Element => { +}: MoveCopyModalProps): JSX.Element => { const { t } = i18nReact.useTranslation(); - const { save: saveForm } = useSaveData(undefined, { + const { save } = useSaveData(undefined, { postOptions: { - qsEncodeData: false, - headers: { - 'Content-Type': 'multipart/form-data' - } + qsEncodeData: false }, skip: !files.length, onSuccess: onSuccess, @@ -80,7 +77,7 @@ const MoveCopyAction = ({ formData.append('destination_path', destination_path); setLoadingFiles(true); - saveForm(formData, { url }); + save(formData, { url }); }; return ( @@ -95,4 +92,4 @@ const MoveCopyAction = ({ ); }; -export default MoveCopyAction; +export default MoveCopyModal; diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Rename/Rename.test.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/RenameModal/RenameModal.test.tsx similarity index 89% rename from desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Rename/Rename.test.tsx rename to desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/RenameModal/RenameModal.test.tsx index a6a7ac6a3ab..633555866a2 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Rename/Rename.test.tsx +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/RenameModal/RenameModal.test.tsx @@ -17,12 +17,11 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; -import RenameAction from './Rename'; +import RenameModal from './RenameModal'; import { StorageDirectoryTableData } from '../../../../../reactComponents/FileChooser/types'; -import { RENAME_API_URL } from '../../../../../reactComponents/FileChooser/api'; const mockSave = jest.fn(); -jest.mock('../../../../../utils/hooks/useSaveData', () => ({ +jest.mock('../../../../../utils/hooks/useSaveData/useSaveData', () => ({ __esModule: true, default: jest.fn(() => ({ save: mockSave, @@ -30,7 +29,7 @@ jest.mock('../../../../../utils/hooks/useSaveData', () => ({ })) })); -describe('RenameAction Component', () => { +describe('RenameModal Component', () => { const mockOnSuccess = jest.fn(); const mockOnError = jest.fn(); const mockOnClose = jest.fn(); @@ -53,7 +52,7 @@ describe('RenameAction Component', () => { it('should render the Rename modal with the correct title and initial input', () => { const { getByText, getByRole } = render( - { it('should call handleRename with the correct data when the form is submitted', async () => { const { getByRole } = render( - { expect(mockSave).toHaveBeenCalledTimes(1); - expect(mockSave).toHaveBeenCalledWith( - { source_path: '/path/to/file1.txt', destination_path: 'file2.txt' }, - { url: RENAME_API_URL } - ); + expect(mockSave).toHaveBeenCalledWith({ + source_path: '/path/to/file1.txt', + destination_path: 'file2.txt' + }); }); it('should call onSuccess when the rename request succeeds', async () => { mockSave.mockImplementationOnce(mockOnSuccess); const { getByRole } = render( - { mockOnError(new Error()); }); const { getByRole } = render( - { it('should call onClose when the modal is closed', () => { const { getByRole } = render( - void; @@ -29,24 +29,23 @@ interface RenameActionProps { onClose: () => void; } -const RenameAction = ({ +const RenameModal = ({ isOpen = true, file, onSuccess, onError, onClose -}: RenameActionProps): JSX.Element => { +}: RenameModalProps): JSX.Element => { const { t } = i18nReact.useTranslation(); - const { save, loading } = useSaveData(undefined, { + const { save, loading } = useSaveData(RENAME_API_URL, { skip: !file.path, onSuccess, onError }); const handleRename = (value: string) => { - const payload = { source_path: file.path, destination_path: value }; - save(payload, { url: RENAME_API_URL }); + save({ source_path: file.path, destination_path: value }); }; return ( @@ -64,4 +63,4 @@ const RenameAction = ({ ); }; -export default RenameAction; +export default RenameModal; diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Replication/Replication.test.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ReplicationModal/ReplicationModal.test.tsx similarity index 88% rename from desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Replication/Replication.test.tsx rename to desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ReplicationModal/ReplicationModal.test.tsx index d492c6b154f..81d33525c1e 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/Replication/Replication.test.tsx +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ReplicationModal/ReplicationModal.test.tsx @@ -17,12 +17,11 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; -import ReplicationAction from './Replication'; +import ReplicationModal from './ReplicationModal'; import { StorageDirectoryTableData } from '../../../../../reactComponents/FileChooser/types'; -import { SET_REPLICATION_API_URL } from '../../../../../reactComponents/FileChooser/api'; const mockSave = jest.fn(); -jest.mock('../../../../../utils/hooks/useSaveData', () => ({ +jest.mock('../../../../../utils/hooks/useSaveData/useSaveData', () => ({ __esModule: true, default: jest.fn(() => ({ save: mockSave, @@ -30,7 +29,7 @@ jest.mock('../../../../../utils/hooks/useSaveData', () => ({ })) })); -describe('ReplicationAction Component', () => { +describe('ReplicationModal Component', () => { const mockOnSuccess = jest.fn(); const mockOnError = jest.fn(); const mockOnClose = jest.fn(); @@ -53,7 +52,7 @@ describe('ReplicationAction Component', () => { it('should render the Replication modal with the correct title and initial input', () => { const { getByText, getByRole } = render( - { it('should call handleReplication with the correct data when the form is submitted', async () => { const { getByRole } = render( - { expect(mockSave).toHaveBeenCalledTimes(1); - expect(mockSave).toHaveBeenCalledWith( - { path: '/path/to/file1.txt', replication_factor: '2' }, - { url: SET_REPLICATION_API_URL } - ); + expect(mockSave).toHaveBeenCalledWith({ path: '/path/to/file1.txt', replication_factor: '2' }); }); it('should call onSuccess when the rename request succeeds', async () => { mockSave.mockImplementationOnce(mockOnSuccess); const { getByRole } = render( - { mockOnError(new Error()); }); const { getByRole } = render( - { it('should call onClose when the modal is closed', () => { const { getByRole } = render( - void; @@ -29,24 +29,23 @@ interface ReplicationActionProps { onClose: () => void; } -const ReplicationAction = ({ +const ReplicationModal = ({ isOpen = true, file, onSuccess, onError, onClose -}: ReplicationActionProps): JSX.Element => { +}: ReplicationModalProps): JSX.Element => { const { t } = i18nReact.useTranslation(); - const { save, loading } = useSaveData(undefined, { + const { save, loading } = useSaveData(SET_REPLICATION_API_URL, { skip: !file.path, onSuccess, onError }); const handleReplication = (replicationFactor: number) => { - const payload = { path: file.path, replication_factor: replicationFactor }; - save(payload, { url: SET_REPLICATION_API_URL }); + save({ path: file.path, replication_factor: replicationFactor }); }; return ( @@ -64,4 +63,4 @@ const ReplicationAction = ({ ); }; -export default ReplicationAction; +export default ReplicationModal; diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.test.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.test.tsx index aea5002d2e3..1a82e78507e 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.test.tsx +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.test.tsx @@ -13,6 +13,7 @@ // 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 } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -21,50 +22,57 @@ import '@testing-library/jest-dom'; import StorageBrowserActions from './StorageBrowserActions'; import { StorageDirectoryTableData } from '../../../../reactComponents/FileChooser/types'; import { get } from '../../../../api/utils'; +import huePubSub from '../../../../utils/huePubSub'; jest.mock('../../../../api/utils', () => ({ get: jest.fn() })); +jest.mock('../../../../utils/huePubSub', () => ({ + publish: jest.fn() +})); + +const mockLastConfig = { + storage_browser: { + enable_file_download_button: true, + enable_extract_uploaded_archive: true + } +}; +jest.mock('config/hueConfig', () => ({ + getLastKnownConfig: jest.fn(() => mockLastConfig) +})); + const mockGet = get as jest.MockedFunction; + describe('StorageBrowserRowActions', () => { //View summary option is enabled and added to the actions menu when the row data is either hdfs/ofs and a single file - const mockRecord: StorageDirectoryTableData = { - name: 'test', - size: '0\u00a0bytes', - user: 'demo', - group: 'demo', - permission: 'drwxr-xr-x', - mtime: 'May 12, 2024 10:37 PM', - type: '', - path: '', - replication: 0 - }; const mockTwoRecords: StorageDirectoryTableData[] = [ { - name: 'test', - size: '0\u00a0bytes', + name: 'test.txt', + size: '0 Bytes', user: 'demo', group: 'demo', permission: 'drwxr-xr-x', mtime: 'May 12, 2024 10:37 PM', type: 'file', - path: '', + path: '/path/to/folder/test.txt', replication: 0 }, { name: 'testFolder', - size: '0\u00a0bytes', + size: '0 Bytes', user: 'demo', group: 'demo', permission: 'drwxr-xr-x', mtime: 'May 12, 2024 10:37 PM', type: 'dir', - path: '', + path: '/path/to/folder/testFolder', replication: 0 } ]; + const mockRecord: StorageDirectoryTableData = mockTwoRecords[0]; + const setLoadingFiles = jest.fn(); const onSuccessfulAction = jest.fn(); @@ -74,17 +82,21 @@ describe('StorageBrowserRowActions', () => { recordType?: string ) => { const user = userEvent.setup(); - if (recordPath) { - records[0].path = recordPath; - } - if (recordType) { - records[0].type = recordType; - } + const selectedFiles = + records.length === 1 + ? [ + { + ...records[0], + path: recordPath ?? records[0].path, + type: recordType ?? records[0].type + } + ] + : records; const { getByRole } = render( ); @@ -117,123 +129,231 @@ describe('StorageBrowserRowActions', () => { replication: 3 } }; - test('does not render view summary option when there are multiple records selected', async () => { + + it('should not render view summary option when there are multiple records selected', async () => { await setUpActionMenu(mockTwoRecords); expect(screen.queryByRole('menuitem', { name: 'View Summary' })).toBeNull(); }); - test('renders view summary option when record is a hdfs file', async () => { + it('should render view summary option when record is a hdfs file', async () => { await setUpActionMenu([mockRecord], '/user/demo/test', 'file'); expect(screen.queryByRole('menuitem', { name: 'View Summary' })).not.toBeNull(); }); - test('renders view summary option when record is a ofs file', async () => { + it('should render view summary option when record is a ofs file', async () => { await setUpActionMenu([mockRecord], 'ofs://demo/test', 'file'); expect(screen.queryByRole('menuitem', { name: 'View Summary' })).not.toBeNull(); }); - test('does not render view summary option when record is a hdfs folder', async () => { + it('should not render view summary option when record is a hdfs folder', async () => { await setUpActionMenu([mockRecord], '/user/demo/test', 'dir'); expect(screen.queryByRole('menuitem', { name: 'View Summary' })).toBeNull(); }); - test('does not render view summary option when record is a an abfs file', async () => { + it('should not render view summary option when record is a an abfs file', async () => { await setUpActionMenu([mockRecord], 'abfs://demo/test', 'file'); expect(screen.queryByRole('menuitem', { name: 'View Summary' })).toBeNull(); }); - - test('renders summary modal when view summary option is clicked', async () => { - const user = userEvent.setup(); - await setUpActionMenu([mockRecord], '/user/demo/test', 'file'); - await user.click(screen.queryByRole('menuitem', { name: 'View Summary' })!); - expect(await screen.findByText('Summary for /user/demo/test')).toBeInTheDocument(); - }); }); describe('Rename option', () => { - test('does not render rename option when there are multiple records selected', async () => { + it('should not render rename option when there are multiple records selected', async () => { await setUpActionMenu(mockTwoRecords); expect(screen.queryByRole('menuitem', { name: 'Rename' })).toBeNull(); }); - test('does not render rename option when selected record is a abfs root folder', async () => { + it('should not render rename option when selected record is a abfs root folder', async () => { await setUpActionMenu([mockRecord], 'abfs://', 'dir'); expect(screen.queryByRole('menuitem', { name: 'Rename' })).toBeNull(); }); - test('does not render rename option when selected record is a gs root folder', async () => { + it('should not render rename option when selected record is a gs root folder', async () => { await setUpActionMenu([mockRecord], 'gs://', 'dir'); expect(screen.queryByRole('menuitem', { name: 'Rename' })).toBeNull(); }); - test('does not render rename option when selected record is a s3 root folder', async () => { + it('should not render rename option when selected record is a s3 root folder', async () => { await setUpActionMenu([mockRecord], 's3a://', 'dir'); expect(screen.queryByRole('menuitem', { name: 'Rename' })).toBeNull(); }); - test('does not render rename option when selected record is a ofs root folder', async () => { + it('should not render rename option when selected record is a ofs root folder', async () => { await setUpActionMenu([mockRecord], 'ofs://', 'dir'); expect(screen.queryByRole('menuitem', { name: 'Rename' })).toBeNull(); }); - test('does not render rename option when selected record is a ofs service ID folder', async () => { + it('should not render rename option when selected record is a ofs service ID folder', async () => { await setUpActionMenu([mockRecord], 'ofs://serviceID', 'dir'); expect(screen.queryByRole('menuitem', { name: 'Rename' })).toBeNull(); }); - test('does not render rename option when selected record is a ofs volume folder', async () => { + it('should not render rename option when selected record is a ofs volume folder', async () => { await setUpActionMenu([mockRecord], 'ofs://serviceID/volume', 'dir'); expect(screen.queryByRole('menuitem', { name: 'Rename' })).toBeNull(); }); - test('renders rename option when selected record is a file or a folder', async () => { + it('should render rename option when selected record is a file or a folder', async () => { await setUpActionMenu([mockRecord], 'abfs://test', 'dir'); expect(screen.queryByRole('menuitem', { name: 'Rename' })).not.toBeNull(); }); - - test('renders rename modal when rename option is clicked', async () => { - const user = userEvent.setup(); - await setUpActionMenu([mockRecord], 'abfs://test', 'dir'); - await user.click(screen.getByRole('menuitem', { name: 'Rename' })); - expect(await screen.findByText('Enter new name')).toBeInTheDocument(); - }); }); describe('Set replication option', () => { - test('does not render set replication option when there are multiple records selected', async () => { + it('should not render set replication option when there are multiple records selected', async () => { await setUpActionMenu(mockTwoRecords); expect(screen.queryByRole('menuitem', { name: 'Set Replication' })).toBeNull(); }); - test('renders set replication option when selected record is a hdfs file', async () => { + it('should render set replication option when selected record is a hdfs file', async () => { await setUpActionMenu([mockRecord], 'hdfs://test', 'file'); expect(screen.queryByRole('menuitem', { name: 'Set Replication' })).not.toBeNull(); }); - test('does not render set replication option when selected record is a hdfs folder', async () => { + it('should not render set replication option when selected record is a hdfs folder', async () => { await setUpActionMenu([mockRecord], 'hdfs://', 'dir'); expect(screen.queryByRole('menuitem', { name: 'Set Replication' })).toBeNull(); }); - test('does not render set replication option when selected record is a gs file/folder', async () => { + it('should not render set replication option when selected record is a gs file/folder', async () => { await setUpActionMenu([mockRecord], 'gs://', 'dir'); expect(screen.queryByRole('menuitem', { name: 'Set Replication' })).toBeNull(); }); - test('does not render set replication option when selected record is a s3 file/folder', async () => { + it('should not render set replication option when selected record is a s3 file/folder', async () => { await setUpActionMenu([mockRecord], 's3a://', 'dir'); expect(screen.queryByRole('menuitem', { name: 'Set Replication' })).toBeNull(); }); - test('does not render set replication option when selected record is a ofs file/folder', async () => { + it('should not render set replication option when selected record is a ofs file/folder', async () => { await setUpActionMenu([mockRecord], 'ofs://', 'dir'); expect(screen.queryByRole('menuitem', { name: 'Set Replication' })).toBeNull(); }); + }); + + describe('Delete option', () => { + it('should render delete option for multiple selected records', async () => { + await setUpActionMenu(mockTwoRecords, mockRecord.path); + expect(screen.queryByRole('menuitem', { name: 'Delete' })).not.toBeNull(); + }); + + it('should render delete option for a single selected file/folder', async () => { + await setUpActionMenu([mockRecord], '/user/demo/test', 'file'); + expect(screen.queryByRole('menuitem', { name: 'Delete' })).not.toBeNull(); + }); + }); + + describe('Download option', () => { + it('should not render download option for multiple selected records', async () => { + await setUpActionMenu(mockTwoRecords); + expect(screen.queryByRole('menuitem', { name: 'Download' })).toBeNull(); + }); + + it('should render download option for a single selected file', async () => { + await setUpActionMenu([mockRecord], '/user/demo/test', 'file'); + expect(screen.queryByRole('menuitem', { name: 'Download' })).not.toBeNull(); + }); + + it('should not render download option for a folder', async () => { + const mockFolder = { ...mockRecord, type: 'dir' }; + await setUpActionMenu([mockFolder], '/user/demo/test', 'dir'); + expect(screen.queryByRole('menuitem', { name: 'Download' })).toBeNull(); + }); + + it('should not render download option when enable_file_download_button is false', async () => { + mockLastConfig.storage_browser.enable_file_download_button = false; + await setUpActionMenu([mockRecord], '/user/demo/test', 'file'); + expect(screen.queryByRole('menuitem', { name: 'Download' })).toBeNull(); + mockLastConfig.storage_browser.enable_file_download_button = true; + }); - test('renders set replication modal when set replication option is clicked', async () => { + it('should trigger file download when download option is clicked for a file', async () => { const user = userEvent.setup(); - await setUpActionMenu([mockRecord], 'hdfs://test', 'file'); - await user.click(screen.getByRole('menuitem', { name: 'Set Replication' })); - expect(await screen.findByText(/Setting Replication factor/i)).toBeInTheDocument(); + await setUpActionMenu([mockRecord], mockRecord.path, 'file'); + await user.click(screen.getByRole('menuitem', { name: 'Download' })); + expect(huePubSub.publish).toHaveBeenCalled(); + }); + }); + + describe('Copy Action', () => { + it('should render copy option when record is a file', async () => { + await setUpActionMenu([mockRecord], '/user/demo/test', 'file'); + expect(screen.queryByRole('menuitem', { name: 'Copy' })).not.toBeNull(); + }); + + it('should render copy option when record is a folder', async () => { + await setUpActionMenu([mockRecord], '/user/demo/test', 'dir'); + expect(screen.queryByRole('menuitem', { name: 'Copy' })).not.toBeNull(); + }); + + it('should render copy option when multiple records are selected', async () => { + await setUpActionMenu(mockTwoRecords); + expect(screen.queryByRole('menuitem', { name: 'Copy' })).not.toBeNull(); + }); + }); + + describe('Move Action', () => { + it('should render move option when record is a file', async () => { + await setUpActionMenu([mockRecord], '/user/demo/test', 'file'); + expect(screen.queryByRole('menuitem', { name: 'Move' })).not.toBeNull(); + }); + + it('should render move option when record is a folder', async () => { + await setUpActionMenu([mockRecord], '/user/demo/test', 'dir'); + expect(screen.queryByRole('menuitem', { name: 'Move' })).not.toBeNull(); + }); + + it('should render move option when multiple records are selected', async () => { + await setUpActionMenu(mockTwoRecords); + expect(screen.queryByRole('menuitem', { name: 'Move' })).not.toBeNull(); + }); + }); + + describe('Compress Action', () => { + it('should render compress option when record is a hdfs file', async () => { + await setUpActionMenu([mockRecord], mockRecord.path, mockRecord.type); + expect(screen.queryByRole('menuitem', { name: 'Compress' })).not.toBeNull(); + }); + + it('should render compress option when multiple records are hdfs file', async () => { + await setUpActionMenu(mockTwoRecords); + expect(screen.queryByRole('menuitem', { name: 'Compress' })).not.toBeNull(); + }); + + it('should render compress option when record is not hdfs file', async () => { + await setUpActionMenu([mockRecord], 's3a://', 'dir'); + expect(screen.queryByRole('menuitem', { name: 'Compress' })).toBeNull(); + }); + + it('should not render compress option when enable_extract_uploaded_archive is false', async () => { + mockLastConfig.storage_browser.enable_extract_uploaded_archive = false; + await setUpActionMenu(mockTwoRecords); + expect(screen.queryByRole('menuitem', { name: 'Compress' })).toBeNull(); + mockLastConfig.storage_browser.enable_extract_uploaded_archive = true; + }); + }); + + describe('Extract Action', () => { + it('should render extract option when record is a compressed file', async () => { + mockRecord.path = '/user/demo/test.zip'; + await setUpActionMenu([mockRecord], '/user/demo/test.zip', 'file'); + expect(screen.queryByRole('menuitem', { name: 'Extract' })).not.toBeNull(); + }); + + it('should not render extract option when multiple records are files', async () => { + await setUpActionMenu(mockTwoRecords); + expect(screen.queryByRole('menuitem', { name: 'Extract' })).toBeNull(); + }); + + it('should not render extract option when file type is not supported', async () => { + mockRecord.path = '/user/demo/test.zip1'; + await setUpActionMenu([mockRecord], '/user/demo/test.zip1', 'file'); + expect(screen.queryByRole('menuitem', { name: 'Extract' })).toBeNull(); + }); + + it('should not render extract option when enable_extract_uploaded_archive is false', async () => { + mockLastConfig.storage_browser.enable_extract_uploaded_archive = false; + await setUpActionMenu([mockRecord], '/user/demo/test.zip', 'file'); + expect(screen.queryByRole('menuitem', { name: 'Extract' })).toBeNull(); + mockLastConfig.storage_browser.enable_extract_uploaded_archive = true; }); }); }); diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.tsx index fc0b11027ea..2daf8bf7fbd 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.tsx +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.tsx @@ -27,23 +27,36 @@ 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 ExpandIcon from '@cloudera/cuix-core/icons/react/ExpandViewIcon'; +import DownloadIcon from '@cloudera/cuix-core/icons/react/DownloadIcon'; +import GroupsIcon from '@cloudera/cuix-core/icons/react/GroupsIcon'; import { i18nReact } from '../../../../utils/i18nReact'; import huePubSub from '../../../../utils/huePubSub'; import './StorageBrowserActions.scss'; import { FileStats, + ListDirectory, StorageDirectoryTableData } from '../../../../reactComponents/FileChooser/types'; import { ActionType, getEnabledActions } from './StorageBrowserActions.util'; -import MoveCopyAction from './MoveCopy/MoveCopy'; -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'; +import MoveCopyModal from './MoveCopyModal/MoveCopyModal'; +import RenameModal from './RenameModal/RenameModal'; +import ReplicationModal from './ReplicationModal/ReplicationModal'; +import SummaryModal from './SummaryModal/SummaryModal'; +import DeletionModal from './DeletionModal/DeletionModal'; +import CompressionModal from './CompressionModal/CompressionModal'; +import ExtractionModal from './ExtractionModal/ExtractionModal'; +import { DOWNLOAD_API_URL } from '../../../../reactComponents/FileChooser/api'; +import ChangeOwnerAndGroupModal from './ChangeOwnerAndGroupModal/ChangeOwnerAndGroupModal'; interface StorageBrowserRowActionsProps { + // TODO: move relevant keys to hue_config + superUser?: ListDirectory['superuser']; + superGroup?: ListDirectory['supergroup']; + users?: ListDirectory['users']; + groups?: ListDirectory['groups']; + isFsSuperUser?: ListDirectory['is_fs_superuser']; isTrashEnabled?: boolean; currentPath: FileStats['path']; selectedFiles: StorageDirectoryTableData[]; @@ -58,10 +71,18 @@ const iconsMap: Record = { [ActionType.Replication]: , [ActionType.Delete]: , [ActionType.Summary]: , - [ActionType.Compress]: + [ActionType.Compress]: , + [ActionType.Extract]: , + [ActionType.Download]: , + [ActionType.ChangeOwnerAndGroup]: }; const StorageBrowserActions = ({ + superUser, + superGroup, + users, + groups, + isFsSuperUser, isTrashEnabled, currentPath, selectedFiles, @@ -76,6 +97,18 @@ const StorageBrowserActions = ({ setSelectedAction(undefined); }; + const downloadFile = () => { + huePubSub.publish('hue.global.info', { message: t('Downloading your file, Please wait...') }); + location.href = `${DOWNLOAD_API_URL}?path=${selectedFiles[0]?.path}`; + }; + + const onActionClick = (actionType: ActionType) => () => { + if (actionType === ActionType.Download) { + return downloadFile(); + } + setSelectedAction(actionType); + }; + const onApiSuccess = () => { setLoadingFiles(false); closeModal(); @@ -88,12 +121,12 @@ const StorageBrowserActions = ({ }; const actionItems: MenuItemType[] = useMemo(() => { - const enabledActions = getEnabledActions(selectedFiles); + const enabledActions = getEnabledActions(selectedFiles, isFsSuperUser); return enabledActions.map(action => ({ key: String(action.type), label: t(action.label), icon: iconsMap[action.type], - onClick: () => setSelectedAction(action.type) + onClick: onActionClick(action.type) })); }, [selectedFiles]); @@ -114,10 +147,10 @@ const StorageBrowserActions = ({ {selectedAction === ActionType.Summary && ( - + )} {selectedAction === ActionType.Rename && ( - )} {selectedAction === ActionType.Replication && ( - )} {(selectedAction === ActionType.Move || selectedAction === ActionType.Copy) && ( - )} {selectedAction === ActionType.Delete && ( - )} {selectedAction === ActionType.Compress && ( - )} + {selectedAction === ActionType.Extract && ( + + )} + {selectedAction === ActionType.ChangeOwnerAndGroup && ( + + )} ); }; diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.util.ts b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.util.ts index d46da3178a2..1f3e1f50c96 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.util.ts +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.util.ts @@ -33,6 +33,7 @@ import { isOFSRoot, inTrash } from '../../../../utils/storageBrowserUtils'; +import { SUPPORTED_COMPRESSED_FILE_EXTENTION } from '../../../../utils/constants/storageBrowser'; export enum ActionType { Copy = 'copy', @@ -41,7 +42,10 @@ export enum ActionType { Rename = 'rename', Replication = 'replication', Delete = 'delete', - Compress = 'compress' + Compress = 'compress', + Extract = 'extract', + Download = 'download', + ChangeOwnerAndGroup = 'changeOwnerAndGroup' } const isValidFileOrFolder = (filePath: string): boolean => { @@ -54,7 +58,12 @@ const isValidFileOrFolder = (filePath: string): boolean => { ); }; +const isFileCompressed = (filePath: string): boolean => { + return SUPPORTED_COMPRESSED_FILE_EXTENTION.some(ext => filePath.endsWith(ext)); +}; + const isActionEnabled = (file: StorageDirectoryTableData, action: ActionType): boolean => { + const config = getLastKnownConfig()?.storage_browser; switch (action) { case ActionType.Summary: return (isHDFS(file.path) || isOFS(file.path)) && file.type === BrowserViewType.file; @@ -65,8 +74,18 @@ const isActionEnabled = (file: StorageDirectoryTableData, action: ActionType): b case ActionType.Delete: case ActionType.Move: return isValidFileOrFolder(file.path); + case ActionType.Extract: + return ( + !!config?.enable_extract_uploaded_archive && + isHDFS(file.path) && + isFileCompressed(file.path) + ); case ActionType.Compress: - return isHDFS(file.path) && isValidFileOrFolder(file.path); + return !!config?.enable_extract_uploaded_archive && isHDFS(file.path); + case ActionType.Download: + return !!config?.enable_file_download_button && file.type === BrowserViewType.file; + case ActionType.ChangeOwnerAndGroup: + return isValidFileOrFolder(file.path); default: return false; } @@ -87,13 +106,13 @@ const isMultipleFileActionEnabled = ( }; export const getEnabledActions = ( - files: StorageDirectoryTableData[] + files: StorageDirectoryTableData[], + isFsSuperUser?: boolean ): { enabled: boolean; type: ActionType; label: string; }[] => { - const config = getLastKnownConfig(); const isAnyFileInTrash = files.some(file => inTrash(file.path)); const isNoFileSelected = files && files.length === 0; if (isAnyFileInTrash || isNoFileSelected) { @@ -133,11 +152,25 @@ export const getEnabledActions = ( label: 'Set Replication' }, { - enabled: - !!config?.storage_browser.enable_extract_uploaded_archive && - isMultipleFileActionEnabled(files, ActionType.Compress), + enabled: isMultipleFileActionEnabled(files, ActionType.Compress), type: ActionType.Compress, label: 'Compress' + }, + { + enabled: isSingleFileActionEnabled(files, 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' } ].filter(e => e.enabled); diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ViewSummary/ViewSummary.scss b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/SummaryModal/SummaryModal.scss similarity index 100% rename from desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ViewSummary/ViewSummary.scss rename to desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/SummaryModal/SummaryModal.scss diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ViewSummary/ViewSummary.test.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/SummaryModal/SummaryModal.test.tsx similarity index 85% rename from desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ViewSummary/ViewSummary.test.tsx rename to desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/SummaryModal/SummaryModal.test.tsx index 273d533a5db..b3d0ff6865c 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ViewSummary/ViewSummary.test.tsx +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/SummaryModal/SummaryModal.test.tsx @@ -20,7 +20,7 @@ import '@testing-library/jest-dom'; import { get } from '../../../../../api/utils'; import formatBytes from '../../../../../utils/formatBytes'; -import ViewSummary from './ViewSummary'; +import SummaryModal from './SummaryModal'; jest.mock('../../../../../api/utils', () => ({ get: jest.fn() @@ -28,7 +28,7 @@ jest.mock('../../../../../api/utils', () => ({ const mockGet = get as jest.MockedFunction; -describe('ViewSummary', () => { +describe('SummaryModal', () => { beforeAll(() => { jest.clearAllMocks(); }); @@ -54,14 +54,16 @@ describe('ViewSummary', () => { }; it('should render path of file in title', async () => { - const { getByText } = render( {}} path="some/path" />); + const { getByText } = render( {}} path="some/path" />); await waitFor(async () => { expect(getByText('Summary for some/path')).toBeInTheDocument(); }); }); it('should render summary content after successful data fetching', async () => { - const { getByText, getAllByText } = render( {}} path="some/path" />); + const { getByText, getAllByText } = render( + {}} path="some/path" /> + ); await waitFor(async () => { expect(getByText('Diskspace Consumed')).toBeInTheDocument(); expect(getAllByText(formatBytes(mockSummary.spaceConsumed))[0]).toBeInTheDocument(); @@ -69,7 +71,7 @@ describe('ViewSummary', () => { }); it('should render space consumed in Bytes after the values are formatted', async () => { - render( {}} />); + render( {}} />); const spaceConsumed = await screen.findAllByText('0 Byte'); await waitFor(() => { expect(spaceConsumed[0]).toBeInTheDocument(); @@ -78,7 +80,7 @@ describe('ViewSummary', () => { it('should call onClose function when close button is clicked', async () => { const mockOnClose = jest.fn(); - const { getByText } = render(); + const { getByText } = render(); const closeButton = getByText('Close'); expect(mockOnClose).not.toHaveBeenCalled(); diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ViewSummary/ViewSummary.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/SummaryModal/SummaryModal.tsx similarity index 92% rename from desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ViewSummary/ViewSummary.tsx rename to desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/SummaryModal/SummaryModal.tsx index 522bb56a6b9..3cdc6e31513 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ViewSummary/ViewSummary.tsx +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/SummaryModal/SummaryModal.tsx @@ -21,22 +21,22 @@ import { Spin } from 'antd'; import huePubSub from '../../../../../utils/huePubSub'; import { i18nReact } from '../../../../../utils/i18nReact'; import formatBytes from '../../../../../utils/formatBytes'; -import useLoadData from '../../../../../utils/hooks/useLoadData'; +import useLoadData from '../../../../../utils/hooks/useLoadData/useLoadData'; import { CONTENT_SUMMARY_API_URL } from '../../../../../reactComponents/FileChooser/api'; import { ContentSummary, StorageDirectoryTableData } from '../../../../../reactComponents/FileChooser/types'; -import './ViewSummary.scss'; +import './SummaryModal.scss'; -interface ViewSummaryProps { +interface SummaryModalProps { path: StorageDirectoryTableData['path']; isOpen?: boolean; onClose: () => void; } -const ViewSummary = ({ isOpen = true, onClose, path }: ViewSummaryProps): JSX.Element => { +const SummaryModal = ({ isOpen = true, onClose, path }: SummaryModalProps): JSX.Element => { const { t } = i18nReact.useTranslation(); const { data: responseSummary, loading } = useLoadData(CONTENT_SUMMARY_API_URL, { @@ -105,4 +105,4 @@ const ViewSummary = ({ isOpen = true, onClose, path }: ViewSummaryProps): JSX.El ); }; -export default ViewSummary; +export default SummaryModal; diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.tsx index 88ebbaa87c4..5d05294ffab 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.tsx +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.tsx @@ -42,7 +42,7 @@ import formatBytes from '../../../utils/formatBytes'; import './StorageDirectoryPage.scss'; import { formatTimestamp } from '../../../utils/dateTimeUtils'; -import useLoadData from '../../../utils/hooks/useLoadData'; +import useLoadData from '../../../utils/hooks/useLoadData/useLoadData'; import { DEFAULT_PAGE_SIZE, DEFAULT_POLLING_TIME, @@ -285,6 +285,11 @@ const StorageDirectoryPage = ({ { +jest.mock('../../../utils/hooks/useLoadData/useLoadData', () => { return jest.fn(() => ({ data: mockData(), loading: false @@ -200,7 +200,7 @@ describe('StorageFilePage', () => { }); const downloadLink = screen.getByRole('link', { name: 'Download' }); - expect(downloadLink).toHaveAttribute('href', `${DOWNLOAD_API_URL}${mockFileStats.path}`); + expect(downloadLink).toHaveAttribute('href', `${DOWNLOAD_API_URL}?path=${mockFileStats.path}`); }); it('should download a file when download button is clicked', async () => { @@ -216,7 +216,7 @@ describe('StorageFilePage', () => { }); const downloadLink = screen.getByRole('link', { name: 'Download' }); - expect(downloadLink).toHaveAttribute('href', `${DOWNLOAD_API_URL}${mockFileStats.path}`); + expect(downloadLink).toHaveAttribute('href', `${DOWNLOAD_API_URL}?path=${mockFileStats.path}`); }); it('should not render the download button when show_download_button is false', () => { diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.tsx index 41147e5f921..41f1b155edd 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.tsx +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.tsx @@ -30,7 +30,7 @@ import { SAVE_FILE_API_URL } from '../../../reactComponents/FileChooser/api'; import huePubSub from '../../../utils/huePubSub'; -import useSaveData from '../../../utils/hooks/useSaveData'; +import useSaveData from '../../../utils/hooks/useSaveData/useSaveData'; import Pagination from '../../../reactComponents/Pagination/Pagination'; import { DEFAULT_PREVIEW_PAGE_SIZE, @@ -38,7 +38,7 @@ import { SUPPORTED_FILE_EXTENSIONS, SupportedFileTypes } from '../../../utils/constants/storageBrowser'; -import useLoadData from '../../../utils/hooks/useLoadData'; +import useLoadData from '../../../utils/hooks/useLoadData/useLoadData'; import { getLastKnownConfig } from '../../../config/hueConfig'; import LoadingErrorWrapper from '../../../reactComponents/LoadingErrorWrapper/LoadingErrorWrapper'; @@ -115,7 +115,8 @@ const StorageFilePage = ({ fileName, fileStats, onReload }: StorageFilePageProps huePubSub.publish('hue.global.info', { message: t('Downloading your file, Please wait...') }); }; - const filePreviewUrl = `${DOWNLOAD_API_URL}${fileStats.path}?disposition=inline`; + const fileDownloadUrl = `${DOWNLOAD_API_URL}?path=${fileStats.path}`; + const filePreviewUrl = `${fileDownloadUrl}&&disposition=inline`; const isEditingEnabled = !isEditing && @@ -192,7 +193,7 @@ const StorageFilePage = ({ fileName, fileStats, onReload }: StorageFilePageProps )} {config?.storage_browser.enable_file_download_button && ( - + { - let id = options.namespace.id; + let id = `${options.namespace.id}_${options.computeName}`; if (options.path) { if (typeof options.path === 'string') { id += '_' + options.path; @@ -327,7 +328,11 @@ export class DataCatalog { return; } - const keyPrefix = generateEntryCacheId({ namespace: namespace, path: pathToClear }); + const keyPrefix = generateEntryCacheId({ + namespace: namespace, + path: pathToClear, + computeName: compute.name + }); Object.keys(this.entries).forEach(key => { if (key.indexOf(keyPrefix) === 0) { delete this.entries[key]; @@ -515,7 +520,13 @@ export class DataCatalog { compute: Compute; path: string | string[]; }): Promise { - return this.entries[generateEntryCacheId(options)]; + return this.entries[ + generateEntryCacheId({ + namespace: options.namespace, + path: options.path, + computeName: options.compute.name + }) + ]; } /** @@ -537,7 +548,8 @@ export class DataCatalog { const sourceIdentifier = generateEntryCacheId({ namespace: options.namespace, - path: [] + path: [], + computeName: options.compute.name }); // Create the source entry if not already present @@ -564,7 +576,8 @@ export class DataCatalog { const existingTemporaryDatabases = await sourceEntry.getChildren(); const databaseIdentifier = generateEntryCacheId({ namespace: options.namespace, - path: [database] + path: [database], + computeName: options.compute.name }); // Create the database entry if not already present @@ -593,7 +606,8 @@ export class DataCatalog { const tableIdentifier = generateEntryCacheId({ namespace: options.namespace, - path: path + path: path, + computeName: options.compute.name }); // Unlink any existing table with the same identifier @@ -677,7 +691,8 @@ export class DataCatalog { const columnIdentifier = generateEntryCacheId({ namespace: options.namespace, - path: columnPath + path: columnPath, + computeName: options.compute.name }); identifiersToClean.push(columnIdentifier); this.temporaryEntries[columnIdentifier] = CancellablePromise.resolve(columnEntry); @@ -697,7 +712,10 @@ export class DataCatalog { } async getEntry(options: GetEntryOptions): Promise { - const identifier = generateEntryCacheId(options); + const identifier = generateEntryCacheId({ + ...options, + computeName: options.compute.name + }); if (options.temporaryOnly) { return this.temporaryEntries[identifier] || $.Deferred().reject().promise(); } @@ -756,7 +774,11 @@ export class DataCatalog { } async getMultiTableEntry(options: GetMultiTableEntryOptions): Promise { - const identifier = generateEntryCacheId(options); + const identifier = generateEntryCacheId({ + namespace: options.namespace, + paths: options.paths, + computeName: options.compute.name + }); if (this.multiTableEntries[identifier]) { return this.multiTableEntries[identifier]; } diff --git a/desktop/core/src/desktop/js/jquery/plugins/jquery.filechooser.js b/desktop/core/src/desktop/js/jquery/plugins/jquery.filechooser.js index 6c7cd9fc156..02e46331bfc 100644 --- a/desktop/core/src/desktop/js/jquery/plugins/jquery.filechooser.js +++ b/desktop/core/src/desktop/js/jquery/plugins/jquery.filechooser.js @@ -807,7 +807,7 @@ let num_of_pending_uploads = 0; function initUploader(path, _parent, el, labels) { let uploader; - if (window.getLastKnownConfig().hue_config.enable_chunked_file_uploader) { + if (window.getLastKnownConfig().storage_browser.enable_chunked_file_upload) { const action = '/filebrowser/upload/chunks/'; const qqTemplate = document.createElement('div'); qqTemplate.id = 'qq-template'; diff --git a/desktop/core/src/desktop/js/ko/components/assist/assistDbNamespace.js b/desktop/core/src/desktop/js/ko/components/assist/assistDbNamespace.js index 447946155d9..8bb0c6662b1 100644 --- a/desktop/core/src/desktop/js/ko/components/assist/assistDbNamespace.js +++ b/desktop/core/src/desktop/js/ko/components/assist/assistDbNamespace.js @@ -268,7 +268,16 @@ class AssistDbNamespace { } }) .catch(() => { - self.hasErrors(true); + const currentComputeIndex = self.namespace?.computes?.findIndex( + namespaceCompute => namespaceCompute.name === self.compute().name + ); + + if (currentComputeIndex < self.namespace.computes.length - 1) { + self.compute(self.namespace.computes[currentComputeIndex + 1]); + self.initDatabases(); + } else { + self.hasErrors(true); + } }) .finally(() => { self.loaded(true); diff --git a/desktop/core/src/desktop/js/ko/components/ko.jobBrowserLinks.js b/desktop/core/src/desktop/js/ko/components/ko.jobBrowserLinks.js index 5eddec6a1aa..85cce8ffb83 100644 --- a/desktop/core/src/desktop/js/ko/components/ko.jobBrowserLinks.js +++ b/desktop/core/src/desktop/js/ko/components/ko.jobBrowserLinks.js @@ -210,8 +210,8 @@ class JobBrowserPanel extends DisposableComponent { success: function (response) { params.onePageViewModel .processHeadersSecure(response) - .done(({ rawHtml, scriptsToLoad }) => { - $('#mini_jobbrowser').html(rawHtml); + .done(({ $rawHtml, scriptsToLoad }) => { + $('#mini_jobbrowser').html($rawHtml); const loadScripts = scriptsToLoad.map(src => params.onePageViewModel.loadScript_nonce(src) ); diff --git a/desktop/core/src/desktop/js/ko/components/ko.pollingCatalogEntriesList.test.js b/desktop/core/src/desktop/js/ko/components/ko.pollingCatalogEntriesList.test.js index 87a2cff47ad..cb47f8250f6 100644 --- a/desktop/core/src/desktop/js/ko/components/ko.pollingCatalogEntriesList.test.js +++ b/desktop/core/src/desktop/js/ko/components/ko.pollingCatalogEntriesList.test.js @@ -29,7 +29,8 @@ describe('ko.pollingCatalogEntriesList.js', () => { const element = await setup.renderComponent(NAME, { sourceType: 'impala', - namespace: { id: 'namespaceId' } + namespace: { id: 'namespaceId' }, + compute: { name: 'sample-compute' } }); expect(element.innerHTML).toMatchSnapshot(); diff --git a/desktop/core/src/desktop/js/reactComponents/FileChooser/FileChooserModal/FileChooserModal.tsx b/desktop/core/src/desktop/js/reactComponents/FileChooser/FileChooserModal/FileChooserModal.tsx index 2e551bcffb1..9f41900f1d9 100644 --- a/desktop/core/src/desktop/js/reactComponents/FileChooser/FileChooserModal/FileChooserModal.tsx +++ b/desktop/core/src/desktop/js/reactComponents/FileChooser/FileChooserModal/FileChooserModal.tsx @@ -26,7 +26,7 @@ import { ApiFileSystem, FILESYSTEMS_API_URL } from '../api'; import { FileSystem } from '../types'; import './FileChooserModal.scss'; import PathBrowser from '../../PathBrowser/PathBrowser'; -import useLoadData from '../../../utils/hooks/useLoadData'; +import useLoadData from '../../../utils/hooks/useLoadData/useLoadData'; interface FileProps { show: boolean; diff --git a/desktop/core/src/desktop/js/reactComponents/FileChooser/api.ts b/desktop/core/src/desktop/js/reactComponents/FileChooser/api.ts index bc424b9ce40..d58d7346fbd 100644 --- a/desktop/core/src/desktop/js/reactComponents/FileChooser/api.ts +++ b/desktop/core/src/desktop/js/reactComponents/FileChooser/api.ts @@ -18,24 +18,26 @@ export const FILESYSTEMS_API_URL = '/api/v1/storage/filesystems'; export const FILE_STATS_API_URL = '/api/v1/storage/stat'; export const LIST_DIRECTORY_API_URL = '/api/v1/storage/list'; export const FILE_PREVIEW_API_URL = '/api/v1/storage/display'; -export const DOWNLOAD_API_URL = '/filebrowser/download='; +export const DOWNLOAD_API_URL = '/api/v1/storage/download'; export const CONTENT_SUMMARY_API_URL = '/api/v1/storage/content_summary'; export const SAVE_FILE_API_URL = '/api/v1/storage/save'; export const UPLOAD_FILE_URL = '/api/v1/storage/upload/file'; -export const CHUNK_UPLOAD_URL = '/filebrowser/upload/chunks/file'; -export const CHUNK_UPLOAD_COMPLETE_URL = '/filebrowser/upload/complete'; -export const CREATE_FILE_API_URL = '/api/v1/storage/create/file/'; -export const CREATE_DIRECTORY_API_URL = '/api/v1/storage/create/directory/'; -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 CHUNK_UPLOAD_URL = '/api/v1/storage/upload/chunks'; +export const CHUNK_UPLOAD_COMPLETE_URL = '/api/v1/storage/upload/complete'; +export const CREATE_FILE_API_URL = '/api/v1/storage/create/file'; +export const CREATE_DIRECTORY_API_URL = '/api/v1/storage/create/directory'; +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/'; -export const BULK_MOVE_API_URL = '/api/v1/storage/move/bulk/'; -export const UPLOAD_AVAILABLE_SPACE_URL = '/api/v1/taskserver/upload/available_space/'; +export const EXTRACT_API_URL = '/api/v1/storage/extract_archive'; +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'; +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 UPLOAD_AVAILABLE_SPACE_URL = '/api/v1/taskserver/upload/available_space'; export interface ApiFileSystem { file_system: string; diff --git a/desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.tsx b/desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.tsx index d3920dcf8fb..6a51062711a 100644 --- a/desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.tsx +++ b/desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.tsx @@ -53,7 +53,8 @@ const sortOrder = [ const FileUploadQueue: React.FC = ({ filesQueue, onClose, onComplete }) => { const config = getLastKnownConfig(); const isChunkUpload = - config?.storage_browser.enable_chunked_file_upload ?? DEFAULT_ENABLE_CHUNK_UPLOAD; + (config?.storage_browser.enable_chunked_file_upload ?? DEFAULT_ENABLE_CHUNK_UPLOAD) && + !!config?.hue_config.enable_task_server; const { t } = i18nReact.useTranslation(); const [isExpanded, setIsExpanded] = useState(true); diff --git a/desktop/core/src/desktop/js/reactComponents/GlobalAlert/GlobalAlert.tsx b/desktop/core/src/desktop/js/reactComponents/GlobalAlert/GlobalAlert.tsx index b8d0bdb5296..265d73e1884 100644 --- a/desktop/core/src/desktop/js/reactComponents/GlobalAlert/GlobalAlert.tsx +++ b/desktop/core/src/desktop/js/reactComponents/GlobalAlert/GlobalAlert.tsx @@ -26,7 +26,7 @@ import { HIDE_GLOBAL_ALERTS_TOPIC } from './events'; import { HueAlert } from './types'; -import { useHuePubSub } from '../../utils/hooks/useHuePubSub'; +import { useHuePubSub } from '../../utils/hooks/useHuePubSub/useHuePubSub'; import { i18nReact } from 'utils/i18nReact'; type alertType = AlertProps['type']; diff --git a/desktop/core/src/desktop/js/reactComponents/Pagination/Pagination.tsx b/desktop/core/src/desktop/js/reactComponents/Pagination/Pagination.tsx index 9d166ede950..ea298b9875f 100644 --- a/desktop/core/src/desktop/js/reactComponents/Pagination/Pagination.tsx +++ b/desktop/core/src/desktop/js/reactComponents/Pagination/Pagination.tsx @@ -58,7 +58,7 @@ const Pagination = ({ label: ( { - setPageSize && setPageSize(option); + setPageSize?.(option); setPageNumber(1); }} className="hue-pagination__page-size-menu-item-btn" diff --git a/desktop/core/src/desktop/js/reactComponents/WelcomeTour/WelcomeTour.tsx b/desktop/core/src/desktop/js/reactComponents/WelcomeTour/WelcomeTour.tsx index 871f1a69405..81469843ef9 100644 --- a/desktop/core/src/desktop/js/reactComponents/WelcomeTour/WelcomeTour.tsx +++ b/desktop/core/src/desktop/js/reactComponents/WelcomeTour/WelcomeTour.tsx @@ -19,7 +19,7 @@ import Joyride from 'react-joyride'; import { hueWindow } from 'types/types'; import I18n from 'utils/i18n'; -import { useHuePubSub } from '../../utils/hooks/useHuePubSub'; +import { useHuePubSub } from '../../utils/hooks/useHuePubSub/useHuePubSub'; import './WelcomeTour.scss'; import scssVariables from './WelcomeTour.module.scss'; diff --git a/desktop/core/src/desktop/js/utils/constants/storageBrowser.ts b/desktop/core/src/desktop/js/utils/constants/storageBrowser.ts index a19428f6e02..2079d6d2982 100644 --- a/desktop/core/src/desktop/js/utils/constants/storageBrowser.ts +++ b/desktop/core/src/desktop/js/utils/constants/storageBrowser.ts @@ -63,3 +63,5 @@ export const SUPPORTED_FILE_EXTENSIONS: Record = { }; export const EDITABLE_FILE_FORMATS = new Set([SupportedFileTypes.TEXT]); + +export const SUPPORTED_COMPRESSED_FILE_EXTENTION = ['zip', 'tar.gz', 'tgz', 'bz2', 'bzip']; diff --git a/desktop/core/src/desktop/js/utils/hooks/useFileUpload/useChunkUpload.ts b/desktop/core/src/desktop/js/utils/hooks/useFileUpload/useChunkUpload.ts index fea271daa75..704c9b576eb 100644 --- a/desktop/core/src/desktop/js/utils/hooks/useFileUpload/useChunkUpload.ts +++ b/desktop/core/src/desktop/js/utils/hooks/useFileUpload/useChunkUpload.ts @@ -16,14 +16,14 @@ import { useEffect, useState } from 'react'; import { getLastKnownConfig } from '../../../config/hueConfig'; -import useSaveData from '../useSaveData'; -import useQueueProcessor from '../useQueueProcessor'; +import useSaveData from '../useSaveData/useSaveData'; +import useQueueProcessor from '../useQueueProcessor/useQueueProcessor'; import { DEFAULT_CHUNK_SIZE, DEFAULT_CONCURRENT_MAX_CONNECTIONS, FileUploadStatus } from '../../constants/storageBrowser'; -import useLoadData from '../useLoadData'; +import useLoadData from '../useLoadData/useLoadData'; import { TaskServerResponse, TaskStatus } from '../../../reactComponents/TaskBrowser/TaskBrowser'; import { createChunks, @@ -57,21 +57,34 @@ const useChunkUpload = ({ }: ChunkUploadOptions): UseUploadQueueResponse => { const config = getLastKnownConfig(); const chunkSize = config?.storage_browser?.file_upload_chunk_size ?? DEFAULT_CHUNK_SIZE; - const [pendingItems, setPendingItems] = useState([]); + const [processingItem, setProcessingItem] = useState(); + const [pendingUploadItems, setPendingUploadItems] = useState([]); + const [awaitingStatusItems, setAwaitingStatusItems] = useState([]); + + const onError = () => { + if (processingItem) { + onStatusUpdate(processingItem, FileUploadStatus.Failed); + setProcessingItem(undefined); + } + }; + + const onSuccess = (item: UploadItem) => () => { + setAwaitingStatusItems(prev => [...prev, item]); + setProcessingItem(undefined); + }; const { save } = useSaveData(undefined, { postOptions: { qsEncodeData: false, - headers: { - 'Content-Type': 'multipart/form-data' - } - } + headers: { 'Content-Type': 'multipart/form-data' } + }, + onError }); const updateItemStatus = (serverResponse: TaskServerResponse[]) => { const statusMap = getStatusHashMap(serverResponse); - const remainingItems = pendingItems.filter(item => { + const remainingItems = awaitingStatusItems.filter(item => { const status = statusMap[item.uuid]; if (status === TaskStatus.Success || status === TaskStatus.Failure) { const ItemStatus = @@ -84,14 +97,14 @@ const useChunkUpload = ({ if (remainingItems.length === 0) { onComplete(); } - setPendingItems(remainingItems); + setAwaitingStatusItems(remainingItems); }; const { data: tasksStatus } = useLoadData( '/desktop/api2/taskserver/get_taskserver_tasks/', { - pollInterval: pendingItems.length ? 5000 : undefined, - skip: !pendingItems.length + pollInterval: awaitingStatusItems.length ? 5000 : undefined, + skip: !awaitingStatusItems.length } ); @@ -101,16 +114,14 @@ const useChunkUpload = ({ } }, [tasksStatus]); - const addItemToPending = (item: UploadItem) => { - setPendingItems(prev => [...prev, item]); - }; - - const onChunksUploadComplete = async (item: UploadItem) => { - const { url, payload } = getChunksCompletePayload(item, chunkSize); - return save(payload, { - url, - onSuccess: () => addItemToPending(item) - }); + const onChunksUploadComplete = async () => { + if (processingItem) { + const { url, payload } = getChunksCompletePayload(processingItem, chunkSize); + return save(payload, { + url, + onSuccess: onSuccess(processingItem) + }); + } }; const uploadChunk = async (chunkItem: UploadChunkItem) => { @@ -118,21 +129,21 @@ const useChunkUpload = ({ return save(payload, { url }); }; - const { enqueueAsync } = useQueueProcessor(uploadChunk, { - concurrentProcess + const { enqueue } = useQueueProcessor(uploadChunk, { + concurrentProcess, + onSuccess: onChunksUploadComplete }); - const uploadItemInChunks = async (item: UploadItem) => { + const uploadItemInChunks = (item: UploadItem) => { const chunks = createChunks(item, chunkSize); - await enqueueAsync(chunks); - return onChunksUploadComplete(item); + return enqueue(chunks); }; - const uploadItemInSingleChunk = (item: UploadItem) => { + const uploadItemInSingleChunk = async (item: UploadItem) => { const { url, payload } = getChunkSinglePayload(item, chunkSize); return save(payload, { url, - onSuccess: () => addItemToPending(item) + onSuccess: onSuccess(item) }); }; @@ -158,15 +169,25 @@ const useChunkUpload = ({ return uploadItemInChunks(item); }; - const { - enqueue: addFiles, - dequeue: removeFile, - isLoading - } = useQueueProcessor(uploadItem, { - concurrentProcess: 1 // This value must be 1 always - }); + const addFiles = (newItems: UploadItem[]) => { + setPendingUploadItems(prev => [...prev, ...newItems]); + }; + + const removeFile = (item: UploadItem) => { + setPendingUploadItems(prev => prev.filter(i => i.uuid !== item.uuid)); + }; + + useEffect(() => { + // Ensures one file is broken down in chunks and uploaded to the server + if (!processingItem && pendingUploadItems.length) { + const item = pendingUploadItems[0]; + setProcessingItem(item); + setPendingUploadItems(prev => prev.slice(1)); + uploadItem(item); + } + }, [pendingUploadItems, processingItem]); - return { addFiles, removeFile, isLoading }; + return { addFiles, removeFile, isLoading: !!processingItem || !!pendingUploadItems.length }; }; export default useChunkUpload; diff --git a/desktop/core/src/desktop/js/utils/hooks/useFileUpload/useRegularUpload.ts b/desktop/core/src/desktop/js/utils/hooks/useFileUpload/useRegularUpload.ts index 0d8438c607f..9741cb3e945 100644 --- a/desktop/core/src/desktop/js/utils/hooks/useFileUpload/useRegularUpload.ts +++ b/desktop/core/src/desktop/js/utils/hooks/useFileUpload/useRegularUpload.ts @@ -14,13 +14,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import useQueueProcessor from '../useQueueProcessor'; +import useQueueProcessor from '../useQueueProcessor/useQueueProcessor'; import { UPLOAD_FILE_URL } from '../../../reactComponents/FileChooser/api'; import { DEFAULT_CONCURRENT_MAX_CONNECTIONS, FileUploadStatus } from '../../constants/storageBrowser'; -import useSaveData from '../useSaveData'; +import useSaveData from '../useSaveData/useSaveData'; import { UploadItem } from './util'; interface UseUploadQueueResponse { diff --git a/desktop/core/src/desktop/js/utils/hooks/useHuePubSub.test.tsx b/desktop/core/src/desktop/js/utils/hooks/useHuePubSub/useHuePubSub.test.tsx similarity index 96% rename from desktop/core/src/desktop/js/utils/hooks/useHuePubSub.test.tsx rename to desktop/core/src/desktop/js/utils/hooks/useHuePubSub/useHuePubSub.test.tsx index 48e148c2a2b..2038e5ac759 100644 --- a/desktop/core/src/desktop/js/utils/hooks/useHuePubSub.test.tsx +++ b/desktop/core/src/desktop/js/utils/hooks/useHuePubSub/useHuePubSub.test.tsx @@ -1,7 +1,7 @@ import { renderHook, act } from '@testing-library/react'; import { useHuePubSub } from './useHuePubSub'; -import huePubSub from '../huePubSub'; -import noop from '../timing/noop'; +import huePubSub from '../../huePubSub'; +import noop from '../../timing/noop'; describe('useHuePubSub', () => { const originalSubscribe = huePubSub.subscribe; diff --git a/desktop/core/src/desktop/js/utils/hooks/useHuePubSub.ts b/desktop/core/src/desktop/js/utils/hooks/useHuePubSub/useHuePubSub.ts similarity index 98% rename from desktop/core/src/desktop/js/utils/hooks/useHuePubSub.ts rename to desktop/core/src/desktop/js/utils/hooks/useHuePubSub/useHuePubSub.ts index 5caf1de17a4..cca161f197a 100644 --- a/desktop/core/src/desktop/js/utils/hooks/useHuePubSub.ts +++ b/desktop/core/src/desktop/js/utils/hooks/useHuePubSub/useHuePubSub.ts @@ -15,7 +15,7 @@ // limitations under the License. import { useState, useEffect } from 'react'; -import huePubSub from '../huePubSub'; +import huePubSub from '../../huePubSub'; // Basic helper hook to let a component subscribe to a huePubSub topic and rerender for each message // by placing the message/info in a state that is automatically updated. diff --git a/desktop/core/src/desktop/js/utils/hooks/useLoadData.test.tsx b/desktop/core/src/desktop/js/utils/hooks/useLoadData/useLoadData.test.tsx similarity index 98% rename from desktop/core/src/desktop/js/utils/hooks/useLoadData.test.tsx rename to desktop/core/src/desktop/js/utils/hooks/useLoadData/useLoadData.test.tsx index c23ba8249f1..cf6ff328108 100644 --- a/desktop/core/src/desktop/js/utils/hooks/useLoadData.test.tsx +++ b/desktop/core/src/desktop/js/utils/hooks/useLoadData/useLoadData.test.tsx @@ -16,10 +16,10 @@ import { renderHook, act, waitFor } from '@testing-library/react'; import useLoadData from './useLoadData'; -import { get } from '../../api/utils'; +import { get } from '../../../api/utils'; // Mock the `get` function -jest.mock('../../api/utils', () => ({ +jest.mock('../../../api/utils', () => ({ get: jest.fn() })); diff --git a/desktop/core/src/desktop/js/utils/hooks/useLoadData.ts b/desktop/core/src/desktop/js/utils/hooks/useLoadData/useLoadData.ts similarity index 98% rename from desktop/core/src/desktop/js/utils/hooks/useLoadData.ts rename to desktop/core/src/desktop/js/utils/hooks/useLoadData/useLoadData.ts index 7e7ad285067..ba4700ba659 100644 --- a/desktop/core/src/desktop/js/utils/hooks/useLoadData.ts +++ b/desktop/core/src/desktop/js/utils/hooks/useLoadData/useLoadData.ts @@ -15,7 +15,7 @@ // limitations under the License. import { useCallback, useEffect, useMemo, useState } from 'react'; -import { ApiFetchOptions, get } from '../../api/utils'; +import { ApiFetchOptions, get } from '../../../api/utils'; import { AxiosError } from 'axios'; export interface Options { diff --git a/desktop/core/src/desktop/js/utils/hooks/useQueueProcessor.test.tsx b/desktop/core/src/desktop/js/utils/hooks/useQueueProcessor/useQueueProcessor.test.tsx similarity index 100% rename from desktop/core/src/desktop/js/utils/hooks/useQueueProcessor.test.tsx rename to desktop/core/src/desktop/js/utils/hooks/useQueueProcessor/useQueueProcessor.test.tsx diff --git a/desktop/core/src/desktop/js/utils/hooks/useQueueProcessor.ts b/desktop/core/src/desktop/js/utils/hooks/useQueueProcessor/useQueueProcessor.ts similarity index 100% rename from desktop/core/src/desktop/js/utils/hooks/useQueueProcessor.ts rename to desktop/core/src/desktop/js/utils/hooks/useQueueProcessor/useQueueProcessor.ts diff --git a/desktop/core/src/desktop/js/utils/hooks/useSaveData.test.tsx b/desktop/core/src/desktop/js/utils/hooks/useSaveData/useSaveData.test.tsx similarity index 98% rename from desktop/core/src/desktop/js/utils/hooks/useSaveData.test.tsx rename to desktop/core/src/desktop/js/utils/hooks/useSaveData/useSaveData.test.tsx index 03fe3d8b34f..aa29938e721 100644 --- a/desktop/core/src/desktop/js/utils/hooks/useSaveData.test.tsx +++ b/desktop/core/src/desktop/js/utils/hooks/useSaveData/useSaveData.test.tsx @@ -15,9 +15,9 @@ // limitations under the License. import { renderHook, act, waitFor } from '@testing-library/react'; import useSaveData from './useSaveData'; -import { post } from '../../api/utils'; +import { post } from '../../../api/utils'; -jest.mock('../../api/utils', () => ({ +jest.mock('../../../api/utils', () => ({ post: jest.fn() })); diff --git a/desktop/core/src/desktop/js/utils/hooks/useSaveData.ts b/desktop/core/src/desktop/js/utils/hooks/useSaveData/useSaveData.ts similarity index 98% rename from desktop/core/src/desktop/js/utils/hooks/useSaveData.ts rename to desktop/core/src/desktop/js/utils/hooks/useSaveData/useSaveData.ts index d0a8e00d2be..c5c07cb9ef4 100644 --- a/desktop/core/src/desktop/js/utils/hooks/useSaveData.ts +++ b/desktop/core/src/desktop/js/utils/hooks/useSaveData/useSaveData.ts @@ -15,7 +15,7 @@ // limitations under the License. import { useCallback, useEffect, useMemo, useState } from 'react'; -import { ApiFetchOptions, post } from '../../api/utils'; +import { ApiFetchOptions, post } from '../../../api/utils'; interface saveOptions { url?: string; diff --git a/desktop/core/src/desktop/log/api.py b/desktop/core/src/desktop/log/api.py index 88683f60d98..c9348e67af5 100644 --- a/desktop/core/src/desktop/log/api.py +++ b/desktop/core/src/desktop/log/api.py @@ -26,6 +26,7 @@ from django.http import HttpResponse from desktop.auth.backend import is_admin +from desktop.lib.conf import coerce_bool from desktop.lib.django_util import JsonResponse from desktop.lib.i18n import smart_str from desktop.log import DEFAULT_LOG_DIR @@ -53,6 +54,7 @@ def decorator(*args, **kwargs): def get_hue_logs(request): """ Retrieves the last X characters of log messages from the log file. + Optionally, the log messages can be returned in reverse order. Args: request: The HTTP request object. @@ -68,11 +70,14 @@ def get_hue_logs(request): Notes: This endpoint retrieves the last X characters of log messages from the log file. The log file is read up to the buffer size, and if it's smaller than the buffer size, - the previous log file is read to complete the buffer. + the previous log file is read to complete the buffer. If the 'reverse' query parameter + is set to True, the log messages are returned in reverse order. """ if not is_admin(request.user): return HttpResponse("You must be a Hue admin to access this endpoint.", status=403) + reverse_logs = coerce_bool(request.GET.get('reverse', False)) + # Buffer size for reading log files LOG_BUFFER_SIZE = 32 * 1024 @@ -98,7 +103,7 @@ def get_hue_logs(request): # Read the previous log file contents buffer = _read_previous_log_file(LOG_BUFFER_SIZE, previous_log_file, prev_log_file_size, log_file_size) + buffer - response = {'hue_hostname': socket.gethostname(), 'logs': ''.join(buffer)} + response = {'hue_hostname': socket.gethostname(), 'logs': buffer[::-1] if reverse_logs else buffer} return JsonResponse(response) diff --git a/desktop/core/src/desktop/log/api_test.py b/desktop/core/src/desktop/log/api_test.py index 4dd4a3c7d67..8c87b3516d6 100644 --- a/desktop/core/src/desktop/log/api_test.py +++ b/desktop/core/src/desktop/log/api_test.py @@ -36,7 +36,7 @@ def setup_method(self): self.user_not_admin = User.objects.get(username="test_not_admin") def test_get_hue_logs_unauthorized(self): - request = Mock(method='GET', user=self.user_not_admin) + request = Mock(method='GET', GET={}, user=self.user_not_admin) response = get_hue_logs(request) res_content = response.content.decode('utf-8') @@ -46,7 +46,7 @@ def test_get_hue_logs_unauthorized(self): def test_log_directory_not_set(self): with patch('desktop.log.api.os.getenv') as os_getenv: - request = Mock(method='GET', user=self.user_admin) + request = Mock(method='GET', GET={}, user=self.user_admin) os_getenv.return_value = None response = get_hue_logs(request) @@ -58,7 +58,7 @@ def test_log_directory_not_set(self): def test_log_file_not_found(self): with patch('desktop.log.api.os.getenv') as os_getenv: with patch('desktop.log.api.os.path.exists') as os_path_exist: - request = Mock(method='GET', user=self.user_admin) + request = Mock(method='GET', GET={}, user=self.user_admin) os_getenv.return_value = '/var/log/hue/' os_path_exist.return_value = False @@ -71,8 +71,8 @@ def test_log_file_not_found(self): def test_get_hue_logs_success(self): with patch('desktop.log.api.os') as mock_os: with patch('desktop.log.api._read_log_file') as _read_log_file: - request = Mock(method='GET', user=self.user_admin) - _read_log_file.return_value = 'test log content' + request = Mock(method='GET', GET={}, user=self.user_admin) + _read_log_file.return_value = ['Line 1: test log content', 'Line 2: more log lines'] mock_os.os_getenv.return_value = '/var/log/hue/' mock_os.path.exists.return_value = True @@ -82,4 +82,20 @@ def test_get_hue_logs_success(self): response_data = json.loads(response.content) assert response.status_code == 200 - assert response_data == {'hue_hostname': socket.gethostname(), 'logs': 'test log content'} + assert response_data == {'hue_hostname': socket.gethostname(), 'logs': ['Line 1: test log content', 'Line 2: more log lines']} + + def test_get_hue_logs_reverse_success(self): + with patch('desktop.log.api.os') as mock_os: + with patch('desktop.log.api._read_log_file') as _read_log_file: + request = Mock(method='GET', GET={'reverse': 'true'}, user=self.user_admin) + _read_log_file.return_value = ['Line 1: test log content', 'Line 2: more log lines'] + + mock_os.os_getenv.return_value = '/var/log/hue/' + mock_os.path.exists.return_value = True + mock_os.path.getsize.return_value = 32 * 1024 * 2 # Greater than log buffer size + + response = get_hue_logs(request) + response_data = json.loads(response.content) + + assert response.status_code == 200 + assert response_data == {'hue_hostname': socket.gethostname(), 'logs': ['Line 2: more log lines', 'Line 1: test log content']} diff --git a/desktop/core/src/desktop/management/commands/sync_warehouses.py b/desktop/core/src/desktop/management/commands/sync_warehouses.py index 76006ce1820..2990ab42392 100644 --- a/desktop/core/src/desktop/management/commands/sync_warehouses.py +++ b/desktop/core/src/desktop/management/commands/sync_warehouses.py @@ -170,12 +170,10 @@ def populate_impala(namespace, impala): catalogd_dep = next((d for d in deployments if d.metadata.labels['app'] == 'catalogd'), None) catalogd_stfs = next((s for s in stfs if s.metadata.labels['app'] == 'catalogd'), None) statestore_dep = next((d for d in deployments if d.metadata.labels['app'] == 'statestored'), None) - admissiond_dep = next((d for d in deployments if d.metadata.labels['app'] == 'admissiond'), None) impala['is_ready'] = bool(((catalogd_dep and catalogd_dep.status.ready_replicas) or ( catalogd_stfs and catalogd_stfs.status.ready_replicas)) - and (statestore_dep and statestore_dep.status.ready_replicas) - and (admissiond_dep and admissiond_dep.status.ready_replicas)) + and (statestore_dep and statestore_dep.status.ready_replicas)) if not impala['is_ready']: LOG.info("Impala %s not ready" % namespace) @@ -187,12 +185,12 @@ def populate_impala(namespace, impala): update_impala_configs(namespace, impala, 'impala-proxy.%s.svc.cluster.local' % namespace) else: coordinator = next((s for s in stfs if s.metadata.labels['app'] == 'coordinator'), None) - impala['is_ready'] = impala['is_ready'] and (coordinator and coordinator.status.ready_replicas) + impala['is_ready'] = bool(impala['is_ready'] and (coordinator and coordinator.status.ready_replicas)) hs2_stfs = next((s for s in stfs if s.metadata.labels['app'] == 'hiveserver2'), None) if hs2_stfs: # Impala is running with UA - impala['is_ready'] = impala['is_ready'] and hs2_stfs.status.ready_replicas + impala['is_ready'] = bool(impala['is_ready'] and hs2_stfs.status.ready_replicas) update_hive_configs(namespace, impala, 'hiveserver2-service.%s.svc.cluster.local' % namespace) else: # Impala is not running with UA diff --git a/desktop/core/src/desktop/static/desktop/js/listdir-inline.js b/desktop/core/src/desktop/static/desktop/js/listdir-inline.js index 59460d47f3c..2a49f2faf8e 100644 --- a/desktop/core/src/desktop/static/desktop/js/listdir-inline.js +++ b/desktop/core/src/desktop/static/desktop/js/listdir-inline.js @@ -383,7 +383,7 @@ var FileBrowserModel = function (files, page, breadcrumbs, currentDirPath) { }); self.isTaskServerEnabled = ko.computed(function() { - return window.getLastKnownConfig().hue_config.enable_chunked_file_uploader && window.getLastKnownConfig().hue_config.enable_task_server; + return window.getLastKnownConfig().storage_browser.enable_chunked_file_upload && window.getLastKnownConfig().hue_config.enable_task_server; }); self.scheme = ko.pureComputed(function () { @@ -1382,7 +1382,7 @@ var FileBrowserModel = function (files, page, breadcrumbs, currentDirPath) { $('.free-space-info').text('- Max file size upload limit: ' + formatBytes(freeSpace)); }, error: function(xhr, status, error) { - huePubSub.publish('hue.global.error', { message: '${ _("Error checking available space: ") }' + error}); + huePubSub.publish('hue.global.error', { message: window.I18n('Error checking available space: ') + error}); $('.free-space-info').text('Error checking available space'); } }); @@ -1393,7 +1393,7 @@ var FileBrowserModel = function (files, page, breadcrumbs, currentDirPath) { var uploader; var scheduleUpload; - if ((window.getLastKnownConfig().hue_config.enable_chunked_file_uploader) && (window.getLastKnownConfig().hue_config.enable_task_server)) { + if ((window.getLastKnownConfig().storage_browser.enable_chunked_file_upload) && (window.getLastKnownConfig().hue_config.enable_task_server)) { self.pendingUploads(0); var action = "/filebrowser/upload/chunks/"; @@ -1459,7 +1459,7 @@ var FileBrowserModel = function (files, page, breadcrumbs, currentDirPath) { self.listItems.push(listItem); if (scheduleUpload && self.pendingUploads() === 0) { $('#uploadFileModal').modal('hide'); - huePubSub.publish('hue.global.info', { message: '${ _("File upload scheduled. Please check the task server page for progress.") }'}); + huePubSub.publish('hue.global.info', { message: window.I18n('File upload scheduled. Please check the task server page for progress.') }); } // Add a delay of 2 seconds before calling pollForTaskProgress, to ensure the upload task is received by the task_server before checking its status. setTimeout(function() { @@ -1489,10 +1489,10 @@ var FileBrowserModel = function (files, page, breadcrumbs, currentDirPath) { // Update the free space display $('.free-space-info').text('- Max file size upload limit: ' + formatBytes(freeSpace)); if ((file.size > freeSpace) || (file.size > window.MAX_FILE_SIZE_UPLOAD_LIMIT)) { - huePubSub.publish('hue.global.error', { message: '${ _("Not enough space available to upload this file.") }'}); + huePubSub.publish('hue.global.error', { message: window.I18n('Not enough space available to upload this file.') }); deferred.failure(); // Reject the promise to cancel the upload } else if (file.size > window.MAX_FILE_SIZE_UPLOAD_LIMIT) { - huePubSub.publish('hue.global.error', { message: '${ _("File size is bigger than MAX_FILE_SIZE_UPLOAD_LIMIT.") }'}); + huePubSub.publish('hue.global.error', { message: window.I18n('File size is bigger than MAX_FILE_SIZE_UPLOAD_LIMIT.') }); deferred.failure(); // Reject the promise to cancel the upload } else { var newPath = "/filebrowser/upload/chunks/file?dest=" + encodeURIComponent(self.currentPath().normalize('NFC')); @@ -1502,7 +1502,7 @@ var FileBrowserModel = function (files, page, breadcrumbs, currentDirPath) { } }, error: function(xhr, status, error) { - huePubSub.publish('hue.global.error', { message: '${ _("Error checking available space: ") }' + error}); + huePubSub.publish('hue.global.error', { message: window.I18n('Error checking available space: ') + error}); deferred.failure(); // Reject the promise to cancel the upload } }); @@ -1517,7 +1517,7 @@ var FileBrowserModel = function (files, page, breadcrumbs, currentDirPath) { }); } // Chunked Fileuploader without Taskserver - else if ((window.getLastKnownConfig().hue_config.enable_chunked_file_uploader) && !(window.getLastKnownConfig().hue_config.enable_task_server)) { + else if ((window.getLastKnownConfig().storage_browser.enable_chunked_file_upload) && !(window.getLastKnownConfig().hue_config.enable_task_server)) { self.pendingUploads(0); var action = "/filebrowser/upload/chunks/"; uploader = new qq.FileUploader({ diff --git a/desktop/core/src/desktop/templates/djangosaml2/example_post_binding_form.html b/desktop/core/src/desktop/templates/djangosaml2/example_post_binding_form.html deleted file mode 100644 index 433ba744e3d..00000000000 --- a/desktop/core/src/desktop/templates/djangosaml2/example_post_binding_form.html +++ /dev/null @@ -1,15 +0,0 @@ - -

-You're being redirected to a SSO login page. -Please click the button below if you're not redirected automatically within a few seconds. -

-
- {% for key, value in params.items %} - - {% endfor %} - -
diff --git a/desktop/core/src/desktop/templates/djangosaml2/post_binding_form.html b/desktop/core/src/desktop/templates/djangosaml2/post_binding_form.html new file mode 100644 index 00000000000..0243515a94d --- /dev/null +++ b/desktop/core/src/desktop/templates/djangosaml2/post_binding_form.html @@ -0,0 +1,14 @@ + + +
+ {% for key, value in params.items %} + + {% endfor %} + +
\ No newline at end of file diff --git a/desktop/libs/notebook/src/notebook/templates/editor_components.mako b/desktop/libs/notebook/src/notebook/templates/editor_components.mako index 2d47139ec1b..e0d119c9cf6 100644 --- a/desktop/libs/notebook/src/notebook/templates/editor_components.mako +++ b/desktop/libs/notebook/src/notebook/templates/editor_components.mako @@ -285,13 +285,15 @@ else: -
+ - + diff --git a/package-lock.json b/package-lock.json index e671f74bb6a..7ee211640f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,6 +125,7 @@ "sass-loader": "11.1.1", "snarkdown": "2.0.0", "source-map-loader": "5.0.0", + "strip-sourcemap-loader": "0.0.1", "style-loader": "2.0.0", "styled-components": "6.0.8", "stylelint": "16.7.0", @@ -13650,9 +13651,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -17025,6 +17026,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-sourcemap-loader": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/strip-sourcemap-loader/-/strip-sourcemap-loader-0.0.1.tgz", + "integrity": "sha512-IrVeMZNYS//7jKzCVF4U6keLyOe/6JYLtjyvCNyteKxXwWQ+MrwNGT42eJQll+pChxgE3K34iLddz4rWG6e8Ow==", + "dev": true + }, "node_modules/style-loader": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz", diff --git a/package.json b/package.json index 762e5e3ff45..6048e825a1c 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "sass-loader": "11.1.1", "snarkdown": "2.0.0", "source-map-loader": "5.0.0", + "strip-sourcemap-loader": "0.0.1", "style-loader": "2.0.0", "styled-components": "6.0.8", "stylelint": "16.7.0", diff --git a/webpack.config.js b/webpack.config.js index 6c7eea62d8b..b79ceeb6c10 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -64,6 +64,14 @@ const config = { enforce: 'pre', use: ['source-map-loader'] }, + // Remove hardcoded references to source map files in third party mjs-files + // since those files will be missing in our builds. + { + test: /\.mjs$/, + include: /node_modules/, + enforce: 'post', + loader: 'strip-sourcemap-loader' + }, { test: /\.scss$/, exclude: /node_modules/,