Răsfoiți Sursa

[ui-core] fix: clears FileUploadQueue when user closes the Queue dialog (#4228)

Ram Prasad Agarwal 2 luni în urmă
părinte
comite
b5577aafbf

+ 1 - 0
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;

+ 14 - 6
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 {

+ 349 - 9
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(<FileUploadQueue />);
+
+    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(<FileUploadQueue />);
+    await waitFor(() => {
+      expect(container.firstChild).not.toBeNull();
+    });
+  });
+
   it('should render the component with initial files in the queue', async () => {
-    const { findByText } = render(<FileUploadQueue />);
+    const { getByText } = render(<FileUploadQueue />);
 
     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(<FileUploadQueue />);
+
+    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(<FileUploadQueue />);
+
+    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(<FileUploadQueue />);
+
+    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(<FileUploadQueue />);
+
+    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(<FileUploadQueue />);
+
+    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(<FileUploadQueue />);
+
+    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(<FileUploadQueue />);
+
+    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<unknown>((resolve, _reject, onCancel) => {
+      resolveFn = resolve;
+      onCancel(() => {});
+    });
+
+    mockGet.mockImplementation(() => pending);
+
+    const { getByText, queryByText } = render(<FileUploadQueue />);
+
+    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(<FileUploadQueue />);
+
+    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(<FileUploadQueue />);
+
+    await waitFor(() => {
+      expect(getByText('2 files uploaded')).toBeVisible();
+    });
+  });
 });

+ 103 - 67
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<boolean>(true);
-  const [isVisible, setIsVisible] = useState<boolean>(false);
+  const [isCheckingConflicts, setIsCheckingConflicts] = useState<boolean>(false);
   const [conflictingFiles, setConflictingFiles] = useState<RegularFile[]>([]);
 
   // 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<FileUploadEvent>({
-    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<FileUploadEvent>({
+    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 => {
           </div>
         </Modal>
       )}
+
       <div className="hue-upload-queue-container antd cuix">
         <div className="hue-upload-queue-container__header">
-          {t(getHeaderText(), { pendingCount, uploadedCount, failedCount })}
+          {getHeaderText(t, uploadQueue, isCheckingConflicts)}
           <div className="hue-upload-queue-container__header__button-group">
             <BorderlessButton
               onClick={() => setExpandQueue(!expandQueue)}
@@ -233,24 +256,37 @@ const FileUploadQueue = (): JSX.Element => {
               aria-label={t('Toggle upload queue')}
               data-testid="hue-upload-queue-container__expand-button"
             />
-            <BorderlessButton onClick={onClose} icon={<CloseIcon />} />
+            <BorderlessButton
+              onClick={removeAllFiles}
+              icon={<CloseIcon />}
+              hidden={isLoading || isCheckingConflicts}
+            />
           </div>
         </div>
+
         {expandQueue && (
-          <div className="hue-upload-queue-container__list">
-            {uploadQueue
-              .sort((a, b) => sortOrder[a.status] - sortOrder[b.status])
-              .map((row: RegularFile, index: number) => (
-                <FileUploadRow
-                  key={`${row.filePath}__${row.file.name}__${index}`}
-                  data={row}
-                  onCancel={() => cancelFile(row)}
-                />
-              ))}
-          </div>
+          <>
+            <div className="hue-upload-queue-container__actions" hidden={!isLoading}>
+              <LinkButton onClick={removeAllFiles}>{t('Cancel All')}</LinkButton>
+            </div>
+            <div className="hue-upload-queue-container__list">
+              <LoadingErrorWrapper loading={isCheckingConflicts}>
+                {uploadQueue
+                  .sort((a, b) => sortOrder[a.status] - sortOrder[b.status])
+                  .map((row: RegularFile, index: number) => (
+                    <FileUploadRow
+                      key={`${row.filePath}__${row.file.name}__${index}`}
+                      data={row}
+                      onCancel={() => cancelFile(row)}
+                    />
+                  ))}
+              </LoadingErrorWrapper>
+            </div>
+          </>
         )}
       </div>
     </>
   );
 };
+
 export default FileUploadQueue;

+ 85 - 0
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();
+  });
+});

+ 51 - 0
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<HueAppName | undefined>(undefined);
+
+  useHuePubSub<HueAppName>({
+    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;

+ 60 - 0
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);
+  });
 });

+ 19 - 1
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;