// Licensed to Cloudera, Inc. under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. Cloudera, Inc. licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import React, { useState } from 'react'; import 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, 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'; import { DEFAULT_ENABLE_CHUNK_UPLOAD } from '../../utils/constants/storageBrowser'; 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, FileStatus.Pending, FileStatus.Cancelled, FileStatus.Uploaded ].reduce((acc: Record, status: FileStatus, index: number) => { 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 [isCheckingConflicts, setIsCheckingConflicts] = useState(false); const [conflictingFiles, setConflictingFiles] = useState([]); // TODO: Need to change this function with a new endpoint once available. const checkFileExists = async (filePath: string): Promise => { try { const response = await get<{ path: string }>( FILE_STATS_API_URL, { path: filePath }, { silenceErrors: true } ); return response?.path === filePath; } catch (error) { if (error.response?.status === 404) { return false; } return false; } }; const onComplete = () => { huePubSub.publish(FILE_UPLOAD_SUCCESS_EVENT); }; const isChunkUpload = (config?.storage_browser.enable_chunked_file_upload ?? DEFAULT_ENABLE_CHUNK_UPLOAD) && !!config?.hue_config.enable_task_server; const { uploadQueue, cancelFile, addFiles, removeAllFiles, isLoading } = useFileUpload({ isChunkUpload, onComplete }); const detectFileConflicts = async ( newFiles: RegularFile[], uploadQueue: RegularFile[] ): Promise<{ conflicts: RegularFile[]; nonConflictingFiles: RegularFile[] }> => { const inProgressFileIdentifiers = new Set( uploadQueue .filter(file => file.status === FileStatus.Uploading || file.status === FileStatus.Pending) .map(file => `${file.filePath}/${file.file.name}`) ); const result = await newFiles.reduce< Promise<{ conflicts: RegularFile[]; nonConflictingFiles: RegularFile[] }> >( async (accPromise, newFile) => { const acc = await accPromise; const fullFilePath = `${newFile.filePath}/${newFile.file.name}`; if (inProgressFileIdentifiers.has(fullFilePath)) { return acc; } const exists = await checkFileExists(fullFilePath); if (exists) { return { ...acc, conflicts: [...acc.conflicts, newFile] }; } else { return { ...acc, nonConflictingFiles: [...acc.nonConflictingFiles, newFile] }; } }, Promise.resolve({ conflicts: [], nonConflictingFiles: [] }) ); return result; }; 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 => ({ ...file, overwrite: true })); addFiles(updatedFilesWithOverwriteFlag); } setConflictingFiles([]); }; // Only render in storage browser app if ( (!uploadQueue.length && !conflictingFiles.length && !isCheckingConflicts) || !isApp('storagebrowser') ) { return <>; } return ( <> {conflictingFiles.length > 0 && ( handleConflictResolution(true)} cancelText={t('Cancel')} onCancel={() => setConflictingFiles([])} secondaryButtonText={t('Skip Upload')} onSecondary={() => handleConflictResolution(false)} className="hue-conflict-detect-modal" > {(() => { const count = conflictingFiles.length; const noun = count === 1 ? 'file' : 'files'; const verb = count === 1 ? 'exists' : 'exist'; return t( `${count} ${noun} you are trying to upload already ${verb} in the uploaded files.` ); })()}
{conflictingFiles.map(file => (
{file.file.name}
))}
)}
{getHeaderText(t, uploadQueue, isCheckingConflicts)}
setExpandQueue(!expandQueue)} icon={expandQueue ? : } 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)} /> ))}
)}
); }; export default FileUploadQueue;