diff --git a/package.json b/package.json index 76e44ebcb..947c46072 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@iconscout/react-unicons": "^1.1.6", "@internxt/css-config": "1.1.0", "@internxt/lib": "1.4.1", - "@internxt/sdk": "=1.11.17", + "@internxt/sdk": "=1.11.24", "@internxt/ui": "0.1.1", "@phosphor-icons/react": "^2.1.7", "@popperjs/core": "^2.11.6", diff --git a/src/app/core/layouts/HeaderAndSidenavLayout/HeaderAndSidenavLayout.tsx b/src/app/core/layouts/HeaderAndSidenavLayout/HeaderAndSidenavLayout.tsx index 79bdf94b4..63c4b05ab 100644 --- a/src/app/core/layouts/HeaderAndSidenavLayout/HeaderAndSidenavLayout.tsx +++ b/src/app/core/layouts/HeaderAndSidenavLayout/HeaderAndSidenavLayout.tsx @@ -58,7 +58,9 @@ export default function HeaderAndSidenavLayout(props: HeaderAndSidenavLayoutProp - +
+ +
diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index a7b1723a0..3d465ea21 100644 --- a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx +++ b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx @@ -12,6 +12,10 @@ import { DriveItemData } from 'app/drive/types'; import { IRoot } from 'app/store/slices/storage/types'; import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; import { uploadFoldersWithManager } from 'app/network/UploadFolderManager'; +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'; type NameCollisionContainerProps = { currentFolderId: string; @@ -43,6 +47,8 @@ const NameCollisionContainer: FC = ({ () => moveDestinationFolderId ?? currentFolderId, [moveDestinationFolderId, currentFolderId], ); + const limits = useAppSelector(fileVersionsSelectors.getLimits); + const isVersioningEnabled = limits?.versioning?.enabled ?? false; const handleNewItems = (files: (File | DriveItemData)[], folders: (IRoot | DriveItemData)[]) => [ ...files, @@ -122,6 +128,47 @@ const NameCollisionContainer: FC = ({ ); }; + const uploadFileAndGetFileId = async (file: File, itemToReplace: DriveItemData) => { + const { bridgeUser, bridgePass, encryptionKey, bucketId } = getEnvironmentConfig(!!selectedWorkspace); + const network = new Network(bridgeUser, bridgePass, encryptionKey); + + const taskId = `replace-${itemToReplace.uuid}-${Date.now()}`; + + const [uploadPromise] = network.uploadFile( + bucketId, + { + filecontent: file, + filesize: file.size, + progressCallback: () => {}, + }, + { taskId }, + ); + + return await uploadPromise; + }; + + const replaceFileVersion = async (file: File, itemToReplace: DriveItemData) => { + const newFileId = await uploadFileAndGetFileId(file, itemToReplace); + await replaceFileService.replaceFile(itemToReplace.uuid, { + fileId: newFileId, + size: file.size, + }); + dispatch(fileVersionsActions.invalidateCache(itemToReplace.uuid)); + }; + + const trashAndUpload = async (file: File, itemToReplace: DriveItemData) => { + await moveItemsToTrash([itemToReplace]); + await dispatch( + storageThunks.uploadItemsThunk({ + files: [file], + parentFolderId: folderId, + options: { + disableDuplicatedNamesCheck: true, + }, + }), + ); + }; + const replaceAndUploadItem = async ({ itemsToReplace, itemsToUpload, @@ -129,11 +176,13 @@ const NameCollisionContainer: FC = ({ itemsToReplace: DriveItemData[]; itemsToUpload: (IRoot | File)[]; }) => { - await moveItemsToTrash(itemsToReplace); + for (let i = 0; i < itemsToUpload.length; i++) { + const itemToUpload = itemsToUpload[i]; + const itemToReplace = itemsToReplace[i]; - itemsToUpload.forEach((itemToUpload) => { if ((itemToUpload as IRoot).fullPathEdited) { - uploadFoldersWithManager({ + await moveItemsToTrash([itemToReplace]); + await uploadFoldersWithManager({ payload: [ { root: { ...(itemToUpload as IRoot) }, @@ -142,23 +191,15 @@ const NameCollisionContainer: FC = ({ ], selectedWorkspace, dispatch, - }).then(() => { - dispatch(fetchSortedFolderContentThunk(folderId)); }); } else { - dispatch( - storageThunks.uploadItemsThunk({ - files: [itemToUpload] as File[], - parentFolderId: folderId, - options: { - disableDuplicatedNamesCheck: true, - }, - }), - ).then(() => { - dispatch(fetchSortedFolderContentThunk(folderId)); - }); + const file = itemToUpload as File; + const canReplaceVersion = isVersioningEnabled && isVersioningExtensionAllowed(itemToReplace); + canReplaceVersion ? await replaceFileVersion(file, itemToReplace) : await trashAndUpload(file, itemToReplace); } - }); + + dispatch(fetchSortedFolderContentThunk(folderId)); + } }; const keepAndUploadItem = async (itemsToUpload: (IRoot | File)[]) => { diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 1bd6e5948..cb0f7c43d 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -795,7 +795,20 @@ "autosaveVersions": "{{count}}/{{total}} automatisch gespeicherte Versionen", "restoreVersion": "Version wiederherstellen", "downloadVersion": "Version herunterladen", - "deleteVersion": "Version löschen" + "deleteVersion": "Version löschen", + "deleteVersionTitle": "Version löschen", + "deleteButton": "Löschen", + "restoreButton": "Wiederherstellen", + "downloadError": "Fehler beim Herunterladen der Version", + "deleteVersionAdvice": "Diese Version wird dauerhaft gelöscht. \nDiese Aktion kann nicht rückgängig gemacht werden.", + "deletingVersion": "Lösche", + "restoreVersionTitle": "Version wiederherstellen", + "restoreVersionAdvice": "Das Wiederherstellen dieser Version ersetzt die aktuelle Datei und entfernt alle neueren Versionen. \nSobald wiederhergestellt, kann diese Aktion nicht rückgängig gemacht werden.\n\nSie können eine Kopie neuerer Versionen herunterladen, bevor Sie wiederherstellen.", + "restoringVersion": "Wiederherstellung", + "restoreSuccess": "Version erfolgreich wiederhergestellt", + "restoreError": "Fehler beim Wiederherstellen der Version", + "deleteSuccess": "Version erfolgreich gelöscht", + "deleteError": "Fehler beim Löschen der Version" }, "shareModal": { "title": "Aktie \"{{name}}\"", diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 541adf8b3..3f61f38dd 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -892,7 +892,20 @@ "autosaveVersions": "{{count}}/{{total}} autosave versions", "restoreVersion": "Restore version", "downloadVersion": "Download version", - "deleteVersion": "Delete version" + "deleteVersion": "Delete version", + "deleteVersionTitle": "Delete version", + "deleteButton": "Delete", + "restoreButton": "Restore", + "downloadError": "Failed to download version", + "deleteVersionAdvice": "This version will be permanently deleted. \nThis action cannot be undone.", + "deletingVersion": "Deleting", + "restoreVersionTitle": "Restore version", + "restoreVersionAdvice": "Restoring this version will replace the current file and remove all newer versions. \nOnce restored, this action cannot be undone.\n\nYou can download a copy of newer versions before restoring.", + "restoringVersion": "Restoring", + "restoreSuccess": "Version restored successfully", + "restoreError": "Failed to restore version", + "deleteSuccess": "Version deleted successfully", + "deleteError": "Failed to delete version" }, "shareModal": { "title": "Share \"{{name}}\"", diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 81c54e751..18798f93e 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -874,7 +874,20 @@ "autosaveVersions": "{{count}}/{{total}} versiones de autoguardado", "restoreVersion": "Restaurar versión", "downloadVersion": "Descargar versión", - "deleteVersion": "Eliminar versión" + "deleteVersion": "Eliminar versión", + "deleteVersionTitle": "Eliminar versión", + "deleteButton": "Eliminar", + "restoreButton": "Restaurar", + "downloadError": "Error al descargar la versión", + "deleteVersionAdvice": "Esta versión será eliminada permanentemente. \nEsta acción no se puede deshacer.", + "deletingVersion": "Eliminando", + "restoreVersionTitle": "Restaurar versión", + "restoreVersionAdvice": "Restaurar esta versión reemplazará el archivo actual y eliminará todas las versiones más recientes. \nUna vez restaurada, esta acción no se puede deshacer.\n\nPuedes descargar una copia de las versiones más recientes antes de restaurar.", + "restoringVersion": "Restaurando", + "restoreSuccess": "Versión restaurada exitosamente", + "restoreError": "Error al restaurar la versión", + "deleteSuccess": "Versión eliminada exitosamente", + "deleteError": "Error al eliminar la versión" }, "shareModal": { "title": "Compartir \"{{name}}\"", diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index e6c643814..7fd403e32 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -816,7 +816,20 @@ "autosaveVersions": "{{count}}/{{total}} versions de sauvegarde automatique", "restoreVersion": "Restaurer la version", "downloadVersion": "Télécharger la version", - "deleteVersion": "Supprimer la version" + "deleteVersion": "Supprimer la version", + "deleteVersionTitle": "Supprimer la version", + "deleteButton": "Supprimer", + "restoreButton": "Restaurer", + "downloadError": "Échec du téléchargement de la version", + "deleteVersionAdvice": "Cette version sera définitivement supprimée. \nCette action ne peut pas être annulée.", + "deletingVersion": "Suppression", + "restoreVersionTitle": "Restaurer la version", + "restoreVersionAdvice": "La restauration de cette version remplacera le fichier actuel et supprimera toutes les versions plus récentes. \nUne fois restaurée, cette action ne peut pas être annulée.\n\nVous pouvez télécharger une copie des versions plus récentes avant de restaurer.", + "restoringVersion": "Restauration", + "restoreSuccess": "Version restaurée avec succès", + "restoreError": "Échec de la restauration de la version", + "deleteSuccess": "Version supprimée avec succès", + "deleteError": "Échec de la suppression de la version" }, "newFolderModal": { "title": "Nouveau dossier", diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index f5c8e3074..916622c36 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -928,7 +928,20 @@ "autosaveVersions": "{{count}}/{{total}} versioni di salvataggio automatico", "restoreVersion": "Ripristina versione", "downloadVersion": "Scarica versione", - "deleteVersion": "Elimina versione" + "deleteVersion": "Elimina versione", + "deleteVersionTitle": "Elimina versione", + "deleteButton": "Elimina", + "restoreButton": "Ripristina", + "downloadError": "Impossibile scaricare la versione", + "deleteVersionAdvice": "Questa versione sarà eliminata in modo permanente. \nQuesta azione non può essere annullata.", + "deletingVersion": "Eliminazione", + "restoreVersionTitle": "Ripristina versione", + "restoreVersionAdvice": "Il ripristino di questa versione sostituirà il file corrente e rimuoverà tutte le versioni più recenti. \nUna volta ripristinata, questa azione non può essere annullata.\n\nPuoi scaricare una copia delle versioni più recenti prima del ripristino.", + "restoringVersion": "Ripristino", + "restoreSuccess": "Versione ripristinata con successo", + "restoreError": "Impossibile ripristinare la versione", + "deleteSuccess": "Versione eliminata con successo", + "deleteError": "Impossibile eliminare la versione" }, "shareModal": { "title": "Condividi \"{{name}}\"", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 59d75dc36..e3d644be9 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -835,7 +835,20 @@ "autosaveVersions": "{{count}}/{{total}} версий автосохранения", "restoreVersion": "Восстановить версию", "downloadVersion": "Скачать версию", - "deleteVersion": "Удалить версию" + "deleteVersion": "Удалить версию", + "deleteVersionTitle": "Удалить версию", + "deleteButton": "Удалить", + "restoreButton": "Восстановить", + "downloadError": "Не удалось загрузить версию", + "deleteVersionAdvice": "Эта версия будет удалена навсегда. \nЭто действие нельзя отменить.", + "deletingVersion": "Удаление", + "restoreVersionTitle": "Восстановить версию", + "restoreVersionAdvice": "Восстановление этой версии заменит текущий файл и удалит все более новые версии. \nПосле восстановления это действие нельзя отменить.\n\nВы можете скачать копию более новых версий перед восстановлением.", + "restoringVersion": "Восстановление", + "restoreSuccess": "Версия успешно восстановлена", + "restoreError": "Не удалось восстановить версию", + "deleteSuccess": "Версия успешно удалена", + "deleteError": "Не удалось удалить версию" }, "shareModal": { "title": "Поделиться \"{{name}}\"", diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index bdf567a55..216d1e305 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -822,7 +822,20 @@ "autosaveVersions": "{{count}}/{{total}} 自動儲存版本", "restoreVersion": "復原版本", "downloadVersion": "下載版本", - "deleteVersion": "刪除版本" + "deleteVersion": "刪除版本", + "deleteVersionTitle": "刪除版本", + "deleteButton": "刪除", + "restoreButton": "復原", + "downloadError": "下載版本失敗", + "deleteVersionAdvice": "此版本將被永久刪除。\n此操作無法復原。", + "deletingVersion": "刪除中", + "restoreVersionTitle": "復原版本", + "restoreVersionAdvice": "復原此版本將取代目前檔案並刪除所有較新的版本。\n一旦復原,此操作無法撤銷。\n\n您可以在復原之前下載較新版本的副本。", + "restoringVersion": "復原中", + "restoreSuccess": "版本復原成功", + "restoreError": "復原版本失敗", + "deleteSuccess": "版本刪除成功", + "deleteError": "刪除版本失敗" }, "shareModal": { "title": "分享“{{name}}”", diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index b341cb2e5..9c448049b 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -858,7 +858,20 @@ "autosaveVersions": "{{count}}/{{total}} 自动保存版本", "restoreVersion": "恢复版本", "downloadVersion": "下载版本", - "deleteVersion": "删除版本" + "deleteVersion": "删除版本", + "deleteVersionTitle": "删除版本", + "deleteButton": "删除", + "restoreButton": "恢复", + "downloadError": "下载版本失败", + "deleteVersionAdvice": "此版本将被永久删除。\n此操作无法撤销。", + "deletingVersion": "删除中", + "restoreVersionTitle": "恢复版本", + "restoreVersionAdvice": "恢复此版本将替换当前文件并删除所有较新的版本。\n一旦恢复,此操作无法撤销。\n\n您可以在恢复之前下载较新版本的副本。", + "restoringVersion": "恢复中", + "restoreSuccess": "版本恢复成功", + "restoreError": "恢复版本失败", + "deleteSuccess": "版本删除成功", + "deleteError": "删除版本失败" }, "shareModal": { "title": "分享 \"{{name}}\"", diff --git a/src/app/store/index.ts b/src/app/store/index.ts index da313723c..76b3e1236 100644 --- a/src/app/store/index.ts +++ b/src/app/store/index.ts @@ -11,6 +11,7 @@ import taskManagerReducer from './slices/taskManager'; import uiReducer from './slices/ui'; import userReducer from './slices/user'; import workspacesReducer from './slices/workspaces/workspacesStore'; +import { fileVersionsReducer } from './slices/fileVersions'; export const store = configureStore({ reducer: { @@ -25,6 +26,7 @@ export const store = configureStore({ referrals: referralsReducer, shared: sharedReducer, workspaces: workspacesReducer, + fileVersions: fileVersionsReducer, }, }); diff --git a/src/app/store/slices/fileVersions/fileVersions.selectors.ts b/src/app/store/slices/fileVersions/fileVersions.selectors.ts new file mode 100644 index 000000000..03c545460 --- /dev/null +++ b/src/app/store/slices/fileVersions/fileVersions.selectors.ts @@ -0,0 +1,16 @@ +import { FileVersion, FileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; +import { RootState } from '../..'; + +const fileVersionsSelectors = { + getLimits(state: RootState): FileLimitsResponse | null { + return state.fileVersions.limits; + }, + getVersionsByFileId(state: RootState, fileId: NonNullable): FileVersion[] { + return state.fileVersions.versionsByFileId[fileId] ?? []; + }, + isLoadingByFileId(state: RootState, fileId: NonNullable): boolean { + return state.fileVersions.isLoadingByFileId[fileId] ?? false; + }, +}; + +export default fileVersionsSelectors; diff --git a/src/app/store/slices/fileVersions/index.test.ts b/src/app/store/slices/fileVersions/index.test.ts new file mode 100644 index 000000000..6ed3ea378 --- /dev/null +++ b/src/app/store/slices/fileVersions/index.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FileVersion, FileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; +import fileVersionService from 'views/Drive/components/VersionHistory/services/fileVersion.service'; +import { fileVersionsActions, fileVersionsReducer, fetchFileVersionsThunk, fetchVersionLimitsThunk } from './index'; +import { RootState } from '../..'; + +vi.mock('views/Drive/components/VersionHistory/services/fileVersion.service', () => ({ + default: { + getFileVersions: vi.fn(), + getLimits: vi.fn(), + }, +})); + +describe('File history state', () => { + const fileUuid = 'file-uuid'; + const versions: FileVersion[] = [ + { id: 'v1', fileId: fileUuid } as FileVersion, + { id: 'v2', fileId: fileUuid } as FileVersion, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Loading file history', () => { + it('when file history is requested, then the versions load successfully', async () => { + const getFileVersionsSpy = vi.spyOn(fileVersionService, 'getFileVersions').mockResolvedValueOnce(versions); + const dispatch = vi.fn(); + + const action = await fetchFileVersionsThunk(fileUuid)(dispatch, () => ({}) as RootState, undefined); + + expect(getFileVersionsSpy).toHaveBeenCalledWith(fileUuid); + expect(action.meta.requestStatus).toBe('fulfilled'); + expect(action.payload).toEqual({ fileUuid, versions }); + }); + + it('when file history fails to load, then the error is reported', async () => { + const getFileVersionsSpy = vi + .spyOn(fileVersionService, 'getFileVersions') + .mockRejectedValueOnce(new Error('failed to fetch')); + const dispatch = vi.fn(); + + const action = await fetchFileVersionsThunk(fileUuid)(dispatch, () => ({}) as RootState, undefined); + + expect(getFileVersionsSpy).toHaveBeenCalledWith(fileUuid); + expect(action.meta.requestStatus).toBe('rejected'); + expect(action.payload).toBe('failed to fetch'); + }); + + it('when version limits are requested, then the limits load successfully', async () => { + const limits: FileLimitsResponse = { + versioning: { enabled: true, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, + }; + const getLimitsSpy = vi.spyOn(fileVersionService, 'getLimits').mockResolvedValueOnce(limits); + const dispatch = vi.fn(); + + const action = await fetchVersionLimitsThunk()(dispatch, () => ({}) as RootState, undefined); + + expect(getLimitsSpy).toHaveBeenCalled(); + expect(action.meta.requestStatus).toBe('fulfilled'); + expect(action.payload).toBe(limits); + }); + + it('when version limits fail to load, then the error is reported', async () => { + const getLimitsSpy = vi + .spyOn(fileVersionService, 'getLimits') + .mockRejectedValueOnce(new Error('limits unavailable')); + const dispatch = vi.fn(); + + const action = await fetchVersionLimitsThunk()(dispatch, () => ({}) as RootState, undefined); + + expect(getLimitsSpy).toHaveBeenCalled(); + expect(action.meta.requestStatus).toBe('rejected'); + expect(action.payload).toBe('limits unavailable'); + }); + }); + + describe('Updating stored history', () => { + it('when history starts loading, then loading and error states update', () => { + const pendingState = fileVersionsReducer(undefined, fetchFileVersionsThunk.pending('', fileUuid)); + + expect(pendingState.isLoadingByFileId[fileUuid]).toBe(true); + expect(pendingState.errorsByFileId[fileUuid]).toBeNull(); + + const rejectedState = fileVersionsReducer(pendingState, { + type: fetchFileVersionsThunk.rejected.type, + meta: { arg: fileUuid }, + payload: 'problem', + } as any); + + expect(rejectedState.isLoadingByFileId[fileUuid]).toBe(false); + expect(rejectedState.errorsByFileId[fileUuid]).toBe('problem'); + + const fulfilledState = fileVersionsReducer( + rejectedState, + fetchFileVersionsThunk.fulfilled({ fileUuid, versions }, '', fileUuid), + ); + + expect(fulfilledState.isLoadingByFileId[fileUuid]).toBe(false); + expect(fulfilledState.versionsByFileId[fileUuid]).toEqual(versions); + }); + + it('when cached history is cleared for a file or all files, then entries are removed', () => { + const populatedState = { + versionsByFileId: { [fileUuid]: versions }, + isLoadingByFileId: { [fileUuid]: false }, + errorsByFileId: { [fileUuid]: 'error' }, + limits: null, + isLimitsLoading: false, + }; + + const afterInvalidate = fileVersionsReducer(populatedState as any, fileVersionsActions.invalidateCache(fileUuid)); + expect(afterInvalidate.versionsByFileId[fileUuid]).toBeUndefined(); + expect(afterInvalidate.isLoadingByFileId[fileUuid]).toBeUndefined(); + expect(afterInvalidate.errorsByFileId[fileUuid]).toBeUndefined(); + + const afterClearAll = fileVersionsReducer(populatedState as any, fileVersionsActions.clearAllCache()); + expect(afterClearAll.versionsByFileId).toEqual({}); + expect(afterClearAll.isLoadingByFileId).toEqual({}); + expect(afterClearAll.errorsByFileId).toEqual({}); + }); + + it('when a version is deleted, then it is removed from the list', () => { + const state = { + versionsByFileId: { [fileUuid]: versions }, + isLoadingByFileId: {}, + errorsByFileId: {}, + limits: null, + isLimitsLoading: false, + }; + + const updatedState = fileVersionsReducer( + state as any, + fileVersionsActions.updateVersionsAfterDelete({ fileUuid, versionId: 'v1' }), + ); + + expect(updatedState.versionsByFileId[fileUuid]).toEqual([versions[1]]); + }); + + it('when limits are loading or finished, then the loading state updates', () => { + const pendingState = fileVersionsReducer(undefined, fetchVersionLimitsThunk.pending('', undefined)); + expect(pendingState.isLimitsLoading).toBe(true); + + const limits: FileLimitsResponse = { + versioning: { enabled: true, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, + }; + const fulfilledState = fileVersionsReducer( + pendingState, + fetchVersionLimitsThunk.fulfilled(limits, '', undefined), + ); + expect(fulfilledState.isLimitsLoading).toBe(false); + expect(fulfilledState.limits).toBe(limits); + + const rejectedState = fileVersionsReducer( + pendingState, + fetchVersionLimitsThunk.rejected(new Error('err'), '', undefined), + ); + expect(rejectedState.isLimitsLoading).toBe(false); + }); + }); +}); diff --git a/src/app/store/slices/fileVersions/index.ts b/src/app/store/slices/fileVersions/index.ts new file mode 100644 index 000000000..70e099e00 --- /dev/null +++ b/src/app/store/slices/fileVersions/index.ts @@ -0,0 +1,93 @@ +import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; +import { FileVersion, FileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; +import fileVersionService from 'views/Drive/components/VersionHistory/services/fileVersion.service'; + +interface FileVersionsState { + versionsByFileId: Record, FileVersion[]>; + isLoadingByFileId: Record, boolean>; + errorsByFileId: Record, string | null>; + limits: FileLimitsResponse | null; + isLimitsLoading: boolean; +} + +const initialState: FileVersionsState = { + versionsByFileId: {}, + isLoadingByFileId: {}, + errorsByFileId: {}, + limits: null, + isLimitsLoading: false, +}; + +export const fetchFileVersionsThunk = createAsyncThunk( + 'fileVersions/fetch', + async (fileUuid: string, { rejectWithValue }) => { + try { + const versions = await fileVersionService.getFileVersions(fileUuid); + return { fileUuid, versions }; + } catch (error) { + return rejectWithValue((error as Error).message); + } + }, +); + +export const fetchVersionLimitsThunk = createAsyncThunk('fileVersions/fetchLimits', async (_, { rejectWithValue }) => { + try { + const limits = await fileVersionService.getLimits(); + return limits; + } catch (error) { + return rejectWithValue((error as Error).message); + } +}); + +export const fileVersionsSlice = createSlice({ + name: 'fileVersions', + initialState, + reducers: { + invalidateCache: (state, action: PayloadAction) => { + delete state.versionsByFileId[action.payload]; + delete state.isLoadingByFileId[action.payload]; + delete state.errorsByFileId[action.payload]; + }, + clearAllCache: (state) => { + state.versionsByFileId = {}; + state.isLoadingByFileId = {}; + state.errorsByFileId = {}; + }, + updateVersionsAfterDelete: (state, action: PayloadAction<{ fileUuid: string; versionId: string }>) => { + const { fileUuid, versionId } = action.payload; + if (state.versionsByFileId[fileUuid]) { + state.versionsByFileId[fileUuid] = state.versionsByFileId[fileUuid].filter((v) => v.id !== versionId); + } + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchFileVersionsThunk.pending, (state, action) => { + state.isLoadingByFileId[action.meta.arg] = true; + state.errorsByFileId[action.meta.arg] = null; + }) + .addCase(fetchFileVersionsThunk.fulfilled, (state, action) => { + const { fileUuid, versions } = action.payload; + state.versionsByFileId[fileUuid] = versions; + state.isLoadingByFileId[fileUuid] = false; + }) + .addCase(fetchFileVersionsThunk.rejected, (state, action) => { + state.isLoadingByFileId[action.meta.arg] = false; + state.errorsByFileId[action.meta.arg] = action.payload as string; + }) + .addCase(fetchVersionLimitsThunk.pending, (state) => { + state.isLimitsLoading = true; + }) + .addCase(fetchVersionLimitsThunk.fulfilled, (state, action) => { + state.limits = action.payload; + state.isLimitsLoading = false; + }) + .addCase(fetchVersionLimitsThunk.rejected, (state) => { + state.isLimitsLoading = false; + }); + }, +}); + +export const fileVersionsActions = fileVersionsSlice.actions; +export const fileVersionsReducer = fileVersionsSlice.reducer; +export { default as fileVersionsSelectors } from './fileVersions.selectors'; diff --git a/src/app/store/slices/ui/index.ts b/src/app/store/slices/ui/index.ts index 7c2ab076d..732f20632 100644 --- a/src/app/store/slices/ui/index.ts +++ b/src/app/store/slices/ui/index.ts @@ -1,6 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { DriveItemData, DriveItemDetails, FileInfoMenuItem, UpgradePlanDialogInfo } from 'app/drive/types'; import { PreviewFileItem } from '../../../share/types'; +import { FileVersion } from 'views/Drive/components/VersionHistory/types'; interface UISliceState { isSidenavCollapsed: boolean; @@ -12,6 +13,8 @@ interface UISliceState { isVersionHistorySidebarOpen: boolean; isCreateFolderDialogOpen: boolean; isDeleteItemsDialogOpen: boolean; + isDeleteVersionDialogOpen: boolean; + isRestoreVersionDialogOpen: boolean; isMoveItemsDialogOpen: boolean; isClearTrashDialogOpen: boolean; isEditFolderNameDialog: boolean; @@ -29,6 +32,8 @@ interface UISliceState { fileViewerItem: PreviewFileItem | null; itemDetails: DriveItemDetails | null; versionHistoryItem: DriveItemData | null; + versionToDelete: FileVersion | null; + versionToRestore: FileVersion | null; currentFileInfoMenuItem: FileInfoMenuItem | null; currentEditingNameDriveItem: DriveItemData | null; currentEditingNameDirty: string; @@ -46,6 +51,8 @@ const initialState: UISliceState = { isVersionHistorySidebarOpen: false, isCreateFolderDialogOpen: false, isDeleteItemsDialogOpen: false, + isDeleteVersionDialogOpen: false, + isRestoreVersionDialogOpen: false, isMoveItemsDialogOpen: false, isClearTrashDialogOpen: false, isEditFolderNameDialog: false, @@ -63,6 +70,8 @@ const initialState: UISliceState = { fileViewerItem: null, itemDetails: null, versionHistoryItem: null, + versionToDelete: null, + versionToRestore: null, currentFileInfoMenuItem: null, currentEditingNameDriveItem: null, currentEditingNameDirty: '', @@ -98,12 +107,24 @@ export const uiSlice = createSlice({ setVersionHistoryItem: (state: UISliceState, action: PayloadAction) => { state.versionHistoryItem = action.payload; }, + setVersionToDelete: (state: UISliceState, action: PayloadAction) => { + state.versionToDelete = action.payload; + }, + setVersionToRestore: (state: UISliceState, action: PayloadAction) => { + state.versionToRestore = action.payload; + }, setIsCreateFolderDialogOpen: (state: UISliceState, action: PayloadAction) => { state.isCreateFolderDialogOpen = action.payload; }, setIsDeleteItemsDialogOpen: (state: UISliceState, action: PayloadAction) => { state.isDeleteItemsDialogOpen = action.payload; }, + setIsDeleteVersionDialogOpen: (state: UISliceState, action: PayloadAction) => { + state.isDeleteVersionDialogOpen = action.payload; + }, + setIsRestoreVersionDialogOpen: (state: UISliceState, action: PayloadAction) => { + state.isRestoreVersionDialogOpen = action.payload; + }, setIsMoveItemsDialogOpen: (state: UISliceState, action: PayloadAction) => { state.isMoveItemsDialogOpen = action.payload; }, diff --git a/src/services/date.service.test.ts b/src/services/date.service.test.ts index d29e6215c..2b1d86c05 100644 --- a/src/services/date.service.test.ts +++ b/src/services/date.service.test.ts @@ -1,5 +1,5 @@ import dayjs from 'dayjs'; -import { describe, expect, test } from 'vitest'; +import { beforeEach, afterEach, describe, expect, test, vi } from 'vitest'; import dateService from './date.service'; describe('dateService', () => { @@ -35,4 +35,30 @@ describe('dateService', () => { expect(isBefore).toBe(false); }); + + describe('Expiration countdown', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2023-01-01T00:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('when the expiration is in the future, then remaining days round up', () => { + const expiresAt = '2023-01-02T06:00:00Z'; + expect(dateService.getDaysUntilExpiration(expiresAt)).toBe(2); + }); + + test('when the expiration has passed, then zero days remain', () => { + const expiresAt = '2022-12-31T23:59:59Z'; + expect(dateService.getDaysUntilExpiration(expiresAt)).toBe(0); + }); + + test('when the expiration is later today, then it counts as one day remaining', () => { + const expiresAt = '2023-01-01T12:00:00Z'; + expect(dateService.getDaysUntilExpiration(expiresAt)).toBe(1); + }); + }); }); diff --git a/src/services/date.service.ts b/src/services/date.service.ts index 518395ed7..2c9377ab2 100644 --- a/src/services/date.service.ts +++ b/src/services/date.service.ts @@ -28,6 +28,13 @@ export const formatDefaultDate = (date: Date | string | number, translate: (key: return dayjs(date).format(`D MMM, YYYY [${translatedAt}] HH:mm`); }; +export const getDaysUntilExpiration = (expiresAt: Date | string): number => { + const expirationDate = dayjs(expiresAt); + const now = dayjs(); + const diffInDays = expirationDate.diff(now, 'day', true); + return Math.max(0, Math.ceil(diffInDays)); +}; + const dateService = { format, fromNow, @@ -35,6 +42,7 @@ const dateService = { getCurrentDate, getExpirationDate, formatDefaultDate, + getDaysUntilExpiration, }; export default dateService; diff --git a/src/views/Drive/DriveView.tsx b/src/views/Drive/DriveView.tsx index 5eef11f20..78dbff885 100644 --- a/src/views/Drive/DriveView.tsx +++ b/src/views/Drive/DriveView.tsx @@ -23,6 +23,7 @@ import { STORAGE_KEYS } from 'services/storage-keys'; import workspacesService from 'services/workspace.service'; import { useHistory } from 'react-router-dom'; import envService from 'services/env.service'; +import { fetchVersionLimitsThunk } from 'app/store/slices/fileVersions'; export interface DriveViewProps { namePath: FolderPath[]; @@ -47,6 +48,7 @@ const DriveView = (props: DriveViewProps) => { dispatch(uiActions.setIsGlobalSearch(false)); dispatch(storageThunks.resetNamePathThunk()); dispatch(storageActions.clearSelectedItems()); + dispatch(fetchVersionLimitsThunk()); }, []); useEffect(() => { diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx index 091b72a3e..cf1b4cb27 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx @@ -34,6 +34,7 @@ import { } from './DriveItemContextMenu'; import { List } from '@internxt/ui'; import { DownloadManager } from 'app/network/DownloadManager'; +import { useVersionHistoryMenuConfig } from '../../VersionHistory/hooks'; interface DriveExplorerListProps { folderId: string; @@ -121,6 +122,8 @@ const DriveExplorerList: React.FC = memo((props) => { const { translate } = useTranslationContext(); + const versionHistoryMenuConfig = useVersionHistoryMenuConfig(props.selectedItems[0]); + const onSelectedItemsChanged = (changes: { props: DriveItemData; value: boolean }[]) => { let updatedSelectedItems = props.selectedItems; @@ -341,6 +344,7 @@ const DriveExplorerList: React.FC = memo((props) => { downloadItem: downloadItem, viewVersionHistory: viewVersionHistory, moveToTrash: props.onOpenStopSharingAndMoveToTrashDialog, + versionHistoryConfig: versionHistoryMenuConfig, }); const selectedSharedFileMenu = contextMenuDriveItemShared({ @@ -355,6 +359,7 @@ const DriveExplorerList: React.FC = memo((props) => { downloadItem: downloadItem, viewVersionHistory: viewVersionHistory, moveToTrash: props.onOpenStopSharingAndMoveToTrashDialog, + versionHistoryConfig: versionHistoryMenuConfig, }); const selectedFolderMenu = contextMenuDriveFolderNotSharedLink({ @@ -366,6 +371,7 @@ const DriveExplorerList: React.FC = memo((props) => { downloadItem: downloadItem, viewVersionHistory: viewVersionHistory, moveToTrash: moveToTrash, + versionHistoryConfig: versionHistoryMenuConfig, }); const selectedFileMenu = contextMenuDriveNotSharedLink({ @@ -378,6 +384,7 @@ const DriveExplorerList: React.FC = memo((props) => { downloadItem: downloadItem, viewVersionHistory: viewVersionHistory, moveToTrash: moveToTrash, + versionHistoryConfig: versionHistoryMenuConfig, }); const shareWithTeam = () => { @@ -395,6 +402,7 @@ const DriveExplorerList: React.FC = memo((props) => { downloadItem: downloadItem, viewVersionHistory: viewVersionHistory, moveToTrash: moveToTrash, + versionHistoryConfig: versionHistoryMenuConfig, }); const workspaceFolderMenu = contextMenuWorkspaceFolder({ @@ -407,6 +415,7 @@ const DriveExplorerList: React.FC = memo((props) => { downloadItem: downloadItem, viewVersionHistory: viewVersionHistory, moveToTrash: moveToTrash, + versionHistoryConfig: versionHistoryMenuConfig, }); const getContextMenu = () => { diff --git a/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx b/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx index 6a43b9f33..81e5b6f96 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx @@ -6,6 +6,7 @@ import { Eye, Icon, Info, + LockSimple, Link, PencilSimple, Trash, @@ -93,14 +94,46 @@ const getDownloadMenuItem = (downloadItems: (target?) => void) => ({ }, }); -const getVersionHistoryMenuItem = (viewVersionHistory: (target?) => void) => ({ - name: t('drive.dropdown.versionHistory'), - icon: ClockCounterClockwise, - action: viewVersionHistory, - disabled: (item) => { - return item.isFolder; - }, -}); +export type VersionHistoryMenuConfig = { + isLocked: boolean; + isExtensionAllowed: boolean; + onUpgradeClick?: () => void; +}; + +const getVersionHistoryMenuItem = ( + viewVersionHistory: (target?) => void, + config?: VersionHistoryMenuConfig, +): MenuItemType => { + const isLocked = config?.isLocked ?? false; + const isExtensionAllowed = config?.isExtensionAllowed ?? true; + const onUpgradeClick = config?.onUpgradeClick; + + if (isLocked) { + return { + name: t('drive.dropdown.versionHistory') as string, + icon: LockSimple, + action: onUpgradeClick, + disabled: () => false, + node: ( +
+ + {t('drive.dropdown.versionHistory')} +
+ ), + }; + } + + return { + name: t('drive.dropdown.versionHistory') as string, + icon: ClockCounterClockwise, + action: viewVersionHistory, + disabled: (item: DriveItemData) => item.isFolder || !isExtensionAllowed, + }; +}; const getMoveToTrashMenuItem = (moveToTrash: (target?) => void) => ({ name: t('drive.dropdown.moveToTrash'), @@ -176,6 +209,7 @@ const contextMenuDriveNotSharedLink = ({ downloadItem, viewVersionHistory, moveToTrash, + versionHistoryConfig, }: { shareLink: (item: DriveItemData) => void; openPreview?: (item: DriveItemData) => void; @@ -186,6 +220,7 @@ const contextMenuDriveNotSharedLink = ({ downloadItem: (item: DriveItemData) => void; viewVersionHistory: (item: DriveItemData) => void; moveToTrash: (item: DriveItemData) => void; + versionHistoryConfig?: VersionHistoryMenuConfig; }): Array> => [ shareLinkMenuItem(shareLink), @@ -196,7 +231,7 @@ const contextMenuDriveNotSharedLink = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), - getVersionHistoryMenuItem(viewVersionHistory), + getVersionHistoryMenuItem(viewVersionHistory, versionHistoryConfig), { separator: true }, getMoveToTrashMenuItem(moveToTrash), ].filter(Boolean) as MenuItemType[]; @@ -210,6 +245,7 @@ const contextMenuDriveFolderNotSharedLink = ({ downloadItem, viewVersionHistory, moveToTrash, + versionHistoryConfig, }: { shareLink: (item: DriveItemData) => void; getLink: (item: DriveItemData) => void; @@ -219,6 +255,7 @@ const contextMenuDriveFolderNotSharedLink = ({ downloadItem: (item: DriveItemData) => void; viewVersionHistory: (item: DriveItemData) => void; moveToTrash: (item: DriveItemData) => void; + versionHistoryConfig?: VersionHistoryMenuConfig; }): Array> => [ shareLinkMenuItem(shareLink), getCopyLinkMenuItem(getLink), @@ -227,7 +264,7 @@ const contextMenuDriveFolderNotSharedLink = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), - getVersionHistoryMenuItem(viewVersionHistory), + getVersionHistoryMenuItem(viewVersionHistory, versionHistoryConfig), { separator: true }, getMoveToTrashMenuItem(moveToTrash), ]; @@ -242,6 +279,7 @@ const contextMenuDriveItemShared = ({ downloadItem, viewVersionHistory, moveToTrash, + versionHistoryConfig, }: { openPreview?: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; showDetails: (item: DriveItemData) => void; @@ -252,6 +290,7 @@ const contextMenuDriveItemShared = ({ downloadItem: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; viewVersionHistory: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; moveToTrash: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; + versionHistoryConfig?: VersionHistoryMenuConfig; }): Array> => { const shareLinkItems = [manageLinkAccessMenuItem(openShareAccessSettings), getCopyLinkMenuItem(copyLink)]; return [ @@ -262,7 +301,7 @@ const contextMenuDriveItemShared = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), - getVersionHistoryMenuItem(viewVersionHistory), + getVersionHistoryMenuItem(viewVersionHistory, versionHistoryConfig), { separator: true }, getMoveToTrashMenuItem(moveToTrash), ].filter(Boolean) as MenuItemType[]; @@ -277,6 +316,7 @@ const contextMenuDriveFolderShared = ({ downloadItem, viewVersionHistory, moveToTrash, + versionHistoryConfig, }: { copyLink: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; openShareAccessSettings: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; @@ -286,6 +326,7 @@ const contextMenuDriveFolderShared = ({ downloadItem: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; viewVersionHistory: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; moveToTrash: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; + versionHistoryConfig?: VersionHistoryMenuConfig; }): Array> => { const shareLinkItems = [manageLinkAccessMenuItem(openShareAccessSettings), getCopyLinkMenuItem(copyLink)]; return [ @@ -295,10 +336,10 @@ const contextMenuDriveFolderShared = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), - getVersionHistoryMenuItem(viewVersionHistory), + getVersionHistoryMenuItem(viewVersionHistory, versionHistoryConfig), { separator: true }, getMoveToTrashMenuItem(moveToTrash), - ]; + ].filter(Boolean) as MenuItemType[]; }; const contextMenuMultipleSharedView = ({ @@ -469,6 +510,7 @@ const contextMenuWorkspaceFolder = ({ downloadItem, viewVersionHistory, moveToTrash, + versionHistoryConfig, }: { shareLink: (item: DriveItemData) => void; getLink: (item: DriveItemData) => void; @@ -479,6 +521,7 @@ const contextMenuWorkspaceFolder = ({ downloadItem: (item: DriveItemData) => void; viewVersionHistory: (item: DriveItemData) => void; moveToTrash: (item: DriveItemData) => void; + versionHistoryConfig?: VersionHistoryMenuConfig; }): Array> => [ shareLinkMenuItem(shareLink), getCopyLinkMenuItem(getLink), @@ -488,7 +531,7 @@ const contextMenuWorkspaceFolder = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), - getVersionHistoryMenuItem(viewVersionHistory), + getVersionHistoryMenuItem(viewVersionHistory, versionHistoryConfig), { separator: true }, getMoveToTrashMenuItem(moveToTrash), ]; @@ -504,6 +547,7 @@ const contextMenuWorkspaceFile = ({ downloadItem, viewVersionHistory, moveToTrash, + versionHistoryConfig, }: { shareLink: (item: DriveItemData) => void; shareWithTeam: (item: DriveItemData) => void; @@ -515,6 +559,7 @@ const contextMenuWorkspaceFile = ({ downloadItem: (item: DriveItemData) => void; viewVersionHistory: (item: DriveItemData) => void; moveToTrash: (item: DriveItemData) => void; + versionHistoryConfig?: VersionHistoryMenuConfig; }): Array> => [ shareLinkMenuItem(shareLink), @@ -526,7 +571,7 @@ const contextMenuWorkspaceFile = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), - getVersionHistoryMenuItem(viewVersionHistory), + getVersionHistoryMenuItem(viewVersionHistory, versionHistoryConfig), { separator: true }, getMoveToTrashMenuItem(moveToTrash), ].filter(Boolean) as MenuItemType[]; diff --git a/src/views/Drive/components/DriveExplorer/components/DriveTopBarActions.tsx b/src/views/Drive/components/DriveExplorer/components/DriveTopBarActions.tsx index 0b71ab574..3a6821f25 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveTopBarActions.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveTopBarActions.tsx @@ -31,6 +31,7 @@ import { } from './DriveItemContextMenu'; import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; import { DownloadManager } from 'app/network/DownloadManager'; +import { useVersionHistoryMenuConfig } from '../../VersionHistory/hooks'; const DriveTopBarActions = ({ selectedItems, @@ -63,6 +64,8 @@ const DriveTopBarActions = ({ const hasItemsAndIsNotTrash = hasAnyItemSelected && !isTrash; const hasItemsAndIsTrash = hasAnyItemSelected && isTrash; + const versionHistoryMenuConfig = useVersionHistoryMenuConfig(selectedItems[0]); + const viewModesIcons = { [FileViewMode.List]: ( { + if (versionHistoryMenuConfig.locked) { + versionHistoryMenuConfig.onLockedClick?.(); + return; + } dispatch(uiActions.setVersionHistoryItem(selectedItems[0])); dispatch(uiActions.setIsVersionHistorySidebarOpen(true)); }; @@ -183,6 +190,7 @@ const DriveTopBarActions = ({ downloadItem: onDownloadButtonClicked, viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, + versionHistoryConfig: versionHistoryMenuConfig, }); const workspaceFolderMenu = contextMenuWorkspaceFolder({ @@ -195,6 +203,7 @@ const DriveTopBarActions = ({ downloadItem: onDownloadButtonClicked, viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, + versionHistoryConfig: versionHistoryMenuConfig, }); const dropdownActions = () => { @@ -217,6 +226,7 @@ const DriveTopBarActions = ({ downloadItem: onDownloadButtonClicked, viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, + versionHistoryConfig: versionHistoryMenuConfig, }) : contextMenuDriveItemShared({ openPreview: onOpenPreviewButtonClicked, @@ -228,6 +238,7 @@ const DriveTopBarActions = ({ downloadItem: onDownloadButtonClicked, viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, + versionHistoryConfig: versionHistoryMenuConfig, }); } else { return selectedItems[0].isFolder @@ -240,6 +251,7 @@ const DriveTopBarActions = ({ downloadItem: onDownloadButtonClicked, viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, + versionHistoryConfig: versionHistoryMenuConfig, }) : contextMenuDriveNotSharedLink({ shareLink: onOpenShareSettingsButtonClicked, @@ -251,6 +263,7 @@ const DriveTopBarActions = ({ downloadItem: onDownloadButtonClicked, viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, + versionHistoryConfig: versionHistoryMenuConfig, }); } }; diff --git a/src/views/Drive/components/VersionHistory/Sidebar.tsx b/src/views/Drive/components/VersionHistory/Sidebar.tsx index a16fd504f..0d1ba4577 100644 --- a/src/views/Drive/components/VersionHistory/Sidebar.tsx +++ b/src/views/Drive/components/VersionHistory/Sidebar.tsx @@ -1,38 +1,207 @@ -import { useState } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { RootState } from 'app/store'; import { useAppDispatch, useAppSelector } from 'app/store/hooks'; import { uiActions } from 'app/store/slices/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { Header, CurrentVersionItem, VersionItem, AutosaveSection } from './components'; -import { FileVersion } from './types'; +import storageSelectors from 'app/store/slices/storage/storage.selectors'; +import { fetchSortedFolderContentThunk } from 'app/store/slices/storage/storage.thunks/fetchSortedFolderContentThunk'; +import { + Header, + CurrentVersionItem, + VersionItem, + AutosaveSection, + VersionActionDialog, + VersionHistorySkeleton, +} from './components'; +import fileVersionService from 'views/Drive/components/VersionHistory/services/fileVersion.service'; +import errorService from 'services/error.service'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { + fetchFileVersionsThunk, + fetchVersionLimitsThunk, + fileVersionsActions, + fileVersionsSelectors, +} from 'app/store/slices/fileVersions'; + +type VersionInfo = { id: string; updatedAt: string }; const Sidebar = () => { const dispatch = useAppDispatch(); const isOpen = useAppSelector((state: RootState) => state.ui.isVersionHistorySidebarOpen); const item = useAppSelector((state: RootState) => state.ui.versionHistoryItem); + const user = useAppSelector((state: RootState) => state.user.user); + const isDeleteVersionDialogOpen = useAppSelector((state: RootState) => state.ui.isDeleteVersionDialogOpen); + const versionToDelete = useAppSelector((state: RootState) => state.ui.versionToDelete); + const isRestoreVersionDialogOpen = useAppSelector((state: RootState) => state.ui.isRestoreVersionDialogOpen); + const versionToRestore = useAppSelector((state: RootState) => state.ui.versionToRestore); + const currentFolderId = useAppSelector(storageSelectors.currentFolderId); const { translate } = useTranslationContext(); - const [versions, setVersions] = useState([ - { - id: '1', - date: new Date('2024-06-20T10:00:00Z'), - userName: 'John Doe', - expiresInDays: 30, - isCurrent: true, - isAutosave: false, - }, - ]); + const limits = useAppSelector(fileVersionsSelectors.getLimits); + const versions = useAppSelector((state: RootState) => + item ? fileVersionsSelectors.getVersionsByFileId(state, item.uuid) : [], + ); + const isLoading = useAppSelector((state: RootState) => + item ? fileVersionsSelectors.isLoadingByFileId(state, item.uuid) : false, + ); + const [selectedAutosaveVersions, setSelectedAutosaveVersions] = useState>(new Set()); + const [isBatchDeleteMode, setIsBatchDeleteMode] = useState(false); + const [currentVersion, setCurrentVersion] = useState({ + id: '', + updatedAt: '', + }); + + useEffect(() => { + if (item) { + setCurrentVersion({ + id: item.fileId, + updatedAt: item.updatedAt, + }); + } + }, [item]); + const totalVersionsCount = versions.length; + const selectedCount = selectedAutosaveVersions.size; + const selectAllAutosave = selectedCount === totalVersionsCount && totalVersionsCount > 0; + const totalAllowedVersions = limits?.versioning.maxVersions ?? 0; + + const userName = useMemo( + () => (user?.name && user?.lastname ? `${user.name} ${user.lastname}` : user?.email || 'Unknown User'), + [user], + ); + + const userAvatar = user?.avatar ?? null; + + useEffect(() => { + if (!item || !isOpen) return; + + if (!limits) { + dispatch(fetchVersionLimitsThunk()); + } - const [selectAllAutosave, setSelectAllAutosave] = useState(false); - const autosaveVersions = versions.filter((v) => v.isAutosave); - const totalAutosaveCount = autosaveVersions.length; + const hasCachedVersions = versions && versions.length > 0; + if (!hasCachedVersions) { + dispatch(fetchFileVersionsThunk(item.uuid)); + } + }, [item?.uuid, isOpen, dispatch]); - const onClose = () => { + const handleError = useCallback( + (error: unknown, messageKey: string) => { + const castedError = errorService.castError(error); + errorService.reportError(castedError); + notificationsService.show({ + text: translate(messageKey), + type: ToastType.Error, + }); + }, + [translate], + ); + + const onClose = useCallback(() => { dispatch(uiActions.setIsVersionHistorySidebarOpen(false)); + setSelectedAutosaveVersions(new Set()); + setIsBatchDeleteMode(false); + }, [dispatch]); + + const removeVersionsFromSelection = (versionIds: string[]) => { + setSelectedAutosaveVersions((prev) => { + const updated = new Set(prev); + versionIds.forEach((id) => updated.delete(id)); + return updated; + }); }; - if (!item) return null; + const handleCloseDeleteDialog = () => { + dispatch(uiActions.setIsDeleteVersionDialogOpen(false)); + dispatch(uiActions.setVersionToDelete(null)); + setIsBatchDeleteMode(false); + }; + + const handleCloseRestoreDialog = () => { + dispatch(uiActions.setIsRestoreVersionDialogOpen(false)); + dispatch(uiActions.setVersionToRestore(null)); + }; + + const handleDeleteConfirm = async () => { + if (!item) return; + + let versionIdsToDelete: string[] = []; + if (isBatchDeleteMode) { + versionIdsToDelete = Array.from(selectedAutosaveVersions); + } else if (versionToDelete) { + versionIdsToDelete = [versionToDelete.id]; + } + + if (versionIdsToDelete.length === 0) return; + + try { + await Promise.all(versionIdsToDelete.map((versionId) => fileVersionService.deleteVersion(item.uuid, versionId))); + + versionIdsToDelete.forEach((versionId) => { + dispatch(fileVersionsActions.updateVersionsAfterDelete({ fileUuid: item.uuid, versionId })); + }); + + notificationsService.show({ + text: translate('modals.versionHistory.deleteSuccess'), + type: ToastType.Success, + }); + removeVersionsFromSelection(versionIdsToDelete); + } catch (error) { + handleError(error, 'modals.versionHistory.deleteError'); + } finally { + setIsBatchDeleteMode(false); + } + }; + + const handleRestoreConfirm = async () => { + if (!versionToRestore || !item) return; + + try { + const restoredVersion = await fileVersionService.restoreVersion(item.uuid, versionToRestore.id); + + setCurrentVersion({ + id: restoredVersion.fileId as string, + updatedAt: new Date().toISOString(), + }); + + dispatch(fileVersionsActions.invalidateCache(item.uuid)); + dispatch(fetchFileVersionsThunk(item.uuid)); + + notificationsService.show({ + text: translate('modals.versionHistory.restoreSuccess'), + type: ToastType.Success, + }); + if (currentFolderId) { + await dispatch(fetchSortedFolderContentThunk(currentFolderId)); + } + removeVersionsFromSelection([versionToRestore.id]); + } catch (error) { + handleError(error, 'modals.versionHistory.restoreError'); + } + }; + + const handleSelectAllAutosave = useCallback( + (checked: boolean) => { + setSelectedAutosaveVersions(checked ? new Set(versions.map((v) => v.id)) : new Set()); + }, + [versions], + ); + + const handleVersionSelectionChange = useCallback((versionId: string, selected: boolean) => { + setSelectedAutosaveVersions((prev) => { + const newSelection = new Set(prev); + selected ? newSelection.add(versionId) : newSelection.delete(versionId); + return newSelection; + }); + }, []); + + const handleDeleteSelectedVersions = useCallback(() => { + if (!item || selectedCount === 0) return; + setIsBatchDeleteMode(true); + dispatch(uiActions.setIsDeleteVersionDialogOpen(true)); + }, [item, selectedCount, dispatch]); + + if (!item) return null; return ( <> {isOpen &&
} @@ -46,27 +215,53 @@ const Sidebar = () => {
- {versions - .filter((v) => v.isCurrent) - .map((version) => ( - - ))} - - {}} - /> - - {versions - .filter((v) => !v.isCurrent) - .map((version) => ( - - ))} + {isLoading ? ( + + ) : ( + <> + + + + + {versions.map((version) => ( + handleVersionSelectionChange(version.id, selected)} + /> + ))} + + )}
+ + ); }; diff --git a/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx index b072ba2f0..321a93637 100644 --- a/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx +++ b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx @@ -3,39 +3,50 @@ import { Checkbox } from '@internxt/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; interface AutosaveSectionProps { - totalAutosaveCount: number; + totalVersionsCount: number; + totalAllowedVersions: number; + selectedCount: number; selectAllAutosave: boolean; onSelectAllChange: (checked: boolean) => void; onDeleteAll: () => void; } export const AutosaveSection = ({ - totalAutosaveCount, + totalVersionsCount, + totalAllowedVersions, + selectedCount, selectAllAutosave, onSelectAllChange, onDeleteAll, }: AutosaveSectionProps) => { const { translate } = useTranslationContext(); - - if (totalAutosaveCount === 0) return null; + const hasSelection = selectedCount > 0; + const isIndeterminate = hasSelection && !selectAllAutosave; return ( -
+
onSelectAllChange(!selectAllAutosave)} className="h-4 w-4" /> {translate('modals.versionHistory.autosaveVersions', { - count: totalAutosaveCount, - total: totalAutosaveCount, + count: totalVersionsCount, + total: totalAllowedVersions, })}
-
); diff --git a/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx b/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx index 95f611b36..44fe54be5 100644 --- a/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx +++ b/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx @@ -1,27 +1,28 @@ import { Avatar } from '@internxt/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { FileVersion } from '../types'; import { formatVersionDate } from '../utils'; interface CurrentVersionItemProps { - version: FileVersion; + createdAt: string; + userName: string; + userAvatar: string | null; } -export const CurrentVersionItem = ({ version }: CurrentVersionItemProps) => { +export const CurrentVersionItem = ({ createdAt, userName, userAvatar }: CurrentVersionItemProps) => { const { translate } = useTranslationContext(); return ( -
+
- {formatVersionDate(version.date)} + {formatVersionDate(createdAt)} {translate('modals.versionHistory.current')}
- - {version.userName} + + {userName}
diff --git a/src/views/Drive/components/VersionHistory/components/Header.tsx b/src/views/Drive/components/VersionHistory/components/Header.tsx index 2ea53a1e0..ed9578fdd 100644 --- a/src/views/Drive/components/VersionHistory/components/Header.tsx +++ b/src/views/Drive/components/VersionHistory/components/Header.tsx @@ -7,7 +7,7 @@ interface HeaderProps { export const Header = ({ title, onClose }: HeaderProps) => { return ( -
+
{title} + +
+
+ + ); +}; diff --git a/src/views/Drive/components/VersionHistory/components/VersionHistorySkeleton.tsx b/src/views/Drive/components/VersionHistory/components/VersionHistorySkeleton.tsx new file mode 100644 index 000000000..a7a445a72 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/components/VersionHistorySkeleton.tsx @@ -0,0 +1,30 @@ +export const VersionHistorySkeleton = () => { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ + {Array.from({ length: 6 }, (_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ); +}; diff --git a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx index 6f7c86607..8415d0cb0 100644 --- a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx +++ b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx @@ -1,96 +1,129 @@ -import { useState } from 'react'; import { Info, DotsThree } from '@phosphor-icons/react'; import { Checkbox, Dropdown, Avatar } from '@internxt/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { FileVersion } from '../types'; import { useDropdownPositioning, useVersionItemActions } from '../hooks'; -import { formatVersionDate } from '../utils'; +import { formatVersionDate, getDaysUntilExpiration } from '../utils'; +import { FileVersion } from '@internxt/sdk/dist/drive/storage/types'; +import { memo } from 'react'; +import sizeService from 'app/drive/services/size.service'; interface VersionItemProps { version: FileVersion; + userName: string; + userAvatar: string | null; + isSelected: boolean; + onSelectionChange: (selected: boolean) => void; } -export const VersionItem = ({ version }: VersionItemProps) => { - const { translate } = useTranslationContext(); - const [isSelected, setIsSelected] = useState(true); - const { isOpen, setIsOpen, dropdownPosition, dropdownRef, itemRef } = useDropdownPositioning(); - const { menuItems } = useVersionItemActions({ - version, - onDropdownClose: () => setIsOpen(false), - }); +export const VersionItem = memo( + ({ version, userName, userAvatar, isSelected, onSelectionChange }: VersionItemProps) => { + const { translate } = useTranslationContext(); + const { isOpen, setIsOpen, dropdownPosition, dropdownRef, itemRef } = useDropdownPositioning(); + const { menuItems } = useVersionItemActions({ + version, + onDropdownClose: () => setIsOpen(false), + }); - const handleItemClick = () => { - setIsSelected(!isSelected); - }; + const handleToggleSelection = () => { + onSelectionChange(!isSelected); + }; - const dropdownOpenDirection = dropdownPosition === 'above' ? 'left' : 'right'; + const handleItemClick = () => { + handleToggleSelection(); + }; - return ( - - ); -}; + return ; + }} + +
+
+ + + ); + }, +); diff --git a/src/views/Drive/components/VersionHistory/components/index.ts b/src/views/Drive/components/VersionHistory/components/index.ts index 58e0f09ed..9bc3ebe77 100644 --- a/src/views/Drive/components/VersionHistory/components/index.ts +++ b/src/views/Drive/components/VersionHistory/components/index.ts @@ -2,3 +2,5 @@ export { Header } from './Header'; export { CurrentVersionItem } from './CurrentVersionItem'; export { VersionItem } from './VersionItem'; export { AutosaveSection } from './AutosaveSection'; +export { VersionActionDialog } from './VersionActionDialog'; +export { VersionHistorySkeleton } from './VersionHistorySkeleton'; diff --git a/src/views/Drive/components/VersionHistory/hooks/index.ts b/src/views/Drive/components/VersionHistory/hooks/index.ts index cb2aeceda..5fb50562c 100644 --- a/src/views/Drive/components/VersionHistory/hooks/index.ts +++ b/src/views/Drive/components/VersionHistory/hooks/index.ts @@ -1,2 +1,3 @@ export { useDropdownPositioning } from './useDropdownPositioning'; export { useVersionItemActions } from './useVersionItemActions'; +export { useVersionHistoryMenuConfig } from './useVersionHistoryMenuConfig'; diff --git a/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts b/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts new file mode 100644 index 000000000..d5ab8abff --- /dev/null +++ b/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts @@ -0,0 +1,87 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { MutableRefObject } from 'react'; +import { useDropdownPositioning } from './useDropdownPositioning'; + +const originalInnerHeight = window.innerHeight; +const setRefCurrent = (ref: React.RefObject, value: T) => { + (ref as MutableRefObject).current = value; +}; + +describe('Version menu behavior', () => { + beforeEach(() => { + Object.defineProperty(window, 'innerHeight', { value: originalInnerHeight, writable: true, configurable: true }); + }); + + afterEach(() => { + Object.defineProperty(window, 'innerHeight', { value: originalInnerHeight, writable: true, configurable: true }); + }); + + it('when clicking inside the menu, then it stays open', () => { + const { result } = renderHook(() => useDropdownPositioning()); + const dropdownElement = document.createElement('div'); + const childElement = document.createElement('span'); + dropdownElement.appendChild(childElement); + setRefCurrent(result.current.dropdownRef, dropdownElement); + + act(() => { + result.current.setIsOpen(true); + }); + + act(() => { + childElement.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + }); + + expect(result.current.isOpen).toBe(true); + }); + + it('when clicking outside the menu, then it closes', () => { + const { result } = renderHook(() => useDropdownPositioning()); + const dropdownElement = document.createElement('div'); + setRefCurrent(result.current.dropdownRef, dropdownElement); + + act(() => { + result.current.setIsOpen(true); + }); + + act(() => { + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + }); + + expect(result.current.isOpen).toBe(false); + }); + + it('when there is space below the item, then the menu opens below', async () => { + Object.defineProperty(window, 'innerHeight', { value: 500, writable: true, configurable: true }); + const { result } = renderHook(() => useDropdownPositioning()); + const mockItem = { + getBoundingClientRect: () => ({ bottom: 100 }) as DOMRect, + } as unknown as HTMLElement; + setRefCurrent(result.current.itemRef, mockItem); + + act(() => { + result.current.setIsOpen(true); + }); + + await waitFor(() => { + expect(result.current.dropdownPosition).toBe('below'); + }); + }); + + it('when space is tight below the item, then the menu opens above', async () => { + Object.defineProperty(window, 'innerHeight', { value: 150, writable: true, configurable: true }); + const { result } = renderHook(() => useDropdownPositioning()); + const mockItem = { + getBoundingClientRect: () => ({ bottom: 20 }) as DOMRect, + } as unknown as HTMLElement; + setRefCurrent(result.current.itemRef, mockItem); + + act(() => { + result.current.setIsOpen(true); + }); + + await waitFor(() => { + expect(result.current.dropdownPosition).toBe('above'); + }); + }); +}); diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts b/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts new file mode 100644 index 000000000..d45bb6e59 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts @@ -0,0 +1,99 @@ +import { renderHook, act } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi, Mock } from 'vitest'; +import { useSelector } from 'react-redux'; +import { useAppDispatch } from 'app/store/hooks'; +import navigationService from 'services/navigation.service'; +import { useVersionHistoryMenuConfig } from './useVersionHistoryMenuConfig'; +import { FileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; +import { WorkspaceData } from '@internxt/sdk/dist/workspaces'; + +vi.mock('react-redux', () => ({ + useSelector: vi.fn(), +})); + +vi.mock('app/store/hooks', () => ({ + useAppDispatch: vi.fn(), +})); + +const mockSetIsPreferencesDialogOpen = vi.hoisted(() => + vi.fn((payload: boolean) => ({ type: 'setIsPreferencesDialogOpen', payload })), +); + +vi.mock('app/store/slices/ui', () => ({ + uiActions: { + setIsPreferencesDialogOpen: mockSetIsPreferencesDialogOpen, + }, +})); + +vi.mock('services/navigation.service', () => ({ + default: { + openPreferencesDialog: vi.fn(), + }, +})); + +const workspaceMock = { + workspaceUser: { workspaceId: 'workspace-1' }, + workspace: { id: 'workspace-1' }, +} as unknown as WorkspaceData; + +const enabledLimits: FileLimitsResponse = { + versioning: { enabled: true, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, +}; + +const disabledLimits: FileLimitsResponse = { + versioning: { enabled: false, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, +}; + +describe('Version history menu', () => { + const mockUseSelector = useSelector as unknown as Mock; + const mockUseAppDispatch = useAppDispatch as unknown as Mock; + const dispatch = vi.fn(); + + const mockState = (limits: FileLimitsResponse | null) => + ({ + workspaces: { selectedWorkspace: workspaceMock }, + fileVersions: { limits }, + }) as any; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseAppDispatch.mockReturnValue(dispatch); + }); + + it('when versioning is locked, then the upgrade flow opens', () => { + mockUseSelector.mockImplementation((selector: (state: any) => unknown) => selector(mockState(disabledLimits))); + + const { result } = renderHook(() => useVersionHistoryMenuConfig({ type: 'pdf' } as any)); + + expect(result.current.isLocked).toBe(true); + expect(result.current.isExtensionAllowed).toBe(true); + + act(() => { + result.current.onUpgradeClick?.(); + }); + + expect(dispatch).toHaveBeenCalledWith({ type: 'setIsPreferencesDialogOpen', payload: true }); + expect(navigationService.openPreferencesDialog).toHaveBeenCalledWith({ + section: 'account', + subsection: 'plans', + workspaceUuid: 'workspace-1', + }); + }); + + it('when versioning is enabled and the file is supported, then the menu is unlocked', () => { + mockUseSelector.mockImplementation((selector: (state: any) => unknown) => selector(mockState(enabledLimits))); + + const { result } = renderHook(() => useVersionHistoryMenuConfig({ type: 'pdf' } as any)); + + expect(result.current.isLocked).toBe(false); + expect(result.current.isExtensionAllowed).toBe(true); + }); + + it('when the file type is unsupported, then it is marked as not allowed', () => { + mockUseSelector.mockImplementation((selector: (state: any) => unknown) => selector(mockState(enabledLimits))); + + const { result } = renderHook(() => useVersionHistoryMenuConfig({ type: 'exe' } as any)); + + expect(result.current.isExtensionAllowed).toBe(false); + }); +}); diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts b/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts new file mode 100644 index 000000000..9b612a67d --- /dev/null +++ b/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts @@ -0,0 +1,30 @@ +import { useSelector } from 'react-redux'; +import { useAppDispatch } from 'app/store/hooks'; +import { uiActions } from 'app/store/slices/ui'; +import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; +import { fileVersionsSelectors } from 'app/store/slices/fileVersions'; +import navigationService from 'services/navigation.service'; +import { DriveItemData } from 'app/drive/types'; +import { isVersioningExtensionAllowed } from '../utils'; +import { VersionHistoryMenuConfig } from '../../DriveExplorer/components/DriveItemContextMenu'; + +export const useVersionHistoryMenuConfig = (selectedItem?: DriveItemData): VersionHistoryMenuConfig => { + const dispatch = useAppDispatch(); + const selectedWorkspace = useSelector(workspacesSelectors.getSelectedWorkspace); + const limits = useSelector(fileVersionsSelectors.getLimits); + const isVersioningEnabled = limits?.versioning?.enabled ?? false; + const isExtensionAllowed = selectedItem ? isVersioningExtensionAllowed(selectedItem) : true; + + return { + isLocked: !isVersioningEnabled, + isExtensionAllowed, + onUpgradeClick: () => { + dispatch(uiActions.setIsPreferencesDialogOpen(true)); + navigationService.openPreferencesDialog({ + section: 'account', + subsection: 'plans', + workspaceUuid: selectedWorkspace?.workspaceUser.workspaceId, + }); + }, + }; +}; diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts b/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts new file mode 100644 index 000000000..f055c78c9 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts @@ -0,0 +1,165 @@ +import { renderHook, act } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi, Mock } from 'vitest'; +import { Trash, ClockCounterClockwise, DownloadSimple } from '@phosphor-icons/react'; +import { useVersionItemActions } from './useVersionItemActions'; +import fileVersionService from '../services/fileVersion.service'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { useAppDispatch, useAppSelector } from 'app/store/hooks'; +import { FileVersion } from '@internxt/sdk/dist/drive/storage/types'; +import { WorkspaceData } from '@internxt/sdk/dist/workspaces'; +import { RootState } from 'app/store'; +import { MenuItemType } from '@internxt/ui'; + +vi.mock('app/i18n/provider/TranslationProvider', () => ({ + useTranslationContext: vi.fn(), +})); + +vi.mock('app/store/hooks', () => ({ + useAppDispatch: vi.fn(), + useAppSelector: vi.fn(), +})); + +vi.mock('views/Drive/components/VersionHistory/services/fileVersion.service', () => ({ + default: { + downloadVersion: vi.fn(), + }, +})); + +vi.mock('app/notifications/services/notifications.service', () => ({ + default: { + show: vi.fn(), + }, + ToastType: { + Error: 'error', + }, +})); + +const mockSetVersionToRestore = vi.hoisted(() => vi.fn((payload) => ({ type: 'setVersionToRestore', payload }))); +const mockSetIsRestoreVersionDialogOpen = vi.hoisted(() => + vi.fn((payload) => ({ type: 'setIsRestoreVersionDialogOpen', payload })), +); +const mockSetVersionToDelete = vi.hoisted(() => vi.fn((payload) => ({ type: 'setVersionToDelete', payload }))); +const mockSetIsDeleteVersionDialogOpen = vi.hoisted(() => + vi.fn((payload) => ({ type: 'setIsDeleteVersionDialogOpen', payload })), +); + +vi.mock('app/store/slices/ui', () => ({ + uiActions: { + setVersionToRestore: mockSetVersionToRestore, + setIsRestoreVersionDialogOpen: mockSetIsRestoreVersionDialogOpen, + setVersionToDelete: mockSetVersionToDelete, + setIsDeleteVersionDialogOpen: mockSetIsDeleteVersionDialogOpen, + }, +})); + +describe('Version item menu', () => { + const translateMock = vi.fn((key: string) => key); + const version = { + id: 'version-id', + fileId: 'file-uuid', + networkFileId: 'network-file-id', + size: '5', + } as FileVersion; + const fileItem = { + id: 'file-id', + name: 'fallback-name', + plainName: 'pretty-name', + fileId: 'file-id', + } as any; + const selectedWorkspace = { workspace: { id: 'workspace-id' } } as WorkspaceData; + const workspaceCredentials = { workspaceId: 'workspace-id', token: 'token' } as any; + const baseState = { + ui: { versionHistoryItem: fileItem }, + workspaces: { selectedWorkspace, workspaceCredentials }, + } as unknown as RootState; + + const mockDispatch = vi.fn(); + const mockUseAppDispatch = useAppDispatch as unknown as Mock; + const mockUseAppSelector = useAppSelector as unknown as Mock; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseAppDispatch.mockReturnValue(mockDispatch); + (useTranslationContext as Mock).mockReturnValue({ translate: translateMock }); + mockUseAppSelector.mockImplementation((selector: (state: RootState) => unknown) => selector(baseState)); + }); + + const getMenuActionByIcon = (items: Array>, icon: any) => { + const item = items.find((item) => 'icon' in item && item.icon === icon); + return item && 'action' in item ? (item as any).action : undefined; + }; + + it('when restore is chosen, then the restore dialog opens', () => { + const onDropdownClose = vi.fn(); + const { result } = renderHook(() => useVersionItemActions({ version, onDropdownClose })); + const restoreAction = getMenuActionByIcon(result.current.menuItems, ClockCounterClockwise); + + act(() => { + restoreAction(); + }); + + expect(onDropdownClose).toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVersionToRestore', payload: version }); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setIsRestoreVersionDialogOpen', payload: true }); + }); + + it('when nothing is selected to download, then an error toast is shown', async () => { + const onDropdownClose = vi.fn(); + mockUseAppSelector.mockImplementation((selector: (state: RootState) => unknown) => + selector({ + ui: { versionHistoryItem: null }, + workspaces: { selectedWorkspace: null, workspaceCredentials: null }, + } as unknown as RootState), + ); + const showSpy = vi.spyOn(notificationsService, 'show'); + const { result } = renderHook(() => useVersionItemActions({ version, onDropdownClose })); + const downloadAction = getMenuActionByIcon(result.current.menuItems, DownloadSimple); + + await act(async () => { + await downloadAction(); + }); + + expect(onDropdownClose).toHaveBeenCalled(); + expect(showSpy).toHaveBeenCalledWith({ + text: 'modals.versionHistory.downloadError', + type: ToastType.Error, + }); + expect(fileVersionService.downloadVersion).not.toHaveBeenCalled(); + }); + + it('when a previous version is downloaded, then it uses the readable name and workspace data', async () => { + const onDropdownClose = vi.fn(); + const downloadVersionSpy = vi.spyOn(fileVersionService, 'downloadVersion').mockResolvedValue(undefined as any); + const { result } = renderHook(() => useVersionItemActions({ version, onDropdownClose })); + const downloadAction = getMenuActionByIcon(result.current.menuItems, DownloadSimple); + + await act(async () => { + await downloadAction(); + }); + + expect(onDropdownClose).toHaveBeenCalled(); + expect(downloadVersionSpy).toHaveBeenCalledWith( + version, + fileItem, + fileItem.plainName, + selectedWorkspace, + workspaceCredentials, + ); + expect(notificationsService.show).not.toHaveBeenCalled(); + }); + + it('when delete is chosen, then the delete dialog opens', () => { + const onDropdownClose = vi.fn(); + const { result } = renderHook(() => useVersionItemActions({ version, onDropdownClose })); + const deleteAction = getMenuActionByIcon(result.current.menuItems, Trash); + + act(() => { + deleteAction(); + }); + + expect(onDropdownClose).toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVersionToDelete', payload: version }); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setIsDeleteVersionDialogOpen', payload: true }); + }); +}); diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts b/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts index c4ce61ae1..0400bdd47 100644 --- a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts +++ b/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts @@ -1,7 +1,13 @@ import { Trash, ClockCounterClockwise, DownloadSimple } from '@phosphor-icons/react'; import { MenuItemType } from '@internxt/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { FileVersion } from '../types'; +import fileVersionService from '../services/fileVersion.service'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { useAppDispatch, useAppSelector } from 'app/store/hooks'; +import { uiActions } from 'app/store/slices/ui'; +import { RootState } from 'app/store'; +import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; +import { FileVersion } from '@internxt/sdk/dist/drive/storage/types'; interface UseVersionItemActionsParams { version: FileVersion; @@ -10,24 +16,43 @@ interface UseVersionItemActionsParams { export const useVersionItemActions = ({ version, onDropdownClose }: UseVersionItemActionsParams) => { const { translate } = useTranslationContext(); + const dispatch = useAppDispatch(); + const item = useAppSelector((state: RootState) => state.ui.versionHistoryItem); + const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); + const workspaceCredentials = useAppSelector(workspacesSelectors.getWorkspaceCredentials); - const handleRestore = () => { + const handleRestoreClick = () => { onDropdownClose(); + dispatch(uiActions.setVersionToRestore(version)); + dispatch(uiActions.setIsRestoreVersionDialogOpen(true)); }; - const handleDownload = () => { + const handleDownload = async () => { onDropdownClose(); + + if (!item) { + notificationsService.show({ + text: translate('modals.versionHistory.downloadError'), + type: ToastType.Error, + }); + return; + } + + const fileName = item.plainName || item.name; + await fileVersionService.downloadVersion(version, item, fileName, selectedWorkspace, workspaceCredentials); }; - const handleDelete = () => { + const handleDeleteClick = () => { onDropdownClose(); + dispatch(uiActions.setVersionToDelete(version)); + dispatch(uiActions.setIsDeleteVersionDialogOpen(true)); }; const menuItems: Array> = [ { name: translate('modals.versionHistory.restoreVersion'), icon: ClockCounterClockwise, - action: handleRestore, + action: handleRestoreClick, }, { name: translate('modals.versionHistory.downloadVersion'), @@ -40,7 +65,7 @@ export const useVersionItemActions = ({ version, onDropdownClose }: UseVersionIt { name: translate('modals.versionHistory.deleteVersion'), icon: Trash, - action: handleDelete, + action: handleDeleteClick, }, ]; diff --git a/src/views/Drive/components/VersionHistory/services/fileVersion.service.test.ts b/src/views/Drive/components/VersionHistory/services/fileVersion.service.test.ts new file mode 100644 index 000000000..8740e9b14 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/services/fileVersion.service.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SdkFactory } from 'app/core/factory/sdk'; +import { DownloadManager } from 'app/network/DownloadManager'; +import fileVersionService from './fileVersion.service'; +import { FileVersion, RestoreFileVersionResponse } from '@internxt/sdk/dist/drive/storage/types'; +import { WorkspaceCredentialsDetails, WorkspaceData } from '@internxt/sdk/dist/workspaces'; + +vi.mock('app/core/factory/sdk', () => ({ + SdkFactory: { + getNewApiInstance: vi.fn(), + }, +})); + +vi.mock('app/network/DownloadManager', () => ({ + DownloadManager: { + downloadItem: vi.fn(), + }, +})); + +describe('File version actions', () => { + const fileUuid = 'file-uuid'; + const versionId = 'version-id'; + let storageClientMock: { + getFileVersions: ReturnType; + deleteFileVersion: ReturnType; + restoreFileVersion: ReturnType; + getFileVersionLimits: ReturnType; + }; + + beforeEach(() => { + storageClientMock = { + getFileVersions: vi.fn(), + deleteFileVersion: vi.fn(), + restoreFileVersion: vi.fn(), + getFileVersionLimits: vi.fn(), + }; + + vi.clearAllMocks(); + + vi.spyOn(SdkFactory, 'getNewApiInstance').mockReturnValue({ + createNewStorageClient: () => storageClientMock, + } as any); + }); + + it('when file versions are requested, then the available list is returned', async () => { + const versions = [{ id: 'v1' } as FileVersion]; + storageClientMock.getFileVersions.mockResolvedValueOnce(versions); + + const result = await fileVersionService.getFileVersions(fileUuid); + + expect(storageClientMock.getFileVersions).toHaveBeenCalledWith(fileUuid); + expect(result).toBe(versions); + }); + + it('when a version is deleted, then the delete request is sent', async () => { + storageClientMock.deleteFileVersion.mockResolvedValueOnce(undefined); + + await fileVersionService.deleteVersion(fileUuid, versionId); + + expect(storageClientMock.deleteFileVersion).toHaveBeenCalledWith(fileUuid, versionId); + }); + + it('when a version is restored, then the restore response is returned', async () => { + const restoreResponse = { restored: true } as unknown as RestoreFileVersionResponse; + storageClientMock.restoreFileVersion.mockResolvedValueOnce(restoreResponse); + + const result = await fileVersionService.restoreVersion(fileUuid, versionId); + + expect(storageClientMock.restoreFileVersion).toHaveBeenCalledWith(fileUuid, versionId); + expect(result).toBe(restoreResponse); + }); + + it('when versioning limits are checked, then the limits are returned', async () => { + const limits = { versioning: { enabled: true, maxFileSize: 0, retentionDays: 0, maxVersions: 0 } } as any; + storageClientMock.getFileVersionLimits.mockResolvedValueOnce(limits); + + const result = await fileVersionService.getLimits(); + + expect(storageClientMock.getFileVersionLimits).toHaveBeenCalled(); + expect(result).toBe(limits); + }); + + it('when a previous version is downloaded, then the request uses the version data', async () => { + const version = { networkFileId: 'network-file-id', size: '42' } as FileVersion; + const fileItem = { fileId: 'file-id', size: 10, name: 'original-name' } as any; + const selectedWorkspace = { workspace: { id: 'workspace-id' }, workspaceUser: {} } as unknown as WorkspaceData; + const workspaceCredentials = { + workspaceId: 'workspace-id', + bucket: 'bucket', + workspaceUserId: 'workspace-user', + email: 'user@example.com', + credentials: {} as any, + tokenHeader: 'token-header', + } as WorkspaceCredentialsDetails; + const downloadItemSpy = vi.spyOn(DownloadManager, 'downloadItem').mockResolvedValueOnce(undefined); + + await fileVersionService.downloadVersion(version, fileItem, 'custom-name', selectedWorkspace, workspaceCredentials); + + expect(downloadItemSpy).toHaveBeenCalledWith({ + payload: [ + { + ...fileItem, + fileId: version.networkFileId, + size: Number(version.size), + name: 'custom-name', + }, + ], + selectedWorkspace, + workspaceCredentials, + }); + }); +}); diff --git a/src/views/Drive/components/VersionHistory/services/fileVersion.service.ts b/src/views/Drive/components/VersionHistory/services/fileVersion.service.ts new file mode 100644 index 000000000..a5d085091 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/services/fileVersion.service.ts @@ -0,0 +1,54 @@ +import { SdkFactory } from 'app/core/factory/sdk'; +import { DownloadManager } from 'app/network/DownloadManager'; +import { DriveItemData } from 'app/drive/types'; +import { WorkspaceCredentialsDetails, WorkspaceData } from '@internxt/sdk/dist/workspaces'; +import { FileLimitsResponse, FileVersion, RestoreFileVersionResponse } from '@internxt/sdk/dist/drive/storage/types'; + +const getStorageClient = () => SdkFactory.getNewApiInstance().createNewStorageClient(); + +export async function getFileVersions(fileUuid: string): Promise { + return getStorageClient().getFileVersions(fileUuid); +} + +export async function deleteVersion(fileUuid: string, versionId: string): Promise { + await getStorageClient().deleteFileVersion(fileUuid, versionId); +} + +export async function restoreVersion(fileUuid: string, versionId: string): Promise { + return getStorageClient().restoreFileVersion(fileUuid, versionId); +} + +export async function getLimits(): Promise { + return getStorageClient().getFileVersionLimits(); +} + +export async function downloadVersion( + version: FileVersion, + fileItem: DriveItemData, + fileName: string, + selectedWorkspace: WorkspaceData | null, + workspaceCredentials: WorkspaceCredentialsDetails | null, +): Promise { + const versionFileData: DriveItemData = { + ...fileItem, + fileId: version.networkFileId, + size: Number(version.size), + name: fileName, + }; + + await DownloadManager.downloadItem({ + payload: [versionFileData], + selectedWorkspace, + workspaceCredentials, + }); +} + +const fileVersionService = { + getFileVersions, + deleteVersion, + restoreVersion, + downloadVersion, + getLimits, +}; + +export default fileVersionService; diff --git a/src/views/Drive/components/VersionHistory/types.ts b/src/views/Drive/components/VersionHistory/types.ts deleted file mode 100644 index 9492b17ff..000000000 --- a/src/views/Drive/components/VersionHistory/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface FileVersion { - id: string; - date: Date; - userName: string; - expiresInDays?: number; - isAutosave?: boolean; - isCurrent?: boolean; -} diff --git a/src/views/Drive/components/VersionHistory/utils/index.ts b/src/views/Drive/components/VersionHistory/utils/index.ts index 862f81431..0178c4433 100644 --- a/src/views/Drive/components/VersionHistory/utils/index.ts +++ b/src/views/Drive/components/VersionHistory/utils/index.ts @@ -1,3 +1,16 @@ import dateService from 'services/date.service'; +import { DriveItemData } from 'app/drive/types'; -export const formatVersionDate = (date: Date): string => dateService.format(date, 'MMM D, h:mm A'); +export const formatVersionDate = (date: string): string => dateService.format(date, 'MMM D, h:mm A'); + +const ALLOWED_VERSIONING_EXTENSIONS = ['pdf', 'docx', 'xlsx', 'csv']; + +export const isVersioningExtensionAllowed = (item?: Pick | null): boolean => { + if (!item || !item.type) { + return false; + } + const extension = item.type.toLowerCase(); + return ALLOWED_VERSIONING_EXTENSIONS.includes(extension); +}; + +export const getDaysUntilExpiration = (expiresAt: string): number => dateService.getDaysUntilExpiration(expiresAt); diff --git a/src/views/Drive/hooks/useDriveItemActions.tsx b/src/views/Drive/hooks/useDriveItemActions.tsx index a64eb0c71..6460615b7 100644 --- a/src/views/Drive/hooks/useDriveItemActions.tsx +++ b/src/views/Drive/hooks/useDriveItemActions.tsx @@ -11,6 +11,7 @@ import { storageActions } from 'app/store/slices/storage'; import { uiActions } from 'app/store/slices/ui'; import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; import { DownloadManager } from 'app/network/DownloadManager'; +import { useVersionHistoryMenuConfig } from '../components/VersionHistory/hooks'; export interface DriveItemActions { nameInputRef: React.RefObject; @@ -38,6 +39,7 @@ const useDriveItemActions = (item): DriveItemActions => { const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); const workspaceCredentials = useAppSelector(workspacesSelectors.getWorkspaceCredentials); const isWorkspace = !!selectedWorkspace; + const versionHistoryConfig = useVersionHistoryMenuConfig(item); const onRenameItemButtonClicked = () => { dispatch(storageActions.setItemToRename(item as DriveItemData)); @@ -102,6 +104,10 @@ const useDriveItemActions = (item): DriveItemActions => { }; const onViewVersionHistoryButtonClicked = () => { + if (versionHistoryConfig.locked) { + versionHistoryConfig.onLockedClick?.(); + return; + } dispatch(uiActions.setVersionHistoryItem(item as DriveItemData)); dispatch(uiActions.setIsVersionHistorySidebarOpen(true)); }; diff --git a/src/views/Drive/services/replaceFile.service.ts b/src/views/Drive/services/replaceFile.service.ts new file mode 100644 index 000000000..b98b40ed6 --- /dev/null +++ b/src/views/Drive/services/replaceFile.service.ts @@ -0,0 +1,18 @@ +import { SdkFactory } from 'app/core/factory/sdk'; +import { DriveFileData } from 'app/drive/types'; + +export interface ReplaceFilePayload { + fileId: string; + size: number; +} + +export async function replaceFile(fileUuid: string, payload: ReplaceFilePayload): Promise { + const storageClient = SdkFactory.getNewApiInstance().createNewStorageClient(); + return storageClient.replaceFile(fileUuid, payload); +} + +const replaceFileService = { + replaceFile, +}; + +export default replaceFileService; diff --git a/yarn.lock b/yarn.lock index 06da92d17..63686d20e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1906,10 +1906,10 @@ version "1.0.2" resolved "https://codeload.github.com/internxt/prettier-config/tar.gz/9fa74e9a2805e1538b50c3809324f1c9d0f3e4f9" -"@internxt/sdk@=1.11.17": - version "1.11.17" - resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.11.17.tgz#2f5bdada5d3cbf5cfc685a21c24b5df3ff51d8c8" - integrity sha512-91iEUvZizlwX6KBEFJ3JdFiGrhMBQ9R54sTc3Pei9QtV2FYTU8nTVEPYAg39tLOGzT/kVuplYOtBxfk6wFtSDA== +"@internxt/sdk@=1.11.24": + version "1.11.24" + resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.11.24.tgz#c0d31f99329f7553a88533bc59ddf8c155cf550b" + integrity sha512-GppLvUA6MhyiL/DIYDRhY/YdddrCZYz/d5/lbTHYwTbqrcVZEyAu8YbCEr0LG7GqhRxjnh4m55VAo//VC0dZyg== dependencies: axios "1.13.2" uuid "11.1.0"