From 64e72cd22e4b0d23d41410e7643e336593770c50 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Mon, 2 Nov 2026 16:53:59 +0100 Subject: [PATCH] refactor(upload): upload from the Drive explorer through a single uploadItems entry with its checks, outside Redux --- .../workspaces/workspaces.selectors.test.ts | 33 +++ .../slices/workspaces/workspaces.selectors.ts | 16 ++ src/app/upload/collisions.ts | 55 +++++ src/app/upload/notifyUploadFailed.ts | 8 + src/app/upload/uploadChecks.ts | 48 ++++ src/app/upload/uploadItems.test.ts | 207 ++++++++++++++++++ src/app/upload/uploadItems.ts | 108 +++++++++ src/app/upload/useUploadItems.ts | 54 +++++ src/components/BreadcrumbsHelper.test.ts | 13 +- src/components/BreadcrumbsHelper.ts | 2 +- .../DriveExplorer/DriveExplorer.tsx | 4 +- .../helpers/uploadHelpers.test.ts | 195 ----------------- .../DriveExplorer/helpers/uploadHelpers.ts | 116 ---------- .../hooks/useDriveExplorerDrop.test.tsx | 25 +-- .../hooks/useDriveExplorerDrop.ts | 2 +- .../hooks/useDriveExplorerUpload.test.tsx | 91 ++------ .../hooks/useDriveExplorerUpload.ts | 22 +- .../Drive/hooks/useDriveItemDragAndDrop.tsx | 2 +- src/views/Shared/SharedView.tsx | 2 +- 19 files changed, 576 insertions(+), 427 deletions(-) create mode 100644 src/app/store/slices/workspaces/workspaces.selectors.test.ts create mode 100644 src/app/upload/collisions.ts create mode 100644 src/app/upload/notifyUploadFailed.ts create mode 100644 src/app/upload/uploadChecks.ts create mode 100644 src/app/upload/uploadItems.test.ts create mode 100644 src/app/upload/uploadItems.ts create mode 100644 src/app/upload/useUploadItems.ts delete mode 100644 src/views/Drive/components/DriveExplorer/helpers/uploadHelpers.test.ts delete mode 100644 src/views/Drive/components/DriveExplorer/helpers/uploadHelpers.ts diff --git a/src/app/store/slices/workspaces/workspaces.selectors.test.ts b/src/app/store/slices/workspaces/workspaces.selectors.test.ts new file mode 100644 index 0000000000..e045d46040 --- /dev/null +++ b/src/app/store/slices/workspaces/workspaces.selectors.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'vitest'; +import { RootState } from '../..'; +import workspacesSelectors from './workspaces.selectors'; + +const stateWith = (workspaces: Partial) => ({ workspaces }) as RootState; + +describe('Credentials to act on behalf of the workspace owner', () => { + test('When no workspace is selected, then there are no workspace credentials', () => { + const state = stateWith({ selectedWorkspace: null, workspaceCredentials: null }); + + expect(workspacesSelectors.getOwnerUserAuthenticationData(state)).toBeUndefined(); + }); + + test('When a workspace is selected, then its network credentials, bucket, key and token are used', () => { + const state = stateWith({ + selectedWorkspace: { workspace: { id: 'workspace-id' }, workspaceUser: { key: 'workspace-key' } } as never, + workspaceCredentials: { + credentials: { networkUser: 'network-user', networkPass: 'network-pass' }, + bucket: 'workspace-bucket', + tokenHeader: 'workspace-token', + } as never, + }); + + expect(workspacesSelectors.getOwnerUserAuthenticationData(state)).toEqual({ + bridgeUser: 'network-user', + bridgePass: 'network-pass', + encryptionKey: 'workspace-key', + bucketId: 'workspace-bucket', + workspaceId: 'workspace-id', + workspacesToken: 'workspace-token', + }); + }); +}); diff --git a/src/app/store/slices/workspaces/workspaces.selectors.ts b/src/app/store/slices/workspaces/workspaces.selectors.ts index 6bc3e5ff2e..069a171790 100644 --- a/src/app/store/slices/workspaces/workspaces.selectors.ts +++ b/src/app/store/slices/workspaces/workspaces.selectors.ts @@ -1,4 +1,5 @@ import { WorkspaceCredentialsDetails, WorkspaceData } from '@internxt/sdk/dist/workspaces'; +import { OwnerUserAuthenticationData } from 'app/network/types'; import { RootState } from '../..'; const workspacesSelectors = { @@ -20,6 +21,21 @@ const workspacesSelectors = { getFirstWorkspace(state: RootState): WorkspaceData | null { return state.workspaces.workspaces[0]; }, + getOwnerUserAuthenticationData(state: RootState): OwnerUserAuthenticationData | undefined { + const selectedWorkspace = workspacesSelectors.getSelectedWorkspace(state); + const workspaceCredentials = workspacesSelectors.getWorkspaceCredentials(state); + const workspaceId = selectedWorkspace?.workspace?.id; + if (!workspaceId) return undefined; + + return { + bridgeUser: workspaceCredentials?.credentials.networkUser, + bridgePass: workspaceCredentials?.credentials.networkPass, + encryptionKey: selectedWorkspace?.workspaceUser?.key, + bucketId: workspaceCredentials?.bucket, + workspaceId, + workspacesToken: workspaceCredentials?.tokenHeader, + } as OwnerUserAuthenticationData; + }, }; export default workspacesSelectors; diff --git a/src/app/upload/collisions.ts b/src/app/upload/collisions.ts new file mode 100644 index 0000000000..31a29f4d4d --- /dev/null +++ b/src/app/upload/collisions.ts @@ -0,0 +1,55 @@ +import { DriveItemData } from 'app/drive/types'; +import { CollisionGroup } from 'app/store/slices/storage/storage.model'; +import { + handleRepeatedUploadingFiles, + handleRepeatedUploadingFolders, +} from 'app/store/slices/storage/storage.thunks/renameItemsThunk'; +import { IRoot } from 'app/store/slices/storage/types'; + +interface RepeatedItemsCheck { + repeatedItems: T[]; + existingItems: DriveItemData[]; + unrepeatedItems: T[]; +} + +interface NameCollisions { + collisionGroups: CollisionGroup[]; + rootsWithoutCollision: IRoot[]; + filesWithoutCollision: File[]; +} + +const noRepeatedItems = (): RepeatedItemsCheck => ({ + repeatedItems: [], + existingItems: [], + unrepeatedItems: [], +}); + +const buildCollisionGroups = (destinationUuid: string, checks: RepeatedItemsCheck[]): CollisionGroup[] => + checks + .filter((check) => check.repeatedItems.length > 0) + .map( + (check) => + ({ + destinationUuid, + duplicatedItems: check.repeatedItems, + existingItems: check.existingItems, + unrepeatedItems: check.unrepeatedItems, + }) as CollisionGroup, + ); + +export const findNameCollisions = async ( + destinationFolderUuid: string, + roots: IRoot[], + files: File[], +): Promise => { + const [filesCheck, foldersCheck] = await Promise.all([ + files.length > 0 ? handleRepeatedUploadingFiles(files, destinationFolderUuid) : noRepeatedItems(), + roots.length > 0 ? handleRepeatedUploadingFolders(roots, destinationFolderUuid) : noRepeatedItems(), + ]); + + return { + collisionGroups: buildCollisionGroups(destinationFolderUuid, [filesCheck, foldersCheck]), + rootsWithoutCollision: foldersCheck.unrepeatedItems as IRoot[], + filesWithoutCollision: filesCheck.unrepeatedItems as File[], + }; +}; diff --git a/src/app/upload/notifyUploadFailed.ts b/src/app/upload/notifyUploadFailed.ts new file mode 100644 index 0000000000..5604a456ec --- /dev/null +++ b/src/app/upload/notifyUploadFailed.ts @@ -0,0 +1,8 @@ +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import errorService from 'services/error.service'; + +export const notifyUploadFailed = (error: unknown) => { + const uploadError = errorService.castError(error); + errorService.reportError(uploadError); + notificationsService.show({ text: uploadError.message, type: ToastType.Error, requestId: uploadError.requestId }); +}; diff --git a/src/app/upload/uploadChecks.ts b/src/app/upload/uploadChecks.ts new file mode 100644 index 0000000000..a2f5c61993 --- /dev/null +++ b/src/app/upload/uploadChecks.ts @@ -0,0 +1,48 @@ +import { filterFilesByMaxSize } from 'app/store/slices/storage/fileUtils/filterFilesByMaxSize'; +import { IRoot } from 'app/store/slices/storage/types'; + +export const UPLOAD_ITEMS_LIMIT = 3000; + +export interface PlanStorage { + limit: number; + usage: number; +} + +export const isHiddenFile = (file: File) => file.name.startsWith('.'); + +/** + * Drops the files that are too large to upload and, inside folders, the hidden files. + */ +export const getAllowedFilesToUpload = (roots: IRoot[], files: File[], maxUploadFileSize: number) => { + const exceededFiles: File[] = []; + + const keepAllowedFiles = (candidates: File[]) => { + const { allowedFilesToUpload, exceededFiles: exceeded } = filterFilesByMaxSize({ + files: candidates, + maxUploadFileSize, + }); + exceededFiles.push(...exceeded); + return allowedFilesToUpload; + }; + + const keepAllowedContent = (folder: IRoot): IRoot => ({ + ...folder, + childrenFiles: keepAllowedFiles(folder.childrenFiles.filter((file) => !isHiddenFile(file))), + childrenFolders: folder.childrenFolders.map(keepAllowedContent), + }); + + return { roots: roots.map(keepAllowedContent), files: keepAllowedFiles(files), exceededFiles }; +}; + +export const getTotalSize = (roots: IRoot[], files: File[]): number => { + const sizeOfFiles = (candidates: File[]) => candidates.reduce((total, file) => total + file.size, 0); + const sizeOfFolder = (folder: IRoot): number => + folder.childrenFolders.reduce((total, child) => total + sizeOfFolder(child), sizeOfFiles(folder.childrenFiles)); + + return roots.reduce((total, root) => total + sizeOfFolder(root), sizeOfFiles(files)); +}; + +export const hasStorageAvailable = ({ limit, usage }: PlanStorage, uploadSize: number): boolean => { + const hasUnlimitedStorage = !limit; + return hasUnlimitedStorage || usage + uploadSize < limit; +}; diff --git a/src/app/upload/uploadItems.test.ts b/src/app/upload/uploadItems.test.ts new file mode 100644 index 0000000000..291d1eddb9 --- /dev/null +++ b/src/app/upload/uploadItems.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, Mock, test, vi } from 'vitest'; +import { AppError } from '@internxt/sdk'; +import uploadFile from 'app/drive/services/file.service/uploadFile'; +import folderService from 'app/drive/services/folder.service'; +import newStorageService from 'app/drive/services/new-storage.service'; +import { HTTP_STATUS_CODES } from 'app/core/constants'; +import { OwnerUserAuthenticationData } from 'app/network/types'; +import DatabaseUploadRepository from 'app/repositories/DatabaseUploadRepository'; +import tasksService from 'app/tasks/services/tasks.service'; +import errorService from 'services/error.service'; +import referralService from 'services/referral.service'; +import { + getCreatedFolder, + getFileToUpload, + getFolderToUpload, + getUploadRepository, +} from 'testUtils/fixtures/upload.fixtures'; +import { UPLOAD_ITEMS_LIMIT } from './uploadChecks'; +import { UploadContext, uploadItems } from './uploadItems'; + +vi.mock('app/drive/services/file.service/uploadFile', () => ({ default: vi.fn() })); +vi.mock('app/network/networkInformation', () => ({ logNetworkInfoForUpload: vi.fn() })); + +const DESTINATION_UUID = 'destination-uuid'; +const MAX_FILE_SIZE = 100; +const noSpaceLeftError = new AppError('No space left', HTTP_STATUS_CODES.MAX_SPACE_USED); + +const getUploadContext = (overrides: Partial = {}): UploadContext => ({ + maxUploadFileSize: MAX_FILE_SIZE, + planStorage: { limit: 0, usage: 0 }, + onUploadItemsLimitExceeded: vi.fn(), + onFilesTooLarge: vi.fn(), + onPlanLimitReached: vi.fn(), + onNameCollisions: vi.fn(), + onItemUploaded: vi.fn(), + onItemRemoved: vi.fn(), + onUploadFinished: vi.fn(), + ...overrides, +}); + +const upload = (roots: ReturnType[], files: File[], context = getUploadContext()) => + uploadItems({ destinationFolderUuid: DESTINATION_UUID, roots, files }, context); + +const uploadedFiles = () => + vi.mocked(uploadFile).mock.calls.map(([file]) => ({ name: file.name, folder: file.parentFolderId })); + +const createdFolders = () => + vi.mocked(folderService.createFolderByUuid).mock.calls.map(([parentUuid, name]) => ({ name, parent: parentUuid })); + +const existingInDestination = ({ files = [], folders = [] }: { files?: string[]; folders?: string[] }) => { + vi.spyOn(newStorageService, 'checkDuplicatedFiles').mockResolvedValue({ + existentFiles: files.map((name) => ({ plainName: name, type: 'pdf' })), + } as never); + vi.spyOn(newStorageService, 'checkDuplicatedFolders').mockResolvedValue({ + existentFolders: folders.map((name) => ({ plainName: name })), + } as never); +}; + +describe('Uploading items into a folder', () => { + beforeEach(() => { + vi.clearAllMocks(); + tasksService.clearTasks(); + vi.spyOn(errorService, 'reportError').mockReturnValue(); + vi.spyOn(referralService, 'trackFileUpload').mockReturnValue(); + vi.spyOn(referralService, 'trackFolderUpload').mockReturnValue(); + vi.spyOn(DatabaseUploadRepository, 'getInstance').mockReturnValue(getUploadRepository() as never); + (uploadFile as Mock).mockImplementation(async (file: { name: string }) => ({ uuid: `${file.name}-uuid` })); + vi.spyOn(folderService, 'createFolderByUuid').mockImplementation( + (_parentUuid, name) => [Promise.resolve(getCreatedFolder(name)), { cancel: vi.fn() }] as never, + ); + existingInDestination({}); + }); + + test('When the items have no name conflicts, then they are uploaded into the folder and it is refreshed shortly after', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const context = getUploadContext(); + + await upload( + [getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('beach.png')] })], + [getFileToUpload('report.pdf')], + context, + ); + vi.runAllTimers(); + vi.useRealTimers(); + + expect(createdFolders()).toEqual([{ name: 'Photos', parent: DESTINATION_UUID }]); + expect(uploadedFiles()).toEqual( + expect.arrayContaining([ + { name: 'report', folder: DESTINATION_UUID }, + { name: 'beach', folder: 'Photos-uuid' }, + ]), + ); + expect(context.onUploadFinished).toHaveBeenCalledWith(DESTINATION_UUID); + }); + + test('When some items already exist in the folder, then the user is asked how to resolve them and only the rest are uploaded', async () => { + const context = getUploadContext(); + const [conflictingFile, newFile] = [getFileToUpload('report.pdf'), getFileToUpload('notes.txt')]; + existingInDestination({ files: ['report'] }); + + await upload([], [conflictingFile, newFile], context); + + expect(context.onNameCollisions).toHaveBeenCalledWith([ + expect.objectContaining({ destinationUuid: DESTINATION_UUID, duplicatedItems: [conflictingFile] }), + ]); + expect(uploadedFiles()).toEqual([{ name: 'notes', folder: DESTINATION_UUID }]); + }); + + test('When every item already exists, then nothing is uploaded', async () => { + existingInDestination({ files: ['report'], folders: ['Photos'] }); + + await upload([getFolderToUpload('Photos')], [getFileToUpload('report.pdf')]); + + expect(createdFolders()).toEqual([]); + expect(uploadedFiles()).toEqual([]); + }); + + test('When there are more files than the upload limit, then the user is warned and nothing is uploaded', async () => { + const context = getUploadContext(); + const files = Array.from({ length: UPLOAD_ITEMS_LIMIT + 1 }, (_, index) => getFileToUpload(`${index}.txt`)); + + await upload([], files, context); + + expect(context.onUploadItemsLimitExceeded).toHaveBeenCalled(); + expect(uploadedFiles()).toEqual([]); + }); + + test('When some files are too large, anywhere in the folders, then the user is told about all of them at once and the rest are uploaded', async () => { + const context = getUploadContext(); + const [largeLooseFile, largeNestedFile] = [getFileToUpload('movie.mp4', 200), getFileToUpload('raw.mov', 200)]; + const videos = getFolderToUpload('Videos', { + childrenFolders: [getFolderToUpload('Raw', { childrenFiles: [largeNestedFile] })], + }); + + await upload([videos], [largeLooseFile, getFileToUpload('notes.txt')], context); + + expect(context.onFilesTooLarge).toHaveBeenCalledWith([largeNestedFile, largeLooseFile]); + expect(uploadedFiles()).toEqual([{ name: 'notes', folder: DESTINATION_UUID }]); + expect(createdFolders()).toEqual([ + { name: 'Videos', parent: DESTINATION_UUID }, + { name: 'Raw', parent: 'Videos-uuid' }, + ]); + }); + + test('When a folder contains hidden files, then they are left out', async () => { + await upload( + [getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('.DS_Store'), getFileToUpload('beach.png')] })], + [], + ); + + expect(uploadedFiles()).toEqual([{ name: 'beach', folder: 'Photos-uuid' }]); + }); + + test('When there is not enough storage left, then the user is told and nothing is uploaded', async () => { + const context = getUploadContext({ planStorage: { limit: 1000, usage: 995 } }); + + await upload([getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('beach.png', 10)] })], [], context); + + expect(context.onPlanLimitReached).toHaveBeenCalled(); + expect(createdFolders()).toEqual([]); + expect(uploadedFiles()).toEqual([]); + }); + + test('When uploading in a workspace, then the files are uploaded with the workspace credentials', async () => { + const ownerUserAuthenticationData = { workspaceId: 'workspace-id' } as OwnerUserAuthenticationData; + + await upload([], [getFileToUpload('report.pdf')], getUploadContext({ ownerUserAuthenticationData })); + + expect(uploadFile).toHaveBeenCalledWith( + expect.objectContaining({ name: 'report' }), + expect.any(Function), + expect.objectContaining({ isTeam: true, ownerUserAuthenticationData }), + expect.any(Object), + ); + }); + + test('When items finish uploading, then the files are listed in their folder and only the folders created in the destination are listed', async () => { + const context = getUploadContext(); + const photos = getFolderToUpload('Photos', { + childrenFiles: [getFileToUpload('beach.png')], + childrenFolders: [getFolderToUpload('Summer')], + }); + + await upload([photos], [], context); + + const listedItems = vi.mocked(context.onItemUploaded).mock.calls.map(([item, folderUuid]) => ({ + uuid: item.uuid, + folderUuid, + })); + expect(listedItems).toEqual( + expect.arrayContaining([ + { uuid: 'Photos-uuid', folderUuid: DESTINATION_UUID }, + { uuid: 'beach-uuid', folderUuid: 'Photos-uuid' }, + ]), + ); + expect(listedItems).not.toContainEqual({ uuid: 'Summer-uuid', folderUuid: 'Photos-uuid' }); + }); + + test('When the storage fills up while the items upload, then the user is told the plan limit was reached', async () => { + const context = getUploadContext(); + (uploadFile as Mock).mockRejectedValue(noSpaceLeftError); + + await upload([], [getFileToUpload('report.pdf'), getFileToUpload('notes.txt')], context); + + expect(context.onPlanLimitReached).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/upload/uploadItems.ts b/src/app/upload/uploadItems.ts new file mode 100644 index 0000000000..b7ad8b4597 --- /dev/null +++ b/src/app/upload/uploadItems.ts @@ -0,0 +1,108 @@ +import { DriveItemData } from 'app/drive/types'; +import { OwnerUserAuthenticationData } from 'app/network/types'; +import DatabaseUploadRepository from 'app/repositories/DatabaseUploadRepository'; +import { CollisionGroup } from 'app/store/slices/storage/storage.model'; +import { IRoot } from 'app/store/slices/storage/types'; +import { findNameCollisions } from './collisions'; +import { Upload } from './Upload'; +import { + getAllowedFilesToUpload, + getTotalSize, + hasStorageAvailable, + PlanStorage, + UPLOAD_ITEMS_LIMIT, +} from './uploadChecks'; + +const REFRESH_DELAY_MS = 1000; + +export interface UploadItemsPayload { + destinationFolderUuid: string; + roots: IRoot[]; + files: File[]; +} + +export type UploadItemsHandler = (payload: UploadItemsPayload) => Promise; + +export interface UploadContext { + ownerUserAuthenticationData?: OwnerUserAuthenticationData; + maxUploadFileSize: number; + planStorage: PlanStorage; + onUploadItemsLimitExceeded: () => void; + onFilesTooLarge: (exceededFiles: File[]) => void; + onPlanLimitReached: () => void; + onNameCollisions: (collisionGroups: CollisionGroup[]) => void; + onItemUploaded: (uploadedItem: DriveItemData, parentFolderUuid: string) => void; + onItemRemoved: (removedItem: DriveItemData, parentFolderUuid: string) => void; + onUploadFinished: (destinationFolderUuid: string) => void; +} + +export const uploadItems = async ( + { destinationFolderUuid, roots, files }: UploadItemsPayload, + context: UploadContext, +): Promise => { + if (files.length > UPLOAD_ITEMS_LIMIT) { + context.onUploadItemsLimitExceeded(); + return; + } + + const allowed = getAllowedFilesToUpload(roots, files, context.maxUploadFileSize); + + if (allowed.exceededFiles.length > 0) { + context.onFilesTooLarge(allowed.exceededFiles); + } + + const hasNothingAllowedToUpload = allowed.roots.length === 0 && allowed.files.length === 0; + if (hasNothingAllowedToUpload) return; + + if (!hasStorageAvailable(context.planStorage, getTotalSize(allowed.roots, allowed.files))) { + context.onPlanLimitReached(); + return; + } + + const { collisionGroups, rootsWithoutCollision, filesWithoutCollision } = await findNameCollisions( + destinationFolderUuid, + allowed.roots, + allowed.files, + ); + + const hasNameCollisions = collisionGroups.length > 0; + if (hasNameCollisions) { + context.onNameCollisions(collisionGroups); + } + + const hasEverythingCollided = rootsWithoutCollision.length === 0 && filesWithoutCollision.length === 0; + if (hasEverythingCollided) return; + + await startUpload({ destinationFolderUuid, roots: rootsWithoutCollision, files: filesWithoutCollision }, context); +}; + +/** + * Uploads items that already went through the upload checks, such as those whose name collision was resolved. + */ +export const startUpload = async ( + { destinationFolderUuid, roots, files }: UploadItemsPayload, + context: UploadContext, +): Promise => { + const uploadRepository = DatabaseUploadRepository.getInstance(); + + const upload = new Upload({ + destinationFolderUuid, + roots, + files, + ownerUserAuthenticationData: context.ownerUserAuthenticationData, + uploadRepository, + onItemUploaded: (uploadedItem, parentFolderUuid, isFolder) => { + const isFolderOutsideDestination = isFolder && parentFolderUuid !== destinationFolderUuid; + if (isFolderOutsideDestination) return; + + context.onItemUploaded(uploadedItem as DriveItemData, parentFolderUuid); + }, + onItemRemoved: (removedItem, parentFolderUuid) => + context.onItemRemoved(removedItem as DriveItemData, parentFolderUuid), + onStorageFull: context.onPlanLimitReached, + }); + + await upload.run(); + + setTimeout(() => context.onUploadFinished(destinationFolderUuid), REFRESH_DELAY_MS); +}; diff --git a/src/app/upload/useUploadItems.ts b/src/app/upload/useUploadItems.ts new file mode 100644 index 0000000000..ae4ca92946 --- /dev/null +++ b/src/app/upload/useUploadItems.ts @@ -0,0 +1,54 @@ +import { useMemo } from 'react'; +import { useStore } from 'react-redux'; +import { t } from 'i18next'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { AppDispatch, RootState } from 'app/store'; +import { useAppDispatch } from 'app/store/hooks'; +import { fileVersionsSelectors } from 'app/store/slices/fileVersions'; +import { planSelectors, planThunks } from 'app/store/slices/plan'; +import { storageActions } from 'app/store/slices/storage'; +import { fetchSortedFolderContentThunk } from 'app/store/slices/storage/storage.thunks/fetchSortedFolderContentThunk'; +import { uiActions } from 'app/store/slices/ui'; +import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; +import { startUpload, UploadContext, uploadItems, UploadItemsPayload } from './uploadItems'; + +const createUploadContext = (dispatch: AppDispatch, state: RootState): UploadContext => ({ + ownerUserAuthenticationData: workspacesSelectors.getOwnerUserAuthenticationData(state), + maxUploadFileSize: fileVersionsSelectors.getMaxFileSizeLimit(state), + planStorage: { limit: planSelectors.planLimitToShow(state), usage: planSelectors.planUsageToShow(state) }, + onUploadItemsLimitExceeded: () => { + dispatch(uiActions.setIsUploadItemsFailsDialogOpen(true)); + notificationsService.show({ text: t('drive.uploadItems.advice'), type: ToastType.Warning }); + }, + onFilesTooLarge: (exceededFiles) => + dispatch(uiActions.setOpenFileSizeLimitReachedDialog({ open: true, info: { exceededFiles } })), + onPlanLimitReached: () => dispatch(uiActions.setOpenReachedPlanLimitDialog({ open: true })), + onNameCollisions: (collisionGroups) => + dispatch( + uiActions.setIsNameCollisionDialogOpen({ open: true, info: { groups: collisionGroups, operation: 'upload' } }), + ), + onItemUploaded: (uploadedItem, parentFolderUuid) => + dispatch(storageActions.pushItems({ updateRecents: true, folderIds: [parentFolderUuid], items: [uploadedItem] })), + onItemRemoved: (removedItem, parentFolderUuid) => + dispatch(storageActions.popItems({ folderIds: [parentFolderUuid], items: [removedItem] })), + onUploadFinished: (destinationFolderUuid) => { + dispatch(fetchSortedFolderContentThunk(destinationFolderUuid)); + dispatch(planThunks.fetchUsageThunk()); + const isWorkspaceMember = !!workspacesSelectors.getSelectedWorkspace(state)?.workspaceUser?.memberId; + if (isWorkspaceMember) dispatch(planThunks.fetchBusinessLimitUsageThunk()); + }, +}); + +export const useUploadItems = () => { + const dispatch = useAppDispatch(); + const store = useStore(); + + return useMemo(() => { + const getUploadContext = () => createUploadContext(dispatch, store.getState()); + + return { + uploadItems: (payload: UploadItemsPayload) => uploadItems(payload, getUploadContext()), + startUpload: (payload: UploadItemsPayload) => startUpload(payload, getUploadContext()), + }; + }, [dispatch, store]); +}; diff --git a/src/components/BreadcrumbsHelper.test.ts b/src/components/BreadcrumbsHelper.test.ts index 84c738f47e..0a52afcbb0 100644 --- a/src/components/BreadcrumbsHelper.test.ts +++ b/src/components/BreadcrumbsHelper.test.ts @@ -13,7 +13,8 @@ import { DropTargetMonitor } from 'react-dnd'; import { NativeTypes } from 'react-dnd-html5-backend'; import storageThunks from 'app/store/slices/storage/storage.thunks'; import { transformDraggedItems } from 'services/drag-and-drop.service'; -import { notifyUploadFailed } from 'views/Drive/components/DriveExplorer/helpers/uploadHelpers'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import errorService from 'services/error.service'; vi.mock('app/store/slices/storage/storage.thunks', () => ({ default: { @@ -38,10 +39,6 @@ vi.mock('services/drag-and-drop.service', () => ({ transformDraggedItems: vi.fn().mockResolvedValue({ rootList: [], files: [] }), })); -vi.mock('views/Drive/components/DriveExplorer/helpers/uploadHelpers', () => ({ - notifyUploadFailed: vi.fn(), -})); - const createMockBreadcrumbItem = (overrides?: Partial): BreadcrumbItemData => ({ uuid: 'folder-123', label: 'Test Folder', @@ -91,6 +88,8 @@ const mockDispatch = vi.fn() as unknown as AppDispatch; beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(notificationsService, 'show').mockReturnValue(undefined as never); + vi.spyOn(errorService, 'reportError').mockReturnValue(); vi.resetModules(); }); @@ -172,7 +171,9 @@ describe('onItemDropped', () => { await onItemDropped(item, [], false, [], mockDispatch)(draggedItem, monitor); - expect(notifyUploadFailed).toHaveBeenCalledWith(readError); + expect(notificationsService.show).toHaveBeenCalledWith( + expect.objectContaining({ text: 'File could not be read', type: ToastType.Error }), + ); expect(storageThunks.uploadItemsThunk).not.toHaveBeenCalled(); }); }); diff --git a/src/components/BreadcrumbsHelper.ts b/src/components/BreadcrumbsHelper.ts index 1913c257d2..5d115ad9c1 100644 --- a/src/components/BreadcrumbsHelper.ts +++ b/src/components/BreadcrumbsHelper.ts @@ -8,7 +8,7 @@ import { getCollisionGroups } from 'app/store/slices/storage/storage.thunks/rena import { uiActions } from 'app/store/slices/ui'; import { DropTargetMonitor } from 'react-dnd'; import { NativeTypes } from 'react-dnd-html5-backend'; -import { notifyUploadFailed } from 'views/Drive/components/DriveExplorer/helpers/uploadHelpers'; +import { notifyUploadFailed } from 'app/upload/notifyUploadFailed'; export const getItemsToMoveWhenSelected = ( droppedData: DriveItemData, diff --git a/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx b/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx index fb4f86bfd3..f7bf191a69 100644 --- a/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx +++ b/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx @@ -80,9 +80,9 @@ const DriveExplorer = ({ onUploadFolderButtonClicked, onFileInputChanged, onFolderInputChanged, - uploadItems, + uploadToCurrentFolder, } = useDriveExplorerUpload(currentFolderId); - const { isOver, dropRef } = useDriveExplorerDrop(uploadItems); + const { isOver, dropRef } = useDriveExplorerDrop(uploadToCurrentFolder); const contextMenu = useDriveExplorerContextMenu(); diff --git a/src/views/Drive/components/DriveExplorer/helpers/uploadHelpers.test.ts b/src/views/Drive/components/DriveExplorer/helpers/uploadHelpers.test.ts deleted file mode 100644 index e9a721b04d..0000000000 --- a/src/views/Drive/components/DriveExplorer/helpers/uploadHelpers.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { AnyAction } from '@reduxjs/toolkit'; -import { uiActions } from 'app/store/slices/ui'; -import storageThunks from 'app/store/slices/storage/storage.thunks'; -import { fetchSortedFolderContentThunk } from 'app/store/slices/storage/storage.thunks/fetchSortedFolderContentThunk'; -import { - handleRepeatedUploadingFiles, - handleRepeatedUploadingFolders, -} from 'app/store/slices/storage/storage.thunks/renameItemsThunk'; -import { IRoot } from 'app/store/slices/storage/types'; -import { uploadFoldersWithTracking } from 'app/drive/services/folder.service/uploadFoldersWithTracking'; -import { DriveItemData } from 'app/drive/types'; -import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; -import { UPLOAD_ITEMS_LIMIT, UploadDestination, uploadItemsToFolder } from './uploadHelpers'; - -vi.mock('app/store/slices/storage/storage.thunks', () => ({ - default: { uploadItemsThunk: vi.fn((payload) => ({ type: 'uploadItems', payload })) }, -})); -vi.mock('app/store/slices/storage/storage.thunks/fetchSortedFolderContentThunk', () => ({ - fetchSortedFolderContentThunk: vi.fn((folderId) => ({ type: 'fetchSortedFolderContent', payload: folderId })), -})); -vi.mock('app/store/slices/storage/storage.thunks/renameItemsThunk', () => ({ - handleRepeatedUploadingFiles: vi.fn(), - handleRepeatedUploadingFolders: vi.fn(), -})); -vi.mock('app/drive/services/folder.service/uploadFoldersWithTracking', () => ({ - uploadFoldersWithTracking: vi.fn(() => Promise.resolve()), -})); -vi.mock('app/notifications/services/notifications.service', () => ({ - default: { show: vi.fn() }, - ToastType: { Warning: 'warning', Error: 'error' }, -})); - -const CURRENT_FOLDER_ID = 'current-folder-uuid'; - -const aFile = (name: string) => new File(['content'], name); -const aRoot = (name: string) => - ({ isFolder: true, name, folderId: CURRENT_FOLDER_ID, childrenFiles: [], childrenFolders: [] }) as IRoot; -const anExistingItem = (name: string) => ({ name, uuid: `${name}-uuid` }) as unknown as DriveItemData; - -const createDestination = (): UploadDestination => ({ - dispatch: vi.fn(() => Promise.resolve()) as unknown as UploadDestination['dispatch'], - currentFolderId: CURRENT_FOLDER_ID, - selectedWorkspace: null, - maxUploadFileSize: 1024, -}); - -const dispatchedActions = (destination: UploadDestination) => - vi.mocked(destination.dispatch).mock.calls.map(([action]) => action as AnyAction); - -const withoutConflicts = (items: T[]) => ({ repeatedItems: [], existingItems: [], unrepeatedItems: items }); - -describe('Uploading items to the current folder', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(handleRepeatedUploadingFiles).mockImplementation(async (files) => withoutConflicts(files)); - vi.mocked(handleRepeatedUploadingFolders).mockImplementation(async (folders) => withoutConflicts(folders)); - }); - - test('When the files have no name conflicts, then they are uploaded to the current folder and the folder is refreshed', async () => { - const destination = createDestination(); - const files = [aFile('report.pdf'), aFile('photo.png')]; - - await uploadItemsToFolder(destination, [], files); - - expect(storageThunks.uploadItemsThunk).toHaveBeenCalledWith({ files, parentFolderId: CURRENT_FOLDER_ID }); - await vi.waitFor(() => - expect(dispatchedActions(destination)).toContainEqual(fetchSortedFolderContentThunk(CURRENT_FOLDER_ID)), - ); - }); - - test('When some files already exist in the folder, then the user is asked how to resolve them and only the rest are uploaded', async () => { - const destination = createDestination(); - const conflictingFile = aFile('report.pdf'); - const newFile = aFile('photo.png'); - const existingReport = anExistingItem('report.pdf'); - vi.mocked(handleRepeatedUploadingFiles).mockResolvedValue({ - repeatedItems: [conflictingFile], - existingItems: [existingReport], - unrepeatedItems: [newFile], - }); - - await uploadItemsToFolder(destination, [], [conflictingFile, newFile]); - - expect(dispatchedActions(destination)).toContainEqual( - uiActions.setIsNameCollisionDialogOpen({ - open: true, - info: { - groups: [ - { - destinationUuid: CURRENT_FOLDER_ID, - duplicatedItems: [conflictingFile], - existingItems: [existingReport], - unrepeatedItems: [newFile] as unknown as DriveItemData[], - }, - ], - operation: 'upload', - }, - }), - ); - expect(storageThunks.uploadItemsThunk).toHaveBeenCalledWith({ - files: [newFile], - parentFolderId: CURRENT_FOLDER_ID, - }); - }); - - test('When both files and folders have name conflicts, then a single dialog shows a group for each', async () => { - const destination = createDestination(); - const conflictingFile = aFile('report.pdf'); - const conflictingFolder = aRoot('Photos'); - vi.mocked(handleRepeatedUploadingFiles).mockResolvedValue({ - repeatedItems: [conflictingFile], - existingItems: [anExistingItem('report.pdf')], - unrepeatedItems: [], - }); - vi.mocked(handleRepeatedUploadingFolders).mockResolvedValue({ - repeatedItems: [conflictingFolder], - existingItems: [anExistingItem('Photos')], - unrepeatedItems: [], - }); - - await uploadItemsToFolder(destination, [conflictingFolder], [conflictingFile]); - - const collisionDialogs = dispatchedActions(destination).filter( - (action) => action.type === uiActions.setIsNameCollisionDialogOpen.type, - ); - expect(collisionDialogs).toHaveLength(1); - expect(collisionDialogs[0].payload.info.groups.map((group) => group.duplicatedItems)).toStrictEqual([ - [conflictingFile], - [conflictingFolder], - ]); - expect(storageThunks.uploadItemsThunk).not.toHaveBeenCalled(); - expect(uploadFoldersWithTracking).not.toHaveBeenCalled(); - }); - - test('When the folders have no name conflicts, then they are uploaded to the current folder and the folder is refreshed', async () => { - const destination = createDestination(); - const folder = aRoot('Photos'); - - await uploadItemsToFolder(destination, [folder], []); - - expect(uploadFoldersWithTracking).toHaveBeenCalledWith({ - payload: [{ root: folder, currentFolderId: CURRENT_FOLDER_ID }], - selectedWorkspace: null, - dispatch: destination.dispatch, - maxUploadFileSize: 1024, - }); - expect(dispatchedActions(destination)).toContainEqual(fetchSortedFolderContentThunk(CURRENT_FOLDER_ID)); - }); - - test('When there are more items than the upload limit, then the user is warned and nothing is uploaded', async () => { - const destination = createDestination(); - const files = Array.from({ length: UPLOAD_ITEMS_LIMIT + 1 }, (_, index) => aFile(`file-${index}.txt`)); - - await uploadItemsToFolder(destination, [], files); - - expect(dispatchedActions(destination)).toContainEqual(uiActions.setIsUploadItemsFailsDialogOpen(true)); - expect(notificationsService.show).toHaveBeenCalledWith(expect.objectContaining({ type: ToastType.Warning })); - expect(handleRepeatedUploadingFiles).not.toHaveBeenCalled(); - expect(storageThunks.uploadItemsThunk).not.toHaveBeenCalled(); - }); - - test('When the number of items is exactly the upload limit, then they are uploaded', async () => { - const destination = createDestination(); - const files = Array.from({ length: UPLOAD_ITEMS_LIMIT }, (_, index) => aFile(`file-${index}.txt`)); - - await uploadItemsToFolder(destination, [], files); - - expect(storageThunks.uploadItemsThunk).toHaveBeenCalledWith({ files, parentFolderId: CURRENT_FOLDER_ID }); - expect(notificationsService.show).not.toHaveBeenCalled(); - }); - - test('When checking for name conflicts fails, then the user is told the upload failed and nothing is uploaded', async () => { - const destination = createDestination(); - vi.mocked(handleRepeatedUploadingFiles).mockRejectedValue(new Error('Network error')); - - await uploadItemsToFolder(destination, [], [aFile('report.pdf')]); - - expect(notificationsService.show).toHaveBeenCalledWith( - expect.objectContaining({ text: 'Network error', type: ToastType.Error }), - ); - expect(storageThunks.uploadItemsThunk).not.toHaveBeenCalled(); - }); - - test('When uploading the folders fails, then the user is told the upload failed', async () => { - const destination = createDestination(); - vi.mocked(uploadFoldersWithTracking).mockRejectedValueOnce(new Error('Upload failed')); - - await uploadItemsToFolder(destination, [aRoot('Photos')], []); - - expect(notificationsService.show).toHaveBeenCalledWith( - expect.objectContaining({ text: 'Upload failed', type: ToastType.Error }), - ); - }); -}); diff --git a/src/views/Drive/components/DriveExplorer/helpers/uploadHelpers.ts b/src/views/Drive/components/DriveExplorer/helpers/uploadHelpers.ts deleted file mode 100644 index 1c41a6a60d..0000000000 --- a/src/views/Drive/components/DriveExplorer/helpers/uploadHelpers.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { WorkspaceData } from '@internxt/sdk/dist/workspaces'; -import { t } from 'i18next'; -import { AppDispatch } from 'app/store'; -import { uiActions } from 'app/store/slices/ui'; -import storageThunks from 'app/store/slices/storage/storage.thunks'; -import { CollisionGroup } from 'app/store/slices/storage/storage.model'; -import { fetchSortedFolderContentThunk } from 'app/store/slices/storage/storage.thunks/fetchSortedFolderContentThunk'; -import { - handleRepeatedUploadingFiles, - handleRepeatedUploadingFolders, -} from 'app/store/slices/storage/storage.thunks/renameItemsThunk'; -import { IRoot } from 'app/store/slices/storage/types'; -import { uploadFoldersWithTracking } from 'app/drive/services/folder.service/uploadFoldersWithTracking'; -import { DriveItemData } from 'app/drive/types'; -import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; -import errorService from 'services/error.service'; - -export const UPLOAD_ITEMS_LIMIT = 3000; - -export interface UploadDestination { - dispatch: AppDispatch; - currentFolderId: string; - selectedWorkspace: WorkspaceData | null; - maxUploadFileSize: number; -} - -interface RepeatedItemsCheck { - repeatedItems: T[]; - existingItems: DriveItemData[]; - unrepeatedItems: T[]; -} - -const noRepeatedItems = (): RepeatedItemsCheck => ({ repeatedItems: [], existingItems: [], unrepeatedItems: [] }); - -const buildCollisionGroups = (destinationUuid: string, checks: RepeatedItemsCheck[]): CollisionGroup[] => - checks - .filter((check) => check.repeatedItems.length > 0) - .map( - (check) => - ({ - destinationUuid, - duplicatedItems: check.repeatedItems, - existingItems: check.existingItems, - unrepeatedItems: check.unrepeatedItems, - }) as CollisionGroup, - ); - -const notifyUploadItemsLimitExceeded = (dispatch: AppDispatch) => { - dispatch(uiActions.setIsUploadItemsFailsDialogOpen(true)); - notificationsService.show({ - text: t('drive.uploadItems.advice'), - type: ToastType.Warning, - }); -}; - -export const notifyUploadFailed = (error: unknown) => { - const uploadError = errorService.castError(error); - errorService.reportError(uploadError); - notificationsService.show({ text: uploadError.message, type: ToastType.Error, requestId: uploadError.requestId }); -}; - -const checkAndUploadItems = async ( - { dispatch, currentFolderId, selectedWorkspace, maxUploadFileSize }: UploadDestination, - rootList: IRoot[], - files: File[], -): Promise => { - if (files.length > UPLOAD_ITEMS_LIMIT) { - notifyUploadItemsLimitExceeded(dispatch); - return; - } - - const [filesCheck, foldersCheck] = await Promise.all([ - files.length > 0 ? handleRepeatedUploadingFiles(files, currentFolderId) : noRepeatedItems(), - rootList.length > 0 ? handleRepeatedUploadingFolders(rootList, currentFolderId) : noRepeatedItems(), - ]); - - const collisionGroups = buildCollisionGroups(currentFolderId, [filesCheck, foldersCheck]); - if (collisionGroups.length > 0) { - dispatch( - uiActions.setIsNameCollisionDialogOpen({ open: true, info: { groups: collisionGroups, operation: 'upload' } }), - ); - } - - if (filesCheck.unrepeatedItems.length > 0) { - dispatch( - storageThunks.uploadItemsThunk({ - files: filesCheck.unrepeatedItems as File[], - parentFolderId: currentFolderId, - }), - ).then(() => { - dispatch(fetchSortedFolderContentThunk(currentFolderId)); - }); - } - - if (foldersCheck.unrepeatedItems.length > 0) { - await uploadFoldersWithTracking({ - payload: (foldersCheck.unrepeatedItems as IRoot[]).map((root) => ({ root, currentFolderId })), - selectedWorkspace, - dispatch, - maxUploadFileSize, - }); - await dispatch(fetchSortedFolderContentThunk(currentFolderId)); - } -}; - -export const uploadItemsToFolder = async ( - destination: UploadDestination, - rootList: IRoot[], - files: File[], -): Promise => { - try { - await checkAndUploadItems(destination, rootList, files); - } catch (error) { - notifyUploadFailed(error); - } -}; diff --git a/src/views/Drive/components/DriveExplorer/hooks/useDriveExplorerDrop.test.tsx b/src/views/Drive/components/DriveExplorer/hooks/useDriveExplorerDrop.test.tsx index 4f179bf148..0c48410154 100644 --- a/src/views/Drive/components/DriveExplorer/hooks/useDriveExplorerDrop.test.tsx +++ b/src/views/Drive/components/DriveExplorer/hooks/useDriveExplorerDrop.test.tsx @@ -4,27 +4,13 @@ import { DndProvider, useDrop } from 'react-dnd'; import { HTML5Backend, NativeTypes } from 'react-dnd-html5-backend'; import { IRoot } from 'app/store/slices/storage/types'; import { transformDraggedItems } from 'services/drag-and-drop.service'; -import notificationsService from 'app/notifications/services/notifications.service'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import errorService from 'services/error.service'; import { useDriveExplorerDrop } from './useDriveExplorerDrop'; vi.mock('services/drag-and-drop.service', () => ({ transformDraggedItems: vi.fn(), })); -vi.mock('app/store/slices/storage/storage.thunks', () => ({ default: { uploadItemsThunk: vi.fn() } })); -vi.mock('app/store/slices/storage/storage.thunks/fetchSortedFolderContentThunk', () => ({ - fetchSortedFolderContentThunk: vi.fn(), -})); -vi.mock('app/store/slices/storage/storage.thunks/renameItemsThunk', () => ({ - handleRepeatedUploadingFiles: vi.fn(), - handleRepeatedUploadingFolders: vi.fn(), -})); -vi.mock('app/drive/services/folder.service/uploadFoldersWithTracking', () => ({ - uploadFoldersWithTracking: vi.fn(), -})); -vi.mock('app/notifications/services/notifications.service', () => ({ - default: { show: vi.fn() }, - ToastType: { Warning: 'warning', Error: 'error' }, -})); const DropArea = ({ onItemsDropped }: { onItemsDropped: (rootList: IRoot[], files: File[]) => Promise }) => { const { isOver, dropRef } = useDriveExplorerDrop(onItemsDropped); @@ -86,6 +72,8 @@ const releasePendingDrag = () => { describe('Dropping files into the Drive explorer', () => { beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(notificationsService, 'show').mockReturnValue(undefined as never); + vi.spyOn(errorService, 'reportError').mockReturnValue(); vi.mocked(transformDraggedItems).mockResolvedValue({ rootList: [], files: [] }); }); @@ -123,14 +111,15 @@ describe('Dropping files into the Drive explorer', () => { }); test('When the dropped items cannot be read, then the user is told the upload failed', async () => { - vi.mocked(transformDraggedItems).mockRejectedValue(new Error('Folder could not be read')); + const readError = new Error('Folder could not be read'); + vi.mocked(transformDraggedItems).mockRejectedValue(readError); const { onItemsDropped } = renderDropArea(); dropOn(screen.getByTestId('drop-area'), draggedFiles('report.pdf')); await vi.waitFor(() => expect(notificationsService.show).toHaveBeenCalledWith( - expect.objectContaining({ text: 'Folder could not be read', type: 'error' }), + expect.objectContaining({ text: 'Folder could not be read', type: ToastType.Error }), ), ); expect(onItemsDropped).not.toHaveBeenCalled(); diff --git a/src/views/Drive/components/DriveExplorer/hooks/useDriveExplorerDrop.ts b/src/views/Drive/components/DriveExplorer/hooks/useDriveExplorerDrop.ts index 2bd9306295..ad92e8dc21 100644 --- a/src/views/Drive/components/DriveExplorer/hooks/useDriveExplorerDrop.ts +++ b/src/views/Drive/components/DriveExplorer/hooks/useDriveExplorerDrop.ts @@ -2,7 +2,7 @@ import { useDrop } from 'react-dnd'; import { NativeTypes } from 'react-dnd-html5-backend'; import { IRoot } from 'app/store/slices/storage/types'; import { transformDraggedItems } from 'services/drag-and-drop.service'; -import { notifyUploadFailed } from '../helpers/uploadHelpers'; +import { notifyUploadFailed } from 'app/upload/notifyUploadFailed'; interface DroppedNativeFiles { files: File[]; diff --git a/src/views/Drive/components/DriveExplorer/hooks/useDriveExplorerUpload.test.tsx b/src/views/Drive/components/DriveExplorer/hooks/useDriveExplorerUpload.test.tsx index 9beba653c7..de34a55fc5 100644 --- a/src/views/Drive/components/DriveExplorer/hooks/useDriveExplorerUpload.test.tsx +++ b/src/views/Drive/components/DriveExplorer/hooks/useDriveExplorerUpload.test.tsx @@ -1,45 +1,13 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/react'; -import storageThunks from 'app/store/slices/storage/storage.thunks'; -import { - handleRepeatedUploadingFiles, - handleRepeatedUploadingFolders, -} from 'app/store/slices/storage/storage.thunks/renameItemsThunk'; -import { uploadFoldersWithTracking } from 'app/drive/services/folder.service/uploadFoldersWithTracking'; -import notificationsService from 'app/notifications/services/notifications.service'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import errorService from 'services/error.service'; import UploadInputs from '../components/UploadInputs'; -import { UPLOAD_ITEMS_LIMIT } from '../helpers/uploadHelpers'; import { useDriveExplorerUpload } from './useDriveExplorerUpload'; -const { dispatch } = vi.hoisted(() => ({ dispatch: vi.fn(() => Promise.resolve()) })); - -vi.mock('app/store/hooks', () => ({ - useAppDispatch: () => dispatch, - useAppSelector: vi.fn(), -})); -vi.mock('app/store/slices/fileVersions', () => ({ - fileVersionsSelectors: { getMaxFileSizeLimit: vi.fn() }, -})); -vi.mock('app/store/slices/workspaces/workspaces.selectors', () => ({ - default: { getSelectedWorkspace: vi.fn() }, -})); -vi.mock('app/store/slices/storage/storage.thunks', () => ({ - default: { uploadItemsThunk: vi.fn((payload) => ({ type: 'uploadItems', payload })) }, -})); -vi.mock('app/store/slices/storage/storage.thunks/fetchSortedFolderContentThunk', () => ({ - fetchSortedFolderContentThunk: vi.fn(), -})); -vi.mock('app/store/slices/storage/storage.thunks/renameItemsThunk', () => ({ - handleRepeatedUploadingFiles: vi.fn(), - handleRepeatedUploadingFolders: vi.fn(), -})); -vi.mock('app/drive/services/folder.service/uploadFoldersWithTracking', () => ({ - uploadFoldersWithTracking: vi.fn(() => Promise.resolve()), -})); -vi.mock('app/notifications/services/notifications.service', () => ({ - default: { show: vi.fn() }, - ToastType: { Warning: 'warning', Error: 'error' }, -})); +const { uploadItems } = vi.hoisted(() => ({ uploadItems: vi.fn((): Promise => Promise.resolve()) })); + +vi.mock('app/upload/useUploadItems', () => ({ useUploadItems: () => ({ uploadItems }) })); const CURRENT_FOLDER_ID = 'current-folder-uuid'; @@ -74,16 +42,8 @@ const folderInput = (container: HTMLElement) => describe('Uploading from the Drive explorer pickers', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(handleRepeatedUploadingFiles).mockImplementation(async (files) => ({ - repeatedItems: [], - existingItems: [], - unrepeatedItems: files, - })); - vi.mocked(handleRepeatedUploadingFolders).mockImplementation(async (folders) => ({ - repeatedItems: [], - existingItems: [], - unrepeatedItems: folders, - })); + vi.spyOn(notificationsService, 'show').mockReturnValue(undefined as never); + vi.spyOn(errorService, 'reportError').mockReturnValue(); }); test('When the user clicks to upload files or a folder, then the matching picker opens', () => { @@ -105,7 +65,7 @@ describe('Uploading from the Drive explorer pickers', () => { pickFiles(fileInput(container), files); await vi.waitFor(() => - expect(storageThunks.uploadItemsThunk).toHaveBeenCalledWith({ files, parentFolderId: CURRENT_FOLDER_ID }), + expect(uploadItems).toHaveBeenCalledWith({ destinationFolderUuid: CURRENT_FOLDER_ID, roots: [], files }), ); }); @@ -116,16 +76,11 @@ describe('Uploading from the Drive explorer pickers', () => { pickFiles(folderInput(container), [photo]); await vi.waitFor(() => - expect(uploadFoldersWithTracking).toHaveBeenCalledWith( - expect.objectContaining({ - payload: [ - { - root: expect.objectContaining({ name: 'Photos', folderId: CURRENT_FOLDER_ID, childrenFiles: [photo] }), - currentFolderId: CURRENT_FOLDER_ID, - }, - ], - }), - ), + expect(uploadItems).toHaveBeenCalledWith({ + destinationFolderUuid: CURRENT_FOLDER_ID, + roots: [expect.objectContaining({ name: 'Photos', childrenFiles: [photo], childrenFolders: [] })], + files: [], + }), ); }); @@ -138,19 +93,9 @@ describe('Uploading from the Drive explorer pickers', () => { await vi.waitFor(() => expect(input.value).toBe('')); }); - test('When the picked files exceed the upload limit, then the picker is still cleared so the user can pick again', async () => { - const { container } = render(); - const input = fileInput(container); - const tooManyFiles = Array.from({ length: UPLOAD_ITEMS_LIMIT + 1 }, (_, index) => new File([''], `${index}.txt`)); - - pickFiles(input, tooManyFiles); - - await vi.waitFor(() => expect(input.value).toBe('')); - expect(storageThunks.uploadItemsThunk).not.toHaveBeenCalled(); - }); - - test('When checking for name conflicts fails, then the user is told the upload failed and the picker is cleared', async () => { - vi.mocked(handleRepeatedUploadingFiles).mockRejectedValue(new Error('Network error')); + test('When the upload fails, then the user is told the upload failed and the picker is cleared', async () => { + const uploadError = new Error('Network error'); + uploadItems.mockRejectedValueOnce(uploadError); const { container } = render(); const input = fileInput(container); @@ -158,7 +103,7 @@ describe('Uploading from the Drive explorer pickers', () => { await vi.waitFor(() => expect(notificationsService.show).toHaveBeenCalledWith( - expect.objectContaining({ text: 'Network error', type: 'error' }), + expect.objectContaining({ text: 'Network error', type: ToastType.Error }), ), ); expect(input.value).toBe(''); @@ -178,7 +123,7 @@ describe('Uploading from the Drive explorer pickers', () => { await vi.waitFor(() => expect(notificationsService.show).toHaveBeenCalledWith( - expect.objectContaining({ text: 'Folder could not be read', type: 'error' }), + expect.objectContaining({ text: 'Folder could not be read', type: ToastType.Error }), ), ); expect(input.value).toBe(''); diff --git a/src/views/Drive/components/DriveExplorer/hooks/useDriveExplorerUpload.ts b/src/views/Drive/components/DriveExplorer/hooks/useDriveExplorerUpload.ts index 9fae41f1b0..9633cda689 100644 --- a/src/views/Drive/components/DriveExplorer/hooks/useDriveExplorerUpload.ts +++ b/src/views/Drive/components/DriveExplorer/hooks/useDriveExplorerUpload.ts @@ -1,22 +1,18 @@ import { ChangeEvent, useCallback, useRef } from 'react'; -import { useAppDispatch, useAppSelector } from 'app/store/hooks'; -import { fileVersionsSelectors } from 'app/store/slices/fileVersions'; -import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; import { transformInputFilesToItems } from 'app/drive/services/folder.service/uploadFolderInput.service'; import { IRoot } from 'app/store/slices/storage/types'; -import { notifyUploadFailed, uploadItemsToFolder } from '../helpers/uploadHelpers'; +import { notifyUploadFailed } from 'app/upload/notifyUploadFailed'; +import { useUploadItems } from 'app/upload/useUploadItems'; export const useDriveExplorerUpload = (currentFolderId: string) => { - const dispatch = useAppDispatch(); - const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); - const maxUploadFileSize = useAppSelector(fileVersionsSelectors.getMaxFileSizeLimit); + const { uploadItems } = useUploadItems(); const fileInputRef = useRef(null); const folderInputRef = useRef(null); - const uploadItems = useCallback( + const uploadToCurrentFolder = useCallback( (rootList: IRoot[], files: File[]) => - uploadItemsToFolder({ dispatch, currentFolderId, selectedWorkspace, maxUploadFileSize }, rootList, files), - [dispatch, currentFolderId, selectedWorkspace, maxUploadFileSize], + uploadItems({ destinationFolderUuid: currentFolderId, roots: rootList, files }).catch(notifyUploadFailed), + [uploadItems, currentFolderId], ); const onUploadFileButtonClicked = useCallback(() => fileInputRef.current?.click(), []); @@ -27,7 +23,7 @@ export const useDriveExplorerUpload = (currentFolderId: string) => { if (!input.files) return; try { - await uploadItems([], Array.from(input.files)); + await uploadToCurrentFolder([], Array.from(input.files)); } finally { input.value = ''; } @@ -39,7 +35,7 @@ export const useDriveExplorerUpload = (currentFolderId: string) => { try { const { rootList, files } = transformInputFilesToItems(Array.from(input.files), currentFolderId); - await uploadItems(rootList, files); + await uploadToCurrentFolder(rootList, files); } catch (error) { notifyUploadFailed(error); } finally { @@ -54,6 +50,6 @@ export const useDriveExplorerUpload = (currentFolderId: string) => { onUploadFolderButtonClicked, onFileInputChanged, onFolderInputChanged, - uploadItems, + uploadToCurrentFolder, }; }; diff --git a/src/views/Drive/hooks/useDriveItemDragAndDrop.tsx b/src/views/Drive/hooks/useDriveItemDragAndDrop.tsx index 8b0af3c91b..7cef8f3c28 100644 --- a/src/views/Drive/hooks/useDriveItemDragAndDrop.tsx +++ b/src/views/Drive/hooks/useDriveItemDragAndDrop.tsx @@ -11,7 +11,7 @@ import { DriveItemData } from 'app/drive/types'; import { uploadFoldersWithTracking } from 'app/drive/services/folder.service/uploadFoldersWithTracking'; import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; import { fileVersionsSelectors } from 'app/store/slices/fileVersions'; -import { notifyUploadFailed } from 'views/Drive/components/DriveExplorer/helpers/uploadHelpers'; +import { notifyUploadFailed } from 'app/upload/notifyUploadFailed'; interface DragSourceCollectorProps { isDraggingThisItem: boolean; diff --git a/src/views/Shared/SharedView.tsx b/src/views/Shared/SharedView.tsx index 5cf9e51225..0fd32cba01 100644 --- a/src/views/Shared/SharedView.tsx +++ b/src/views/Shared/SharedView.tsx @@ -19,7 +19,7 @@ import { NativeTypes } from 'react-dnd-html5-backend'; import { Helmet } from 'react-helmet-async'; import { moveItemsToTrash } from '../Trash/services'; import errorService from 'services/error.service'; -import { UPLOAD_ITEMS_LIMIT } from 'views/Drive/components/DriveExplorer/helpers/uploadHelpers'; +import { UPLOAD_ITEMS_LIMIT } from 'app/upload/uploadChecks'; import EditItemNameDialog from '../../app/drive/components/EditItemNameDialog/EditItemNameDialog'; import FileViewerWrapper from '../../app/drive/components/FileViewer/FileViewerWrapper'; import ItemDetailsDialog from '../../app/drive/components/ItemDetailsDialog/ItemDetailsDialog';