diff --git a/src/app/drive/components/DriveExplorer/DriveExplorer.tsx b/src/app/drive/components/DriveExplorer/DriveExplorer.tsx index 7f6e9b840..77018f1af 100644 --- a/src/app/drive/components/DriveExplorer/DriveExplorer.tsx +++ b/src/app/drive/components/DriveExplorer/DriveExplorer.tsx @@ -16,10 +16,10 @@ import DriveExplorerList from './DriveExplorerList/DriveExplorerList'; import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; import { useHotkeys } from 'react-hotkeys-hook'; -import moveItemsToTrash from 'use_cases/trash/move-items-to-trash'; +import moveItemsToTrash from '../../../../use_cases/trash/move-items-to-trash'; import { Role } from '@internxt/sdk/dist/drive/share/types'; -import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; +import workspacesSelectors from '../../../store/slices/workspaces/workspaces.selectors'; import { t } from 'i18next'; import BannerWrapper from '../../../banners/BannerWrapper'; import deviceService from '../../../core/services/device.service'; @@ -68,9 +68,11 @@ import WarningMessageWrapper from '../WarningMessage/WarningMessageWrapper'; import './DriveExplorer.scss'; import { DriveTopBarItems } from './DriveTopBarItems'; import DriveTopBarActions from './components/DriveTopBarActions'; -import { getAncestorsAndSetNamePath } from 'app/store/slices/storage/storage.thunks/goToFolderThunk'; +import { getAncestorsAndSetNamePath } from '../../../store/slices/storage/storage.thunks/goToFolderThunk'; import { IRoot } from '../../../store/slices/storage/types'; -import { useTrashPagination } from 'app/drive/hooks/trash/useTrashPagination'; +import { useTrashPagination } from '../../../drive/hooks/trash/useTrashPagination'; +import { WorkspaceData } from '@internxt/sdk/dist/workspaces'; +import { uploadFoldersWithManager } from '../../../network/UploadFolderManager'; export const UPLOAD_ITEMS_LIMIT = 3000; @@ -99,6 +101,7 @@ interface DriveExplorerProps { namePath: FolderPath[]; dispatch: AppDispatch; workspace: Workspace; + selectedWorkspace: WorkspaceData | null; planLimit: number; planUsage: number; isOver: boolean; @@ -143,11 +146,11 @@ const DriveExplorer = (props: DriveExplorerProps): JSX.Element => { user, getTrashPaginated, roles, + selectedWorkspace, } = props; const [isOpen, setIsOpen] = useState(false); const dispatch = useAppDispatch(); const { translate } = useTranslationContext(); - const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); const menuItemsRef = useRef(null); const menuContextItemsRef = useRef(null); @@ -904,9 +907,13 @@ const uploadItems = async (props: DriveExplorerProps, rootList: IRoot[], files: onSuccess: onDragAndDropEnd, }, })); - dispatch(storageThunks.uploadMultipleFolderThunkNoCheck(folderDataToUpload)).then(() => { - dispatch(fetchSortedFolderContentThunk(currentFolderId)); + + await uploadFoldersWithManager({ + payload: folderDataToUpload, + selectedWorkspace: props.selectedWorkspace, + dispatch, }); + dispatch(fetchSortedFolderContentThunk(currentFolderId)); } } } else { @@ -952,6 +959,7 @@ const dropTargetCollect: DropTargetCollector< export default connect((state: RootState) => { const currentFolderId: string = storageSelectors.currentFolderId(state); + const selectedWorkspace = workspacesSelectors.getSelectedWorkspace(state); const hasMoreFolders = state.storage.hasMoreDriveFolders[currentFolderId] ?? true; const hasMoreFiles = state.storage.hasMoreDriveFiles[currentFolderId] ?? true; @@ -968,6 +976,7 @@ export default connect((state: RootState) => { viewMode: state.storage.viewMode, namePath: state.storage.namePath, workspace: state.session.workspace, + selectedWorkspace: selectedWorkspace, planLimit: planSelectors.planLimitToShow(state), planUsage: planSelectors.planUsageToShow(state), folderOnTrashLength: state.storage.folderOnTrashLength, diff --git a/src/app/drive/components/DriveExplorer/DriveExplorerItem/hooks/useDriveItemDragAndDrop.tsx b/src/app/drive/components/DriveExplorer/DriveExplorerItem/hooks/useDriveItemDragAndDrop.tsx index 78677f861..70d45033f 100644 --- a/src/app/drive/components/DriveExplorer/DriveExplorerItem/hooks/useDriveItemDragAndDrop.tsx +++ b/src/app/drive/components/DriveExplorer/DriveExplorerItem/hooks/useDriveItemDragAndDrop.tsx @@ -11,6 +11,8 @@ import { handleRepeatedUploadingFolders, } from '../../../../../store/slices/storage/storage.thunks/renameItemsThunk'; import { DriveItemData } from '../../../../types'; +import { uploadFoldersWithManager } from '../../../../../network/UploadFolderManager'; +import workspacesSelectors from '../../../../../store/slices/workspaces/workspaces.selectors'; interface DragSourceCollectorProps { isDraggingThisItem: boolean; @@ -49,6 +51,7 @@ export const useDriveItemDrop = (item: DriveItemData): DriveItemDrop => { const isSomeItemSelected = useAppSelector(storageSelectors.isSomeItemSelected); const { selectedItems } = useAppSelector((state) => state.storage); const namePath = useAppSelector((state) => state.storage.namePath); + const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); const [{ isDraggingOverThisItem, canDrop }, connectDropTarget] = useDrop< DriveItemData | DriveItemData[], unknown, @@ -111,11 +114,15 @@ export const useDriveItemDrop = (item: DriveItemData): DriveItemDrop => { } if (rootList.length) { // Directory tree - for (const root of rootList) { - const currentFolderId = item.uuid; - - await dispatch(storageThunks.uploadFolderThunk({ root, currentFolderId })); - } + const folderDataToUpload = rootList.map((root) => ({ + root, + currentFolderId: item.uuid, + })); + await uploadFoldersWithManager({ + payload: folderDataToUpload, + selectedWorkspace, + dispatch, + }); } }); } diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index 2526f3b36..273f3ac01 100644 --- a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx +++ b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx @@ -11,6 +11,8 @@ import { fetchSortedFolderContentThunk } from '../../../store/slices/storage/sto import { uiActions } from '../../../store/slices/ui'; import { DriveItemData } from '../../types'; import { IRoot } from '../../../store/slices/storage/types'; +import workspacesSelectors from '../../../store/slices/workspaces/workspaces.selectors'; +import { uploadFoldersWithManager } from '../../../network/UploadFolderManager'; type NameCollisionContainerProps = { currentFolderId: string; @@ -36,6 +38,7 @@ const NameCollisionContainer: FC = ({ const [driveRepeatedFolder, setDriveRepeatedFolder] = useState([]); const isOpen = useAppSelector((state: RootState) => state.ui.isNameCollisionDialogOpen); + const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); const isMoveDialog = useMemo(() => !!moveDestinationFolderId, [moveDestinationFolderId]); const folderId = useMemo( () => moveDestinationFolderId ?? currentFolderId, @@ -131,14 +134,16 @@ const NameCollisionContainer: FC = ({ itemsToUpload.forEach((itemToUpload) => { if ((itemToUpload as IRoot).fullPathEdited) { - dispatch( - storageThunks.uploadMultipleFolderThunkNoCheck([ + uploadFoldersWithManager({ + payload: [ { root: { ...(itemToUpload as IRoot) }, currentFolderId: folderId, }, - ]), - ).then(() => { + ], + selectedWorkspace, + dispatch, + }).then(() => { dispatch(fetchSortedFolderContentThunk(folderId)); }); } else { @@ -160,12 +165,16 @@ const NameCollisionContainer: FC = ({ const keepAndUploadItem = async (itemsToUpload: (IRoot | File)[]) => { itemsToUpload.forEach((itemToUpload) => { if ((itemToUpload as IRoot).fullPathEdited) { - dispatch( - storageThunks.uploadFolderThunk({ - root: { ...(itemToUpload as IRoot) }, - currentFolderId: folderId, - }), - ).then(() => { + uploadFoldersWithManager({ + payload: [ + { + root: { ...(itemToUpload as IRoot) }, + currentFolderId: folderId, + }, + ], + selectedWorkspace, + dispatch, + }).then(() => { dispatch(fetchSortedFolderContentThunk(folderId)); }); } else { diff --git a/src/app/network/UploadFolderManager.ts b/src/app/network/UploadFolderManager.ts new file mode 100644 index 000000000..1c1043bf5 --- /dev/null +++ b/src/app/network/UploadFolderManager.ts @@ -0,0 +1,428 @@ +import { TaskData, TaskEvent, TaskStatus, TaskType, UploadFolderTask } from '../tasks/types'; +import { DriveFolderData, DriveItemData } from '../drive/types'; +import { IRoot } from '../store/slices/storage/types'; +import tasksService from '../tasks/services/tasks.service'; +import errorService from '../core/services/error.service'; +import { queue, QueueObject } from 'async'; +import { t } from 'i18next'; +import { AnyAction, ThunkDispatch } from '@reduxjs/toolkit'; +import { RootState } from '../store'; +import { SdkFactory } from '../core/factory/sdk'; +import { WorkspaceData } from '@internxt/sdk/dist/workspaces'; +import { planThunks } from '../store/slices/plan'; +import { uploadItemsParallelThunk } from '../store/slices/storage/storage.thunks/uploadItemsThunk'; +import { createFolder } from '../store/slices/storage/folderUtils/createFolder'; +import { deleteItemsThunk } from '../store/slices/storage/storage.thunks/deleteItemsThunk'; +import { checkFolderDuplicated } from '../store/slices/storage/folderUtils/checkFolderDuplicated'; +import { getUniqueFolderName } from '../store/slices/storage/folderUtils/getUniqueFolderName'; + +interface UploadFolderPayload { + root: IRoot; + currentFolderId: string; + options?: { + taskId?: string; + withNotification?: boolean; + onSuccess?: () => void; + }; +} + +export interface TaskFolder { + root: IRoot; + currentFolderId: string; + options?: { + withNotification?: boolean; + onSuccess?: () => void; + }; + taskId: string; + abortController: AbortController; +} + +interface TaskInfo { + rootFolderItem?: DriveFolderData; + progress: { + itemsUploaded: number; + totalItems: number; + }; +} + +const generateTaskIdForFolders = async (foldersPayload: UploadFolderPayload[]): Promise => { + const taskFolders: TaskFolder[] = []; + + for (const folder of foldersPayload) { + const { root: originalRoot, currentFolderId, options: payloadOptions } = folder; + const options = { withNotification: true, ...payloadOptions }; + const root = await handleFoldersRename(originalRoot, currentFolderId); + + const uploadFolderAbortController = new AbortController(); + + let taskId = options?.taskId; + + if (taskId) { + tasksService.updateTask({ + taskId, + merge: { + folderName: root.name, + status: TaskStatus.InProcess, + progress: 0, + }, + }); + } else { + taskId = tasksService.create({ + action: TaskType.UploadFolder, + folderName: root.name, + item: root, + parentFolderId: currentFolderId, + showNotification: !!options.withNotification, + cancellable: true, + }); + } + + taskFolders.push({ + root, + currentFolderId, + options: payloadOptions, + taskId, + abortController: uploadFolderAbortController, + }); + } + return taskFolders; +}; + +const countItemsUnderRoot = (root: IRoot): number => { + let count = 1; + + const queueOfFolders: Array = [root]; + + while (queueOfFolders.length > 0) { + const folder = queueOfFolders.shift() as IRoot; + count += folder.childrenFiles.length; + + if (folder.childrenFolders) { + count += folder.childrenFolders.length; + queueOfFolders.push(...folder.childrenFolders); + } + } + + return count; +}; + +const handleFoldersRename = async (root: IRoot, currentFolderId: string) => { + const { duplicatedFoldersResponse } = await checkFolderDuplicated([root], currentFolderId); + + let finalFilename = root.name; + if (duplicatedFoldersResponse.length > 0) { + finalFilename = await getUniqueFolderName( + root.name, + duplicatedFoldersResponse as DriveFolderData[], + currentFolderId, + ); + } + + const folder: IRoot = { ...root, name: finalFilename }; + return folder; +}; + +const wait = (ms: number): Promise => { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; + +export const uploadFoldersWithManager = ({ + payload, + selectedWorkspace, + dispatch, +}: { + payload: UploadFolderPayload[]; + selectedWorkspace: WorkspaceData | null; + dispatch: ThunkDispatch; +}): Promise => { + const uploadFoldersManager = new UploadFoldersManager(payload, selectedWorkspace, dispatch); + return uploadFoldersManager.run(); +}; + +export class UploadFoldersManager { + private static readonly MAX_CONCURRENT_UPLOADS = 6; + private static readonly MAX_UPLOAD_ATTEMPTS = 2; + + private readonly payload: UploadFolderPayload[]; + private readonly selectedWorkspace: WorkspaceData | null; + private readonly dispatch: ThunkDispatch; + private readonly abortController?: AbortController; + + private tasksInfo: Record = {}; + + constructor( + payload: UploadFolderPayload[], + selectedWorkspace: WorkspaceData | null, + dispatch: ThunkDispatch, + ) { + this.payload = payload; + this.selectedWorkspace = selectedWorkspace; + this.dispatch = dispatch; + } + + private readonly uploadFoldersQueue: QueueObject = queue( + (task, next: (err: Error | null, res?: DriveFolderData) => void) => { + if (this.abortController?.signal.aborted) return; + + this.manageMemoryUsage(); + + this.uploadFolderAsync(task) + .then((uploadedFolder: DriveFolderData | undefined) => { + next(null, uploadedFolder); + }) + .catch((e) => { + next(e); + }); + }, + UploadFoldersManager.MAX_CONCURRENT_UPLOADS, + ); + + private readonly uploadFolderAsync = async (taskFolder: TaskFolder) => { + const { root: level, currentFolderId, taskId, abortController } = taskFolder; + + if (abortController.signal.aborted) return; + + let createdFolder: DriveFolderData | undefined; + + let uploadAttempts = 0; + while (!createdFolder && uploadAttempts < UploadFoldersManager.MAX_UPLOAD_ATTEMPTS) { + uploadAttempts++; + try { + createdFolder = await createFolder( + { + parentFolderId: level.folderId as string, + folderName: level.name, + options: { relatedTaskId: taskId, showErrors: false }, + }, + currentFolderId, + this.selectedWorkspace, + { dispatch: this.dispatch }, + ); + } catch { + if (uploadAttempts >= UploadFoldersManager.MAX_UPLOAD_ATTEMPTS) { + this.stopUploadTask(taskId, abortController); + this.killQueueAndNotifyError(taskId); + return; + } + } + } + + if (!createdFolder) { + this.stopUploadTask(taskId, abortController); + this.killQueueAndNotifyError(taskId); + return; + } + + if (!this.tasksInfo[taskId].rootFolderItem) { + this.tasksInfo[taskId].rootFolderItem = createdFolder; + } + + this.tasksInfo[taskId].progress.itemsUploaded += 1; + + tasksService.updateTask({ + taskId, + merge: { + progress: this.tasksInfo[taskId].progress.itemsUploaded / this.tasksInfo[taskId].progress.totalItems, + stop: () => this.stopUploadTask(taskId, abortController), + }, + }); + + if (level.childrenFiles.length > 0 || level.childrenFolders.length > 0) { + // Added wait in order to allow enough time for the server to create the folder + await wait(600); + } + + if (level.childrenFiles.length > 0) { + if (abortController.signal.aborted) return; + await this.dispatch( + uploadItemsParallelThunk({ + files: level.childrenFiles, + parentFolderId: createdFolder.uuid, + options: { + relatedTaskId: taskId, + showNotifications: false, + showErrors: false, + abortController: abortController, + disableDuplicatedNamesCheck: true, + disableExistenceCheck: true, + }, + onFileUploadCallback: () => { + this.tasksInfo[taskId].progress.itemsUploaded += 1; + tasksService.updateTask({ + taskId, + merge: { + progress: this.tasksInfo[taskId].progress.itemsUploaded / this.tasksInfo[taskId].progress.totalItems, + stop: () => this.stopUploadTask(taskId, abortController), + }, + }); + }, + }), + ) + .unwrap() + .catch(() => { + this.stopUploadTask(taskId, abortController); + this.killQueueAndNotifyError(taskId); + return; + }); + } + + for (const child of level.childrenFolders) { + if (abortController.signal.aborted) return; + + this.uploadFoldersQueue.push({ + root: { ...child, folderId: createdFolder.uuid }, + currentFolderId: taskFolder.currentFolderId, + options: taskFolder.options, + abortController: taskFolder.abortController, + taskId: taskFolder.taskId, + }); + } + + return createdFolder; + }; + + private readonly manageMemoryUsage = () => { + if (window?.performance?.memory) { + const memory = window.performance.memory; + + if (memory.jsHeapSizeLimit != null && memory.usedJSHeapSize != null) { + const memoryUsagePercentage = memory.usedJSHeapSize / memory.jsHeapSizeLimit; + + const shouldIncreaseConcurrency = memoryUsagePercentage < 0.7; + if (shouldIncreaseConcurrency) { + const newConcurrency = Math.min(this.uploadFoldersQueue.concurrency + 1, 6); + if (newConcurrency !== this.uploadFoldersQueue.concurrency) { + console.warn(`Memory usage under 70%. Increasing folder upload concurrency to ${newConcurrency}`); + this.uploadFoldersQueue.concurrency = newConcurrency; + } + } + + const shouldReduceConcurrency = memoryUsagePercentage >= 0.8 && this.uploadFoldersQueue.concurrency > 1; + if (shouldReduceConcurrency) { + console.warn('Memory usage reached 80%. Reducing folder upload concurrency.'); + this.uploadFoldersQueue.concurrency = 1; + } + } + } else { + console.warn('Memory usage control is not available'); + } + }; + + private readonly stopUploadTask = async (taskId: string, uploadFolderAbortController: AbortController) => { + uploadFolderAbortController.abort(); + const relatedTasks = tasksService.getTasks({ relatedTaskId: taskId }); + const promises: Promise[] = []; + + // Cancels related tasks + promises.push( + ...(relatedTasks.map((task) => task.stop?.()).filter((promise) => promise !== undefined) as Promise[]), + ); + // Deletes the root folder + const rootFolderItem = this.tasksInfo[taskId].rootFolderItem; + if (rootFolderItem) { + promises.push(this.dispatch(deleteItemsThunk([rootFolderItem as DriveItemData])).unwrap()); + const storageClient = SdkFactory.getInstance().createStorageClient(); + promises.push(storageClient.deleteFolder(rootFolderItem.id) as Promise); + } + await Promise.allSettled(promises); + }; + + private readonly killQueueAndNotifyError = (taskId: string) => { + this.uploadFoldersQueue.kill(); + tasksService.updateTask({ + taskId: taskId, + merge: { + status: TaskStatus.Error, + subtitle: t('tasks.subtitles.upload-failed') as string, + }, + }); + }; + + public readonly run = async (): Promise => { + const payloadWithTaskId = await generateTaskIdForFolders(this.payload); + + const memberId = this.selectedWorkspace?.workspaceUser?.memberId; + + for (const taskFolder of payloadWithTaskId) { + const { root, currentFolderId, options: payloadOptions, taskId } = taskFolder; + const options = { withNotification: true, ...payloadOptions }; + + this.tasksInfo[taskId] = { + progress: { + itemsUploaded: 0, + totalItems: countItemsUnderRoot(root), + }, + }; + + tasksService.updateTask({ + taskId, + merge: { + status: TaskStatus.InProcess, + progress: 0, + }, + }); + + const cancelQueueListener = (task?: TaskData) => { + const isCurrentTask = task && task.id === taskId; + if (isCurrentTask && task.status === TaskStatus.Cancelled) { + this.uploadFoldersQueue.kill(); + } + }; + + const updateQueueListener = (task?: TaskData) => { + const isCurrentTask = task && task.id === taskId; + if (isCurrentTask && task.status === TaskStatus.InProcess) { + this.uploadFoldersQueue.resume(); + } else if (isCurrentTask && task.status === TaskStatus.Paused) { + this.uploadFoldersQueue.pause(); + } + }; + + tasksService.addListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener }); + tasksService.addListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener }); + + try { + root.folderId = currentFolderId; + await this.uploadFoldersQueue.pushAsync(taskFolder); + + while (this.uploadFoldersQueue.running() > 0 || this.uploadFoldersQueue.length() > 0) { + await this.uploadFoldersQueue.drain(); + } + + tasksService.updateTask({ + taskId: taskId, + merge: { + itemUUID: { rootFolderUUID: this.tasksInfo[taskId].rootFolderItem?.uuid }, + status: TaskStatus.Success, + }, + }); + + options.onSuccess?.(); + + setTimeout(() => { + this.dispatch(planThunks.fetchUsageThunk()); + if (memberId) this.dispatch(planThunks.fetchBusinessLimitUsageThunk()); + }, 1000); + } catch (err: unknown) { + const castedError = errorService.castError(err); + const updatedTask = tasksService.findTask(taskId); + + if (updatedTask?.status !== TaskStatus.Cancelled && taskId === updatedTask?.id) { + tasksService.updateTask({ + taskId: taskId, + merge: { + status: TaskStatus.Error, + subtitle: t('tasks.subtitles.upload-failed') as string, + }, + }); + // Log the error or report it but don't re-throw it to allow the next folder to be processed + errorService.reportError(castedError); + continue; + } + } finally { + tasksService.removeListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener }); + tasksService.removeListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener }); + } + } + }; +} diff --git a/src/app/network/UploadFoldersManager.test.ts b/src/app/network/UploadFoldersManager.test.ts new file mode 100644 index 000000000..1ac7d6b69 --- /dev/null +++ b/src/app/network/UploadFoldersManager.test.ts @@ -0,0 +1,397 @@ +import errorService from 'app/core/services/error.service'; +import AppError from 'app/core/types'; +import { DriveFolderData } from 'app/drive/types'; +import { createFolder } from 'app/store/slices/storage/folderUtils/createFolder'; +import { checkFolderDuplicated } from 'app/store/slices/storage/folderUtils/checkFolderDuplicated'; +import { getUniqueFolderName } from 'app/store/slices/storage/folderUtils/getUniqueFolderName'; +import tasksService from 'app/tasks/services/tasks.service'; +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { TaskFolder, UploadFoldersManager, uploadFoldersWithManager } from './UploadFolderManager'; + +vi.mock('app/store/slices/storage/storage.thunks', () => ({ + default: { + initializeThunk: vi.fn(), + resetNamePathThunk: vi.fn(), + uploadItemsThunk: vi.fn(), + downloadItemsThunk: vi.fn(), + downloadFileThunk: vi.fn(), + downloadFolderThunk: vi.fn(), + fetchPaginatedFolderContentThunk: vi.fn(), + deleteItemsThunk: vi.fn(), + goToFolderThunk: vi.fn(), + uploadFolderThunk: vi.fn(), + updateItemMetadataThunk: vi.fn(), + fetchRecentsThunk: vi.fn(), + createFolderThunk: vi.fn(), + moveItemsThunk: vi.fn(), + fetchDeletedThunk: vi.fn(), + renameItemsThunk: vi.fn(), + uploadSharedItemsThunk: vi.fn(), + }, + storageExtraReducers: vi.fn(), +})); + +vi.mock('app/store/slices/plan', () => ({ + default: { + initializeThunk: vi.fn(), + fetchLimitThunk: vi.fn(), + fetchUsageThunk: vi.fn(), + fetchSubscriptionThunk: vi.fn(), + fetchBusinessLimitUsageThunk: vi.fn(), + }, + planThunks: { + initializeThunk: vi.fn(), + fetchLimitThunk: vi.fn(), + fetchUsageThunk: vi.fn(), + fetchSubscriptionThunk: vi.fn(), + fetchBusinessLimitUsageThunk: vi.fn(), + }, +})); + +vi.mock('app/drive/services/download.service/downloadFolder', () => ({ + default: { + fetchFileBlob: vi.fn(), + downloadFileFromBlob: vi.fn(), + downloadFile: vi.fn(), + downloadFolder: vi.fn(), + downloadBackup: vi.fn(), + }, +})); + +vi.mock('app/store/slices/storage/folderUtils/createFolder', () => ({ + createFolder: vi.fn(), +})); + +vi.mock('app/store/slices/storage/folderUtils/checkFolderDuplicated', () => ({ + checkFolderDuplicated: vi.fn(), +})); + +vi.mock('app/store/slices/storage/folderUtils/getUniqueFolderName', () => ({ + getUniqueFolderName: vi.fn(), +})); + +describe('checkUploadFolders', () => { + const mockDispatch = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should upload folder using an async queue', async () => { + const mockFolder: DriveFolderData = { + id: 0, + uuid: 'uuid', + name: 'Folder1', + bucket: 'bucket', + parentId: 0, + parent_id: 0, + parentUuid: 'parentUuid', + userId: 0, + user_id: 0, + icon: null, + iconId: null, + icon_id: null, + isFolder: true, + color: null, + encrypt_version: null, + plain_name: 'Folder1', + deleted: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const taskId = 'task-id'; + + const createFolderSpy = (createFolder as Mock).mockResolvedValueOnce(mockFolder); + + (checkFolderDuplicated as Mock).mockResolvedValueOnce({ + duplicatedFoldersResponse: [] as DriveFolderData[], + foldersWithDuplicates: [] as DriveFolderData[], + foldersWithoutDuplicates: [mockFolder], + }); + vi.spyOn(tasksService, 'create').mockReturnValue(taskId); + vi.spyOn(tasksService, 'updateTask').mockReturnValue(); + vi.spyOn(tasksService, 'addListener').mockReturnValue(); + vi.spyOn(tasksService, 'removeListener').mockReturnValue(); + vi.spyOn(errorService, 'castError').mockResolvedValue(new AppError('error')); + + await uploadFoldersWithManager({ + payload: [ + { + currentFolderId: 'currentFolderId', + root: { + folderId: mockFolder.uuid, + childrenFiles: [], + childrenFolders: [], + name: mockFolder.name, + fullPathEdited: 'path1', + }, + options: { + taskId, + }, + }, + ], + selectedWorkspace: null, + dispatch: mockDispatch, + }); + + expect(createFolderSpy).toHaveBeenCalledOnce(); + }); + + it('should rename folder before upload using an async queue', async () => { + const mockFolder: DriveFolderData = { + id: 0, + uuid: 'uuid', + name: 'Folder1', + bucket: 'bucket', + parentId: 0, + parent_id: 0, + parentUuid: 'parentUuid', + userId: 0, + user_id: 0, + icon: null, + iconId: null, + icon_id: null, + isFolder: true, + color: null, + encrypt_version: null, + plain_name: 'Folder1', + deleted: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const taskId = 'task-id'; + + const createFolderSpy = (createFolder as Mock).mockResolvedValueOnce(mockFolder); + + (checkFolderDuplicated as Mock).mockResolvedValueOnce({ + duplicatedFoldersResponse: [mockFolder] as DriveFolderData[], + foldersWithDuplicates: [mockFolder] as DriveFolderData[], + foldersWithoutDuplicates: [], + }); + + const renameFolderSpy = (getUniqueFolderName as Mock).mockResolvedValueOnce('renamed-folder1'); + + vi.spyOn(tasksService, 'create').mockReturnValue(taskId); + vi.spyOn(tasksService, 'updateTask').mockReturnValue(); + vi.spyOn(tasksService, 'addListener').mockReturnValue(); + vi.spyOn(tasksService, 'removeListener').mockReturnValue(); + vi.spyOn(errorService, 'castError').mockResolvedValue(new AppError('error')); + + await uploadFoldersWithManager({ + payload: [ + { + currentFolderId: 'currentFolderId', + root: { + folderId: mockFolder.uuid, + childrenFiles: [], + childrenFolders: [], + name: mockFolder.name, + fullPathEdited: 'path1', + }, + options: { + taskId, + }, + }, + ], + selectedWorkspace: null, + dispatch: mockDispatch, + }); + + expect(createFolderSpy).toHaveBeenCalledOnce(); + expect(renameFolderSpy).toHaveBeenCalledOnce(); + }); + + it('should upload multiple folders using an async queue', async () => { + const mockParentFolder: DriveFolderData = { + id: 1, + uuid: 'uuid1', + name: 'Folder1', + bucket: 'bucket', + parentId: 0, + parent_id: 0, + parentUuid: 'parentUuid', + userId: 0, + user_id: 0, + icon: null, + iconId: null, + icon_id: null, + isFolder: true, + color: null, + encrypt_version: null, + plain_name: 'Folder1', + deleted: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const mockChildFolder: DriveFolderData = { + id: 2, + uuid: 'uuid2', + name: 'Folder2', + bucket: 'bucket', + parentId: 1, + parent_id: 1, + parentUuid: 'uuid1', + userId: 0, + user_id: 0, + icon: null, + iconId: null, + icon_id: null, + isFolder: true, + color: null, + encrypt_version: null, + plain_name: 'Folder2', + deleted: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const taskId = 'task-id'; + + const createFolderSpy = (createFolder as Mock) + .mockResolvedValueOnce(mockParentFolder) + .mockResolvedValueOnce(mockChildFolder); + + (checkFolderDuplicated as Mock).mockResolvedValueOnce({ + duplicatedFoldersResponse: [] as DriveFolderData[], + foldersWithDuplicates: [] as DriveFolderData[], + foldersWithoutDuplicates: [mockParentFolder], + }); + + const renameFolderSpy = (getUniqueFolderName as Mock).mockResolvedValueOnce(''); + + vi.spyOn(tasksService, 'create').mockReturnValue(taskId); + vi.spyOn(tasksService, 'updateTask').mockReturnValue(); + vi.spyOn(errorService, 'castError').mockResolvedValue(new AppError('error')); + + await uploadFoldersWithManager({ + payload: [ + { + currentFolderId: 'currentFolderId', + root: { + folderId: mockParentFolder.parentUuid, + childrenFiles: [], + childrenFolders: [ + { + folderId: mockParentFolder.uuid, + childrenFiles: [], + childrenFolders: [], + name: mockChildFolder.name, + fullPathEdited: 'path2', + }, + ], + name: mockParentFolder.name, + fullPathEdited: 'path1', + }, + options: { + taskId, + }, + }, + ], + selectedWorkspace: null, + dispatch: mockDispatch, + }); + + expect(createFolderSpy).toHaveBeenCalledTimes(2); + expect(renameFolderSpy).not.toHaveBeenCalled(); + }); + + it('should abort the upload if abortController is called', async () => { + const mockParentFolder: DriveFolderData = { + id: 1, + uuid: 'uuid1', + name: 'Folder1', + bucket: 'bucket', + parentId: 0, + parent_id: 0, + parentUuid: 'parentUuid', + userId: 0, + user_id: 0, + icon: null, + iconId: null, + icon_id: null, + isFolder: true, + color: null, + encrypt_version: null, + plain_name: 'Folder1', + deleted: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const mockChildFolder: DriveFolderData = { + id: 2, + uuid: 'uuid2', + name: 'Folder2', + bucket: 'bucket', + parentId: 1, + parent_id: 1, + parentUuid: 'uuid1', + userId: 0, + user_id: 0, + icon: null, + iconId: null, + icon_id: null, + isFolder: true, + color: null, + encrypt_version: null, + plain_name: 'Folder2', + deleted: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const payload = []; + const selectedWorkspace = null; + const taskId = 'task-id'; + + const manager = new UploadFoldersManager(payload, selectedWorkspace, mockDispatch); + const abortController = new AbortController(); + + const taskFolder: TaskFolder = { + currentFolderId: 'currentFolderId', + root: { + folderId: mockParentFolder.parentUuid, + childrenFiles: [], + childrenFolders: [ + { + folderId: mockParentFolder.uuid, + childrenFiles: [], + childrenFolders: [], + name: mockChildFolder.name, + fullPathEdited: 'path2', + }, + ], + name: mockParentFolder.name, + fullPathEdited: 'path1', + }, + taskId, + abortController, + }; + + const createFolderSpy = (createFolder as Mock) + .mockResolvedValueOnce(mockParentFolder) + .mockResolvedValueOnce(mockChildFolder); + + (checkFolderDuplicated as Mock).mockResolvedValueOnce({ + duplicatedFoldersResponse: [] as DriveFolderData[], + foldersWithDuplicates: [] as DriveFolderData[], + foldersWithoutDuplicates: [mockParentFolder], + }); + + const renameFolderSpy = (getUniqueFolderName as Mock).mockResolvedValueOnce(''); + + manager['tasksInfo'][taskId] = { + progress: { + itemsUploaded: 0, + totalItems: 2, + }, + rootFolderItem: mockParentFolder, + }; + + abortController.abort(); + + const uploadPromise = manager['uploadFolderAsync'](taskFolder); + + await expect(uploadPromise).resolves.toBeUndefined(); + expect(abortController.signal.aborted).toBe(true); + expect(createFolderSpy).not.toHaveBeenCalled(); + expect(renameFolderSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/network/UploadManager.ts b/src/app/network/UploadManager.ts index 14da73c1b..17a02ab20 100644 --- a/src/app/network/UploadManager.ts +++ b/src/app/network/UploadManager.ts @@ -14,7 +14,7 @@ import { FileToUpload } from '../drive/services/file.service/types'; const TWENTY_MEGABYTES = 20 * 1024 * 1024; const USE_MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024; -const MAX_UPLOAD_ATTEMPTS = 1; +const MAX_UPLOAD_ATTEMPTS = 2; enum FileSizeType { Big = 'big', @@ -63,6 +63,7 @@ export const uploadFileWithManager = ( abortController?: AbortController, options?: Options, relatedTaskProgress?: { filesUploaded: number; totalFilesToUpload: number }, + onFileUploadCallback?: (driveFileData: DriveFileData) => void, ): Promise => { const uploadManager = new UploadManager( files, @@ -71,6 +72,7 @@ export const uploadFileWithManager = ( abortController, options, relatedTaskProgress, + onFileUploadCallback, ); return uploadManager.run(); }; @@ -84,6 +86,7 @@ class UploadManager { private options?: Options; private relatedTaskProgress?: { filesUploaded: number; totalFilesToUpload: number }; private maxSpaceOccupiedCallback: () => void; + private onFileUploadCallback?: (driveFileData: DriveFileData) => void; private uploadRepository?: PersistUploadRepository; private filesUploadedList: (DriveFileData & { taskId: string })[] = []; private filesGroups: Record< @@ -110,6 +113,7 @@ class UploadManager { concurrency: 6, }, }; + private uploadQueue: QueueObject = queue( (fileData, next: (err: Error | null, res?: DriveFileData) => void) => { if (this.abortController?.signal.aborted ?? fileData.abortController?.signal.aborted) return; @@ -158,6 +162,8 @@ class UploadManager { isRetriedUpload: !!this.options?.isRetriedUpload, }; + let abortListener: (task: TaskData) => void; + uploadFile( fileData.userEmail, { @@ -185,15 +191,17 @@ class UploadManager { isTeam: false, abortController: this.abortController ?? fileData.abortController, ownerUserAuthenticationData: this.options?.ownerUserAuthenticationData, - abortCallback: (abort?: () => void) => + abortCallback: (abort?: () => void) => { + abortListener = (task) => { + if (task.id === taskId) { + abort?.(); + } + }; tasksService.addListener({ event: TaskEvent.TaskCancelled, - listener: (task) => { - if (task.id === taskId) { - abort?.(); - } - }, - }), + listener: abortListener, + }); + }, }, continueUploadOptions, ) @@ -241,6 +249,10 @@ class UploadManager { uploadProgress: this.uploadsProgress[uploadId] ?? 0, }, }); + + if (this.onFileUploadCallback) { + this.onFileUploadCallback(driveFileDataWithNameParsed); + } next(null, driveFileDataWithNameParsed); }) .catch((error) => { @@ -262,6 +274,12 @@ class UploadManager { uploadId, }); } + }) + .finally(() => { + tasksService.removeListener({ + event: TaskEvent.TaskCancelled, + listener: abortListener, + }); }); }; @@ -277,6 +295,7 @@ class UploadManager { abortController?: AbortController, options?: Options, relatedTaskProgress?: { filesUploaded: number; totalFilesToUpload: number }, + onFileUploadCallback?: (driveFileData: DriveFileData) => void, ) { this.items = items; this.abortController = abortController; @@ -284,6 +303,7 @@ class UploadManager { this.relatedTaskProgress = relatedTaskProgress; this.maxSpaceOccupiedCallback = maxSpaceOccupiedCallback; this.uploadRepository = uploadRepository; + this.onFileUploadCallback = onFileUploadCallback; } private handleUploadErrors({ @@ -373,7 +393,7 @@ class UploadManager { } private manageMemoryUsage() { - if (window.performance && (window.performance as any).memory) { + if (window?.performance?.memory) { const memory = window.performance.memory; if (memory && memory?.jsHeapSizeLimit !== null && memory.usedJSHeapSize !== null) { @@ -386,8 +406,10 @@ class UploadManager { this.uploadQueue.concurrency + 1, this.filesGroups[FileSizeType.Small].concurrency, ); - console.warn(`Memory usage under 70%. Increasing upload concurrency to ${newConcurrency}`); - this.uploadQueue.concurrency = newConcurrency; + if (newConcurrency !== this.uploadQueue.concurrency) { + console.warn(`Memory usage under 70%. Increasing upload concurrency to ${newConcurrency}`); + this.uploadQueue.concurrency = newConcurrency; + } } const shouldReduceConcurrency = memoryUsagePercentage >= 0.8 && this.uploadQueue.concurrency > 1; diff --git a/src/app/store/slices/storage/fileUtils/prepareFilesToUpload.ts b/src/app/store/slices/storage/fileUtils/prepareFilesToUpload.ts index f0f74ffb1..26d725618 100644 --- a/src/app/store/slices/storage/fileUtils/prepareFilesToUpload.ts +++ b/src/app/store/slices/storage/fileUtils/prepareFilesToUpload.ts @@ -10,11 +10,13 @@ export const prepareFilesToUpload = async ({ parentFolderId, disableDuplicatedNamesCheck = false, fileType, + disableExistenceCheck = false, }: { files: File[]; parentFolderId: string; disableDuplicatedNamesCheck?: boolean; fileType?: string; + disableExistenceCheck?: boolean; }): Promise<{ filesToUpload: FileToUpload[]; zeroLengthFilesNumber: number }> => { let filesToUpload: FileToUpload[] = []; let zeroLengthFilesNumber = 0; @@ -38,13 +40,17 @@ export const prepareFilesToUpload = async ({ }; const processFilesBatch = async (filesBatch: File[]) => { - const { duplicatedFilesResponse, filesWithoutDuplicates, filesWithDuplicates } = await checkDuplicatedFiles( - filesBatch, - parentFolderId, - ); - - await processFiles(filesWithoutDuplicates as File[], true); - await processFiles(filesWithDuplicates as File[], disableDuplicatedNamesCheck, duplicatedFilesResponse); + if (disableExistenceCheck) { + await processFiles(filesBatch, true); + } else { + const { duplicatedFilesResponse, filesWithoutDuplicates, filesWithDuplicates } = await checkDuplicatedFiles( + filesBatch, + parentFolderId, + ); + + await processFiles(filesWithoutDuplicates as File[], true); + await processFiles(filesWithDuplicates as File[], disableDuplicatedNamesCheck, duplicatedFilesResponse); + } }; for (let i = 0; i < files.length; i += BATCH_SIZE) { diff --git a/src/app/store/slices/storage/folderUtils/createFolder.test.ts b/src/app/store/slices/storage/folderUtils/createFolder.test.ts new file mode 100644 index 000000000..49ba7b1dc --- /dev/null +++ b/src/app/store/slices/storage/folderUtils/createFolder.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { createFolder } from './createFolder'; +import { CreateFolderResponse, EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types'; +import folderService from '../../../../drive/services/folder.service'; +import tasksService from '../../../../tasks/services/tasks.service'; +import errorService from '../../../../core/services/error.service'; +import AppError from '../../../../core/types'; +import { DriveFolderData } from '../../../../drive/types'; + +//StorageActions mock +vi.mock('..', () => ({ + default: { + pushItems: vi.fn(), + }, + storageActions: vi.fn(), + storageSelectors: vi.fn(), +})); + +vi.mock('../storage.thunks', () => ({ + default: { + initializeThunk: vi.fn(), + resetNamePathThunk: vi.fn(), + uploadItemsThunk: vi.fn(), + downloadItemsThunk: vi.fn(), + downloadFileThunk: vi.fn(), + downloadFolderThunk: vi.fn(), + fetchPaginatedFolderContentThunk: vi.fn(), + deleteItemsThunk: vi.fn(), + goToFolderThunk: vi.fn(), + uploadFolderThunk: vi.fn(), + updateItemMetadataThunk: vi.fn(), + fetchRecentsThunk: vi.fn(), + createFolderThunk: vi.fn(), + moveItemsThunk: vi.fn(), + fetchDeletedThunk: vi.fn(), + renameItemsThunk: vi.fn(), + uploadSharedItemsThunk: vi.fn(), + }, +})); + +vi.mock('../../../../drive/services/folder.service', () => ({ + default: { + createFolder: vi.fn(), + createFolderByUuid: vi.fn(), + updateMetaData: vi.fn(), + moveFolder: vi.fn(), + moveFolderByUuid: vi.fn(), + fetchFolderTree: vi.fn(), + downloadFolderAsZip: vi.fn(), + addAllFoldersToZip: vi.fn(), + addAllFilesToZip: vi.fn(), + downloadSharedFolderAsZip: vi.fn(), + }, + createFilesIterator: vi.fn(), + createFoldersIterator: vi.fn(), +})); + +describe('checkCreateFolder', () => { + const parentFolderId = 'parent-folder-id'; + const mockDispatch = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should create folder via folderService', async () => { + const currentFolderId = 'currentFolderId'; + const mockFolder: CreateFolderResponse = { + id: 0, + name: 'Folder1', + parentId: 0, + plainName: 'Folder1', + bucket: 'bucket', + createdAt: new Date(), + updatedAt: new Date(), + creationTime: new Date(), + deleted: false, + deletedAt: null, + encryptVersion: EncryptionVersion.Aes03, + modificationTime: new Date(), + parentUuid: parentFolderId, + removed: false, + removedAt: null, + userId: 0, + uuid: 'uuid', + }; + + (folderService.createFolderByUuid as Mock).mockReturnValue([Promise.resolve(mockFolder), { cancel: vi.fn() }]); + vi.spyOn(tasksService, 'create').mockReturnValue('task-id'); + vi.spyOn(tasksService, 'updateTask').mockReturnValue(); + vi.spyOn(errorService, 'castError').mockResolvedValue(new AppError('error')); + + const result = await createFolder( + { + folderName: 'Folder1', + parentFolderId, + options: { showErrors: true }, + }, + currentFolderId, + null, + { dispatch: mockDispatch }, + ); + + const mockFolderNormalized: DriveFolderData = { + ...mockFolder, + name: 'Folder1', + parent_id: mockFolder.parentId, + user_id: mockFolder.userId, + icon: null, + iconId: null, + icon_id: null, + isFolder: true, + color: null, + encrypt_version: null, + plain_name: mockFolder.plainName, + deleted: false, + createdAt: new Date(mockFolder.createdAt || '').toISOString(), + updatedAt: new Date(mockFolder.updatedAt || '').toISOString(), + }; + + expect(mockFolderNormalized).toStrictEqual(result); + }); +}); diff --git a/src/app/store/slices/storage/folderUtils/createFolder.ts b/src/app/store/slices/storage/folderUtils/createFolder.ts new file mode 100644 index 000000000..1c491f52e --- /dev/null +++ b/src/app/store/slices/storage/folderUtils/createFolder.ts @@ -0,0 +1,99 @@ +import { DriveFolderData, DriveItemData } from '../../../../drive/types'; +import tasksService from '../../../../tasks/services/tasks.service'; +import { CreateFolderTask, TaskProgress, TaskStatus, TaskType } from '../../../../tasks/types'; +import workspacesService from '../../../../core/services/workspace.service'; +import folderService from '../../../../drive/services/folder.service'; +import { storageActions } from '..'; +import errorService from '../../../../core/services/error.service'; +import { CreateFolderResponse } from '@internxt/sdk/dist/drive/storage/types'; +import { RequestCanceler } from '@internxt/sdk/dist/shared/http/types'; +import { AnyAction, ThunkDispatch } from '@reduxjs/toolkit'; +import { RootState } from '../../../../store'; +import { WorkspaceData } from '@internxt/sdk/dist/workspaces'; + +interface CreateFolderOptions { + relatedTaskId: string; + showErrors: boolean; +} + +interface CreateFolderPayload { + parentFolderId: string; + folderName: string; + options?: Partial; + uuid?: string; +} + +export const createFolder = async ( + { folderName, parentFolderId, options }: CreateFolderPayload, + currentFolderId: string, + selectedWorkspace: WorkspaceData | null, + { dispatch }: { dispatch: ThunkDispatch }, +): Promise => { + options = Object.assign({ showErrors: true }, options || {}); + const workspaceId = selectedWorkspace?.workspace?.id; + let createdFolderPromise: Promise; + let requestCanceler: RequestCanceler; + + try { + if (workspaceId) { + [createdFolderPromise, requestCanceler] = workspacesService.createFolder({ + workspaceId, + parentFolderUuid: parentFolderId, + plainName: folderName, + }); + } else { + [createdFolderPromise, requestCanceler] = folderService.createFolderByUuid(parentFolderId, folderName); + } + + const taskId = tasksService.create({ + relatedTaskId: options.relatedTaskId, + action: TaskType.CreateFolder, + folderName: folderName, + parentFolderId: parentFolderId, + showNotification: false, + cancellable: false, + stop: async () => requestCanceler.cancel(), + }); + + const createdFolder = await createdFolderPromise; + + const createdFolderNormalized: DriveFolderData = { + ...createdFolder, + name: folderName, + parent_id: createdFolder.parentId, + user_id: createdFolder.userId, + icon: null, + iconId: null, + icon_id: null, + isFolder: true, + color: null, + encrypt_version: null, + plain_name: createdFolder.plainName, + deleted: false, + createdAt: new Date(createdFolder.createdAt || '').toISOString(), + updatedAt: new Date(createdFolder.updatedAt || '').toISOString(), + }; + + tasksService.updateTask({ + taskId: taskId, + merge: { + status: TaskStatus.Success, + progress: TaskProgress.Max, + }, + }); + + if (currentFolderId === parentFolderId) { + dispatch( + storageActions.pushItems({ + folderIds: [currentFolderId], + items: createdFolderNormalized as DriveItemData, + }), + ); + } + + return createdFolderNormalized; + } catch (err: unknown) { + const castedError = errorService.castError(err); + throw castedError; + } +}; diff --git a/src/app/store/slices/storage/storage.thunks/index.ts b/src/app/store/slices/storage/storage.thunks/index.ts index b1ce1c2c6..18024e81e 100644 --- a/src/app/store/slices/storage/storage.thunks/index.ts +++ b/src/app/store/slices/storage/storage.thunks/index.ts @@ -15,11 +15,7 @@ import { moveItemsThunk, moveItemsThunkExtraReducers } from './moveItemsThunk'; import { renameItemsThunk, renameItemsThunkExtraReducers } from './renameItemsThunk'; import { resetNamePathThunk, resetNamePathThunkExtraReducers } from './resetNamePathThunk'; import { updateItemMetadataThunk, updateItemMetadataThunkExtraReducers } from './updateItemMetadataThunk'; -import { - uploadFolderThunk, - uploadFolderThunkExtraReducers, - uploadMultipleFolderThunkNoCheck, -} from './uploadFolderThunk'; +import { uploadFolderThunk, uploadFolderThunkExtraReducers } from './uploadFolderThunk'; import { uploadItemsThunk, uploadItemsThunkExtraReducers, uploadSharedItemsThunk } from './uploadItemsThunk'; const storageThunks = { @@ -33,7 +29,6 @@ const storageThunks = { deleteItemsThunk, goToFolderThunk, uploadFolderThunk, - uploadMultipleFolderThunkNoCheck, updateItemMetadataThunk, fetchRecentsThunk, createFolderThunk, diff --git a/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.ts b/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.ts index f170682c3..77aa2b0ec 100644 --- a/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.ts +++ b/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.ts @@ -229,159 +229,6 @@ export const uploadFolderThunk = createAsyncThunk { - return foldersPayload.map(({ root, currentFolderId, options: payloadOptions }) => { - const options = { withNotification: true, ...payloadOptions }; - - const uploadFolderAbortController = new AbortController(); - - let taskId = options?.taskId; - - if (taskId) { - tasksService.updateTask({ - taskId, - merge: { - status: TaskStatus.InProcess, - progress: 0, - }, - }); - } else { - taskId = tasksService.create({ - action: TaskType.UploadFolder, - folderName: root.name, - item: root, - parentFolderId: currentFolderId, - showNotification: !!options.withNotification, - cancellable: true, - }); - } - - return { root, currentFolderId, options: payloadOptions, taskId, abortController: uploadFolderAbortController }; - }); -}; - -export const uploadMultipleFolderThunkNoCheck = createAsyncThunk< - void, - UploadFolderThunkPayload[], - { state: RootState } ->('storage/createFolderStructure', async (payload, { dispatch, getState }) => { - const state = getState(); - const payloadWithTaskId = generateTaskIdForFolders(payload); - - const selectedWorkspace = workspacesSelectors.getSelectedWorkspace(state); - const memberId = selectedWorkspace?.workspaceUser?.memberId; - - // checking why is not aborting correctly the folder upload - for (const { root, currentFolderId, options: payloadOptions, taskId, abortController } of payloadWithTaskId) { - const options = { withNotification: true, ...payloadOptions }; - - let alreadyUploaded = 0; - - let rootFolderItem: DriveFolderData | undefined; - let rootFolderData: DriveFolderData | undefined; - const levels = [root]; - const itemsUnderRoot = countItemsUnderRoot(root); - const uploadFolderAbortController = abortController; - - try { - root.folderId = currentFolderId; - - while (levels.length > 0) { - if (uploadFolderAbortController.signal.aborted) break; - const level: IRoot = levels.shift() as IRoot; - const createdFolder = await dispatch( - storageThunks.createFolderThunk({ - parentFolderId: level.folderId as string, - folderName: level.name, - options: { relatedTaskId: taskId, showErrors: false }, - }), - ).unwrap(); - - // Added wait in order to allow enough time for the server to create the folder - await wait(500); - - if (!rootFolderItem) { - rootFolderItem = createdFolder; - } - - tasksService.updateTask({ - taskId, - merge: { - stop: () => stopUploadTask(uploadFolderAbortController, dispatch, taskId, rootFolderItem), - }, - }); - if (!rootFolderData) { - rootFolderData = createdFolder; - } - - if (level.childrenFiles) { - if (uploadFolderAbortController.signal.aborted) break; - - await dispatch( - uploadItemsParallelThunk({ - files: level.childrenFiles, - parentFolderId: createdFolder.uuid, - options: { - relatedTaskId: taskId, - showNotifications: false, - showErrors: false, - abortController: uploadFolderAbortController, - disableDuplicatedNamesCheck: true, - }, - filesProgress: { filesUploaded: alreadyUploaded, totalFilesToUpload: itemsUnderRoot }, - }), - ) - .unwrap() - .then(() => { - alreadyUploaded += level.childrenFiles.length; - alreadyUploaded += 1; - }); - - if (uploadFolderAbortController.signal.aborted) break; - } - - const childrenFolders = [] as IRoot[]; - for (const child of level.childrenFolders) { - childrenFolders.push({ ...child, folderId: createdFolder.uuid }); - } - - levels.push(...childrenFolders); - } - - tasksService.updateTask({ - taskId: taskId, - merge: { - itemUUID: { rootFolderUUID: rootFolderData?.uuid }, - status: TaskStatus.Success, - }, - }); - - options.onSuccess?.(); - - setTimeout(() => { - dispatch(planThunks.fetchUsageThunk()); - if (memberId) dispatch(planThunks.fetchBusinessLimitUsageThunk()); - }, 1000); - } catch (err: unknown) { - const castedError = errorService.castError(err); - const updatedTask = tasksService.findTask(taskId); - - if (updatedTask?.status !== TaskStatus.Cancelled && taskId === updatedTask?.id) { - tasksService.updateTask({ - taskId: taskId, - merge: { - status: TaskStatus.Error, - subtitle: t('tasks.subtitles.upload-failed') as string, - }, - }); - // Log the error or report it but don't re-throw it to allow the next folder to be processed - errorService.reportError(castedError); - continue; - } - } - } -}); - function countItemsUnderRoot(root: IRoot): number { let count = 0; diff --git a/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts b/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts index cd26d5f39..8e46a717f 100644 --- a/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts +++ b/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts @@ -34,6 +34,7 @@ interface UploadItemsThunkOptions { onSuccess: () => void; isRetriedUpload?: boolean; disableDuplicatedNamesCheck?: boolean; + disableExistenceCheck?: boolean; } interface UploadItemsPayload { @@ -43,6 +44,7 @@ interface UploadItemsPayload { parentFolderId: string; options?: Partial; filesProgress?: { filesUploaded: number; totalFilesToUpload: number }; + onFileUploadCallback?: (driveFileData: DriveFileData) => void; } const DEFAULT_OPTIONS: Partial = { @@ -394,7 +396,7 @@ export const uploadSharedItemsThunk = createAsyncThunk( 'storage/uploadItems', async ( - { files, parentFolderId, options: payloadOptions, filesProgress }: UploadItemsPayload, + { files, parentFolderId, options: payloadOptions, filesProgress, onFileUploadCallback }: UploadItemsPayload, { getState, dispatch }, ) => { const state = getState(); @@ -423,6 +425,7 @@ export const uploadItemsParallelThunk = createAsyncThunk