diff --git a/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx b/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx index 43657af804..adae2d04e7 100644 --- a/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx +++ b/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx @@ -11,23 +11,18 @@ import { RootState, store } from 'app/store'; import { useAppDispatch, useAppSelector } from 'app/store/hooks'; import { setItemsToMove, storageActions } from 'app/store/slices/storage'; import storageSelectors from 'app/store/slices/storage/storage.selectors'; -import storageThunks from 'app/store/slices/storage/storage.thunks'; import { fetchDialogContentThunk } from 'app/store/slices/storage/storage.thunks/fetchDialogContentThunk'; import { getAncestorsAndSetNamePath } from 'app/store/slices/storage/storage.thunks/goToFolderThunk'; import { uiActions } from 'app/store/slices/ui'; import folderImage from 'assets/icons/light/folder.svg'; import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; -import { - handleRepeatedUploadingFiles, - handleRepeatedUploadingFolders, -} from 'app/store/slices/storage/storage.thunks/renameItemsThunk'; -import { IRoot } from 'app/store/slices/storage/types'; -import { DriveFileData, DriveFolderData, DriveItemData, FolderPathDialog } from 'app/drive/types'; +import { DriveItemData, FolderPathDialog } from 'app/drive/types'; import { CreateFolderDialog } from 'views/Drive/components'; import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; import localStorageService from 'services/local-storage.service'; import { STORAGE_KEYS } from 'services/storage-keys'; +import { useMoveItems } from 'hooks/moveItems/useMoveItems'; interface MoveItemsDialogProps { onItemsMoved?: () => void; @@ -43,7 +38,6 @@ const MoveItemsDialog = (props: MoveItemsDialogProps): JSX.Element => { const [currentFolderId, setCurrentFolderId] = useState(''); const [shownFolders, setShownFolders] = useState(props.items); const [currentFolderName, setCurrentFolderName] = useState(''); - const [selectedFolderName, setSelectedFolderName] = useState(''); const arrayOfPaths: FolderPathDialog[] = []; const [currentNamePaths, setCurrentNamePaths] = useState(arrayOfPaths); const dispatch = useAppDispatch(); @@ -54,6 +48,7 @@ const MoveItemsDialog = (props: MoveItemsDialogProps): JSX.Element => { const isDriveAndCurrentFolder = !props.isTrash && itemParentId === destinationId; const workspaceSelected = useSelector(workspacesSelectors.getSelectedWorkspace); const isWorkspaceSelected = !!workspaceSelected; + const { moveItemFromDialog } = useMoveItems(); const onCreateFolderButtonClicked = () => { dispatch(uiActions.setIsCreateFolderDialogOpen(true)); @@ -124,7 +119,6 @@ const MoveItemsDialog = (props: MoveItemsDialogProps): JSX.Element => { } else { setDestinationId(currentFolderId); } - name && setSelectedFolderName(name); }; const onClose = (): void => { @@ -167,32 +161,6 @@ const MoveItemsDialog = (props: MoveItemsDialogProps): JSX.Element => { } }; - const prepareDestinationFolder = (destinationFolderId: string, namePaths: FolderPathDialog[]) => { - if (destinationFolderId !== currentFolderId) { - namePaths.push({ uuid: destinationId, name: selectedFolderName }); - } - return destinationFolderId || currentFolderId; - }; - - const processItemsMove = async (finalDestinationId: string) => { - const files = itemsToMove.filter((item) => item.type !== 'folder') as DriveFileData[]; - const folders = itemsToMove.filter((item) => item.type === 'folder') as (IRoot | DriveFolderData)[]; - - const filesWithoutDuplicates = await handleRepeatedUploadingFiles(files, dispatch, finalDestinationId); - const foldersWithoutDuplicates = await handleRepeatedUploadingFolders(folders, dispatch, finalDestinationId); - - const itemsToMoveWithoutDuplicates = [...filesWithoutDuplicates, ...foldersWithoutDuplicates]; - - if (itemsToMoveWithoutDuplicates.length > 0) { - await dispatch( - storageThunks.moveItemsThunk({ - items: itemsToMoveWithoutDuplicates as DriveItemData[], - destinationFolderId: finalDestinationId, - }), - ); - } - }; - const finalizeMove = () => { props.onItemsMoved?.(); setIsLoading(false); @@ -203,9 +171,8 @@ const MoveItemsDialog = (props: MoveItemsDialogProps): JSX.Element => { store.dispatch(storageActions.popItemsToDelete(itemsToMove)); }; - const onAccept = async (destinationFolderId: string, _: string, namePaths: FolderPathDialog[]): Promise => { + const onAccept = async (destinationFolderId: string): Promise => { try { - dispatch(storageActions.setMoveDestinationFolderId(destinationFolderId)); setIsLoading(true); if (itemsToMove.length === 0) { @@ -213,8 +180,10 @@ const MoveItemsDialog = (props: MoveItemsDialogProps): JSX.Element => { return; } - const finalDestinationId = prepareDestinationFolder(destinationFolderId, namePaths); - await processItemsMove(finalDestinationId); + await moveItemFromDialog({ + finalDestinationId: destinationFolderId, + items: itemsToMove, + }); finalizeMove(); } catch (err: unknown) { const castedError = errorService.castError(err); @@ -261,11 +230,11 @@ const MoveItemsDialog = (props: MoveItemsDialogProps): JSX.Element => { ) : ( shownFolders - .sort((a, b) => a.name.localeCompare(b.name)) + .toSorted((a, b) => a.name.localeCompare(b.name)) .map((folder) => { return ( -
{ onClick={() => onFolderClicked(folder.uuid, folder.name)} key={folder.id} > - Folder icon - - {folder.name} - +
+ Folder icon + + {folder.name} + +
onShowFolderContentClicked(folder.uuid, folder.name)} className="h-6 w-6" /> -
+ ); }) )} @@ -303,9 +274,7 @@ const MoveItemsDialog = (props: MoveItemsDialogProps): JSX.Element => { diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index 3d465ea21b..715da0df21 100644 --- a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx +++ b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx @@ -16,6 +16,11 @@ import replaceFileService from 'views/Drive/services/replaceFile.service'; import { Network, getEnvironmentConfig } from 'app/drive/services/network.service'; import { fileVersionsActions, fileVersionsSelectors } from 'app/store/slices/fileVersions'; import { isVersioningExtensionAllowed } from 'views/Drive/components/VersionHistory/utils'; +import { checkFolderDuplicated } from 'app/store/slices/storage/folderUtils/checkFolderDuplicated'; +import { getUniqueFolderName } from 'app/store/slices/storage/folderUtils/getUniqueFolderName'; +import { getUniqueFilename } from 'app/store/slices/storage/fileUtils/getUniqueFilename'; +import { checkDuplicatedFiles } from 'app/store/slices/storage/fileUtils/checkDuplicatedFiles'; +import { MoveItemPayload } from 'app/store/slices/storage/storage.thunks/moveItemsThunk'; type NameCollisionContainerProps = { currentFolderId: string; @@ -107,25 +112,45 @@ const NameCollisionContainer: FC = ({ dispatch( storageThunks.moveItemsThunk({ items: itemsToMove, - destinationFolderId: moveDestinationFolderId as string, + destinationFolderId: folderId, }), ); }; const keepAndMoveItem = async (itemsToUpload: DriveItemData[]) => { - await dispatch( - storageThunks.renameItemsThunk({ - items: itemsToUpload, - destinationFolderId: folderId, - onRenameSuccess: (itemToUpload: DriveItemData) => - dispatch( - storageThunks.moveItemsThunk({ - items: [itemToUpload], - destinationFolderId: moveDestinationFolderId as string, - }), - ), - }), - ); + for (const item of itemsToUpload) { + let itemParsed: MoveItemPayload; + let finalItemName = item.plainName ?? item.name; + + if (item.isFolder) { + const { duplicatedFoldersResponse } = await checkFolderDuplicated([item], folderId); + + finalItemName = await getUniqueFolderName( + item.plainName ?? item.name, + duplicatedFoldersResponse as DriveItemData[], + folderId, + ); + itemParsed = { ...item, name: finalItemName, plain_name: finalItemName, newItemName: finalItemName }; + } else { + const { duplicatedFilesResponse } = await checkDuplicatedFiles([item], folderId); + + finalItemName = await getUniqueFilename(item.name, item.type, duplicatedFilesResponse, folderId); + itemParsed = { + ...item, + name: finalItemName, + plainName: finalItemName, + plain_name: finalItemName, + newItemName: finalItemName, + }; + } + + await dispatch( + storageThunks.moveItemsThunk({ + items: [itemParsed], + destinationFolderId: folderId, + }), + ); + } }; const uploadFileAndGetFileId = async (file: File, itemToReplace: DriveItemData) => { @@ -230,6 +255,10 @@ const NameCollisionContainer: FC = ({ }); }; + const popMovedItemsFromTrash = (movedItems: DriveItemData[]) => { + dispatch(storageActions.popItemsToDelete(movedItems)); + }; + const triggerSelectedOptinsOnSubmit = async ({ operationType, operation, @@ -239,12 +268,14 @@ const NameCollisionContainer: FC = ({ switch (operationType + operation) { case 'move' + 'keep': await keepAndMoveItem(itemsToUpload as DriveItemData[]); + popMovedItemsFromTrash(itemsToUpload as DriveItemData[]); break; case 'move' + 'replace': await replaceAndMoveItem({ itemsToReplace: itemsToReplace as DriveItemData[], itemsToMove: itemsToUpload as DriveItemData[], }); + popMovedItemsFromTrash(itemsToUpload as DriveItemData[]); break; case 'upload' + 'keep': await keepAndUploadItem(itemsToUpload as (File | IRoot)[]); diff --git a/src/app/drive/services/file.service/index.ts b/src/app/drive/services/file.service/index.ts index 8285ec8e75..11ebddbc81 100644 --- a/src/app/drive/services/file.service/index.ts +++ b/src/app/drive/services/file.service/index.ts @@ -17,10 +17,15 @@ export function updateMetaData( return storageClient.updateFileNameWithUUID(payload, resourcesToken); } -export async function moveFileByUuid(fileUuid: string, destinationFolderUuid: string): Promise { +export async function moveFileByUuid( + fileUuid: string, + destinationFolderUuid: string, + newFileName?: string, +): Promise { const storageClient = SdkFactory.getNewApiInstance().createNewStorageClient(); const payload: StorageTypes.MoveFileUuidPayload = { destinationFolder: destinationFolderUuid, + name: newFileName, }; return storageClient .moveFileByUuid(fileUuid, payload) diff --git a/src/app/drive/services/folder.service.ts b/src/app/drive/services/folder.service.ts index 12757d8d81..7b4b307288 100644 --- a/src/app/drive/services/folder.service.ts +++ b/src/app/drive/services/folder.service.ts @@ -527,10 +527,12 @@ export const checkIfCachedSourceIsOlder = ({ export async function moveFolderByUuid( folderUuid: string, destinationFolderUuid: string, + newFolderName?: string, ): Promise { const storageClient = SdkFactory.getNewApiInstance().createNewStorageClient(); const payload: StorageTypes.MoveFolderUuidPayload = { destinationFolder: destinationFolderUuid, + name: newFolderName, }; return storageClient.moveFolderByUuid(folderUuid, payload).catch((err) => { diff --git a/src/app/drive/services/storage.service/index.ts b/src/app/drive/services/storage.service/index.ts index 78c7046905..56d2c940c0 100644 --- a/src/app/drive/services/storage.service/index.ts +++ b/src/app/drive/services/storage.service/index.ts @@ -3,10 +3,10 @@ import { DriveFolderData, DriveItemData } from '../../types'; import fileService from '../file.service'; import folderService from '../folder.service'; -export function moveItem(item: DriveItemData, destinationFolderId: string): Promise { +export function moveItem(item: DriveItemData, destinationFolderId: string, newItemName?: string): Promise { return item.isFolder - ? folderService.moveFolderByUuid((item as DriveFolderData).uuid, destinationFolderId).then() - : fileService.moveFileByUuid((item as DriveFileData).uuid, destinationFolderId).then(); + ? folderService.moveFolderByUuid((item as DriveFolderData).uuid, destinationFolderId, newItemName).then() + : fileService.moveFileByUuid((item as DriveFileData).uuid, destinationFolderId, newItemName).then(); } const storageService = { diff --git a/src/app/drive/types/index.ts b/src/app/drive/types/index.ts index d95f9032b3..b2a5c54b40 100644 --- a/src/app/drive/types/index.ts +++ b/src/app/drive/types/index.ts @@ -97,6 +97,7 @@ export type DriveItemData = DriveFileData & parent?: { plainName: string; status: FileStatus; + uuid: string; }; }; diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index c23e338cec..16d61df654 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -1440,7 +1440,8 @@ "membersUpdatedSuccessfully": "Mitglieder erfolgreich aktualisiert", "errorModifyingStorage": "Der neue Speicherplatz ist für dieses Mitglied ungültig.", "generalErrorWhileModifyingStorage": "Beim Ändern des Speicherplatzes ist ein Fehler aufgetreten.", - "restoreItems": "{{itemsToRecover}} verschoben nach {{destination}}", + "restoreItems": "Die Elemente wurden wiederhergestellt", + "goToDrive": "Zu Drive gehen", "itemDeleted": "{{item}} gelöscht", "itemsDeleted": "Dateien gelöscht", "emailNotEmpty": "E-Mail darf nicht leer sein", diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 9536a5ebd1..118e90d962 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -1513,7 +1513,8 @@ "copyLink": "Shared link copied to clipboard", "linkUpdated": "Link updated", "itemsMovedToTrash": "{{item}} moved to trash", - "restoreItems": "{{itemsToRecover}} restored successfully", + "restoreItems": "The items have been restored", + "goToDrive": "Go to Drive", "moveItems": "{{itemsToMove}} moved successfully", "storageModified": "Storage modified successfully", "membersUpdatedSuccessfully": "Members updated successfully", diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 0169c4c4dc..65d92bdd2f 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -1476,7 +1476,8 @@ "copyLink": "Enlace copiado al portapapeles", "linkUpdated": "Enlace actualizado", "itemsMovedToTrash": "{{item}} movid{{s}} a la papelera", - "restoreItems": "{{itemsToRecover}} restaurad{{s}} con éxito", + "restoreItems": "Los elementos han sido restaurados", + "goToDrive": "Ir a Drive", "storageModified": "Almacenamiento modificado con éxito", "membersUpdatedSuccessfully": "Miembros actualizados con éxito", "errorModifyingStorage": "El nuevo almacenamiento no es válido para este miembro.", diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index 5ec67d27e2..1943c7a472 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -1441,7 +1441,8 @@ "copyLink": "Copier le lien dans le presse-papiers", "linkUpdated": "Lien mis à jour", "itemsMovedToTrash": "{{item}} déplacé vers la corbeille", - "restoreItems": "{{itemsToRecover}} restauré avec succès", + "restoreItems": "Les éléments ont été restaurés", + "goToDrive": "Aller à Drive", "storageModified": "Stockage modifié avec succès", "membersUpdatedSuccessfully": "Membres mis à jour avec succès", "errorModifyingStorage": "Le nouvel espace de stockage n'est pas valide pour ce membre.", diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index c0fbce7499..47ac8092f3 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -1548,7 +1548,8 @@ "copyLink": "Link condiviso copiato negli appunti", "linkUpdated": "Link aggiornato", "itemsMovedToTrash": "{{item}} spostati nel cestino", - "restoreItems": "{{itemsToRecover}} ripristinato correttamente", + "restoreItems": "Gli elementi sono stati ripristinati", + "goToDrive": "Vai a Drive", "storageModified": "Archiviazione modificata con successo", "membersUpdatedSuccessfully": "Membri aggiornati con successo", "errorModifyingStorage": "Il nuovo spazio di archiviazione non è valido per questo membro.", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 8de89fbd5b..072743edbf 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -1460,7 +1460,8 @@ "membersUpdatedSuccessfully": "Участники успешно обновлены", "errorModifyingStorage": "Новый объём хранения недоступен для этого участника.", "generalErrorWhileModifyingStorage": "Произошла ошибка при изменении объёма хранения.", - "restoreItems": "{{itemsToRecover}} успешно восстановлено", + "restoreItems": "Элементы были восстановлены", + "goToDrive": "Перейти к Drive", "itemDeleted": "{{item}} удален", "itemsDeleted": "Файлы удалены", "emailNotEmpty": "Поле электронной почты не должно быть пустым", diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index 3f63e34caa..ec175337b1 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -1443,7 +1443,8 @@ "copyLink": "已複製共享鏈接到剪貼板", "linkUpdated": "鏈接已更新", "itemsMovedToTrash": "{{item}} 已移到垃圾桶", - "restoreItems": "{{itemsToRecover}} 成功還原", + "restoreItems": "項目已成功還原", + "goToDrive": "前往 Drive", "storageModified": "儲存空間修改成功", "membersUpdatedSuccessfully": "成員更新成功", "errorModifyingStorage": "新存儲空間對此成員無效。", diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index cdb84bbf39..f00d4e7e97 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -1483,7 +1483,8 @@ "errorModifyingStorage": "新存储空间对该成员无效。", "generalErrorWhileModifyingStorage": "修改存储时出错。", "moveItems": "{{itemsToMove}} 成功移动", - "restoreItems": "{{itemsToRecover}} 成功恢复", + "restoreItems": "项目已成功恢复", + "goToDrive": "前往 Drive", "itemDeleted": "{{item}} 已删除", "itemsDeleted": "文件已删除", "emailNotEmpty": "电子邮件不能为空", diff --git a/src/app/store/slices/storage/fileUtils/checkDuplicatedFiles.ts b/src/app/store/slices/storage/fileUtils/checkDuplicatedFiles.ts index afa308fea1..562907d837 100644 --- a/src/app/store/slices/storage/fileUtils/checkDuplicatedFiles.ts +++ b/src/app/store/slices/storage/fileUtils/checkDuplicatedFiles.ts @@ -17,7 +17,7 @@ export const checkDuplicatedFiles = async ( duplicatedFilesResponse: [], filesWithDuplicates: [], filesWithoutDuplicates: files, - } as DuplicatedFilesResult; + }; } const parsedFiles = files.map(parseFile); diff --git a/src/app/store/slices/storage/storage.thunks/moveItemsThunk.test.ts b/src/app/store/slices/storage/storage.thunks/moveItemsThunk.test.ts new file mode 100644 index 0000000000..55725a1b36 --- /dev/null +++ b/src/app/store/slices/storage/storage.thunks/moveItemsThunk.test.ts @@ -0,0 +1,70 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import tasksService from 'app/tasks/services/tasks.service'; +import storageService from 'app/drive/services/storage.service'; +import { buildDriveItemData } from '../../../../../../test/unit/fixtures/drive.fixtures'; + +const { mockDispatch } = vi.hoisted(() => ({ + mockDispatch: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('app/database/services/database.service', () => ({ + default: { get: vi.fn().mockResolvedValue(null) }, + DatabaseCollection: { Levels: 'levels' }, + DatabaseProvider: {}, + LRUCacheTypes: {}, +})); +vi.mock('app/drive/services/items-list.service', () => ({ default: { pushItems: vi.fn() } })); +vi.mock('app/notifications/services/notifications.service', () => ({ + default: { show: vi.fn() }, + ToastType: { Error: 'error' }, +})); +vi.mock('app/store/slices/storage', () => ({ + storageActions: { popItems: vi.fn(), pushItems: vi.fn(), clearSelectedItems: vi.fn() }, + storageSelectors: {}, + default: {}, +})); +vi.mock('i18next', () => ({ default: { t: (key: string) => key }, t: (key: string) => key })); +vi.mock('services/error.service', () => ({ default: { reportError: vi.fn() } })); + +import { moveItemsThunk } from './moveItemsThunk'; + +const runThunk = (payload: Parameters[0]) => + moveItemsThunk(payload)(mockDispatch, () => ({}) as any, undefined); + +describe('Move items task logger', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDispatch.mockResolvedValue(undefined); + vi.spyOn(storageService, 'moveItem').mockResolvedValue(undefined as any); + vi.spyOn(tasksService, 'create').mockReturnValue('task-id'); + vi.spyOn(tasksService, 'updateTask').mockReturnValue(undefined as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('When a file is moved and the task logger is enabled, then a task is created for it', async () => { + const file = buildDriveItemData({ isFolder: false }); + + await runThunk({ items: [file], destinationFolderId: 'dest-uuid', displayTaskLogger: true }); + + expect(tasksService.create).toHaveBeenCalledWith(expect.objectContaining({ action: 'move-file' })); + }); + + test('When a folder is moved and the task logger is enabled, then a task is created for it', async () => { + const folder = buildDriveItemData({ isFolder: true, type: 'folder' }); + + await runThunk({ items: [folder], destinationFolderId: 'dest-uuid', displayTaskLogger: true }); + + expect(tasksService.create).toHaveBeenCalledWith(expect.objectContaining({ action: 'move-folder' })); + }); + + test('When an item is moved without the task logger, then no task is created', async () => { + const file = buildDriveItemData({ isFolder: false }); + + await runThunk({ items: [file], destinationFolderId: 'dest-uuid', displayTaskLogger: false }); + + expect(tasksService.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts b/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts index a9fb495b15..d0233769e0 100644 --- a/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts +++ b/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts @@ -13,15 +13,20 @@ import { RootState } from '../../..'; import errorService from 'services/error.service'; import { StorageState } from '../storage.model'; +export interface MoveItemPayload extends DriveItemData { + newItemName?: string; +} + export interface MoveItemsPayload { - items: DriveItemData[]; + items: MoveItemPayload[]; destinationFolderId: string; + displayTaskLogger?: boolean; } export const moveItemsThunk = createAsyncThunk( 'storage/moveItems', async (payload: MoveItemsPayload, { dispatch }) => { - const { items, destinationFolderId } = payload; + const { items, destinationFolderId, displayTaskLogger } = payload; const promises: Promise[] = []; if (items.some((item) => item.isFolder && item.uuid === destinationFolderId)) { @@ -33,34 +38,38 @@ export const moveItemsThunk = createAsyncThunk({ - action: TaskType.MoveFolder, - showNotification: true, - folder: item, - destinationFolderId, - cancellable: false, - }); - } else { - taskId = tasksService.create({ - action: TaskType.MoveFile, - showNotification: true, - file: item, - destinationFolderId, - cancellable: false, - }); + if (displayTaskLogger) { + if (item.isFolder) { + taskId = tasksService.create({ + action: TaskType.MoveFolder, + showNotification: true, + folder: item, + destinationFolderId, + cancellable: false, + }); + } else { + taskId = tasksService.create({ + action: TaskType.MoveFile, + showNotification: true, + file: item, + destinationFolderId, + cancellable: false, + }); + } } - promises.push(storageService.moveItem(item, destinationFolderId)); + promises.push(storageService.moveItem(item, destinationFolderId, item.newItemName)); promises[index] .then(async () => { - tasksService.updateTask({ - taskId, - merge: { - status: TaskStatus.Success, - }, - }); + if (displayTaskLogger) { + tasksService.updateTask({ + taskId, + merge: { + status: TaskStatus.Success, + }, + }); + } dispatch( storageActions.popItems({ diff --git a/src/app/store/slices/storage/storage.thunks/renameItemsThunk.test.ts b/src/app/store/slices/storage/storage.thunks/renameItemsThunk.test.ts new file mode 100644 index 0000000000..24cc81ba74 --- /dev/null +++ b/src/app/store/slices/storage/storage.thunks/renameItemsThunk.test.ts @@ -0,0 +1,130 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { buildDriveItemData } from '../../../../../../test/unit/fixtures/drive.fixtures'; + +const { + mockDispatch, + mockSetFilesToRename, + mockSetDriveFilesToRename, + mockSetMoveDestinationFolderId, + mockSetIsNameCollisionDialogOpen, + mockSetFoldersToRename, + mockSetDriveFoldersToRename, + mockCheckDuplicatedFiles, + mockCheckFolderDuplicated, +} = vi.hoisted(() => ({ + mockDispatch: vi.fn(), + mockSetFilesToRename: vi.fn((items: unknown) => ({ type: 'setFilesToRename', payload: items })), + mockSetDriveFilesToRename: vi.fn((items: unknown) => ({ type: 'setDriveFilesToRename', payload: items })), + mockSetMoveDestinationFolderId: vi.fn((id: unknown) => ({ type: 'setMoveDestinationFolderId', payload: id })), + mockSetIsNameCollisionDialogOpen: vi.fn((val: unknown) => ({ type: 'setIsNameCollisionDialogOpen', payload: val })), + mockSetFoldersToRename: vi.fn((items: unknown) => ({ type: 'setFoldersToRename', payload: items })), + mockSetDriveFoldersToRename: vi.fn((items: unknown) => ({ type: 'setDriveFoldersToRename', payload: items })), + mockCheckDuplicatedFiles: vi.fn(), + mockCheckFolderDuplicated: vi.fn(), +})); + +vi.mock('app/store/slices/storage', () => ({ + storageActions: { + setFilesToRename: mockSetFilesToRename, + setDriveFilesToRename: mockSetDriveFilesToRename, + setMoveDestinationFolderId: mockSetMoveDestinationFolderId, + setFoldersToRename: mockSetFoldersToRename, + setDriveFoldersToRename: mockSetDriveFoldersToRename, + }, + storageSelectors: {}, + default: {}, +})); +vi.mock('app/store/slices/ui', () => ({ + uiActions: { setIsNameCollisionDialogOpen: mockSetIsNameCollisionDialogOpen }, +})); +vi.mock('app/store/slices/storage/fileUtils/checkDuplicatedFiles', () => ({ + checkDuplicatedFiles: mockCheckDuplicatedFiles, +})); +vi.mock('app/store/slices/storage/folderUtils/checkFolderDuplicated', () => ({ + checkFolderDuplicated: mockCheckFolderDuplicated, +})); +vi.mock('app/store/slices/storage/fileUtils/getFilesByBatchs', () => ({ + getFilesByBatchs: (items: unknown[]) => [items], +})); +vi.mock('app/store/slices/storage/storage.thunks', () => ({ default: {} })); +vi.mock('i18next', () => ({ default: { t: (key: string) => key }, t: (key: string) => key })); + +import { handleRepeatedUploadingFiles, handleRepeatedUploadingFolders } from './renameItemsThunk'; + +const duplicateResult = (file: unknown) => ({ + duplicatedFilesResponse: [file], + filesWithDuplicates: [file], + filesWithoutDuplicates: [], +}); + +const folderDuplicateResult = (folder: unknown) => ({ + duplicatedFoldersResponse: [folder], + foldersWithDuplicates: [folder], + foldersWithoutDuplicates: [], +}); + +describe('Duplicate file detection when moving files', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('When moving a file to a folder where a file with the same name exists, the destination folder is remembered so the dialog can resolve the conflict there', async () => { + const file = buildDriveItemData({ isFolder: false }); + mockCheckDuplicatedFiles.mockResolvedValue(duplicateResult(file)); + + await handleRepeatedUploadingFiles([file], mockDispatch, 'dest-uuid', true); + + expect(mockSetMoveDestinationFolderId).toHaveBeenCalledWith('dest-uuid'); + }); + + test('When uploading a file to a folder where a file with the same name exists, the destination is not stored as a move destination so the dialog stays in upload mode', async () => { + const file = buildDriveItemData({ isFolder: false }); + mockCheckDuplicatedFiles.mockResolvedValue(duplicateResult(file)); + + await handleRepeatedUploadingFiles([file], mockDispatch, 'dest-uuid', false); + + expect(mockSetMoveDestinationFolderId).not.toHaveBeenCalled(); + }); + + test('When no operation type is specified and a duplicate file is found, the dialog opens in upload mode by default', async () => { + const file = buildDriveItemData({ isFolder: false }); + mockCheckDuplicatedFiles.mockResolvedValue(duplicateResult(file)); + + await handleRepeatedUploadingFiles([file], mockDispatch, 'dest-uuid'); + + expect(mockSetMoveDestinationFolderId).not.toHaveBeenCalled(); + }); +}); + +describe('Duplicate folder detection when moving folders', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('When moving a folder to a location where a folder with the same name exists, the destination folder is remembered so the dialog can resolve the conflict there', async () => { + const folder = buildDriveItemData({ isFolder: true }); + mockCheckFolderDuplicated.mockResolvedValue(folderDuplicateResult(folder)); + + await handleRepeatedUploadingFolders([folder], mockDispatch, 'dest-uuid', true); + + expect(mockSetMoveDestinationFolderId).toHaveBeenCalledWith('dest-uuid'); + }); + + test('When uploading a folder to a location where a folder with the same name exists, the destination is not stored as a move destination so the dialog stays in upload mode', async () => { + const folder = buildDriveItemData({ isFolder: true }); + mockCheckFolderDuplicated.mockResolvedValue(folderDuplicateResult(folder)); + + await handleRepeatedUploadingFolders([folder], mockDispatch, 'dest-uuid', false); + + expect(mockSetMoveDestinationFolderId).not.toHaveBeenCalled(); + }); + + test('When no operation type is specified and a duplicate folder is found, the dialog opens in upload mode by default', async () => { + const folder = buildDriveItemData({ isFolder: true }); + mockCheckFolderDuplicated.mockResolvedValue(folderDuplicateResult(folder)); + + await handleRepeatedUploadingFolders([folder], mockDispatch, 'dest-uuid'); + + expect(mockSetMoveDestinationFolderId).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/store/slices/storage/storage.thunks/renameItemsThunk.ts b/src/app/store/slices/storage/storage.thunks/renameItemsThunk.ts index d7f1cbb9ba..56f9b6b788 100644 --- a/src/app/store/slices/storage/storage.thunks/renameItemsThunk.ts +++ b/src/app/store/slices/storage/storage.thunks/renameItemsThunk.ts @@ -22,6 +22,7 @@ export const handleRepeatedUploadingFiles = async ( files: (DriveFileData | File)[], dispatch: Dispatch, destinationFolderUuid: string, + isMoveOperation = false, ): Promise<(DriveFileData | File)[]> => { const batchs = getFilesByBatchs(files); const promises = batchs.map((batch) => @@ -58,15 +59,17 @@ export const handleRepeatedUploadingFiles = async ( if (hasRepeatedNameFiles) { dispatch(storageActions.setFilesToRename(filesRepeated as DriveItemData[])); dispatch(storageActions.setDriveFilesToRename(duplicatedFilesResponse as DriveItemData[])); + if (isMoveOperation) dispatch(storageActions.setMoveDestinationFolderId(destinationFolderUuid)); dispatch(uiActions.setIsNameCollisionDialogOpen(true)); } - return unrepeatedFiles as DriveItemData[]; + return unrepeatedFiles; }; export const handleRepeatedUploadingFolders = async ( folders: (DriveFolderData | IRoot)[], dispatch: Dispatch, destinationFolderUuid: string, + isMoveOperation = false, ): Promise<(DriveFolderData | IRoot)[]> => { const batchs = getFilesByBatchs(folders as (IRoot | DriveFolderData)[]); const promises = batchs.map((batch) => @@ -103,6 +106,7 @@ export const handleRepeatedUploadingFolders = async ( if (hasRepeatedNameFolders) { dispatch(storageActions.setFoldersToRename(foldersRepeated as DriveItemData[])); dispatch(storageActions.setDriveFoldersToRename(duplicatedFoldersResponse as DriveItemData[])); + if (isMoveOperation) dispatch(storageActions.setMoveDestinationFolderId(destinationFolderUuid)); dispatch(uiActions.setIsNameCollisionDialogOpen(true)); } diff --git a/src/components/BreadcrumbsHelper.test.ts b/src/components/BreadcrumbsHelper.test.ts index eb5b14a0eb..181b787aa3 100644 --- a/src/components/BreadcrumbsHelper.test.ts +++ b/src/components/BreadcrumbsHelper.test.ts @@ -12,7 +12,6 @@ import { AppDispatch } from 'app/store'; import { DragAndDropType } from 'app/core/types'; import { DropTargetMonitor } from 'react-dnd'; import { NativeTypes } from 'react-dnd-html5-backend'; -import { storageActions } from 'app/store/slices/storage'; import storageThunks from 'app/store/slices/storage/storage.thunks'; import { transformDraggedItems } from 'services/drag-and-drop.service'; @@ -159,7 +158,6 @@ describe('onItemDropped', () => { await onItemDropped(item, namePath, true, selectedItems, mockDispatch)(draggedItem, monitor); - expect(storageActions.setMoveDestinationFolderId).toHaveBeenCalledWith(item.uuid); expect(storageThunks.moveItemsThunk).toHaveBeenCalled(); }); diff --git a/src/components/BreadcrumbsHelper.ts b/src/components/BreadcrumbsHelper.ts index f5e0548847..825280dede 100644 --- a/src/components/BreadcrumbsHelper.ts +++ b/src/components/BreadcrumbsHelper.ts @@ -3,7 +3,6 @@ import { transformDraggedItems } from 'services/drag-and-drop.service'; import { DragAndDropType } from 'app/core/types'; import { FolderPath, DriveItemData } from 'app/drive/types'; import { AppDispatch } from 'app/store'; -import { storageActions } from 'app/store/slices/storage'; import storageThunks from 'app/store/slices/storage/storage.thunks'; import { handleRepeatedUploadingFiles, @@ -52,16 +51,10 @@ export const handleDriveItemDrop = async ( return i.isFolder; }); - dispatch(storageActions.setMoveDestinationFolderId(item.uuid)); - const unrepeatedFiles = await handleRepeatedUploadingFiles(filesToMove, dispatch, item.uuid); const unrepeatedFolders = await handleRepeatedUploadingFolders(foldersToMove, dispatch, item.uuid); const unrepeatedItems: DriveItemData[] = [...unrepeatedFiles, ...unrepeatedFolders] as DriveItemData[]; - if (unrepeatedItems.length === itemsToMove.length) { - dispatch(storageActions.setMoveDestinationFolderId(null)); - } - dispatch( storageThunks.moveItemsThunk({ items: unrepeatedItems, diff --git a/src/hooks/moveItems/useMoveItems.test.ts b/src/hooks/moveItems/useMoveItems.test.ts new file mode 100644 index 0000000000..7abfbd51ab --- /dev/null +++ b/src/hooks/moveItems/useMoveItems.test.ts @@ -0,0 +1,276 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { FileStatus } from '@internxt/sdk/dist/drive/storage/types'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { errorService } from 'services'; +import { DriveItemData } from 'app/drive/types'; +import { useMoveItems } from './useMoveItems'; +import { buildDriveItemData } from '../../../test/unit/fixtures/drive.fixtures'; + +const { + mockDispatch, + mockMoveItemsThunk, + mockPopItemsToDelete, + mockSetItemsToMove, + mockSetIsMoveItemsDialogOpen, + mockSetMoveDestinationFolderId, +} = vi.hoisted(() => ({ + mockDispatch: vi.fn(), + mockMoveItemsThunk: vi.fn((payload: unknown) => ({ type: 'moveItemsThunk', payload })), + mockPopItemsToDelete: vi.fn((items: unknown) => ({ type: 'popItemsToDelete', payload: items })), + mockSetItemsToMove: vi.fn((items: unknown) => ({ type: 'setItemsToMove', payload: items })), + mockSetIsMoveItemsDialogOpen: vi.fn((val: unknown) => ({ type: 'setIsMoveItemsDialogOpen', payload: val })), + mockSetMoveDestinationFolderId: vi.fn((id: unknown) => ({ type: 'setMoveDestinationFolderId', payload: id })), +})); + +vi.mock('app/store/hooks', () => ({ useAppDispatch: () => mockDispatch })); +vi.mock('react-redux', () => ({ + useSelector: vi.fn((selector: (s: unknown) => unknown) => selector({ storage: { itemsToMove: [] } })), +})); +vi.mock('i18next', () => ({ default: { t: (key: string) => key }, t: (key: string) => key })); +vi.mock('app/store/slices/storage/storage.thunks', () => ({ default: { moveItemsThunk: mockMoveItemsThunk } })); +vi.mock('app/store/slices/storage/storage.thunks/renameItemsThunk', () => ({ + handleRepeatedUploadingFiles: vi.fn(async (files: unknown[]) => files), + handleRepeatedUploadingFolders: vi.fn(async (folders: unknown[]) => folders), +})); +vi.mock('app/store/slices/storage', () => ({ + storageActions: { + popItemsToDelete: mockPopItemsToDelete, + setItemsToMove: mockSetItemsToMove, + setMoveDestinationFolderId: mockSetMoveDestinationFolderId, + }, + storageSelectors: {}, + setItemsToMove: mockSetItemsToMove, + default: {}, +})); +vi.mock('app/store/slices/ui', () => ({ + uiActions: { setIsMoveItemsDialogOpen: mockSetIsMoveItemsDialogOpen }, +})); + +const buildItemWithExistingParent = (overrides: Partial = {}): DriveItemData => + buildDriveItemData({ + parent: { uuid: 'parent-uuid', plainName: 'My Folder', status: FileStatus.EXISTS }, + ...overrides, + }); + +const buildItemWithDeletedParent = (overrides: Partial = {}): DriveItemData => + buildDriveItemData({ + parent: { uuid: 'parent-uuid', plainName: 'Deleted Folder', status: FileStatus.TRASHED }, + ...overrides, + }); + +describe('Restore items from trash', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDispatch.mockImplementation((action: unknown) => action); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Single item restore', () => { + test('When the original folder still exists, then the item is moved there directly', async () => { + const item = buildItemWithExistingParent(); + const { result } = renderHook(() => useMoveItems()); + + await act(async () => { + await result.current.restoreItemFromTrash(item); + }); + + expect(mockMoveItemsThunk).toHaveBeenCalledWith(expect.objectContaining({ destinationFolderId: 'parent-uuid' })); + }); + + test('When the original folder still exists, then the item is removed from the trash list', async () => { + const item = buildItemWithExistingParent(); + const { result } = renderHook(() => useMoveItems()); + + await act(async () => { + await result.current.restoreItemFromTrash(item); + }); + + expect(mockPopItemsToDelete).toHaveBeenCalledWith([item]); + }); + + test('When the original folder still exists, then a success notification is shown', async () => { + const item = buildItemWithExistingParent(); + const { result } = renderHook(() => useMoveItems()); + const notificationsSpy = vi.spyOn(notificationsService, 'show'); + + await act(async () => { + await result.current.restoreItemFromTrash(item); + }); + + expect(notificationsSpy).toHaveBeenCalledWith(expect.objectContaining({ type: ToastType.Success })); + }); + + test('When the original folder no longer exists, then the move dialog is opened so the user can pick a destination', async () => { + const item = buildItemWithDeletedParent(); + const { result } = renderHook(() => useMoveItems()); + + await act(async () => { + await result.current.restoreItemFromTrash(item); + }); + + expect(mockSetItemsToMove).toHaveBeenCalledWith([item]); + expect(mockSetIsMoveItemsDialogOpen).toHaveBeenCalledWith(true); + }); + + test('When the original folder no longer exists, then no success notification is shown', async () => { + const item = buildItemWithDeletedParent(); + const { result } = renderHook(() => useMoveItems()); + const notificationsSpy = vi.spyOn(notificationsService, 'show'); + + await act(async () => { + await result.current.restoreItemFromTrash(item); + }); + + expect(notificationsSpy).not.toHaveBeenCalled(); + }); + + test('When the restore fails, then an error notification is shown', async () => { + const item = buildItemWithExistingParent(); + mockDispatch.mockImplementation(() => { + throw new Error('Network error'); + }); + const { result } = renderHook(() => useMoveItems()); + const notificationsSpy = vi.spyOn(notificationsService, 'show'); + vi.spyOn(errorService, 'castError').mockReturnValue({ message: 'Network error', requestId: undefined } as any); + + await act(async () => { + await result.current.restoreItemFromTrash(item); + }); + + expect(notificationsSpy).toHaveBeenCalledWith(expect.objectContaining({ type: ToastType.Error })); + }); + }); + + describe('Multiple items restore', () => { + test('When all items have their original folder available, then all are restored and a success notification is shown', async () => { + const items = [ + buildItemWithExistingParent({ + parent: { uuid: 'parent-uuid-1', status: FileStatus.EXISTS, plainName: 'My First Folder' }, + }), + buildItemWithExistingParent({ + parent: { uuid: 'parent-uuid-1', status: FileStatus.EXISTS, plainName: 'My Second Folder' }, + }), + ]; + const { result } = renderHook(() => useMoveItems()); + const notificationsSpy = vi.spyOn(notificationsService, 'show'); + + await act(async () => { + await result.current.restoreItemsFromTrash(items); + }); + + expect(mockMoveItemsThunk).toHaveBeenCalledTimes(1); + expect(notificationsSpy).toHaveBeenCalledWith(expect.objectContaining({ type: ToastType.Success })); + }); + + test('When all items have their original folders available, then all are restored in the correct folder and a success notification is shown', async () => { + const items = [ + buildItemWithExistingParent({ + parent: { uuid: 'parent-uuid-1', status: FileStatus.EXISTS, plainName: 'My First Folder' }, + }), + buildItemWithExistingParent({ + parent: { uuid: 'parent-uuid-2', status: FileStatus.EXISTS, plainName: 'My Second Folder' }, + }), + ]; + const { result } = renderHook(() => useMoveItems()); + const notificationsSpy = vi.spyOn(notificationsService, 'show'); + + await act(async () => { + await result.current.restoreItemsFromTrash(items); + }); + + expect(mockMoveItemsThunk).toHaveBeenCalledTimes(2); + expect(notificationsSpy).toHaveBeenCalledWith(expect.objectContaining({ type: ToastType.Success })); + }); + + test('When some items have their folder deleted, then the dialog is opened only for those items', async () => { + const restorable = buildItemWithExistingParent({ uuid: 'item-1' }); + const needsDestination = buildItemWithDeletedParent({ uuid: 'item-2' }); + const { result } = renderHook(() => useMoveItems()); + + await act(async () => { + await result.current.restoreItemsFromTrash([restorable, needsDestination]); + }); + + expect(mockSetItemsToMove).toHaveBeenCalledWith([needsDestination]); + expect(mockSetIsMoveItemsDialogOpen).toHaveBeenCalledWith(true); + }); + + test('When all items need a new destination, then no success notification is shown before the user picks one', async () => { + const items = [buildItemWithDeletedParent({ uuid: 'item-1' }), buildItemWithDeletedParent({ uuid: 'item-2' })]; + const { result } = renderHook(() => useMoveItems()); + const notificationsSpy = vi.spyOn(notificationsService, 'show'); + + await act(async () => { + await result.current.restoreItemsFromTrash(items); + }); + + expect(notificationsSpy).not.toHaveBeenCalled(); + }); + + test('When any restore fails, then an error notification is shown', async () => { + const items = [buildItemWithExistingParent({ uuid: 'item-1' }), buildItemWithExistingParent({ uuid: 'item-2' })]; + mockDispatch.mockImplementation(() => { + throw new Error('Network error'); + }); + const { result } = renderHook(() => useMoveItems()); + const notificationsSpy = vi.spyOn(notificationsService, 'show'); + vi.spyOn(errorService, 'castError').mockReturnValue({ message: 'Network error', requestId: undefined } as any); + + await act(async () => { + await result.current.restoreItemsFromTrash(items); + }); + + expect(notificationsSpy).toHaveBeenCalledWith(expect.objectContaining({ type: ToastType.Error })); + }); + }); + + describe('Move from dialog', () => { + test('When the user confirms a destination in the dialog, then the item is moved there', async () => { + const item = buildDriveItemData(); + const { result } = renderHook(() => useMoveItems()); + + await act(async () => { + await result.current.moveItemFromDialog({ finalDestinationId: 'chosen-folder-uuid', items: [item] }); + }); + + expect(mockMoveItemsThunk).toHaveBeenCalledWith( + expect.objectContaining({ destinationFolderId: 'chosen-folder-uuid' }), + ); + }); + + test('When the item has a new name, then the move is called with that new name', async () => { + const item = buildDriveItemData({ newItemName: 'renamed-file' } as any); + const { result } = renderHook(() => useMoveItems()); + + await act(async () => { + await result.current.moveItemFromDialog({ finalDestinationId: 'chosen-folder-uuid', items: [item] }); + }); + + expect(mockMoveItemsThunk).toHaveBeenCalledWith( + expect.objectContaining({ + items: expect.arrayContaining([expect.objectContaining({ newItemName: 'renamed-file' })]), + }), + ); + }); + + test('When the move from dialog fails, then an error notification is shown', async () => { + const item = buildDriveItemData(); + mockDispatch.mockImplementation(() => { + throw new Error('Move failed'); + }); + const { result } = renderHook(() => useMoveItems()); + const notificationsSpy = vi.spyOn(notificationsService, 'show'); + vi.spyOn(errorService, 'castError').mockReturnValue({ message: 'Move failed', requestId: undefined } as any); + + await act(async () => { + await result.current.moveItemFromDialog({ finalDestinationId: 'chosen-folder-uuid', items: [item] }); + }); + + expect(notificationsSpy).toHaveBeenCalledWith(expect.objectContaining({ type: ToastType.Error })); + }); + }); +}); diff --git a/src/hooks/moveItems/useMoveItems.ts b/src/hooks/moveItems/useMoveItems.ts new file mode 100644 index 0000000000..09d9584286 --- /dev/null +++ b/src/hooks/moveItems/useMoveItems.ts @@ -0,0 +1,139 @@ +import { FileStatus } from '@internxt/sdk/dist/drive/storage/types'; +import { AppView } from 'app/core/types'; +import { DriveFileData, DriveFolderData, DriveItemData } from 'app/drive/types'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { useAppDispatch } from 'app/store/hooks'; +import { storageActions } from 'app/store/slices/storage'; +import storageThunks from 'app/store/slices/storage/storage.thunks'; +import { + handleRepeatedUploadingFiles, + handleRepeatedUploadingFolders, +} from 'app/store/slices/storage/storage.thunks/renameItemsThunk'; +import { IRoot } from 'app/store/slices/storage/types'; +import { uiActions } from 'app/store/slices/ui'; +import { t } from 'i18next'; +import { errorService, navigationService } from 'services'; + +interface ProcessMoveProps { + finalDestinationId: string; + items: DriveItemData[]; + displayTaskLogger?: boolean; +} + +export const useMoveItems = () => { + const dispatch = useAppDispatch(); + const processMove = async ({ finalDestinationId, items, displayTaskLogger }: ProcessMoveProps) => { + const files = items.filter((item) => !item.isFolder) as DriveFileData[]; + const folders = items.filter((item) => item.isFolder) as (IRoot | DriveFolderData)[]; + + const filesWithoutDuplicates = await handleRepeatedUploadingFiles(files, dispatch, finalDestinationId, true); + const foldersWithoutDuplicates = await handleRepeatedUploadingFolders(folders, dispatch, finalDestinationId, true); + + const itemsToMoveWithoutDuplicates = [...filesWithoutDuplicates, ...foldersWithoutDuplicates]; + + if (itemsToMoveWithoutDuplicates.length > 0) { + await dispatch( + storageThunks.moveItemsThunk({ + items: itemsToMoveWithoutDuplicates as DriveItemData[], + destinationFolderId: finalDestinationId, + displayTaskLogger, + }), + ); + } + + return { + areItemsMoved: itemsToMoveWithoutDuplicates.length > 0, + totalItemsMoved: itemsToMoveWithoutDuplicates as DriveItemData[], + }; + }; + + const goFolder = async (folderUuid: string, workspacesToken?: string) => { + try { + navigationService.pushFolder(folderUuid, workspacesToken); + } catch (error) { + navigationService.push(AppView.FolderFileNotFound, { itemType: 'folder' }); + errorService.reportError(error); + } + }; + + const withErrorHandler = async (fn: () => Promise): Promise => { + try { + await fn(); + } catch (error) { + const castedError = errorService.castError(error); + notificationsService.show({ + text: t('error.movingItem'), + type: ToastType.Error, + requestId: castedError.requestId, + }); + } + }; + + const restoreItemFromTrash = (item: DriveItemData): Promise => + withErrorHandler(async () => { + if (item.parent?.status === FileStatus.EXISTS) { + const { areItemsMoved } = await processMove({ finalDestinationId: item.parent.uuid, items: [item] }); + + if (!areItemsMoved) return; + + const goToDriveId = notificationsService.show({ + text: t('notificationMessages.restoreItems'), + type: ToastType.Success, + action: { + text: t('notificationMessages.goToDrive'), + onClick: () => { + goFolder(item.parent?.uuid as string); + notificationsService.dismiss(goToDriveId); + }, + }, + }); + dispatch(storageActions.popItemsToDelete([item])); + } else { + dispatch(storageActions.setItemsToMove([item])); + dispatch(uiActions.setIsMoveItemsDialogOpen(true)); + } + }); + + const restoreItemsFromTrash = (items: DriveItemData[]): Promise => + withErrorHandler(async () => { + const restorableItems = items.filter((item) => item.parent?.status === FileStatus.EXISTS); + const needsDestination = items.filter((item) => item.parent?.status !== FileStatus.EXISTS); + + const restorableByDestination = restorableItems.reduce((map, item) => { + const key = item.parent?.uuid; + map.set(key, [...(map.get(key) ?? []), item]); + return map; + }, new Map()); + const restoredItems = await Promise.all( + [...restorableByDestination.entries()].map(([destinationId, items]) => + processMove({ finalDestinationId: destinationId as string, items }), + ), + ); + + const totalItemsMovedConcat = restoredItems.flatMap((item) => item.totalItemsMoved); + + if (totalItemsMovedConcat.length > 0) { + notificationsService.show({ text: t('notificationMessages.restoreItems'), type: ToastType.Success }); + dispatch(storageActions.popItemsToDelete(totalItemsMovedConcat)); + } + + if (needsDestination.length > 0) { + dispatch(storageActions.setItemsToMove(needsDestination)); + dispatch(uiActions.setIsMoveItemsDialogOpen(true)); + } + }); + + const moveItemFromDialog = ({ + finalDestinationId, + items, + }: Omit): Promise => + withErrorHandler(async () => { + await processMove({ finalDestinationId, items, displayTaskLogger: true }); + }); + + return { + restoreItemFromTrash, + restoreItemsFromTrash, + moveItemFromDialog, + }; +}; diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerList.tsx b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerList.tsx index 780c3a3767..ab73f30d20 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerList.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerList.tsx @@ -36,6 +36,7 @@ import { import { List } from '@internxt/ui'; import { DownloadManager } from 'app/network/DownloadManager'; import { useVersionHistoryMenuConfig } from 'views/Drive/hooks/useVersionHistoryMenuConfig'; +import { useMoveItems } from 'hooks/moveItems/useMoveItems'; interface DriveExplorerListProps { folderId: string; @@ -90,7 +91,7 @@ const resetDriveOrder = ({ direction, currentFolderId, }: { - dispatch; + dispatch: AppDispatch; orderType: string; direction: string; currentFolderId: string; @@ -108,6 +109,7 @@ const DriveExplorerList: React.FC = memo((props) => { const selectedWorkspace = useSelector(workspacesSelectors.getSelectedWorkspace); const workspaceCredentials = useAppSelector(workspacesSelectors.getWorkspaceCredentials); const [editNameItem, setEditNameItem] = useState(null); + const { restoreItemFromTrash, restoreItemsFromTrash } = useMoveItems(); const isWorkspaceSelected = !!selectedWorkspace; const isSelectedMultipleItemsAndNotTrash = props.selectedItems.length > 1 && !props.isTrash; @@ -198,11 +200,10 @@ const DriveExplorerList: React.FC = memo((props) => { ); const restoreItem = useCallback( - (item: DriveItemData | Pick) => { - dispatch(storageActions.setItemsToMove([item as DriveItemData])); - dispatch(uiActions.setIsMoveItemsDialogOpen(true)); + async (item: DriveItemData | Pick) => { + await restoreItemFromTrash(item as DriveItemData); }, - [dispatch, storageActions, uiActions], + [restoreItemFromTrash], ); const deletePermanently = useCallback( @@ -310,9 +311,8 @@ const DriveExplorerList: React.FC = memo((props) => { }); const multipleSelectedTrashItemsContextMenu = contextMenuMultipleSelectedTrashItems({ - restoreItem: () => { - dispatch(storageActions.setItemsToMove(props.selectedItems)); - dispatch(uiActions.setIsMoveItemsDialogOpen(true)); + restoreItem: async () => { + await restoreItemsFromTrash(props.selectedItems); }, deletePermanently: () => { dispatch(storageActions.setItemsToDelete(props.selectedItems)); diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx index 73e646a41a..9cac6cd76e 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx @@ -87,7 +87,10 @@ const DriveExplorerListItem = ({ item, isTrash }: DriveExplorerItemProps): JSX.E const isItemShared = (item.sharings?.length ?? 0) > 0; const isInteractive = isItemInteractive(item); const itemClassNames = getItemClassNames(isItemSelected(item), isDraggingOverThisItem, isDraggingThisItem); - const parentFolderName = item.parent?.status === FileStatus.EXISTS ? (item.parent?.plainName ?? 'Drive') : '-'; + const hasExistingParent = item.parent?.status === FileStatus.EXISTS; + const parentFolderName = hasExistingParent ? (item.parent?.plainName ?? 'Drive') : undefined; + const basicFileDataTest = `file-list-${item.isFolder ? 'folder' : 'file'}-${transformItemService.getItemPlainNameWithExtension(item)}`; + const itemName = transformItemService.getItemPlainNameWithExtension(item) ?? items.getItemDisplayName(item); const template = (
{/* ICON */} @@ -107,18 +110,11 @@ const DriveExplorerListItem = ({ item, isTrash }: DriveExplorerItemProps): JSX.E className="aspect-square h-full max-h-full object-contain object-center" src={item.currentThumbnail.urlObject} alt={transformItemService.getItemPlainNameWithExtension(item)} - data-test={`file-list-${ - item.isFolder ? 'folder' : 'file' - }-${transformItemService.getItemPlainNameWithExtension(item)}`} + data-test={`${basicFileDataTest}-image`} />
) : ( - + )} {isItemShared && (
@@ -166,7 +160,7 @@ const DriveExplorerListItem = ({ item, isTrash }: DriveExplorerItemProps): JSX.E {isTrash && (
-

{parentFolderName}

+ {hasExistingParent ?

{parentFolderName}

: }
)} diff --git a/src/views/Drive/components/DriveExplorer/components/DriveTopBarActions.tsx b/src/views/Drive/components/DriveExplorer/components/DriveTopBarActions.tsx index dc6a4efeba..5d31f997e5 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveTopBarActions.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveTopBarActions.tsx @@ -32,6 +32,7 @@ import { import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; import { DownloadManager } from 'app/network/DownloadManager'; import { useVersionHistoryMenuConfig } from 'views/Drive/hooks/useVersionHistoryMenuConfig'; +import { useMoveItems } from 'hooks/moveItems/useMoveItems'; const DriveTopBarActions = ({ selectedItems, @@ -57,6 +58,7 @@ const DriveTopBarActions = ({ const { translate } = useTranslationContext(); const { dirtyName } = useDriveItemStoreProps(); + const { restoreItemFromTrash, restoreItemsFromTrash } = useMoveItems(); const viewMode = useAppSelector((state) => state.storage.viewMode); const separatorV =
; @@ -157,9 +159,12 @@ const DriveTopBarActions = ({ navigationService.pushFile(selectedItems[0].uuid, selectedWorkspace?.workspaceUser.workspaceId); }; - const onRecoverButtonClicked = (): void => { - dispatch(storageActions.setItemsToMove(selectedItems)); - dispatch(uiActions.setIsMoveItemsDialogOpen(true)); + const onRecoverButtonClicked = async (): Promise => { + if (selectedItems.length === 1) { + await restoreItemFromTrash(selectedItems[0]); + } else { + await restoreItemsFromTrash(selectedItems); + } }; const onDeletePermanentlyButtonClicked = (): void => { diff --git a/src/views/Drive/hooks/useDriveItemDragAndDrop.tsx b/src/views/Drive/hooks/useDriveItemDragAndDrop.tsx index 189833cbc1..4e2a3a493e 100644 --- a/src/views/Drive/hooks/useDriveItemDragAndDrop.tsx +++ b/src/views/Drive/hooks/useDriveItemDragAndDrop.tsx @@ -3,7 +3,6 @@ import { NativeTypes } from 'react-dnd-html5-backend'; import { transformDraggedItems } from 'services/drag-and-drop.service'; import { DragAndDropType } from 'app/core/types'; import { useAppDispatch, useAppSelector } from 'app/store/hooks'; -import { storageActions } from 'app/store/slices/storage'; import storageSelectors from 'app/store/slices/storage/storage.selectors'; import storageThunks from 'app/store/slices/storage/storage.thunks'; import { @@ -65,16 +64,10 @@ const handleDriveItemDrop = async ( return i.isFolder; }); - dispatch(storageActions.setMoveDestinationFolderId(item.uuid)); - const unrepeatedFiles = await handleRepeatedUploadingFiles(filesToMove, dispatch, item.uuid); const unrepeatedFolders = await handleRepeatedUploadingFolders(foldersToMove, dispatch, item.uuid); const unrepeatedItems: DriveItemData[] = [...unrepeatedFiles, ...unrepeatedFolders] as DriveItemData[]; - if (unrepeatedItems.length === itemsToMove.length) { - dispatch(storageActions.setMoveDestinationFolderId(item.uuid)); - } - dispatch( storageThunks.moveItemsThunk({ items: unrepeatedItems, diff --git a/test/unit/fixtures/drive.fixtures.ts b/test/unit/fixtures/drive.fixtures.ts new file mode 100644 index 0000000000..c7daf2a9ce --- /dev/null +++ b/test/unit/fixtures/drive.fixtures.ts @@ -0,0 +1,12 @@ +import { DriveItemData } from 'app/drive/types'; + +export const buildDriveItemData = (overrides: Partial = {}): DriveItemData => + ({ + id: 1, + uuid: 'item-uuid', + name: 'document', + type: 'pdf', + isFolder: false, + size: 1024, + ...overrides, + }) as DriveItemData;