diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.scss b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.scss index 3fe3b82198d..f14edb0f951 100644 --- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.scss +++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.scss @@ -24,6 +24,7 @@ $icon-margin: 5px; display: flex; flex-direction: column; flex: 1; + margin-bottom: vars.$cdl-spacing-xl; &__actions-bar { display: flex; diff --git a/desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.scss b/desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.scss index 00b5af3ff3f..8b43638a858 100644 --- a/desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.scss +++ b/desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.scss @@ -39,7 +39,7 @@ &__button-group { display: flex; - gap: 8px; + gap: vars.$cdl-spacing-xxs; button { color: vars.$fluidx-black; @@ -47,13 +47,21 @@ } } + .hue-upload-queue-container__actions { + background-color: vars.$fluidx-white; + text-align: right; + padding: vars.$cdl-spacing-xxs vars.$cdl-spacing-l; + border-bottom: 1px solid vars.$fluidx-gray-300; + } + .hue-upload-queue-container__list { display: flex; flex-direction: column; + min-height: 85px; max-height: 40vh; background-color: vars.$fluidx-white; overflow: auto; - padding: 16px; + padding: vars.$cdl-spacing-s; gap: 16px; } } @@ -64,22 +72,22 @@ &__body { display: flex; flex-direction: column; - gap: 8px; + gap: vars.$cdl-spacing-xxs; } } .conflict-files__container { - margin-top: 8px; + margin-top: vars.$cdl-spacing-xxs; overflow-y: auto; max-height: calc(50vh - 100px); border-top: 1px solid vars.$fluidx-gray-400; - padding-top: 8px; + padding-top: vars.$cdl-spacing-xxs; } .conflict-files__item { display: flex; align-items: center; - gap: 8px; + gap: vars.$cdl-spacing-xxs; } .conflict-files__icon { diff --git a/desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.test.tsx b/desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.test.tsx index 8374f944813..e580be9868a 100644 --- a/desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.test.tsx +++ b/desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.test.tsx @@ -22,11 +22,11 @@ import { FileStatus, RegularFile } from '../../utils/hooks/useFileUpload/types'; import { act } from 'react-dom/test-utils'; import huePubSub from '../../utils/huePubSub'; import { FILE_UPLOAD_START_EVENT } from './event'; +import { CancellablePromise } from '../../api/cancellablePromise'; +import useFileUpload from '../../utils/hooks/useFileUpload/useFileUpload'; +import * as apiUtils from '../../api/utils'; -jest.mock('../../api/utils', () => ({ - __esModule: true, - get: jest.fn(() => Promise.reject({ response: { status: 404 } })) -})); +const mockGet = jest.spyOn(apiUtils, 'get'); const mockFilesQueue: RegularFile[] = [ { @@ -43,26 +43,85 @@ const mockFilesQueue: RegularFile[] = [ } ]; +const mockCancelFile = jest.fn(); +const mockAddFiles = jest.fn(); +const mockRemoveAllFiles = jest.fn(); + jest.mock('../../utils/hooks/useFileUpload/useFileUpload', () => ({ __esModule: true, default: jest.fn(() => ({ uploadQueue: mockFilesQueue, - cancelFile: jest.fn(), - addFiles: jest.fn(() => {}), + cancelFile: mockCancelFile, + addFiles: mockAddFiles, + removeAllFiles: mockRemoveAllFiles, isLoading: false })) })); +const mockUseFileUpload = jest.mocked(useFileUpload); + describe('FileUploadQueue', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGet.mockRejectedValue({ response: { status: 404 } }); + + huePubSub.removeAll(FILE_UPLOAD_START_EVENT); + huePubSub.removeAll('set.current.app.name'); + huePubSub.removeAll('get.current.app.name'); + + // Set up the get.current.app.name subscription to return storagebrowser by default + huePubSub.subscribe('get.current.app.name', (callback: (appName: string) => void) => { + callback('storagebrowser'); + }); + + mockUseFileUpload.mockReturnValue({ + uploadQueue: mockFilesQueue, + cancelFile: mockCancelFile, + addFiles: mockAddFiles, + removeAllFiles: mockRemoveAllFiles, + isLoading: false + }); + }); + + afterEach(() => { + huePubSub.removeAll('get.current.app.name'); + }); + + it('should not render when current app is not storagebrowser', async () => { + huePubSub.removeAll('get.current.app.name'); + huePubSub.subscribe('get.current.app.name', (callback: (appName: string) => void) => { + callback('editor'); + }); + + const { container } = render(); + + act(() => { + huePubSub.publish(FILE_UPLOAD_START_EVENT, { files: mockFilesQueue }); + }); + + await waitFor(() => { + expect(container.firstChild).toBeNull(); + }); + }); + + it('should render when current app is storagebrowser', async () => { + const { container } = render(); + await waitFor(() => { + expect(container.firstChild).not.toBeNull(); + }); + }); + it('should render the component with initial files in the queue', async () => { - const { findByText } = render(); + const { getByText } = render(); act(() => { huePubSub.publish(FILE_UPLOAD_START_EVENT, { files: mockFilesQueue }); }); - expect(await findByText('file1.txt')).toBeInTheDocument(); - expect(await findByText('file2.txt')).toBeInTheDocument(); + await waitFor(() => { + expect(getByText('file1.txt')).toBeVisible(); + expect(getByText('file2.txt')).toBeVisible(); + }); }); it('should toggle the visibility of the queue when the header is clicked', async () => { @@ -88,4 +147,285 @@ describe('FileUploadQueue', () => { await waitFor(() => expect(getByText('file1.txt')).toBeVisible()); await waitFor(() => expect(getByText('file2.txt')).toBeVisible()); }); + + it('should show conflict modal when files already exist', async () => { + const files: RegularFile[] = [ + { + uuid: 'c1', + filePath: '/dir', + status: FileStatus.Pending, + file: new File([], 'conflict.txt') + }, + { + uuid: 'n1', + filePath: '/dir', + status: FileStatus.Pending, + file: new File([], 'new.txt') + } + ]; + + mockGet.mockImplementation((_url: string, data?: unknown) => { + const params = data as { path: string }; + return CancellablePromise.resolve({ path: params.path }); + }); + + const { getByText } = render(); + + act(() => { + huePubSub.publish(FILE_UPLOAD_START_EVENT, { files }); + }); + + await waitFor(() => { + expect(mockGet).toHaveBeenCalled(); + expect(getByText('Detected Filename Conflicts')).toBeVisible(); + expect( + getByText('2 files you are trying to upload already exist in the uploaded files.') + ).toBeVisible(); + expect(getByText('conflict.txt')).toBeVisible(); + }); + }); + + it('should allow canceling conflict resolution', async () => { + const files: RegularFile[] = [ + { + uuid: 'c1', + filePath: '/dir', + status: FileStatus.Pending, + file: new File([], 'conflict.txt') + } + ]; + + mockGet.mockImplementation((_url: string, data?: unknown) => { + const params = data as { path: string }; + return CancellablePromise.resolve({ path: params.path }); + }); + + const { getByText, queryByText } = render(); + + act(() => { + huePubSub.publish(FILE_UPLOAD_START_EVENT, { files }); + }); + + await waitFor(() => { + expect(mockGet).toHaveBeenCalled(); + expect(getByText('Detected Filename Conflicts')).toBeVisible(); + expect(getByText('conflict.txt')).toBeVisible(); + }); + + fireEvent.click(getByText('Cancel')); + + await waitFor(() => { + expect(queryByText('Detected Filename Conflicts')).toBeNull(); + expect(mockAddFiles).toHaveBeenCalledWith([]); + }); + }); + + it('should allow skipping upload for conflicted files', async () => { + const files: RegularFile[] = [ + { + uuid: 'c1', + filePath: '/dir', + status: FileStatus.Pending, + file: new File([], 'conflict.txt') + } + ]; + + mockGet.mockImplementation(() => { + return CancellablePromise.resolve({ path: '/dir/conflict.txt' }); + }); + + const { getByText, queryByText } = render(); + + act(() => { + huePubSub.publish(FILE_UPLOAD_START_EVENT, { files }); + }); + + await waitFor(() => { + expect(mockGet).toHaveBeenCalled(); + expect(getByText('Detected Filename Conflicts')).toBeVisible(); + expect(getByText('conflict.txt')).toBeVisible(); + }); + + fireEvent.click(getByText('Skip Upload')); + + await waitFor(() => { + expect(queryByText('Detected Filename Conflicts')).toBeNull(); + expect(mockAddFiles).toHaveBeenCalledWith([]); + }); + }); + + it('should allow individual file cancellation', async () => { + const { getAllByTestId } = render(); + + act(() => { + huePubSub.publish(FILE_UPLOAD_START_EVENT, { files: mockFilesQueue }); + }); + + await waitFor(() => { + const closeButtons = getAllByTestId('hue-upload-queue-row__close-icon'); + expect(closeButtons).toHaveLength(2); + fireEvent.click(closeButtons[0]); + expect(mockCancelFile).toHaveBeenCalledWith(mockFilesQueue[0]); + }); + }); + + it('should show cancel all button when files are in pending or uploading state', async () => { + mockUseFileUpload.mockReturnValue({ + uploadQueue: mockFilesQueue, + cancelFile: mockCancelFile, + addFiles: mockAddFiles, + removeAllFiles: mockRemoveAllFiles, + isLoading: true + }); + + const { getByText } = render(); + + await waitFor(() => { + expect(getByText('Cancel All')).toBeVisible(); + }); + + fireEvent.click(getByText('Cancel All')); + expect(mockRemoveAllFiles).toHaveBeenCalled(); + }); + + it('should sort files by status priority', async () => { + const mixedStatusFiles: RegularFile[] = [ + { + uuid: '1', + filePath: '/path/to/uploaded.txt', + status: FileStatus.Uploaded, + file: new File([], 'uploaded.txt') + }, + { + uuid: '2', + filePath: '/path/to/uploading.txt', + status: FileStatus.Uploading, + file: new File([], 'uploading.txt') + }, + { + uuid: '3', + filePath: '/path/to/failed.txt', + status: FileStatus.Failed, + file: new File([], 'failed.txt') + }, + { + uuid: '4', + filePath: '/path/to/pending.txt', + status: FileStatus.Pending, + file: new File([], 'pending.txt') + } + ]; + + mockUseFileUpload.mockReturnValue({ + uploadQueue: mixedStatusFiles, + cancelFile: mockCancelFile, + addFiles: mockAddFiles, + removeAllFiles: mockRemoveAllFiles, + isLoading: true + }); + + const { container } = render(); + + await waitFor(() => { + const fileRows = container.querySelectorAll('.hue-upload-queue-row__name'); + const fileNames = Array.from(fileRows).map(row => row.textContent); + + expect(fileNames).toEqual(['uploading.txt', 'failed.txt', 'pending.txt', 'uploaded.txt']); + }); + }); + + it('should display uploading progress for files in progress', async () => { + const uploadingFile: RegularFile = { + uuid: '1', + filePath: '/path/to/uploading.txt', + status: FileStatus.Uploading, + progress: 65, + file: new File([], 'uploading.txt') + }; + + mockUseFileUpload.mockReturnValue({ + uploadQueue: [uploadingFile], + cancelFile: mockCancelFile, + addFiles: mockAddFiles, + removeAllFiles: mockRemoveAllFiles, + isLoading: false + }); + + const { getByTestId } = render(); + + await waitFor(() => { + const progressBar = getByTestId('hue-upload-queue-row__progressbar'); + expect(progressBar).toHaveStyle('width: 65%'); + }); + }); + + it('should show preparing to upload state during conflict checking', async () => { + const files: RegularFile[] = [ + { + uuid: 'p1', + filePath: '/dir', + status: FileStatus.Pending, + file: new File([], 'pending.txt') + } + ]; + + let resolveFn: (value: unknown) => void; + const pending = new CancellablePromise((resolve, _reject, onCancel) => { + resolveFn = resolve; + onCancel(() => {}); + }); + + mockGet.mockImplementation(() => pending); + + const { getByText, queryByText } = render(); + + act(() => { + huePubSub.publish(FILE_UPLOAD_START_EVENT, { files }); + }); + + await waitFor(() => { + expect(getByText('Preparing to upload...')).toBeVisible(); + }); + + act(() => { + resolveFn({ path: undefined }); + }); + + await waitFor(() => { + expect(queryByText('Preparing to upload...')).toBeNull(); + }); + }); + + it('should display correct header text for different upload states', async () => { + // Test pending files + const { getByText, rerender } = render(); + + act(() => { + huePubSub.publish(FILE_UPLOAD_START_EVENT, { files: mockFilesQueue }); + }); + + await waitFor(() => { + expect(getByText('2 files remaining')).toBeVisible(); + }); + + // Test completed files + const completedFiles: RegularFile[] = mockFilesQueue.map(file => ({ + ...file, + status: FileStatus.Uploaded + })); + + mockUseFileUpload.mockReturnValue({ + uploadQueue: completedFiles, + cancelFile: mockCancelFile, + addFiles: mockAddFiles, + removeAllFiles: mockRemoveAllFiles, + isLoading: false + }); + + rerender(); + + await waitFor(() => { + expect(getByText('2 files uploaded')).toBeVisible(); + }); + }); }); diff --git a/desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.tsx b/desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.tsx index fc7d5381596..de313636291 100644 --- a/desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.tsx +++ b/desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.tsx @@ -14,13 +14,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import FileIcon from '@cloudera/cuix-core/icons/react/DocumentationIcon'; import CloseIcon from '@cloudera/cuix-core/icons/react/CloseIcon'; import CaratDownIcon from '@cloudera/cuix-core/icons/react/CaratDownIcon'; -import { BorderlessButton } from 'cuix/dist/components/Button'; +import { BorderlessButton, LinkButton } from 'cuix/dist/components/Button'; import CaratUpIcon from '@cloudera/cuix-core/icons/react/CaratUpIcon'; import Modal from 'cuix/dist/components/Modal'; +import { TFunction } from 'i18next'; + import { i18nReact } from '../../utils/i18nReact'; import { RegularFile, FileStatus } from '../../utils/hooks/useFileUpload/types'; import useFileUpload from '../../utils/hooks/useFileUpload/useFileUpload'; @@ -29,14 +31,18 @@ import { get } from '../../api/utils'; import { getLastKnownConfig } from '../../config/hueConfig'; import FileUploadRow from './FileUploadRow/FileUploadRow'; import { useHuePubSub } from '../../utils/hooks/useHuePubSub/useHuePubSub'; +import { useCurrentApp } from '../../utils/hooks/useCurrentApp/useCurrentApp'; import huePubSub from '../../utils/huePubSub'; import { FILE_UPLOAD_START_EVENT, FILE_UPLOAD_SUCCESS_EVENT } from './event'; import { FILE_STATS_API_URL } from '../../apps/storageBrowser/api'; +import LoadingErrorWrapper from '../LoadingErrorWrapper/LoadingErrorWrapper'; + import './FileUploadQueue.scss'; interface FileUploadEvent { files: RegularFile[]; } + const sortOrder = [ FileStatus.Uploading, FileStatus.Failed, @@ -47,10 +53,48 @@ const sortOrder = [ acc[status] = index + 1; return acc; }, {}); + +const getCountByStatus = (uploadQueue: RegularFile[]) => { + return uploadQueue.reduce( + (acc, item) => { + if (item.status === FileStatus.Uploaded) { + acc.uploaded++; + } else if (item.status === FileStatus.Pending || item.status === FileStatus.Uploading) { + acc.pending++; + } else if (item.status === FileStatus.Failed) { + acc.failed++; + } + return acc; + }, + { uploaded: 0, pending: 0, failed: 0 } + ); +}; + +const getHeaderText = (t: TFunction, uploadQueue: RegularFile[], isCheckingConflicts: boolean) => { + if (isCheckingConflicts) { + return t('Preparing to upload...'); + } + + const { failed, pending } = getCountByStatus(uploadQueue); + const fileText = uploadQueue.length > 1 ? t('files') : t('file'); + const failedText = failed > 0 ? `, ${failed} ${t('failed')}` : ''; + + if (pending > 0) { + const pendingText = `${pending} ${fileText} ${t('remaining')}`; + return `${pendingText}${failedText}`; + } + + const uploadedText = `${uploadQueue.length} ${fileText} ${t('uploaded')}`; + return `${uploadedText}${failedText}`; +}; + const FileUploadQueue = (): JSX.Element => { const { t } = i18nReact.useTranslation(); + const config = getLastKnownConfig(); + const { isApp } = useCurrentApp(); + const [expandQueue, setExpandQueue] = useState(true); - const [isVisible, setIsVisible] = useState(false); + const [isCheckingConflicts, setIsCheckingConflicts] = useState(false); const [conflictingFiles, setConflictingFiles] = useState([]); // TODO: Need to change this function with a new endpoint once available. @@ -74,45 +118,15 @@ const FileUploadQueue = (): JSX.Element => { huePubSub.publish(FILE_UPLOAD_SUCCESS_EVENT); }; - const config = getLastKnownConfig(); const isChunkUpload = (config?.storage_browser.enable_chunked_file_upload ?? DEFAULT_ENABLE_CHUNK_UPLOAD) && !!config?.hue_config.enable_task_server; - const { uploadQueue, cancelFile, addFiles } = useFileUpload({ + const { uploadQueue, cancelFile, addFiles, removeAllFiles, isLoading } = useFileUpload({ isChunkUpload, onComplete }); - useEffect(() => { - if (uploadQueue.length > 0) { - setIsVisible(true); - } - }, [uploadQueue.length]); - - useHuePubSub({ - topic: FILE_UPLOAD_START_EVENT, - callback: async (data?: FileUploadEvent) => { - const newFiles = data?.files ?? []; - if (newFiles.length === 0) { - huePubSub.publish('hue.global.error', { message: 'Something went wrong!' }); - return; - } - if (newFiles.length > 0) { - const { conflicts, nonConflictingFiles } = await detectFileConflicts(newFiles, uploadQueue); - if (conflicts.length > 0) { - setConflictingFiles(conflicts); - } else { - setConflictingFiles([]); - } - if (nonConflictingFiles.length > 0) { - addFiles(nonConflictingFiles); - setIsVisible(true); - } - } - } - }); - const detectFileConflicts = async ( newFiles: RegularFile[], uploadQueue: RegularFile[] @@ -153,11 +167,31 @@ const FileUploadQueue = (): JSX.Element => { return result; }; - const onClose = () => { - uploadQueue.forEach(file => cancelFile(file)); - setIsVisible(false); + const handleFileUploadStart = async (data?: FileUploadEvent) => { + const newFiles = data?.files ?? []; + if (newFiles.length === 0) { + huePubSub.publish('hue.global.error', { message: t('No new files to upload!') }); + return; + } + setIsCheckingConflicts(true); + try { + const { conflicts, nonConflictingFiles } = await detectFileConflicts(newFiles, uploadQueue); + setConflictingFiles(conflicts); + addFiles(nonConflictingFiles); + } catch (error) { + huePubSub.publish('hue.global.error', { + message: t('Failed to check for file conflicts. Please try again.') + }); + } finally { + setIsCheckingConflicts(false); + } }; + useHuePubSub({ + topic: FILE_UPLOAD_START_EVENT, + callback: handleFileUploadStart + }); + const handleConflictResolution = (overwrite: boolean) => { if (overwrite) { const updatedFilesWithOverwriteFlag = conflictingFiles.map(file => ({ @@ -167,28 +201,16 @@ const FileUploadQueue = (): JSX.Element => { addFiles(updatedFilesWithOverwriteFlag); } setConflictingFiles([]); - setIsVisible(true); }; - const uploadedCount = uploadQueue.filter(item => item.status === FileStatus.Uploaded).length; - const pendingCount = uploadQueue.filter( - item => item.status === FileStatus.Pending || item.status === FileStatus.Uploading - ).length; - const failedCount = uploadQueue.filter(item => item.status === FileStatus.Failed).length; - if (!isVisible && conflictingFiles.length === 0) { + // Only render in storage browser app + if ( + (!uploadQueue.length && !conflictingFiles.length && !isCheckingConflicts) || + !isApp('storagebrowser') + ) { return <>; } - const getHeaderText = () => { - const fileText = uploadQueue.length > 1 ? 'files' : 'file'; - const uploadedText = `{{uploadedCount}} ${fileText} uploaded`; - const pendingText = pendingCount > 0 ? `{{pendingCount}} ${fileText} remaining` : ''; - const failedText = failedCount > 0 ? `, {{failedCount}} failed` : ''; - if (pendingCount > 0) { - return `${pendingText}${failedText}`; - } - return `${uploadedText}${failedText}`; - }; return ( <> {conflictingFiles.length > 0 && ( @@ -223,9 +245,10 @@ const FileUploadQueue = (): JSX.Element => { )} +
- {t(getHeaderText(), { pendingCount, uploadedCount, failedCount })} + {getHeaderText(t, uploadQueue, isCheckingConflicts)}
setExpandQueue(!expandQueue)} @@ -233,24 +256,37 @@ const FileUploadQueue = (): JSX.Element => { aria-label={t('Toggle upload queue')} data-testid="hue-upload-queue-container__expand-button" /> - } /> + } + hidden={isLoading || isCheckingConflicts} + />
+ {expandQueue && ( -
- {uploadQueue - .sort((a, b) => sortOrder[a.status] - sortOrder[b.status]) - .map((row: RegularFile, index: number) => ( - cancelFile(row)} - /> - ))} -
+ <> + +
+ + {uploadQueue + .sort((a, b) => sortOrder[a.status] - sortOrder[b.status]) + .map((row: RegularFile, index: number) => ( + cancelFile(row)} + /> + ))} + +
+ )}
); }; + export default FileUploadQueue; diff --git a/desktop/core/src/desktop/js/utils/hooks/useCurrentApp/useCurrentApp.test.tsx b/desktop/core/src/desktop/js/utils/hooks/useCurrentApp/useCurrentApp.test.tsx new file mode 100644 index 00000000000..cb6515622f7 --- /dev/null +++ b/desktop/core/src/desktop/js/utils/hooks/useCurrentApp/useCurrentApp.test.tsx @@ -0,0 +1,85 @@ +// 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 { renderHook, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import huePubSub from '../../huePubSub'; +import { useCurrentApp } from './useCurrentApp'; + +describe('useCurrentApp', () => { + beforeEach(() => { + huePubSub.removeAll('set.current.app.name'); + huePubSub.removeAll('set.current.app.name'); + }); + + afterEach(() => { + huePubSub.removeAll('set.current.app.name'); + huePubSub.removeAll('set.current.app.name'); + }); + + it('should return undefined initially when no app is set', () => { + const { result } = renderHook(useCurrentApp); + + expect(result.current.currentApp).toBeUndefined(); + expect(result.current.isApp('storagebrowser')).toBe(false); + }); + + it('should update when app name is published', () => { + const { result } = renderHook(useCurrentApp); + + act(() => { + huePubSub.publish('set.current.app.name', 'storagebrowser'); + }); + + expect(result.current.currentApp).toBe('storagebrowser'); + expect(result.current.isApp('storagebrowser')).toBe(true); + expect(result.current.isApp('editor')).toBe(false); + }); + + it('should handle app name changes', () => { + const { result } = renderHook(useCurrentApp); + + act(() => { + huePubSub.publish('set.current.app.name', 'editor'); + }); + + expect(result.current.currentApp).toBe('editor'); + expect(result.current.isApp('editor')).toBe(true); + + act(() => { + huePubSub.publish('set.current.app.name', 'dashboard'); + }); + + expect(result.current.currentApp).toBe('dashboard'); + expect(result.current.isApp('dashboard')).toBe(true); + expect(result.current.isApp('editor')).toBe(false); + }); + + it('should request current app name on mount', () => { + const subscription = huePubSub.subscribe( + 'get.current.app.name', + (callback: (appName: string) => void) => { + callback('metastore'); + } + ); + + const { result } = renderHook(useCurrentApp); + + expect(result.current.currentApp).toBe('metastore'); + + subscription.remove(); + }); +}); diff --git a/desktop/core/src/desktop/js/utils/hooks/useCurrentApp/useCurrentApp.ts b/desktop/core/src/desktop/js/utils/hooks/useCurrentApp/useCurrentApp.ts new file mode 100644 index 00000000000..c08695f3615 --- /dev/null +++ b/desktop/core/src/desktop/js/utils/hooks/useCurrentApp/useCurrentApp.ts @@ -0,0 +1,51 @@ +// 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 { useState, useEffect } from 'react'; +import { useHuePubSub } from '../useHuePubSub/useHuePubSub'; +import huePubSub from '../../huePubSub'; +import { HueAppName } from '../../appUtils'; + +interface UseCurrentAppReturnType { + currentApp: HueAppName | undefined; + isApp: (targetApp: HueAppName) => boolean; +} + +export const useCurrentApp = (): UseCurrentAppReturnType => { + const [currentApp, setCurrentApp] = useState(undefined); + + useHuePubSub({ + topic: 'set.current.app.name', + callback: (appName: HueAppName) => setCurrentApp(appName) + }); + + useEffect(() => { + huePubSub.publish('get.current.app.name', (appName: HueAppName) => { + setCurrentApp(appName); + }); + }, []); + + const isApp = (targetApp: HueAppName): boolean => { + return currentApp === targetApp; + }; + + return { + currentApp, + isApp + }; +}; + +export default useCurrentApp; diff --git a/desktop/core/src/desktop/js/utils/hooks/useFileUpload/useFileUpload.test.tsx b/desktop/core/src/desktop/js/utils/hooks/useFileUpload/useFileUpload.test.tsx index e60327254e5..ce6fe1c369b 100644 --- a/desktop/core/src/desktop/js/utils/hooks/useFileUpload/useFileUpload.test.tsx +++ b/desktop/core/src/desktop/js/utils/hooks/useFileUpload/useFileUpload.test.tsx @@ -229,4 +229,64 @@ describe('useFileUpload', () => { expect(result.current.uploadQueue[1].status).toBe(FileStatus.Failed); expect(result.current.uploadQueue[1].error).toBe(testError); }); + + it('should call onComplete when all files are processed', () => { + let capturedOnComplete: () => void; + + mockUseRegularUpload.mockImplementation(options => { + capturedOnComplete = options.onComplete; + return { + addFiles: mockAddRegularFiles, + cancelFile: mockCancelRegularFile, + isLoading: false + }; + }); + + renderHook(() => useFileUpload({ isChunkUpload: false, onComplete: mockOnComplete })); + + act(() => { + capturedOnComplete(); + }); + + expect(mockOnComplete).toHaveBeenCalled(); + }); + + it('should remove all files from the queue', () => { + const { result } = renderHook(() => + useFileUpload({ isChunkUpload: false, onComplete: mockOnComplete }) + ); + + act(() => { + result.current.addFiles(mockFiles); + }); + + expect(result.current.uploadQueue.length).toBe(2); + + act(() => { + result.current.removeAllFiles(); + }); + + expect(result.current.uploadQueue.length).toBe(0); + expect(mockCancelRegularFile).toHaveBeenCalledWith('file-1'); + expect(mockCancelRegularFile).toHaveBeenCalledWith('file-2'); + }); + + it('should not cancel a file that is not pending', () => { + const { result } = renderHook(() => + useFileUpload({ isChunkUpload: false, onComplete: mockOnComplete }) + ); + + const nonPendingFile = { ...mockFiles[0], status: FileStatus.Uploading }; + + act(() => { + result.current.addFiles([nonPendingFile]); + }); + + act(() => { + result.current.cancelFile(nonPendingFile); + }); + + expect(mockCancelRegularFile).not.toHaveBeenCalled(); + expect(result.current.uploadQueue[0].status).toBe(FileStatus.Uploading); + }); }); diff --git a/desktop/core/src/desktop/js/utils/hooks/useFileUpload/useFileUpload.ts b/desktop/core/src/desktop/js/utils/hooks/useFileUpload/useFileUpload.ts index a612b14e30f..714e01d7252 100644 --- a/desktop/core/src/desktop/js/utils/hooks/useFileUpload/useFileUpload.ts +++ b/desktop/core/src/desktop/js/utils/hooks/useFileUpload/useFileUpload.ts @@ -27,6 +27,7 @@ interface UseUploadQueueResponse { addFiles: (newFiles: RegularFile[]) => void; cancelFile: (item: RegularFile) => void; isLoading: boolean; + removeAllFiles: () => void; } interface UploadQueueOptions { @@ -99,6 +100,17 @@ const useFileUpload = ({ } }; + const removeAllFiles = () => { + uploadQueue.forEach(file => { + if (isChunkUpload) { + cancelFromChunkUpload(file.uuid); + } else { + cancelFromRegularUpload(file.uuid); + } + }); + setUploadQueue([]); + }; + const addFiles = (fileItems: RegularFile[]) => { if (fileItems.length > 0) { setUploadQueue(prev => [...prev, ...fileItems]); @@ -111,7 +123,13 @@ const useFileUpload = ({ } }; - return { uploadQueue, cancelFile, addFiles, isLoading: isChunkLoading || isRegularLoading }; + return { + uploadQueue, + cancelFile, + addFiles, + removeAllFiles, + isLoading: isChunkLoading || isRegularLoading + }; }; export default useFileUpload;