From 1eec6ed0c799da03fc0379d252fab050838addd9 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Mon, 11 May 2026 13:51:06 +0200 Subject: [PATCH 1/9] feat(trash): add auto-restore for trashed items --- .../MoveItemsDialog/MoveItemsDialog.tsx | 66 +++-------- src/app/drive/types/index.ts | 1 + src/app/i18n/locales/de.json | 3 +- src/app/i18n/locales/en.json | 3 +- src/app/i18n/locales/es.json | 3 +- src/app/i18n/locales/fr.json | 3 +- src/app/i18n/locales/it.json | 3 +- src/app/i18n/locales/ru.json | 3 +- src/app/i18n/locales/tw.json | 3 +- src/app/i18n/locales/zh.json | 3 +- .../storage/storage.thunks/moveItemsThunk.ts | 51 ++++---- src/hooks/moveItems/useMoveItems.ts | 111 ++++++++++++++++++ .../DriveExplorerList/DriveExplorerList.tsx | 50 ++++++-- .../DriveExplorerListItem.tsx | 8 +- 14 files changed, 223 insertions(+), 88 deletions(-) create mode 100644 src/hooks/moveItems/useMoveItems.ts diff --git a/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx b/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx index 43657af804..ae7c4caa75 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; @@ -54,6 +49,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)); @@ -167,32 +163,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,7 +173,7 @@ 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); @@ -213,8 +183,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 +233,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 +277,7 @@ const MoveItemsDialog = (props: MoveItemsDialogProps): JSX.Element => { 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 25d3d64608..955bc22636 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 bf7d01a967..73bb7c8051 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 416587f897..71bafe68d2 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 83cb82d56d..4f2b9b7e9f 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 4309e7787c..035f7be491 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 b8ab173d0e..d57b6f217f 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 feb7fd34ed..d982fa09f0 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 72ee6cb405..2a15027b38 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/storage.thunks/moveItemsThunk.ts b/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts index a9fb495b15..fd4c022b10 100644 --- a/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts +++ b/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts @@ -16,12 +16,13 @@ import { StorageState } from '../storage.model'; export interface MoveItemsPayload { items: DriveItemData[]; 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 +34,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[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/hooks/moveItems/useMoveItems.ts b/src/hooks/moveItems/useMoveItems.ts new file mode 100644 index 0000000000..c259dd543d --- /dev/null +++ b/src/hooks/moveItems/useMoveItems.ts @@ -0,0 +1,111 @@ +import { FileStatus } from '@internxt/sdk/dist/drive/storage/types'; +import { DriveFileData, DriveFolderData, DriveItemData } from 'app/drive/types'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { RootState } from 'app/store'; +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 { useSelector } from 'react-redux'; +import { errorService } from 'services'; + +interface ProcessMoveProps { + finalDestinationId: string; + items: DriveItemData[]; + displayTaskLogger?: boolean; +} + +export const useMoveItems = () => { + const dispatch = useAppDispatch(); + const itemsToMove: DriveItemData[] = useSelector((state: RootState) => state.storage.itemsToMove); + const processMove = async ({ finalDestinationId, items, displayTaskLogger }: ProcessMoveProps) => { + const processItems = items ?? itemsToMove; + const files = processItems.filter((item) => item.type !== 'folder' || !item.isFolder) as DriveFileData[]; + const folders = processItems.filter((item) => item.type === 'folder' || item.isFolder) 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, + displayTaskLogger, + }), + ); + } + }; + + const restoreItemFromTrash = async ({ finalDestinationId, items }: Omit) => { + try { + await processMove({ + finalDestinationId, + items, + }); + + dispatch(storageActions.popItemsToDelete(items)); + } catch (error) { + const castedError = errorService.castError(error); + notificationsService.show({ + text: t('error.errorRestoringItemFromTrash'), + type: ToastType.Error, + requestId: castedError.requestId, + }); + } + }; + + const restoreItemsFromTrash = async (items: DriveItemData[]) => { + const restorable = items.filter((item) => item.parent?.status === FileStatus.EXISTS); + const needsDestination = items.filter((item) => item.parent?.status !== FileStatus.EXISTS); + + await Promise.all( + restorable.map((item) => + restoreItemFromTrash({ + finalDestinationId: item.parent!.uuid, + items: [item], + }), + ), + ); + + if (needsDestination.length > 0) { + dispatch(storageActions.setItemsToMove(needsDestination)); + dispatch(uiActions.setIsMoveItemsDialogOpen(true)); + } + }; + + const moveItemFromDialog = async ({ finalDestinationId, items }: Omit) => { + try { + await processMove({ + finalDestinationId, + items, + displayTaskLogger: true, + }); + } catch (error) { + const castedError = errorService.castError(error); + notificationsService.show({ + text: t('error.errorMovingItem'), + type: ToastType.Error, + requestId: castedError.requestId, + }); + } + }; + + return { + processMove, + 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..ee7a35fafe 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerList.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerList.tsx @@ -10,7 +10,7 @@ import navigationService from 'services/navigation.service'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { skinSkeleton, skinSkeletonTrash } from 'components/Skeleton'; import { moveItemsToTrash } from 'views/Trash/services'; -import { OrderDirection, OrderSettings } from 'app/core/types'; +import { AppView, OrderDirection, OrderSettings } from 'app/core/types'; import shareService from 'app/share/services/share.service'; import { AppDispatch, RootState } from 'app/store'; import { sharedThunks } from 'app/store/slices/sharedLinks'; @@ -36,6 +36,10 @@ import { import { List } from '@internxt/ui'; import { DownloadManager } from 'app/network/DownloadManager'; import { useVersionHistoryMenuConfig } from 'views/Drive/hooks/useVersionHistoryMenuConfig'; +import { FileStatus } from '@internxt/sdk/dist/drive/storage/types'; +import { useMoveItems } from 'hooks/moveItems/useMoveItems'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { errorService } from 'services'; interface DriveExplorerListProps { folderId: string; @@ -90,7 +94,7 @@ const resetDriveOrder = ({ direction, currentFolderId, }: { - dispatch; + dispatch: AppDispatch; orderType: string; direction: string; currentFolderId: string; @@ -108,6 +112,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,9 +203,35 @@ 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) => { + if (item.parent?.status === FileStatus.EXISTS) { + await restoreItemFromTrash({ + finalDestinationId: item.parent?.uuid, + items: [item as DriveItemData], + }); + + const goToDriveId = notificationsService.show({ + text: translate('notificationMessages.restoreItems'), + type: ToastType.Success, + action: { + text: translate('notificationMessages.goToDrive'), + onClick: () => { + if (props.selectedItems.length === 1) { + try { + navigationService.pushFolder(props.selectedItems[0].parent?.uuid); + } catch (error) { + navigationService.push(AppView.FolderFileNotFound, { itemType: 'folder' }); + errorService.reportError(error); + } + } + notificationsService.dismiss(goToDriveId); + }, + }, + }); + } else { + dispatch(storageActions.setItemsToMove([item as DriveItemData])); + dispatch(uiActions.setIsMoveItemsDialogOpen(true)); + } }, [dispatch, storageActions, uiActions], ); @@ -310,9 +341,12 @@ 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); + notificationsService.show({ + text: translate('notificationMessages.restoreItems'), + type: ToastType.Success, + }); }, 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..e83dcb303d 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx @@ -87,7 +87,7 @@ 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 parentFolderName = item.parent?.plainName ?? 'Drive'; const template = (
-

{parentFolderName}

+ {item.parent?.status === FileStatus.EXISTS ? ( +

{parentFolderName}

+ ) : ( + + )}
)} From bfd20a39d53d156c279e470ebe105ffdef796044 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Mon, 11 May 2026 14:07:40 +0200 Subject: [PATCH 2/9] refactor(restore): centralice the logic in the same place --- src/hooks/moveItems/useMoveItems.ts | 60 ++++++++++++++----- .../DriveExplorerList/DriveExplorerList.tsx | 38 +----------- .../DriveExplorerListItem.tsx | 9 +-- 3 files changed, 50 insertions(+), 57 deletions(-) diff --git a/src/hooks/moveItems/useMoveItems.ts b/src/hooks/moveItems/useMoveItems.ts index c259dd543d..401e601a55 100644 --- a/src/hooks/moveItems/useMoveItems.ts +++ b/src/hooks/moveItems/useMoveItems.ts @@ -1,4 +1,5 @@ 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 { RootState } from 'app/store'; @@ -13,7 +14,7 @@ import { IRoot } from 'app/store/slices/storage/types'; import { uiActions } from 'app/store/slices/ui'; import { t } from 'i18next'; import { useSelector } from 'react-redux'; -import { errorService } from 'services'; +import { errorService, navigationService } from 'services'; interface ProcessMoveProps { finalDestinationId: string; @@ -48,21 +49,45 @@ export const useMoveItems = () => { } }; - const restoreItemFromTrash = async ({ finalDestinationId, items }: Omit) => { + const goFolder = async (folderUuid: string, workspacesToken?: string) => { try { - await processMove({ - finalDestinationId, - items, - }); - - dispatch(storageActions.popItemsToDelete(items)); + navigationService.pushFolder(folderUuid, workspacesToken); } catch (error) { - const castedError = errorService.castError(error); - notificationsService.show({ - text: t('error.errorRestoringItemFromTrash'), - type: ToastType.Error, - requestId: castedError.requestId, - }); + navigationService.push(AppView.FolderFileNotFound, { itemType: 'folder' }); + errorService.reportError(error); + } + }; + + const restoreItemFromTrash = async (item: DriveItemData) => { + if (item.parent?.status === FileStatus.EXISTS) { + dispatch(storageActions.setItemsToMove([item])); + dispatch(uiActions.setIsMoveItemsDialogOpen(true)); + } else { + try { + await processMove({ + finalDestinationId: item.parent?.uuid as string, + items: [item], + }); + const goToDriveId = notificationsService.show({ + text: t('notificationMessages.restoreItems'), + type: ToastType.Success, + action: { + text: t('notificationMessages.goToDrive'), + onClick: () => { + goFolder(item[0].parent?.uuid as string); + notificationsService.dismiss(goToDriveId); + }, + }, + }); + dispatch(storageActions.popItemsToDelete([item])); + } catch (error) { + const castedError = errorService.castError(error); + notificationsService.show({ + text: t('error.errorRestoringItemFromTrash'), + type: ToastType.Error, + requestId: castedError.requestId, + }); + } } }; @@ -72,13 +97,18 @@ export const useMoveItems = () => { await Promise.all( restorable.map((item) => - restoreItemFromTrash({ + processMove({ finalDestinationId: item.parent!.uuid, items: [item], }), ), ); + notificationsService.show({ + text: t('notificationMessages.restoreItems'), + type: ToastType.Success, + }); + if (needsDestination.length > 0) { dispatch(storageActions.setItemsToMove(needsDestination)); dispatch(uiActions.setIsMoveItemsDialogOpen(true)); diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerList.tsx b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerList.tsx index ee7a35fafe..05937f3c23 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerList.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerList.tsx @@ -10,7 +10,7 @@ import navigationService from 'services/navigation.service'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { skinSkeleton, skinSkeletonTrash } from 'components/Skeleton'; import { moveItemsToTrash } from 'views/Trash/services'; -import { AppView, OrderDirection, OrderSettings } from 'app/core/types'; +import { OrderDirection, OrderSettings } from 'app/core/types'; import shareService from 'app/share/services/share.service'; import { AppDispatch, RootState } from 'app/store'; import { sharedThunks } from 'app/store/slices/sharedLinks'; @@ -36,10 +36,7 @@ import { import { List } from '@internxt/ui'; import { DownloadManager } from 'app/network/DownloadManager'; import { useVersionHistoryMenuConfig } from 'views/Drive/hooks/useVersionHistoryMenuConfig'; -import { FileStatus } from '@internxt/sdk/dist/drive/storage/types'; import { useMoveItems } from 'hooks/moveItems/useMoveItems'; -import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; -import { errorService } from 'services'; interface DriveExplorerListProps { folderId: string; @@ -204,34 +201,7 @@ const DriveExplorerList: React.FC = memo((props) => { const restoreItem = useCallback( async (item: DriveItemData | Pick) => { - if (item.parent?.status === FileStatus.EXISTS) { - await restoreItemFromTrash({ - finalDestinationId: item.parent?.uuid, - items: [item as DriveItemData], - }); - - const goToDriveId = notificationsService.show({ - text: translate('notificationMessages.restoreItems'), - type: ToastType.Success, - action: { - text: translate('notificationMessages.goToDrive'), - onClick: () => { - if (props.selectedItems.length === 1) { - try { - navigationService.pushFolder(props.selectedItems[0].parent?.uuid); - } catch (error) { - navigationService.push(AppView.FolderFileNotFound, { itemType: 'folder' }); - errorService.reportError(error); - } - } - notificationsService.dismiss(goToDriveId); - }, - }, - }); - } else { - dispatch(storageActions.setItemsToMove([item as DriveItemData])); - dispatch(uiActions.setIsMoveItemsDialogOpen(true)); - } + await restoreItemFromTrash(item as DriveItemData); }, [dispatch, storageActions, uiActions], ); @@ -343,10 +313,6 @@ const DriveExplorerList: React.FC = memo((props) => { const multipleSelectedTrashItemsContextMenu = contextMenuMultipleSelectedTrashItems({ restoreItem: async () => { await restoreItemsFromTrash(props.selectedItems); - notificationsService.show({ - text: translate('notificationMessages.restoreItems'), - type: ToastType.Success, - }); }, 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 e83dcb303d..9c011c79c1 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx @@ -87,7 +87,8 @@ 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?.plainName ?? 'Drive'; + const isItemParentExist = item.parent?.status === FileStatus.EXISTS; + const parentFolderName = isItemParentExist ? (item.parent?.plainName ?? 'Drive') : undefined; const template = (
- {item.parent?.status === FileStatus.EXISTS ? ( -

{parentFolderName}

- ) : ( - - )} + {isItemParentExist ?

{parentFolderName}

: }
)} From 73ae9528fbca8d57a36763f218e523276ee4b0bc Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Mon, 11 May 2026 15:46:36 +0200 Subject: [PATCH 3/9] test: add coverage to useMoveItems test --- src/hooks/moveItems/useMoveItems.test.ts | 259 ++++++++++++++++++ src/hooks/moveItems/useMoveItems.ts | 99 +++---- .../DriveExplorerList/DriveExplorerList.tsx | 2 +- test/unit/fixtures/drive.fixtures.ts | 12 + 4 files changed, 314 insertions(+), 58 deletions(-) create mode 100644 src/hooks/moveItems/useMoveItems.test.ts create mode 100644 test/unit/fixtures/drive.fixtures.ts diff --git a/src/hooks/moveItems/useMoveItems.test.ts b/src/hooks/moveItems/useMoveItems.test.ts new file mode 100644 index 0000000000..ddb175b416 --- /dev/null +++ b/src/hooks/moveItems/useMoveItems.test.ts @@ -0,0 +1,259 @@ +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 } = + 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 })), + })); + +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 }, + 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 () => { + // Arrange + const item = buildItemWithExistingParent(); + const { result } = renderHook(() => useMoveItems()); + + // Act + await act(async () => { + await result.current.restoreItemFromTrash(item); + }); + + // Assert + 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 () => { + // Arrange + const item = buildItemWithExistingParent(); + const { result } = renderHook(() => useMoveItems()); + + // Act + await act(async () => { + await result.current.restoreItemFromTrash(item); + }); + + // Assert + expect(mockPopItemsToDelete).toHaveBeenCalledWith([item]); + }); + + test('When the original folder still exists, then a success notification is shown', async () => { + // Arrange + const item = buildItemWithExistingParent(); + const { result } = renderHook(() => useMoveItems()); + const notificationsSpy = vi.spyOn(notificationsService, 'show'); + + // Act + await act(async () => { + await result.current.restoreItemFromTrash(item); + }); + + // Assert + 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 () => { + // Arrange + const item = buildItemWithDeletedParent(); + const { result } = renderHook(() => useMoveItems()); + + // Act + await act(async () => { + await result.current.restoreItemFromTrash(item); + }); + + // Assert + expect(mockSetItemsToMove).toHaveBeenCalledWith([item]); + expect(mockSetIsMoveItemsDialogOpen).toHaveBeenCalledWith(true); + }); + + test('When the original folder no longer exists, then no success notification is shown', async () => { + // Arrange + const item = buildItemWithDeletedParent(); + const { result } = renderHook(() => useMoveItems()); + const notificationsSpy = vi.spyOn(notificationsService, 'show'); + + // Act + await act(async () => { + await result.current.restoreItemFromTrash(item); + }); + + // Assert + expect(notificationsSpy).not.toHaveBeenCalled(); + }); + + test('When the restore fails, then an error notification is shown', async () => { + // Arrange + 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); + + // Act + await act(async () => { + await result.current.restoreItemFromTrash(item); + }); + + // Assert + 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 () => { + // Arrange + const items = [buildItemWithExistingParent({ uuid: 'item-1' }), buildItemWithExistingParent({ uuid: 'item-2' })]; + const { result } = renderHook(() => useMoveItems()); + const notificationsSpy = vi.spyOn(notificationsService, 'show'); + + // Act + await act(async () => { + await result.current.restoreItemsFromTrash(items); + }); + + // Assert + 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 () => { + // Arrange + const restorable = buildItemWithExistingParent({ uuid: 'item-1' }); + const needsDestination = buildItemWithDeletedParent({ uuid: 'item-2' }); + const { result } = renderHook(() => useMoveItems()); + + // Act + await act(async () => { + await result.current.restoreItemsFromTrash([restorable, needsDestination]); + }); + + // Assert + 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 () => { + // Arrange + const items = [buildItemWithDeletedParent({ uuid: 'item-1' }), buildItemWithDeletedParent({ uuid: 'item-2' })]; + const { result } = renderHook(() => useMoveItems()); + const notificationsSpy = vi.spyOn(notificationsService, 'show'); + + // Act + await act(async () => { + await result.current.restoreItemsFromTrash(items); + }); + + // Assert + expect(notificationsSpy).not.toHaveBeenCalled(); + }); + + test('When any restore fails, then an error notification is shown', async () => { + // Arrange + 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); + + // Act + await act(async () => { + await result.current.restoreItemsFromTrash(items); + }); + + // Assert + 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 () => { + // Arrange + const item = buildDriveItemData(); + const { result } = renderHook(() => useMoveItems()); + + // Act + await act(async () => { + await result.current.moveItemFromDialog({ finalDestinationId: 'chosen-folder-uuid', items: [item] }); + }); + + // Assert + expect(mockMoveItemsThunk).toHaveBeenCalledWith( + expect.objectContaining({ destinationFolderId: 'chosen-folder-uuid' }), + ); + }); + + test('When the move from dialog fails, then an error notification is shown', async () => { + // Arrange + 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); + + // Act + await act(async () => { + await result.current.moveItemFromDialog({ finalDestinationId: 'chosen-folder-uuid', items: [item] }); + }); + + // Assert + expect(notificationsSpy).toHaveBeenCalledWith(expect.objectContaining({ type: ToastType.Error })); + }); + }); +}); diff --git a/src/hooks/moveItems/useMoveItems.ts b/src/hooks/moveItems/useMoveItems.ts index 401e601a55..b038b27221 100644 --- a/src/hooks/moveItems/useMoveItems.ts +++ b/src/hooks/moveItems/useMoveItems.ts @@ -58,82 +58,67 @@ export const useMoveItems = () => { } }; - const restoreItemFromTrash = async (item: DriveItemData) => { - if (item.parent?.status === FileStatus.EXISTS) { - dispatch(storageActions.setItemsToMove([item])); - dispatch(uiActions.setIsMoveItemsDialogOpen(true)); - } else { - try { - await processMove({ - finalDestinationId: item.parent?.uuid as string, - items: [item], - }); + const withErrorHandler = async (fn: () => Promise): Promise => { + try { + await fn(); + } catch (error) { + const castedError = errorService.castError(error); + notificationsService.show({ + text: t('error.errorMovingItem'), + type: ToastType.Error, + requestId: castedError.requestId, + }); + } + }; + + const restoreItemFromTrash = (item: DriveItemData): Promise => + withErrorHandler(async () => { + if (item.parent?.status === FileStatus.EXISTS) { + await processMove({ finalDestinationId: item.parent.uuid, items: [item] }); const goToDriveId = notificationsService.show({ text: t('notificationMessages.restoreItems'), type: ToastType.Success, action: { text: t('notificationMessages.goToDrive'), onClick: () => { - goFolder(item[0].parent?.uuid as string); + goFolder(item.parent?.uuid as string); notificationsService.dismiss(goToDriveId); }, }, }); dispatch(storageActions.popItemsToDelete([item])); - } catch (error) { - const castedError = errorService.castError(error); - notificationsService.show({ - text: t('error.errorRestoringItemFromTrash'), - type: ToastType.Error, - requestId: castedError.requestId, - }); + } else { + dispatch(storageActions.setItemsToMove([item])); + dispatch(uiActions.setIsMoveItemsDialogOpen(true)); } - } - }; + }); - const restoreItemsFromTrash = async (items: DriveItemData[]) => { - const restorable = items.filter((item) => item.parent?.status === FileStatus.EXISTS); - const needsDestination = items.filter((item) => item.parent?.status !== FileStatus.EXISTS); + const restoreItemsFromTrash = (items: DriveItemData[]): Promise => + withErrorHandler(async () => { + const restorable = items.filter((item) => item.parent?.status === FileStatus.EXISTS); + const needsDestination = items.filter((item) => item.parent?.status !== FileStatus.EXISTS); - await Promise.all( - restorable.map((item) => - processMove({ - finalDestinationId: item.parent!.uuid, - items: [item], - }), - ), - ); + await Promise.all( + restorable.map((item) => processMove({ finalDestinationId: item.parent!.uuid, items: [item] })), + ); - notificationsService.show({ - text: t('notificationMessages.restoreItems'), - type: ToastType.Success, - }); + if (restorable.length > 0) { + notificationsService.show({ text: t('notificationMessages.restoreItems'), type: ToastType.Success }); + } - if (needsDestination.length > 0) { - dispatch(storageActions.setItemsToMove(needsDestination)); - dispatch(uiActions.setIsMoveItemsDialogOpen(true)); - } - }; + if (needsDestination.length > 0) { + dispatch(storageActions.setItemsToMove(needsDestination)); + dispatch(uiActions.setIsMoveItemsDialogOpen(true)); + } + }); - const moveItemFromDialog = async ({ finalDestinationId, items }: Omit) => { - try { - await processMove({ - finalDestinationId, - items, - displayTaskLogger: true, - }); - } catch (error) { - const castedError = errorService.castError(error); - notificationsService.show({ - text: t('error.errorMovingItem'), - type: ToastType.Error, - requestId: castedError.requestId, - }); - } - }; + const moveItemFromDialog = ({ + finalDestinationId, + items, + }: Omit): Promise => + withErrorHandler(() => processMove({ finalDestinationId, items, displayTaskLogger: true })); return { - processMove, 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 05937f3c23..ab73f30d20 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerList.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerList.tsx @@ -203,7 +203,7 @@ const DriveExplorerList: React.FC = memo((props) => { async (item: DriveItemData | Pick) => { await restoreItemFromTrash(item as DriveItemData); }, - [dispatch, storageActions, uiActions], + [restoreItemFromTrash], ); const deletePermanently = useCallback( 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; From 64a8867f3952bdb0a669f2b0b8aaa319690ef24f Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Mon, 11 May 2026 16:30:21 +0200 Subject: [PATCH 4/9] test: add coverage for renameItemsThunk --- .../storage.thunks/moveItemsThunk.test.ts | 70 +++++++++++++++++++ src/hooks/moveItems/useMoveItems.test.ts | 36 ---------- 2 files changed, 70 insertions(+), 36 deletions(-) create mode 100644 src/app/store/slices/storage/storage.thunks/moveItemsThunk.test.ts 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/hooks/moveItems/useMoveItems.test.ts b/src/hooks/moveItems/useMoveItems.test.ts index ddb175b416..8ca542e559 100644 --- a/src/hooks/moveItems/useMoveItems.test.ts +++ b/src/hooks/moveItems/useMoveItems.test.ts @@ -60,80 +60,64 @@ describe('Restore items from trash', () => { describe('Single item restore', () => { test('When the original folder still exists, then the item is moved there directly', async () => { - // Arrange const item = buildItemWithExistingParent(); const { result } = renderHook(() => useMoveItems()); - // Act await act(async () => { await result.current.restoreItemFromTrash(item); }); - // Assert 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 () => { - // Arrange const item = buildItemWithExistingParent(); const { result } = renderHook(() => useMoveItems()); - // Act await act(async () => { await result.current.restoreItemFromTrash(item); }); - // Assert expect(mockPopItemsToDelete).toHaveBeenCalledWith([item]); }); test('When the original folder still exists, then a success notification is shown', async () => { - // Arrange const item = buildItemWithExistingParent(); const { result } = renderHook(() => useMoveItems()); const notificationsSpy = vi.spyOn(notificationsService, 'show'); - // Act await act(async () => { await result.current.restoreItemFromTrash(item); }); - // Assert 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 () => { - // Arrange const item = buildItemWithDeletedParent(); const { result } = renderHook(() => useMoveItems()); - // Act await act(async () => { await result.current.restoreItemFromTrash(item); }); - // Assert expect(mockSetItemsToMove).toHaveBeenCalledWith([item]); expect(mockSetIsMoveItemsDialogOpen).toHaveBeenCalledWith(true); }); test('When the original folder no longer exists, then no success notification is shown', async () => { - // Arrange const item = buildItemWithDeletedParent(); const { result } = renderHook(() => useMoveItems()); const notificationsSpy = vi.spyOn(notificationsService, 'show'); - // Act await act(async () => { await result.current.restoreItemFromTrash(item); }); - // Assert expect(notificationsSpy).not.toHaveBeenCalled(); }); test('When the restore fails, then an error notification is shown', async () => { - // Arrange const item = buildItemWithExistingParent(); mockDispatch.mockImplementation(() => { throw new Error('Network error'); @@ -142,66 +126,54 @@ describe('Restore items from trash', () => { const notificationsSpy = vi.spyOn(notificationsService, 'show'); vi.spyOn(errorService, 'castError').mockReturnValue({ message: 'Network error', requestId: undefined } as any); - // Act await act(async () => { await result.current.restoreItemFromTrash(item); }); - // Assert 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 () => { - // Arrange const items = [buildItemWithExistingParent({ uuid: 'item-1' }), buildItemWithExistingParent({ uuid: 'item-2' })]; const { result } = renderHook(() => useMoveItems()); const notificationsSpy = vi.spyOn(notificationsService, 'show'); - // Act await act(async () => { await result.current.restoreItemsFromTrash(items); }); - // Assert 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 () => { - // Arrange const restorable = buildItemWithExistingParent({ uuid: 'item-1' }); const needsDestination = buildItemWithDeletedParent({ uuid: 'item-2' }); const { result } = renderHook(() => useMoveItems()); - // Act await act(async () => { await result.current.restoreItemsFromTrash([restorable, needsDestination]); }); - // Assert 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 () => { - // Arrange const items = [buildItemWithDeletedParent({ uuid: 'item-1' }), buildItemWithDeletedParent({ uuid: 'item-2' })]; const { result } = renderHook(() => useMoveItems()); const notificationsSpy = vi.spyOn(notificationsService, 'show'); - // Act await act(async () => { await result.current.restoreItemsFromTrash(items); }); - // Assert expect(notificationsSpy).not.toHaveBeenCalled(); }); test('When any restore fails, then an error notification is shown', async () => { - // Arrange const items = [buildItemWithExistingParent({ uuid: 'item-1' }), buildItemWithExistingParent({ uuid: 'item-2' })]; mockDispatch.mockImplementation(() => { throw new Error('Network error'); @@ -210,35 +182,29 @@ describe('Restore items from trash', () => { const notificationsSpy = vi.spyOn(notificationsService, 'show'); vi.spyOn(errorService, 'castError').mockReturnValue({ message: 'Network error', requestId: undefined } as any); - // Act await act(async () => { await result.current.restoreItemsFromTrash(items); }); - // Assert 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 () => { - // Arrange const item = buildDriveItemData(); const { result } = renderHook(() => useMoveItems()); - // Act await act(async () => { await result.current.moveItemFromDialog({ finalDestinationId: 'chosen-folder-uuid', items: [item] }); }); - // Assert expect(mockMoveItemsThunk).toHaveBeenCalledWith( expect.objectContaining({ destinationFolderId: 'chosen-folder-uuid' }), ); }); test('When the move from dialog fails, then an error notification is shown', async () => { - // Arrange const item = buildDriveItemData(); mockDispatch.mockImplementation(() => { throw new Error('Move failed'); @@ -247,12 +213,10 @@ describe('Restore items from trash', () => { const notificationsSpy = vi.spyOn(notificationsService, 'show'); vi.spyOn(errorService, 'castError').mockReturnValue({ message: 'Move failed', requestId: undefined } as any); - // Act await act(async () => { await result.current.moveItemFromDialog({ finalDestinationId: 'chosen-folder-uuid', items: [item] }); }); - // Assert expect(notificationsSpy).toHaveBeenCalledWith(expect.objectContaining({ type: ToastType.Error })); }); }); From eeff7f8110c2a28f924ad99019a18c8230efeca2 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 12 May 2026 08:47:10 +0200 Subject: [PATCH 5/9] feat(trash): restore items correctly from top actions bar --- .../MoveItemsDialog/MoveItemsDialog.tsx | 2 -- .../NameCollisionContainer.tsx | 6 ++++ src/hooks/moveItems/useMoveItems.test.ts | 29 +++++++++++++------ src/hooks/moveItems/useMoveItems.ts | 29 +++++++++++++------ .../DriveExplorerListItem.tsx | 23 +++++---------- .../components/DriveTopBarActions.tsx | 11 +++++-- 6 files changed, 62 insertions(+), 38 deletions(-) diff --git a/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx b/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx index ae7c4caa75..c3567e2f3a 100644 --- a/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx +++ b/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx @@ -38,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(); @@ -120,7 +119,6 @@ const MoveItemsDialog = (props: MoveItemsDialogProps): JSX.Element => { } else { setDestinationId(currentFolderId); } - name && setSelectedFolderName(name); }; const onClose = (): void => { diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index 3d465ea21b..8b4361117a 100644 --- a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx +++ b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx @@ -230,6 +230,10 @@ const NameCollisionContainer: FC = ({ }); }; + const popMovedItemsFromTrash = (movedItems: DriveItemData[]) => { + dispatch(storageActions.popItemsToDelete(movedItems)); + }; + const triggerSelectedOptinsOnSubmit = async ({ operationType, operation, @@ -239,12 +243,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/hooks/moveItems/useMoveItems.test.ts b/src/hooks/moveItems/useMoveItems.test.ts index 8ca542e559..d9627d8078 100644 --- a/src/hooks/moveItems/useMoveItems.test.ts +++ b/src/hooks/moveItems/useMoveItems.test.ts @@ -7,14 +7,21 @@ import { DriveItemData } from 'app/drive/types'; import { useMoveItems } from './useMoveItems'; import { buildDriveItemData } from '../../../test/unit/fixtures/drive.fixtures'; -const { mockDispatch, mockMoveItemsThunk, mockPopItemsToDelete, mockSetItemsToMove, mockSetIsMoveItemsDialogOpen } = - 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 })), - })); +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', () => ({ @@ -27,7 +34,11 @@ vi.mock('app/store/slices/storage/storage.thunks/renameItemsThunk', () => ({ handleRepeatedUploadingFolders: vi.fn(async (folders: unknown[]) => folders), })); vi.mock('app/store/slices/storage', () => ({ - storageActions: { popItemsToDelete: mockPopItemsToDelete, setItemsToMove: mockSetItemsToMove }, + storageActions: { + popItemsToDelete: mockPopItemsToDelete, + setItemsToMove: mockSetItemsToMove, + setMoveDestinationFolderId: mockSetMoveDestinationFolderId, + }, storageSelectors: {}, setItemsToMove: mockSetItemsToMove, default: {}, diff --git a/src/hooks/moveItems/useMoveItems.ts b/src/hooks/moveItems/useMoveItems.ts index b038b27221..e70642d1eb 100644 --- a/src/hooks/moveItems/useMoveItems.ts +++ b/src/hooks/moveItems/useMoveItems.ts @@ -27,11 +27,8 @@ export const useMoveItems = () => { const itemsToMove: DriveItemData[] = useSelector((state: RootState) => state.storage.itemsToMove); const processMove = async ({ finalDestinationId, items, displayTaskLogger }: ProcessMoveProps) => { const processItems = items ?? itemsToMove; - const files = processItems.filter((item) => item.type !== 'folder' || !item.isFolder) as DriveFileData[]; - const folders = processItems.filter((item) => item.type === 'folder' || item.isFolder) as ( - | IRoot - | DriveFolderData - )[]; + const files = processItems.filter((item) => !item.isFolder) as DriveFileData[]; + const folders = processItems.filter((item) => item.isFolder) as (IRoot | DriveFolderData)[]; const filesWithoutDuplicates = await handleRepeatedUploadingFiles(files, dispatch, finalDestinationId); const foldersWithoutDuplicates = await handleRepeatedUploadingFolders(folders, dispatch, finalDestinationId); @@ -47,6 +44,10 @@ export const useMoveItems = () => { }), ); } + + return { + itemsMoved: itemsToMoveWithoutDuplicates.length > 0, + }; }; const goFolder = async (folderUuid: string, workspacesToken?: string) => { @@ -64,7 +65,7 @@ export const useMoveItems = () => { } catch (error) { const castedError = errorService.castError(error); notificationsService.show({ - text: t('error.errorMovingItem'), + text: t('error.movingItem'), type: ToastType.Error, requestId: castedError.requestId, }); @@ -74,7 +75,11 @@ export const useMoveItems = () => { const restoreItemFromTrash = (item: DriveItemData): Promise => withErrorHandler(async () => { if (item.parent?.status === FileStatus.EXISTS) { - await processMove({ finalDestinationId: item.parent.uuid, items: [item] }); + dispatch(storageActions.setMoveDestinationFolderId(item.parent.uuid)); + const { itemsMoved } = await processMove({ finalDestinationId: item.parent.uuid, items: [item] }); + + if (!itemsMoved) return; + const goToDriveId = notificationsService.show({ text: t('notificationMessages.restoreItems'), type: ToastType.Success, @@ -99,11 +104,15 @@ export const useMoveItems = () => { const needsDestination = items.filter((item) => item.parent?.status !== FileStatus.EXISTS); await Promise.all( - restorable.map((item) => processMove({ finalDestinationId: item.parent!.uuid, items: [item] })), + restorable.map((item) => { + dispatch(storageActions.setMoveDestinationFolderId(item.parent?.uuid as string)); + return processMove({ finalDestinationId: item.parent!.uuid, items: [item] }); + }), ); if (restorable.length > 0) { notificationsService.show({ text: t('notificationMessages.restoreItems'), type: ToastType.Success }); + dispatch(storageActions.popItemsToDelete(restorable)); } if (needsDestination.length > 0) { @@ -116,7 +125,9 @@ export const useMoveItems = () => { finalDestinationId, items, }: Omit): Promise => - withErrorHandler(() => processMove({ finalDestinationId, items, displayTaskLogger: true })); + withErrorHandler(async () => { + await processMove({ finalDestinationId, items, displayTaskLogger: true }); + }); return { restoreItemFromTrash, diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx index 9c011c79c1..6401603b34 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx @@ -89,6 +89,8 @@ const DriveExplorerListItem = ({ item, isTrash }: DriveExplorerItemProps): JSX.E const itemClassNames = getItemClassNames(isItemSelected(item), isDraggingOverThisItem, isDraggingThisItem); const isItemParentExist = item.parent?.status === FileStatus.EXISTS; const parentFolderName = isItemParentExist ? (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 */} @@ -108,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 && (
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 => { From 57b53eae47d3d4fce3429057464ed62144dc0719 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 12 May 2026 08:58:37 +0200 Subject: [PATCH 6/9] fix: improve constant name --- .../components/DriveExplorerList/DriveExplorerListItem.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx index 6401603b34..9cac6cd76e 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx @@ -87,8 +87,8 @@ 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 isItemParentExist = item.parent?.status === FileStatus.EXISTS; - const parentFolderName = isItemParentExist ? (item.parent?.plainName ?? 'Drive') : undefined; + 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); @@ -160,7 +160,7 @@ const DriveExplorerListItem = ({ item, isTrash }: DriveExplorerItemProps): JSX.E {isTrash && (
- {isItemParentExist ?

{parentFolderName}

: } + {hasExistingParent ?

{parentFolderName}

: }
)} From 83e644280bbcb9d29a3f48c757c46225aa591984 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Wed, 13 May 2026 11:09:14 +0200 Subject: [PATCH 7/9] fix: rename item and move to original location folder --- .../MoveItemsDialog/MoveItemsDialog.tsx | 1 - .../NameCollisionContainer.tsx | 47 +++++++++++++------ src/app/drive/services/file.service/index.ts | 7 ++- src/app/drive/services/folder.service.ts | 2 + .../drive/services/storage.service/index.ts | 6 +-- .../storage/fileUtils/checkDuplicatedFiles.ts | 2 +- .../storage/storage.thunks/moveItemsThunk.ts | 5 +- .../storage.thunks/renameItemsThunk.ts | 4 +- src/components/BreadcrumbsHelper.test.ts | 2 - src/components/BreadcrumbsHelper.ts | 7 --- src/hooks/moveItems/useMoveItems.test.ts | 29 +++++++++++- src/hooks/moveItems/useMoveItems.ts | 32 +++++++------ .../Drive/hooks/useDriveItemDragAndDrop.tsx | 7 --- 13 files changed, 97 insertions(+), 54 deletions(-) diff --git a/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx b/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx index c3567e2f3a..adae2d04e7 100644 --- a/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx +++ b/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx @@ -173,7 +173,6 @@ const MoveItemsDialog = (props: MoveItemsDialogProps): JSX.Element => { const onAccept = async (destinationFolderId: string): Promise => { try { - dispatch(storageActions.setMoveDestinationFolderId(destinationFolderId)); setIsLoading(true); if (itemsToMove.length === 0) { diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index 8b4361117a..cfb6d31eb3 100644 --- a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx +++ b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx @@ -16,6 +16,10 @@ 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'; type NameCollisionContainerProps = { currentFolderId: string; @@ -107,25 +111,40 @@ 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: DriveItemData; + 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 }; + } else { + const { duplicatedFilesResponse } = await checkDuplicatedFiles([item], folderId); + + finalItemName = await getUniqueFilename(item.name, item.type, duplicatedFilesResponse, folderId); + itemParsed = { ...item, name: finalItemName, plain_name: finalItemName }; + } + + await dispatch( + storageThunks.moveItemsThunk({ + items: [itemParsed], + destinationFolderId: folderId, + newItemName: finalItemName, + }), + ); + } }; const uploadFileAndGetFileId = async (file: File, itemToReplace: DriveItemData) => { 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/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.ts b/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts index fd4c022b10..80bb9f24df 100644 --- a/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts +++ b/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts @@ -16,13 +16,14 @@ import { StorageState } from '../storage.model'; export interface MoveItemsPayload { items: DriveItemData[]; destinationFolderId: string; + newItemName?: string; displayTaskLogger?: boolean; } export const moveItemsThunk = createAsyncThunk( 'storage/moveItems', async (payload: MoveItemsPayload, { dispatch }) => { - const { items, destinationFolderId, displayTaskLogger } = payload; + const { items, destinationFolderId, newItemName, displayTaskLogger } = payload; const promises: Promise[] = []; if (items.some((item) => item.isFolder && item.uuid === destinationFolderId)) { @@ -54,7 +55,7 @@ export const moveItemsThunk = createAsyncThunk { diff --git a/src/app/store/slices/storage/storage.thunks/renameItemsThunk.ts b/src/app/store/slices/storage/storage.thunks/renameItemsThunk.ts index d7f1cbb9ba..3b10ac6a8c 100644 --- a/src/app/store/slices/storage/storage.thunks/renameItemsThunk.ts +++ b/src/app/store/slices/storage/storage.thunks/renameItemsThunk.ts @@ -58,9 +58,10 @@ export const handleRepeatedUploadingFiles = async ( if (hasRepeatedNameFiles) { dispatch(storageActions.setFilesToRename(filesRepeated as DriveItemData[])); dispatch(storageActions.setDriveFilesToRename(duplicatedFilesResponse as DriveItemData[])); + dispatch(storageActions.setMoveDestinationFolderId(destinationFolderUuid)); dispatch(uiActions.setIsNameCollisionDialogOpen(true)); } - return unrepeatedFiles as DriveItemData[]; + return unrepeatedFiles; }; export const handleRepeatedUploadingFolders = async ( @@ -103,6 +104,7 @@ export const handleRepeatedUploadingFolders = async ( if (hasRepeatedNameFolders) { dispatch(storageActions.setFoldersToRename(foldersRepeated as DriveItemData[])); dispatch(storageActions.setDriveFoldersToRename(duplicatedFoldersResponse as DriveItemData[])); + 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 index d9627d8078..6b5e623bae 100644 --- a/src/hooks/moveItems/useMoveItems.test.ts +++ b/src/hooks/moveItems/useMoveItems.test.ts @@ -147,7 +147,34 @@ describe('Restore items from trash', () => { 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({ uuid: 'item-1' }), buildItemWithExistingParent({ uuid: 'item-2' })]; + 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'); diff --git a/src/hooks/moveItems/useMoveItems.ts b/src/hooks/moveItems/useMoveItems.ts index e70642d1eb..b2f68e7d5f 100644 --- a/src/hooks/moveItems/useMoveItems.ts +++ b/src/hooks/moveItems/useMoveItems.ts @@ -2,7 +2,6 @@ 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 { RootState } from 'app/store'; import { useAppDispatch } from 'app/store/hooks'; import { storageActions } from 'app/store/slices/storage'; import storageThunks from 'app/store/slices/storage/storage.thunks'; @@ -13,7 +12,6 @@ import { import { IRoot } from 'app/store/slices/storage/types'; import { uiActions } from 'app/store/slices/ui'; import { t } from 'i18next'; -import { useSelector } from 'react-redux'; import { errorService, navigationService } from 'services'; interface ProcessMoveProps { @@ -24,11 +22,14 @@ interface ProcessMoveProps { export const useMoveItems = () => { const dispatch = useAppDispatch(); - const itemsToMove: DriveItemData[] = useSelector((state: RootState) => state.storage.itemsToMove); const processMove = async ({ finalDestinationId, items, displayTaskLogger }: ProcessMoveProps) => { - const processItems = items ?? itemsToMove; - const files = processItems.filter((item) => !item.isFolder) as DriveFileData[]; - const folders = processItems.filter((item) => item.isFolder) as (IRoot | DriveFolderData)[]; + console.log('[MOVE ITEMS]: Processing move items', { + finalDestinationId, + items, + displayTaskLogger, + }); + 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); const foldersWithoutDuplicates = await handleRepeatedUploadingFolders(folders, dispatch, finalDestinationId); @@ -75,7 +76,6 @@ export const useMoveItems = () => { const restoreItemFromTrash = (item: DriveItemData): Promise => withErrorHandler(async () => { if (item.parent?.status === FileStatus.EXISTS) { - dispatch(storageActions.setMoveDestinationFolderId(item.parent.uuid)); const { itemsMoved } = await processMove({ finalDestinationId: item.parent.uuid, items: [item] }); if (!itemsMoved) return; @@ -100,19 +100,23 @@ export const useMoveItems = () => { const restoreItemsFromTrash = (items: DriveItemData[]): Promise => withErrorHandler(async () => { - const restorable = items.filter((item) => item.parent?.status === FileStatus.EXISTS); + 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()); await Promise.all( - restorable.map((item) => { - dispatch(storageActions.setMoveDestinationFolderId(item.parent?.uuid as string)); - return processMove({ finalDestinationId: item.parent!.uuid, items: [item] }); - }), + [...restorableByDestination.entries()].map(([destinationId, items]) => + processMove({ finalDestinationId: destinationId as string, items }), + ), ); - if (restorable.length > 0) { + if (restorableItems.length > 0) { notificationsService.show({ text: t('notificationMessages.restoreItems'), type: ToastType.Success }); - dispatch(storageActions.popItemsToDelete(restorable)); + dispatch(storageActions.popItemsToDelete(restorableItems)); } if (needsDestination.length > 0) { 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, From dbb7b83783459ca1518ff4bc4bceb026ef155a89 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Wed, 13 May 2026 12:54:58 +0200 Subject: [PATCH 8/9] fix: move items when updating the file name --- .../NameCollisionContainer.tsx | 14 +++++++---- .../storage/storage.thunks/moveItemsThunk.ts | 11 +++++---- .../storage.thunks/renameItemsThunk.ts | 6 +++-- src/hooks/moveItems/useMoveItems.test.ts | 15 ++++++++++++ src/hooks/moveItems/useMoveItems.ts | 24 +++++++++---------- 5 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index cfb6d31eb3..715da0df21 100644 --- a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx +++ b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx @@ -20,6 +20,7 @@ import { checkFolderDuplicated } from 'app/store/slices/storage/folderUtils/chec 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; @@ -118,7 +119,7 @@ const NameCollisionContainer: FC = ({ const keepAndMoveItem = async (itemsToUpload: DriveItemData[]) => { for (const item of itemsToUpload) { - let itemParsed: DriveItemData; + let itemParsed: MoveItemPayload; let finalItemName = item.plainName ?? item.name; if (item.isFolder) { @@ -129,19 +130,24 @@ const NameCollisionContainer: FC = ({ duplicatedFoldersResponse as DriveItemData[], folderId, ); - itemParsed = { ...item, name: finalItemName, plain_name: finalItemName }; + 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, plain_name: finalItemName }; + itemParsed = { + ...item, + name: finalItemName, + plainName: finalItemName, + plain_name: finalItemName, + newItemName: finalItemName, + }; } await dispatch( storageThunks.moveItemsThunk({ items: [itemParsed], destinationFolderId: folderId, - newItemName: finalItemName, }), ); } diff --git a/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts b/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts index 80bb9f24df..d0233769e0 100644 --- a/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts +++ b/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts @@ -13,17 +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; - newItemName?: string; displayTaskLogger?: boolean; } export const moveItemsThunk = createAsyncThunk( 'storage/moveItems', async (payload: MoveItemsPayload, { dispatch }) => { - const { items, destinationFolderId, newItemName, displayTaskLogger } = payload; + const { items, destinationFolderId, displayTaskLogger } = payload; const promises: Promise[] = []; if (items.some((item) => item.isFolder && item.uuid === destinationFolderId)) { @@ -55,7 +58,7 @@ export const moveItemsThunk = createAsyncThunk { diff --git a/src/app/store/slices/storage/storage.thunks/renameItemsThunk.ts b/src/app/store/slices/storage/storage.thunks/renameItemsThunk.ts index 3b10ac6a8c..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,7 +59,7 @@ export const handleRepeatedUploadingFiles = async ( if (hasRepeatedNameFiles) { dispatch(storageActions.setFilesToRename(filesRepeated as DriveItemData[])); dispatch(storageActions.setDriveFilesToRename(duplicatedFilesResponse as DriveItemData[])); - dispatch(storageActions.setMoveDestinationFolderId(destinationFolderUuid)); + if (isMoveOperation) dispatch(storageActions.setMoveDestinationFolderId(destinationFolderUuid)); dispatch(uiActions.setIsNameCollisionDialogOpen(true)); } return unrepeatedFiles; @@ -68,6 +69,7 @@ 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) => @@ -104,7 +106,7 @@ export const handleRepeatedUploadingFolders = async ( if (hasRepeatedNameFolders) { dispatch(storageActions.setFoldersToRename(foldersRepeated as DriveItemData[])); dispatch(storageActions.setDriveFoldersToRename(duplicatedFoldersResponse as DriveItemData[])); - dispatch(storageActions.setMoveDestinationFolderId(destinationFolderUuid)); + if (isMoveOperation) dispatch(storageActions.setMoveDestinationFolderId(destinationFolderUuid)); dispatch(uiActions.setIsNameCollisionDialogOpen(true)); } diff --git a/src/hooks/moveItems/useMoveItems.test.ts b/src/hooks/moveItems/useMoveItems.test.ts index 6b5e623bae..7abfbd51ab 100644 --- a/src/hooks/moveItems/useMoveItems.test.ts +++ b/src/hooks/moveItems/useMoveItems.test.ts @@ -242,6 +242,21 @@ describe('Restore items from trash', () => { ); }); + 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(() => { diff --git a/src/hooks/moveItems/useMoveItems.ts b/src/hooks/moveItems/useMoveItems.ts index b2f68e7d5f..09d9584286 100644 --- a/src/hooks/moveItems/useMoveItems.ts +++ b/src/hooks/moveItems/useMoveItems.ts @@ -23,16 +23,11 @@ interface ProcessMoveProps { export const useMoveItems = () => { const dispatch = useAppDispatch(); const processMove = async ({ finalDestinationId, items, displayTaskLogger }: ProcessMoveProps) => { - console.log('[MOVE ITEMS]: Processing move items', { - finalDestinationId, - items, - displayTaskLogger, - }); 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); - const foldersWithoutDuplicates = await handleRepeatedUploadingFolders(folders, dispatch, finalDestinationId); + const filesWithoutDuplicates = await handleRepeatedUploadingFiles(files, dispatch, finalDestinationId, true); + const foldersWithoutDuplicates = await handleRepeatedUploadingFolders(folders, dispatch, finalDestinationId, true); const itemsToMoveWithoutDuplicates = [...filesWithoutDuplicates, ...foldersWithoutDuplicates]; @@ -47,7 +42,8 @@ export const useMoveItems = () => { } return { - itemsMoved: itemsToMoveWithoutDuplicates.length > 0, + areItemsMoved: itemsToMoveWithoutDuplicates.length > 0, + totalItemsMoved: itemsToMoveWithoutDuplicates as DriveItemData[], }; }; @@ -76,9 +72,9 @@ export const useMoveItems = () => { const restoreItemFromTrash = (item: DriveItemData): Promise => withErrorHandler(async () => { if (item.parent?.status === FileStatus.EXISTS) { - const { itemsMoved } = await processMove({ finalDestinationId: item.parent.uuid, items: [item] }); + const { areItemsMoved } = await processMove({ finalDestinationId: item.parent.uuid, items: [item] }); - if (!itemsMoved) return; + if (!areItemsMoved) return; const goToDriveId = notificationsService.show({ text: t('notificationMessages.restoreItems'), @@ -108,15 +104,17 @@ export const useMoveItems = () => { map.set(key, [...(map.get(key) ?? []), item]); return map; }, new Map()); - await Promise.all( + const restoredItems = await Promise.all( [...restorableByDestination.entries()].map(([destinationId, items]) => processMove({ finalDestinationId: destinationId as string, items }), ), ); - if (restorableItems.length > 0) { + const totalItemsMovedConcat = restoredItems.flatMap((item) => item.totalItemsMoved); + + if (totalItemsMovedConcat.length > 0) { notificationsService.show({ text: t('notificationMessages.restoreItems'), type: ToastType.Success }); - dispatch(storageActions.popItemsToDelete(restorableItems)); + dispatch(storageActions.popItemsToDelete(totalItemsMovedConcat)); } if (needsDestination.length > 0) { From efaea001bf204f2c954a5a00373508b21f6bf8eb Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Wed, 13 May 2026 13:09:53 +0200 Subject: [PATCH 9/9] test: add coverage for renameItemsThunk --- .../storage.thunks/renameItemsThunk.test.ts | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 src/app/store/slices/storage/storage.thunks/renameItemsThunk.test.ts 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(); + }); +});