diff --git a/src/app/core/layouts/HeaderAndSidenavLayout/HeaderAndSidenavLayout.tsx b/src/app/core/layouts/HeaderAndSidenavLayout/HeaderAndSidenavLayout.tsx index 57bf04e565..63c4b05ab6 100644 --- a/src/app/core/layouts/HeaderAndSidenavLayout/HeaderAndSidenavLayout.tsx +++ b/src/app/core/layouts/HeaderAndSidenavLayout/HeaderAndSidenavLayout.tsx @@ -11,6 +11,7 @@ import DriveItemInfoMenu from 'app/drive/components/DriveItemInfoMenu/DriveItemI import SharedFolderTooBigDialog from '../../../drive/components/SharedFolderTooBigDialog/SharedFolderTooBigDialog'; import { getAppConfig } from 'services/config.service'; import ShareItemDialog from '../../../../views/Shared/components/ShareItemDialog/ShareItemDialog'; +import { Sidebar as VersionHistorySidebar } from '../../../../views/Drive/components/VersionHistory'; export interface HeaderAndSidenavLayoutProps { children: JSX.Element; @@ -48,14 +49,18 @@ export default function HeaderAndSidenavLayout(props: HeaderAndSidenavLayoutProp
-
+
{children} {isDriveItemInfoMenuOpen && driveItemInfo && ( )} + + +
+
+
-
diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index a7b1723a04..3d465ea21b 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/drive/services/downloadManager.service.test.ts b/src/app/drive/services/downloadManager.service.test.ts index 9246b469c5..fe4d426cf4 100644 --- a/src/app/drive/services/downloadManager.service.test.ts +++ b/src/app/drive/services/downloadManager.service.test.ts @@ -892,6 +892,7 @@ describe('downloadManagerService', () => { updateProgressCallback: mockUpdateProgress, abortController: mockTask.abortController, sharingOptions: mockTask.credentials, + downloadName: mockTask.options.downloadName, }); }); diff --git a/src/app/drive/services/downloadManager.service.ts b/src/app/drive/services/downloadManager.service.ts index 82ec911bcc..6c32e54145 100644 --- a/src/app/drive/services/downloadManager.service.ts +++ b/src/app/drive/services/downloadManager.service.ts @@ -47,6 +47,7 @@ export type DownloadItem = { downloadOptions?: { areSharedItems?: boolean; showErrors?: boolean; + downloadName?: string; }; createFoldersIterator?: FolderIterator | SharedFolderIterator; createFilesIterator?: FileIterator | SharedFileIterator; @@ -92,6 +93,13 @@ export type DownloadTask = { export class DownloadManagerService { public static readonly instance: DownloadManagerService = new DownloadManagerService(); + private getSingleItemName(item: DownloadItem['payload'][0]): string { + if (item.isFolder) { + return item.name; + } + return item.type ? `${item.name}.${item.type}` : item.name; + } + readonly getDownloadCredentialsFromWorkspace = ( selectedWorkspace: WorkspaceData | null, workspaceCredentials: WorkspaceCredentialsDetails | null, @@ -119,14 +127,11 @@ export class DownloadManagerService { const abort = () => Promise.resolve(uploadFolderAbortController.abort('Download cancelled')); const formattedDate = date.format(new Date(), 'YYYY-MM-DD_HHmmss'); - let downloadName = `Internxt (${formattedDate})`; - if (itemsPayload.length === 1) { - const item = itemsPayload[0]; - if (itemsPayload[0].isFolder) { - downloadName = item.name; - } else { - downloadName = item.type ? `${item.name}.${item.type}` : item.name; - } + let downloadName = downloadItem.downloadOptions?.downloadName; + + if (!downloadName) { + downloadName = + itemsPayload.length === 1 ? this.getSingleItemName(itemsPayload[0]) : `Internxt (${formattedDate})`; } let taskId = downloadItem.taskId; @@ -295,6 +300,7 @@ export class DownloadManagerService { updateProgressCallback, abortController, sharingOptions: credentials, + downloadName: options.downloadName, }); console.timeEnd(`download-file-${file.uuid}`); @@ -508,6 +514,7 @@ export class DownloadManagerService { updateProgressCallback: (progress: number) => void; abortController?: AbortController; sharingOptions: { credentials: { user: string; pass: string }; mnemonic: string }; + downloadName?: string; }) => { const shouldDownloadUsingBlob = !!(navigator.brave && (await navigator.brave.isBrave())) || @@ -531,6 +538,7 @@ export class DownloadManagerService { itemData: payload.file, updateProgressCallback: payload.updateProgressCallback, abortController: payload.abortController, + downloadName: payload.downloadName, }); }; } diff --git a/src/app/drive/services/worker.service/downloadWorkerHandler.ts b/src/app/drive/services/worker.service/downloadWorkerHandler.ts index 14cc3e9a95..bbdf5f8b34 100644 --- a/src/app/drive/services/worker.service/downloadWorkerHandler.ts +++ b/src/app/drive/services/worker.service/downloadWorkerHandler.ts @@ -11,6 +11,7 @@ interface HandleWorkerMessagesPayload { abortController?: AbortController; itemData: DriveFileData; updateProgressCallback: (progress: number) => void; + downloadName?: string; } interface HandleMessagesPayload { @@ -37,9 +38,10 @@ export class DownloadWorkerHandler { abortController, itemData, updateProgressCallback, + downloadName, }: HandleWorkerMessagesPayload) { const fileName = itemData.plainName ?? itemData.name; - const completeFilename = itemData.type ? `${fileName}.${itemData.type}` : fileName; + const completeFilename = downloadName || (itemData.type ? `${fileName}.${itemData.type}` : fileName); const downloadId = itemData.fileId; const fileSize = itemData.size; diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index a24474fdb4..f68ecc0684 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -793,6 +793,34 @@ "Ort": "Ort" } }, + "versionHistory": { + "title": "Versionsverlauf", + "current": "Aktuell", + "expiresInDays": "Läuft in {{days}} Tagen ab", + "autosaveVersions": "{{count}}/{{total}} automatisch gespeicherte Versionen", + "restoreVersion": "Version wiederherstellen", + "downloadVersion": "Version herunterladen", + "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", + "lockedFeature": { + "title": "Gesperrte Funktion", + "description": "Stellen Sie frühere Versionen Ihrer Dateien wieder her und verfolgen Sie Änderungen im Laufe der Zeit.", + "supportedFormats": "Verfügbar für PDF-, Word-, Excel- und CSV-Dateien in kostenpflichtigen Plänen.", + "upgradeButton": "Upgrade" + } + }, "shareModal": { "title": "\"{{name}}\" teilen", "list": { @@ -1462,7 +1490,8 @@ "restore": "Wiederherstellen", "deletePermanently": "Lösche dauerhaft", "move": "Verschiebe", - "delete": "Lösche" + "delete": "Lösche", + "versionHistory": "Versionsverlauf" }, "deleteItems": { "title": "Dauerhaft löschen?", diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index a76f386ebe..aad8880b64 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -890,6 +890,34 @@ "location": "Location" } }, + "versionHistory": { + "title": "Version history", + "current": "Current", + "expiresInDays": "Expires in {{days}} days", + "autosaveVersions": "{{count}}/{{total}} autosave versions", + "restoreVersion": "Restore version", + "downloadVersion": "Download 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", + "lockedFeature": { + "title": "Locked feature", + "description": "Restore previous versions of your files and track changes over time.", + "supportedFormats": "Available for PDF, Word, Excel, and CSV files in paid plans.", + "upgradeButton": "Upgrade" + } + }, "shareModal": { "title": "Share \"{{name}}\"", "list": { @@ -1538,6 +1566,7 @@ "share": "Share", "manageAccess": "Manage access", "download": "Download", + "versionHistory": "Version history", "moveToTrash": "Move to trash", "copyLink": "Copy link", "linkSettings": "Link settings", diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 9a97d9cfe0..3bd311b36a 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -872,6 +872,34 @@ "location": "Ubicación" } }, + "versionHistory": { + "title": "Historial de versiones", + "current": "Actual", + "expiresInDays": "Expira en {{days}} días", + "autosaveVersions": "{{count}}/{{total}} versiones de autoguardado", + "restoreVersion": "Restaurar versión", + "downloadVersion": "Descargar 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", + "lockedFeature": { + "title": "Función bloqueada", + "description": "Restaura versiones anteriores de tus archivos y realiza un seguimiento de los cambios a lo largo del tiempo.", + "supportedFormats": "Disponible para archivos PDF, Word, Excel y CSV en planes de pago.", + "upgradeButton": "Mejorar plan" + } + }, "shareModal": { "title": "Compartir \"{{name}}\"", "list": { @@ -1515,6 +1543,7 @@ "manageLinkAccess": "Gestionar acceso", "manageAccess": "Gestionar acceso", "download": "Descargar", + "versionHistory": "Historial de versiones", "moveToTrash": "Mover a la papelera", "copyLink": "Copiar enlace", "linkSettings": "Ajustes del enlace", diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index 4c05922941..83289d1cbb 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -814,6 +814,34 @@ "location": "Emplacement" } }, + "versionHistory": { + "title": "Historique des versions", + "current": "Actuelle", + "expiresInDays": "Expire dans {{days}} jours", + "autosaveVersions": "{{count}}/{{total}} versions de sauvegarde automatique", + "restoreVersion": "Restaurer la version", + "downloadVersion": "Télécharger 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", + "lockedFeature": { + "title": "Fonctionnalité verrouillée", + "description": "Restaurez les versions précédentes de vos fichiers et suivez les modifications au fil du temps.", + "supportedFormats": "Disponible pour les fichiers PDF, Word, Excel et CSV dans les forfaits payants.", + "upgradeButton": "Mettre à niveau" + } + }, "newFolderModal": { "title": "Nouveau dossier", "label": "Nom", @@ -1461,6 +1489,7 @@ "manageLinkAccess": "Gestion de l'accès", "manageAccess": "Gestion de l'accès", "download": "Télécharger", + "versionHistory": "Historique des versions", "moveToTrash": "Déplacer vers la corbeille", "copyLink": "Copier le lien", "linkSettings": "Paramètres du lien", diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 2aaa139db4..0b0172a886 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -926,6 +926,34 @@ "location": "Posizione" } }, + "versionHistory": { + "title": "Cronologia versioni", + "current": "Attuale", + "expiresInDays": "Scade tra {{days}} giorni", + "autosaveVersions": "{{count}}/{{total}} versioni di salvataggio automatico", + "restoreVersion": "Ripristina versione", + "downloadVersion": "Scarica 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", + "lockedFeature": { + "title": "Funzione bloccata", + "description": "Ripristina le versioni precedenti dei tuoi file e monitora le modifiche nel tempo.", + "supportedFormats": "Disponibile per file PDF, Word, Excel e CSV nei piani a pagamento.", + "upgradeButton": "Aggiorna" + } + }, "shareModal": { "title": "Condividi \"{{name}}\"", "list": { @@ -1575,7 +1603,8 @@ "restore": "Ripristinare", "deletePermanently": "Eliminare definitivamente", "move": "Sposta", - "delete": "Elimina" + "delete": "Elimina", + "versionHistory": "Cronologia versioni" }, "deleteItems": { "title": "Eliminare definitivamente?", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index b5197f3087..ef7b329ee2 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -833,6 +833,34 @@ "location": "Расположение" } }, + "versionHistory": { + "title": "История версий", + "current": "Текущая", + "expiresInDays": "Истекает через {{days}} дней", + "autosaveVersions": "{{count}}/{{total}} версий автосохранения", + "restoreVersion": "Восстановить версию", + "downloadVersion": "Скачать версию", + "deleteVersion": "Удалить версию", + "deleteVersionTitle": "Удалить версию", + "deleteButton": "Удалить", + "restoreButton": "Восстановить", + "downloadError": "Не удалось загрузить версию", + "deleteVersionAdvice": "Эта версия будет удалена навсегда. \nЭто действие нельзя отменить.", + "deletingVersion": "Удаление", + "restoreVersionTitle": "Восстановить версию", + "restoreVersionAdvice": "Восстановление этой версии заменит текущий файл и удалит все более новые версии. \nПосле восстановления это действие нельзя отменить.\n\nВы можете скачать копию более новых версий перед восстановлением.", + "restoringVersion": "Восстановление", + "restoreSuccess": "Версия успешно восстановлена", + "restoreError": "Не удалось восстановить версию", + "deleteSuccess": "Версия успешно удалена", + "deleteError": "Не удалось удалить версию", + "lockedFeature": { + "title": "Заблокированная функция", + "description": "Восстанавливайте предыдущие версии файлов и отслеживайте изменения со временем.", + "supportedFormats": "Доступно для файлов PDF, Word, Excel и CSV в платных тарифах.", + "upgradeButton": "Обновить" + } + }, "shareModal": { "title": "Поделиться \"{{name}}\"", "list": { @@ -1474,6 +1502,7 @@ "manageLinkAccess": "Управление доступом", "manageAccess": "Управление доступом", "download": "Загрузить", + "versionHistory": "История версий", "moveToTrash": "Переместить в корзину", "copyLink": "Копировать ссылку", "linkSettings": "Настройки ссылки", diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index 41d3201328..77c1f31d44 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -820,6 +820,34 @@ "location": "位置" } }, + "versionHistory": { + "title": "版本歷史", + "current": "目前", + "expiresInDays": "{{days}} 天後過期", + "autosaveVersions": "{{count}}/{{total}} 自動儲存版本", + "restoreVersion": "復原版本", + "downloadVersion": "下載版本", + "deleteVersion": "刪除版本", + "deleteVersionTitle": "刪除版本", + "deleteButton": "刪除", + "restoreButton": "復原", + "downloadError": "下載版本失敗", + "deleteVersionAdvice": "此版本將被永久刪除。\n此操作無法復原。", + "deletingVersion": "刪除中", + "restoreVersionTitle": "復原版本", + "restoreVersionAdvice": "復原此版本將取代目前檔案並刪除所有較新的版本。\n一旦復原,此操作無法撤銷。\n\n您可以在復原之前下載較新版本的副本。", + "restoringVersion": "復原中", + "restoreSuccess": "版本復原成功", + "restoreError": "復原版本失敗", + "deleteSuccess": "版本刪除成功", + "deleteError": "刪除版本失敗", + "lockedFeature": { + "title": "功能已鎖定", + "description": "復原檔案的先前版本並追蹤隨時間的變化。", + "supportedFormats": "適用於付費方案中的PDF、Word、Excel和CSV檔案。", + "upgradeButton": "升級" + } + }, "shareModal": { "title": "分享“{{name}}”", "list": { @@ -1458,6 +1486,7 @@ }, "dropdown": { "openPreview": "打開預覽", + "versionHistory": "版本歷史", "details": "詳細信息", "shareLink": "分享", "shareTeam": "与团队分享", diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index e6cc7677eb..00ba95f8b1 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -856,6 +856,34 @@ "location": "位置" } }, + "versionHistory": { + "title": "版本历史", + "current": "当前", + "expiresInDays": "{{days}} 天后到期", + "autosaveVersions": "{{count}}/{{total}} 自动保存版本", + "restoreVersion": "恢复版本", + "downloadVersion": "下载版本", + "deleteVersion": "删除版本", + "deleteVersionTitle": "删除版本", + "deleteButton": "删除", + "restoreButton": "恢复", + "downloadError": "下载版本失败", + "deleteVersionAdvice": "此版本将被永久删除。\n此操作无法撤销。", + "deletingVersion": "删除中", + "restoreVersionTitle": "恢复版本", + "restoreVersionAdvice": "恢复此版本将替换当前文件并删除所有较新的版本。\n一旦恢复,此操作无法撤销。\n\n您可以在恢复之前下载较新版本的副本。", + "restoringVersion": "恢复中", + "restoreSuccess": "版本恢复成功", + "restoreError": "恢复版本失败", + "deleteSuccess": "版本删除成功", + "deleteError": "删除版本失败", + "lockedFeature": { + "title": "功能已锁定", + "description": "恢复文件的以前版本并跟踪随时间的变化。", + "supportedFormats": "适用于付费计划中的PDF、Word、Excel和CSV文件。", + "upgradeButton": "升级" + } + }, "shareModal": { "title": "分享 \"{{name}}\"", "list": { @@ -1503,6 +1531,7 @@ "share": "分享", "manageAccess": "管理访问权限", "download": "下载", + "versionHistory": "版本历史", "moveToTrash": "移入垃圾箱", "copyLink": "复制链接", "linkSettings": "链接设置", diff --git a/src/app/store/index.ts b/src/app/store/index.ts index da313723c5..76b3e1236b 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 0000000000..55b0d24b2e --- /dev/null +++ b/src/app/store/slices/fileVersions/fileVersions.selectors.ts @@ -0,0 +1,19 @@ +import { FileVersion, FileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; +import { RootState } from '../..'; + +const fileVersionsSelectors = { + getLimits(state: RootState): FileLimitsResponse | null { + return state.fileVersions.limits; + }, + isLimitsLoading(state: RootState): boolean { + return state.fileVersions.isLimitsLoading; + }, + 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 0000000000..c935495416 --- /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/services/fileVersion.service'; +import { fileVersionsActions, fileVersionsReducer, fetchFileVersionsThunk, fetchVersionLimitsThunk } from './index'; +import { RootState } from '../..'; + +vi.mock('views/Drive/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).toEqual({ limits, isSilent: false }); + }); + + 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('', {})); + expect(pendingState.isLimitsLoading).toBe(true); + + const limits: FileLimitsResponse = { + versioning: { enabled: true, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, + }; + const fulfilledState = fileVersionsReducer( + pendingState, + fetchVersionLimitsThunk.fulfilled({ limits, isSilent: false }, '', {}), + ); + expect(fulfilledState.isLimitsLoading).toBe(false); + expect(fulfilledState.limits).toEqual(limits); + + const rejectedState = fileVersionsReducer( + pendingState, + fetchVersionLimitsThunk.rejected(new Error('err'), '', {}), + ); + 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 0000000000..cee9190397 --- /dev/null +++ b/src/app/store/slices/fileVersions/index.ts @@ -0,0 +1,102 @@ +import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; +import { FileVersion, FileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; +import fileVersionService from 'views/Drive/services/fileVersion.service'; + +export const VERSION_LIMITS_POLL_MAX_ATTEMPTS = 3; +export const VERSION_LIMITS_POLL_DELAYS = [2000, 4000, 6000]; + +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 ({ isSilent = false }: { isSilent?: boolean } = {}, { rejectWithValue }) => { + try { + const limits = await fileVersionService.getLimits(); + return { limits, isSilent }; + } 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, action) => { + const isSilent = action.meta.arg?.isSilent || false; + if (!isSilent) { + state.isLimitsLoading = true; + } + }) + .addCase(fetchVersionLimitsThunk.fulfilled, (state, action) => { + state.limits = action.payload.limits; + 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 e1e3f8e96e..732f20632e 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; @@ -9,8 +10,11 @@ interface UISliceState { isShareDialogOpen: boolean; isInvitationsDialogOpen: boolean; isItemDetailsDialogOpen: boolean; + isVersionHistorySidebarOpen: boolean; isCreateFolderDialogOpen: boolean; isDeleteItemsDialogOpen: boolean; + isDeleteVersionDialogOpen: boolean; + isRestoreVersionDialogOpen: boolean; isMoveItemsDialogOpen: boolean; isClearTrashDialogOpen: boolean; isEditFolderNameDialog: boolean; @@ -27,6 +31,9 @@ interface UISliceState { isFileViewerOpen: boolean; fileViewerItem: PreviewFileItem | null; itemDetails: DriveItemDetails | null; + versionHistoryItem: DriveItemData | null; + versionToDelete: FileVersion | null; + versionToRestore: FileVersion | null; currentFileInfoMenuItem: FileInfoMenuItem | null; currentEditingNameDriveItem: DriveItemData | null; currentEditingNameDirty: string; @@ -41,8 +48,11 @@ const initialState: UISliceState = { isShareDialogOpen: false, isInvitationsDialogOpen: false, isItemDetailsDialogOpen: false, + isVersionHistorySidebarOpen: false, isCreateFolderDialogOpen: false, isDeleteItemsDialogOpen: false, + isDeleteVersionDialogOpen: false, + isRestoreVersionDialogOpen: false, isMoveItemsDialogOpen: false, isClearTrashDialogOpen: false, isEditFolderNameDialog: false, @@ -59,6 +69,9 @@ const initialState: UISliceState = { isFileViewerOpen: false, fileViewerItem: null, itemDetails: null, + versionHistoryItem: null, + versionToDelete: null, + versionToRestore: null, currentFileInfoMenuItem: null, currentEditingNameDriveItem: null, currentEditingNameDirty: '', @@ -88,12 +101,30 @@ export const uiSlice = createSlice({ setIsItemDetailsDialogOpen(state: UISliceState, action: PayloadAction) { state.isItemDetailsDialogOpen = action.payload; }, + setIsVersionHistorySidebarOpen(state: UISliceState, action: PayloadAction) { + state.isVersionHistorySidebarOpen = action.payload; + }, + 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 6b5791aa87..334d2117e4 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', () => { @@ -27,4 +27,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 b21c1c83d3..2e2213ffd9 100644 --- a/src/services/date.service.ts +++ b/src/services/date.service.ts @@ -24,12 +24,20 @@ 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, isDateOneBefore, getCurrentDate, getExpirationDate, formatDefaultDate, + getDaysUntilExpiration, }; export default dateService; diff --git a/src/views/Drive/DriveView.tsx b/src/views/Drive/DriveView.tsx index 5eef11f20b..78dbff8852 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 b3749e34ba..0cf66c4be1 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 'views/Drive/hooks/useVersionHistoryMenuConfig'; 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; @@ -269,6 +272,14 @@ const DriveExplorerList: React.FC = memo((props) => { [selectedWorkspace, workspaceCredentials], ); + const viewVersionHistory = useCallback( + (item: ContextMenuDriveItem) => { + dispatch(uiActions.setVersionHistoryItem(item as DriveItemData)); + dispatch(uiActions.setIsVersionHistorySidebarOpen(true)); + }, + [dispatch, uiActions], + ); + const moveToTrash = useCallback( (item: ContextMenuDriveItem) => { const driveItem = item as DriveItemData; @@ -331,7 +342,9 @@ const DriveExplorerList: React.FC = memo((props) => { renameItem: renameItem, moveItem: moveItem, downloadItem: downloadItem, + viewVersionHistory: viewVersionHistory, moveToTrash: props.onOpenStopSharingAndMoveToTrashDialog, + versionHistoryConfig: versionHistoryMenuConfig, }); const selectedSharedFileMenu = contextMenuDriveItemShared({ @@ -344,7 +357,9 @@ const DriveExplorerList: React.FC = memo((props) => { renameItem: renameItem, moveItem: moveItem, downloadItem: downloadItem, + viewVersionHistory: viewVersionHistory, moveToTrash: props.onOpenStopSharingAndMoveToTrashDialog, + versionHistoryConfig: versionHistoryMenuConfig, }); const selectedFolderMenu = contextMenuDriveFolderNotSharedLink({ @@ -354,7 +369,9 @@ const DriveExplorerList: React.FC = memo((props) => { renameItem: renameItem, moveItem: moveItem, downloadItem: downloadItem, + viewVersionHistory: viewVersionHistory, moveToTrash: moveToTrash, + versionHistoryConfig: versionHistoryMenuConfig, }); const selectedFileMenu = contextMenuDriveNotSharedLink({ @@ -365,7 +382,9 @@ const DriveExplorerList: React.FC = memo((props) => { renameItem: renameItem, moveItem: moveItem, downloadItem: downloadItem, + viewVersionHistory: viewVersionHistory, moveToTrash: moveToTrash, + versionHistoryConfig: versionHistoryMenuConfig, }); const shareWithTeam = () => { @@ -381,7 +400,9 @@ const DriveExplorerList: React.FC = memo((props) => { renameItem: renameItem, moveItem: moveItem, downloadItem: downloadItem, + viewVersionHistory: viewVersionHistory, moveToTrash: moveToTrash, + versionHistoryConfig: versionHistoryMenuConfig, }); const workspaceFolderMenu = contextMenuWorkspaceFolder({ @@ -392,7 +413,9 @@ const DriveExplorerList: React.FC = memo((props) => { renameItem: renameItem, moveItem: moveItem, 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 3467b1b69e..1666039545 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,6 +94,46 @@ const getDownloadMenuItem = (downloadItems: (target?) => void) => ({ }, }); +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; + + if (isLocked) { + return { + name: t('drive.dropdown.versionHistory') as string, + icon: LockSimple, + action: viewVersionHistory, + 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'), icon: Trash, @@ -165,7 +206,9 @@ const contextMenuDriveNotSharedLink = ({ renameItem, moveItem, downloadItem, + viewVersionHistory, moveToTrash, + versionHistoryConfig, }: { shareLink: (item: DriveItemData) => void; openPreview?: (item: DriveItemData) => void; @@ -174,7 +217,9 @@ const contextMenuDriveNotSharedLink = ({ renameItem: (item: DriveItemData) => void; moveItem: (item: DriveItemData) => void; downloadItem: (item: DriveItemData) => void; + viewVersionHistory: (item: DriveItemData) => void; moveToTrash: (item: DriveItemData) => void; + versionHistoryConfig?: VersionHistoryMenuConfig; }): Array> => [ shareLinkMenuItem(shareLink), @@ -185,6 +230,7 @@ const contextMenuDriveNotSharedLink = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), + getVersionHistoryMenuItem(viewVersionHistory, versionHistoryConfig), { separator: true }, getMoveToTrashMenuItem(moveToTrash), ].filter(Boolean) as MenuItemType[]; @@ -196,7 +242,9 @@ const contextMenuDriveFolderNotSharedLink = ({ renameItem, moveItem, downloadItem, + viewVersionHistory, moveToTrash, + versionHistoryConfig, }: { shareLink: (item: DriveItemData) => void; getLink: (item: DriveItemData) => void; @@ -204,7 +252,9 @@ const contextMenuDriveFolderNotSharedLink = ({ renameItem: (item: DriveItemData) => void; moveItem: (item: DriveItemData) => void; downloadItem: (item: DriveItemData) => void; + viewVersionHistory: (item: DriveItemData) => void; moveToTrash: (item: DriveItemData) => void; + versionHistoryConfig?: VersionHistoryMenuConfig; }): Array> => [ shareLinkMenuItem(shareLink), getCopyLinkMenuItem(getLink), @@ -213,6 +263,7 @@ const contextMenuDriveFolderNotSharedLink = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), + getVersionHistoryMenuItem(viewVersionHistory, versionHistoryConfig), { separator: true }, getMoveToTrashMenuItem(moveToTrash), ]; @@ -225,7 +276,9 @@ const contextMenuDriveItemShared = ({ renameItem, moveItem, downloadItem, + viewVersionHistory, moveToTrash, + versionHistoryConfig, }: { openPreview?: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; showDetails: (item: DriveItemData) => void; @@ -234,7 +287,9 @@ const contextMenuDriveItemShared = ({ renameItem: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; moveItem: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; 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 [ @@ -245,6 +300,7 @@ const contextMenuDriveItemShared = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), + getVersionHistoryMenuItem(viewVersionHistory, versionHistoryConfig), { separator: true }, getMoveToTrashMenuItem(moveToTrash), ].filter(Boolean) as MenuItemType[]; @@ -257,7 +313,9 @@ const contextMenuDriveFolderShared = ({ renameItem, moveItem, downloadItem, + viewVersionHistory, moveToTrash, + versionHistoryConfig, }: { copyLink: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; openShareAccessSettings: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; @@ -265,7 +323,9 @@ const contextMenuDriveFolderShared = ({ renameItem: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; moveItem: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; 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 [ @@ -275,9 +335,10 @@ const contextMenuDriveFolderShared = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), + getVersionHistoryMenuItem(viewVersionHistory, versionHistoryConfig), { separator: true }, getMoveToTrashMenuItem(moveToTrash), - ]; + ].filter(Boolean) as MenuItemType[]; }; const contextMenuTrashItems = ({ @@ -423,7 +484,9 @@ const contextMenuWorkspaceFolder = ({ renameItem, moveItem, downloadItem, + viewVersionHistory, moveToTrash, + versionHistoryConfig, }: { shareLink: (item: DriveItemData) => void; getLink: (item: DriveItemData) => void; @@ -432,7 +495,9 @@ const contextMenuWorkspaceFolder = ({ renameItem: (item: DriveItemData) => void; moveItem: (item: DriveItemData) => void; downloadItem: (item: DriveItemData) => void; + viewVersionHistory: (item: DriveItemData) => void; moveToTrash: (item: DriveItemData) => void; + versionHistoryConfig?: VersionHistoryMenuConfig; }): Array> => [ shareLinkMenuItem(shareLink), getCopyLinkMenuItem(getLink), @@ -442,6 +507,7 @@ const contextMenuWorkspaceFolder = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), + getVersionHistoryMenuItem(viewVersionHistory, versionHistoryConfig), { separator: true }, getMoveToTrashMenuItem(moveToTrash), ]; @@ -455,7 +521,9 @@ const contextMenuWorkspaceFile = ({ renameItem, moveItem, downloadItem, + viewVersionHistory, moveToTrash, + versionHistoryConfig, }: { shareLink: (item: DriveItemData) => void; shareWithTeam: (item: DriveItemData) => void; @@ -465,7 +533,9 @@ const contextMenuWorkspaceFile = ({ renameItem: (item: DriveItemData) => void; moveItem: (item: DriveItemData) => void; downloadItem: (item: DriveItemData) => void; + viewVersionHistory: (item: DriveItemData) => void; moveToTrash: (item: DriveItemData) => void; + versionHistoryConfig?: VersionHistoryMenuConfig; }): Array> => [ shareLinkMenuItem(shareLink), @@ -477,6 +547,7 @@ const contextMenuWorkspaceFile = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), + 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 be6990ef77..dc6a4efeba 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 'views/Drive/hooks/useVersionHistoryMenuConfig'; 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]: ( { + dispatch(uiActions.setVersionHistoryItem(selectedItems[0])); + dispatch(uiActions.setIsVersionHistorySidebarOpen(true)); + }; + const onBulkDeleteButtonClicked = (): void => { moveItemsToTrash(selectedItems); }; @@ -176,7 +184,9 @@ const DriveTopBarActions = ({ renameItem: onSelectedOneItemRename, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, + versionHistoryConfig: versionHistoryMenuConfig, }); const workspaceFolderMenu = contextMenuWorkspaceFolder({ @@ -187,7 +197,9 @@ const DriveTopBarActions = ({ renameItem: onSelectedOneItemRename, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, + versionHistoryConfig: versionHistoryMenuConfig, }); const dropdownActions = () => { @@ -208,7 +220,9 @@ const DriveTopBarActions = ({ renameItem: onSelectedOneItemRename, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, + versionHistoryConfig: versionHistoryMenuConfig, }) : contextMenuDriveItemShared({ openPreview: onOpenPreviewButtonClicked, @@ -218,7 +232,9 @@ const DriveTopBarActions = ({ renameItem: onSelectedOneItemRename, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, + versionHistoryConfig: versionHistoryMenuConfig, }); } else { return selectedItems[0].isFolder @@ -229,7 +245,9 @@ const DriveTopBarActions = ({ renameItem: onSelectedOneItemRename, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, + versionHistoryConfig: versionHistoryMenuConfig, }) : contextMenuDriveNotSharedLink({ shareLink: onOpenShareSettingsButtonClicked, @@ -239,7 +257,9 @@ const DriveTopBarActions = ({ renameItem: onSelectedOneItemRename, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, + versionHistoryConfig: versionHistoryMenuConfig, }); } }; diff --git a/src/views/Drive/components/DriveItemDropdownActions.tsx b/src/views/Drive/components/DriveItemDropdownActions.tsx index 863ebdb8b8..43c4257771 100644 --- a/src/views/Drive/components/DriveItemDropdownActions.tsx +++ b/src/views/Drive/components/DriveItemDropdownActions.tsx @@ -25,6 +25,7 @@ const FileDropdownActions = (props: FileDropdownActionsProps) => { onMoveToTrashButtonClicked, onShowDetailsButtonClicked, onDownloadItemButtonClicked, + onViewVersionHistoryButtonClicked, onLinkSettingsButtonClicked, onRenameItemButtonClicked, onOpenPreviewButtonClicked, @@ -50,6 +51,7 @@ const FileDropdownActions = (props: FileDropdownActionsProps) => { renameItem: onRenameItemButtonClicked, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadItemButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onMoveToTrashButtonClicked, }); } @@ -61,6 +63,7 @@ const FileDropdownActions = (props: FileDropdownActionsProps) => { renameItem: onRenameItemButtonClicked, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadItemButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onMoveToTrashButtonClicked, }); } else if (item?.isFolder) { @@ -71,6 +74,7 @@ const FileDropdownActions = (props: FileDropdownActionsProps) => { renameItem: onRenameItemButtonClicked, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadItemButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onMoveToTrashButtonClicked, }); } @@ -82,6 +86,7 @@ const FileDropdownActions = (props: FileDropdownActionsProps) => { renameItem: onRenameItemButtonClicked, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadItemButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onMoveToTrashButtonClicked, }); }; diff --git a/src/views/Drive/components/VersionHistory/Sidebar.tsx b/src/views/Drive/components/VersionHistory/Sidebar.tsx new file mode 100644 index 0000000000..68c6266e75 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/Sidebar.tsx @@ -0,0 +1,314 @@ +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 storageSelectors from 'app/store/slices/storage/storage.selectors'; +import { fetchSortedFolderContentThunk } from 'app/store/slices/storage/storage.thunks/fetchSortedFolderContentThunk'; +import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; +import navigationService from 'services/navigation.service'; +import { + Header, + CurrentVersionItem, + VersionItem, + AutosaveSection, + VersionActionDialog, + VersionHistorySkeleton, + LockedFeatureModal, +} from './components'; +import fileVersionService from 'views/Drive/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'; +import { getDaysUntilExpiration } from 'services/date.service'; +import { FileVersion } from '@internxt/sdk/dist/drive/storage/types'; + +type VersionInfo = { id: string; updatedAt: string }; + +const EMPTY_ARRAY: FileVersion[] = []; + +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 selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); + const { translate } = useTranslationContext(); + + const limits = useAppSelector(fileVersionsSelectors.getLimits); + const isLimitsLoading = useAppSelector(fileVersionsSelectors.isLimitsLoading); + const versions = useAppSelector((state: RootState) => + item ? fileVersionsSelectors.getVersionsByFileId(state, item.uuid) : EMPTY_ARRAY, + ); + 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: '', + }); + + const isVersioningEnabled = limits?.versioning?.enabled ?? false; + const isLoadingContent = (isVersioningEnabled && isLoading) || isLimitsLoading; + + const blurredBackgroundVersions = useMemo(() => { + if (isVersioningEnabled) return []; + + const fixedDate = new Date('2024-05-01').toISOString(); + return Array.from({ length: 10 }, (_, i) => ({ + id: `mock-${i}`, + fileId: '', + size: '1000000', + createdAt: fixedDate, + updatedAt: fixedDate, + type: 'file', + })); + }, [isVersioningEnabled]); + + const displayVersions = useMemo(() => { + if (!isVersioningEnabled) return blurredBackgroundVersions; + return versions.filter((version) => { + if (!version.expiresAt) return true; + return getDaysUntilExpiration(version.expiresAt) > 0; + }); + }, [isVersioningEnabled, versions]); + + useEffect(() => { + if (item) { + setCurrentVersion({ + id: item.fileId, + updatedAt: item.updatedAt, + }); + } + }, [item]); + const totalVersionsCount = displayVersions.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 hasCachedVersions = versions && versions.length > 0; + if (!hasCachedVersions) { + dispatch(fetchFileVersionsThunk(item.uuid)); + } + }, [item?.uuid, isOpen, dispatch]); + + 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; + }); + }; + + 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]); + + const handleUpgrade = () => { + dispatch(uiActions.setIsPreferencesDialogOpen(true)); + navigationService.openPreferencesDialog({ + section: 'account', + subsection: 'plans', + workspaceUuid: selectedWorkspace?.workspaceUser.workspaceId, + }); + }; + + const shouldShowSidebar = isOpen && item; + + return ( + <> + {shouldShowSidebar &&
} +
+
+
+ +
+ {isLoadingContent ? ( + + ) : ( + <> + + + + + {displayVersions.map((version) => ( + handleVersionSelectionChange(version.id, selected)} + /> + ))} + + )} + {!isVersioningEnabled && !isLimitsLoading && } +
+
+
+ + + + ); +}; + +export default Sidebar; diff --git a/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx new file mode 100644 index 0000000000..4d404ae975 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx @@ -0,0 +1,53 @@ +import { Trash } from '@phosphor-icons/react'; +import { Checkbox } from '@internxt/ui'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; + +interface AutosaveSectionProps { + totalVersionsCount: number; + totalAllowedVersions: number; + selectedCount: number; + selectAllAutosave: boolean; + onSelectAllChange: (checked: boolean) => void; + onDeleteAll: () => void; +} + +export const AutosaveSection = ({ + totalVersionsCount, + totalAllowedVersions, + selectedCount, + selectAllAutosave, + onSelectAllChange, + onDeleteAll, +}: AutosaveSectionProps) => { + const { translate } = useTranslationContext(); + const hasSelection = selectedCount > 0; + const isIndeterminate = hasSelection && !selectAllAutosave; + + return ( +
+
+ onSelectAllChange(!selectAllAutosave)} + className="h-4 w-4" + /> + + {translate('modals.versionHistory.autosaveVersions', { + count: totalVersionsCount, + total: totalAllowedVersions, + })} + +
+ +
+ ); +}; diff --git a/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx b/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx new file mode 100644 index 0000000000..e48da3477f --- /dev/null +++ b/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx @@ -0,0 +1,30 @@ +import { Avatar } from '@internxt/ui'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { formatVersionDate } from '../utils'; + +interface CurrentVersionItemProps { + createdAt: string; + userName: string; + userAvatar: string | null; +} + +export const CurrentVersionItem = ({ createdAt, userName, userAvatar }: CurrentVersionItemProps) => { + const { translate } = useTranslationContext(); + + return ( +
+
+
+ {formatVersionDate(createdAt)} + + {translate('modals.versionHistory.current')} + +
+
+ + {userName} +
+
+
+ ); +}; diff --git a/src/views/Drive/components/VersionHistory/components/Header.tsx b/src/views/Drive/components/VersionHistory/components/Header.tsx new file mode 100644 index 0000000000..ed9578fdda --- /dev/null +++ b/src/views/Drive/components/VersionHistory/components/Header.tsx @@ -0,0 +1,20 @@ +import { X } from '@phosphor-icons/react'; + +interface HeaderProps { + title: string; + onClose: () => void; +} + +export const Header = ({ title, onClose }: HeaderProps) => { + return ( +
+ {title} + +
+ ); +}; diff --git a/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx b/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx new file mode 100644 index 0000000000..4150619b44 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx @@ -0,0 +1,49 @@ +import { Button } from '@internxt/ui'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { ClockCounterClockwise, LockSimple } from '@phosphor-icons/react'; + +interface LockedFeatureModalProps { + onUpgrade: () => void; +} + +const ICON_SIZES = { + clock: 64, + lock: 35, +} as const; + +export const LockedFeatureModal = ({ onUpgrade }: LockedFeatureModalProps) => { + const { translate } = useTranslationContext(); + + return ( +
+
+
+
+ +
+
+ +
+
+ +
+

+ {translate('modals.versionHistory.lockedFeature.title')} +

+

+ {translate('modals.versionHistory.lockedFeature.description')} +

+

+ {translate('modals.versionHistory.lockedFeature.supportedFormats')} +

+
+ +
+ +
+
+
+ ); +}; diff --git a/src/views/Drive/components/VersionHistory/components/VersionActionDialog.tsx b/src/views/Drive/components/VersionHistory/components/VersionActionDialog.tsx new file mode 100644 index 0000000000..c2f8f8d6a5 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/components/VersionActionDialog.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import { Button, Modal } from '@internxt/ui'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import errorService from 'services/error.service'; + +type VersionActionType = 'delete' | 'restore'; + +interface VersionActionDialogProps { + isOpen: boolean; + actionType: VersionActionType; + onClose: () => void; + onConfirm: () => Promise; +} + +export const VersionActionDialog = ({ isOpen, actionType, onClose, onConfirm }: VersionActionDialogProps) => { + const { translate } = useTranslationContext(); + const [isLoading, setIsLoading] = useState(false); + + const handleClose = () => { + if (isLoading) return; + onClose(); + }; + + const handleConfirm = async () => { + try { + setIsLoading(true); + await onConfirm(); + setIsLoading(false); + onClose(); + } catch (error) { + setIsLoading(false); + const castedError = errorService.castError(error); + errorService.reportError(castedError); + } + }; + + const config = { + delete: { + titleKey: 'deleteVersionTitle', + adviceKey: 'deleteVersionAdvice', + buttonKey: 'deleteButton', + buttonVariant: 'destructive' as const, + buttonClassName: '[&:not(:disabled)]:!bg-[#E50B00] [&:not(:disabled)]:hover:!bg-[#C00A00]', + }, + restore: { + titleKey: 'restoreVersionTitle', + adviceKey: 'restoreVersionAdvice', + buttonKey: 'restoreButton', + buttonVariant: 'primary' as const, + buttonClassName: '', + }, + }[actionType]; + + return ( + +
+

{translate(`modals.versionHistory.${config.titleKey}`)}

+

+ {translate(`modals.versionHistory.${config.adviceKey}`)} +

+ +
+ + +
+
+
+ ); +}; 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 0000000000..a7a445a72b --- /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 new file mode 100644 index 0000000000..4439d4c1da --- /dev/null +++ b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx @@ -0,0 +1,131 @@ +import { Info, DotsThree } from '@phosphor-icons/react'; +import { Checkbox, Dropdown, Avatar } from '@internxt/ui'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { useDropdownPositioning } from 'views/Drive/hooks/useDropdownPositioning'; +import { useVersionItemActions } from 'views/Drive/hooks/useVersionItemActions'; +import { formatVersionDate } from '../utils'; +import { getDaysUntilExpiration } from 'services/date.service'; +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 = 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 handleToggleSelection = () => { + onSelectionChange(!isSelected); + }; + + const handleItemClick = () => { + handleToggleSelection(); + }; + + const dropdownOpenDirection = dropdownPosition === 'above' ? 'left' : 'right'; + const versionSize = sizeService.bytesToString(Number.parseInt(version.size), false); + + return ( + <> + {isOpen && ( +
{ + e.stopPropagation(); + setIsOpen(false); + }} + aria-hidden="true" + /> + )} + + + ); + }, +); diff --git a/src/views/Drive/components/VersionHistory/components/index.ts b/src/views/Drive/components/VersionHistory/components/index.ts new file mode 100644 index 0000000000..f63f5c3aee --- /dev/null +++ b/src/views/Drive/components/VersionHistory/components/index.ts @@ -0,0 +1,7 @@ +export { Header } from './Header'; +export { CurrentVersionItem } from './CurrentVersionItem'; +export { VersionItem } from './VersionItem'; +export { AutosaveSection } from './AutosaveSection'; +export { VersionActionDialog } from './VersionActionDialog'; +export { VersionHistorySkeleton } from './VersionHistorySkeleton'; +export { LockedFeatureModal } from './LockedFeatureModal'; diff --git a/src/views/Drive/components/VersionHistory/index.ts b/src/views/Drive/components/VersionHistory/index.ts new file mode 100644 index 0000000000..871a4d9d0a --- /dev/null +++ b/src/views/Drive/components/VersionHistory/index.ts @@ -0,0 +1 @@ +export { default as Sidebar } from './Sidebar'; diff --git a/src/views/Drive/components/VersionHistory/utils/index.ts b/src/views/Drive/components/VersionHistory/utils/index.ts new file mode 100644 index 0000000000..6c461b18c2 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/utils/index.ts @@ -0,0 +1,14 @@ +import dateService from 'services/date.service'; +import { DriveItemData } from 'app/drive/types'; + +export const formatVersionDate = (date: string): string => dateService.format(date, 'MMM D, h:mm A'); + +const ALLOWED_VERSIONING_EXTENSIONS = new Set(['pdf', 'docx', 'xlsx', 'csv']); + +export const isVersioningExtensionAllowed = (item?: Pick | null): boolean => { + if (!item?.type) { + return false; + } + const extension = item.type.toLowerCase(); + return ALLOWED_VERSIONING_EXTENSIONS.has(extension); +}; diff --git a/src/views/Drive/hooks/index.ts b/src/views/Drive/hooks/index.ts index ae8b20a932..8a207b8786 100644 --- a/src/views/Drive/hooks/index.ts +++ b/src/views/Drive/hooks/index.ts @@ -3,4 +3,7 @@ export { useDriveItemDrag, useDriveItemDrop } from './useDriveItemDragAndDrop'; export { default as useDriveItemStoreProps } from './useDriveStoreProps'; export { usePaginationState } from './usePaginationState'; export { useTutorialState } from './useTutorialState'; +export { useVersionHistoryMenuConfig } from './useVersionHistoryMenuConfig'; +export { useVersionItemActions } from './useVersionItemActions'; +export { useDropdownPositioning } from './useDropdownPositioning'; export type { DriveItemActions } from './useDriveItemActions'; diff --git a/src/views/Drive/hooks/useDriveItemActions.tsx b/src/views/Drive/hooks/useDriveItemActions.tsx index ae8edecd00..bd93a6a77b 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 './useVersionHistoryMenuConfig'; export interface DriveItemActions { nameInputRef: React.RefObject; @@ -23,6 +24,7 @@ export interface DriveItemActions { onCopyLinkButtonClicked: () => void; onLinkSettingsButtonClicked: () => void; onDownloadItemButtonClicked: () => void; + onViewVersionHistoryButtonClicked: () => void; onShowDetailsButtonClicked: () => void; onMoveToTrashButtonClicked: () => void; onNameClicked: (e) => void; @@ -100,6 +102,11 @@ const useDriveItemActions = (item): DriveItemActions => { }); }; + const onViewVersionHistoryButtonClicked = () => { + dispatch(uiActions.setVersionHistoryItem(item as DriveItemData)); + dispatch(uiActions.setIsVersionHistorySidebarOpen(true)); + }; + const onMoveToTrashButtonClicked = () => { moveItemsToTrash([item as DriveItemData]); }; @@ -159,6 +166,7 @@ const useDriveItemActions = (item): DriveItemActions => { onCopyLinkButtonClicked, onLinkSettingsButtonClicked, onDownloadItemButtonClicked, + onViewVersionHistoryButtonClicked, onShowDetailsButtonClicked, onMoveToTrashButtonClicked, onNameClicked, diff --git a/src/views/Drive/hooks/useDropdownPositioning.test.ts b/src/views/Drive/hooks/useDropdownPositioning.test.ts new file mode 100644 index 0000000000..d5ab8abffa --- /dev/null +++ b/src/views/Drive/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/hooks/useDropdownPositioning.ts b/src/views/Drive/hooks/useDropdownPositioning.ts new file mode 100644 index 0000000000..70ba700b9e --- /dev/null +++ b/src/views/Drive/hooks/useDropdownPositioning.ts @@ -0,0 +1,58 @@ +import { useEffect, useState, useRef, RefObject } from 'react'; + +type DropdownPosition = 'above' | 'below'; + +const DROPDOWN_POSITION = { + ABOVE: 'above' as const, + BELOW: 'below' as const, +}; + +interface UseDropdownPositioningReturn { + isOpen: boolean; + setIsOpen: (open: boolean) => void; + dropdownPosition: DropdownPosition; + dropdownRef: RefObject; + itemRef: RefObject; +} + +export const useDropdownPositioning = (): UseDropdownPositioningReturn => { + const [isOpen, setIsOpen] = useState(false); + const [dropdownPosition, setDropdownPosition] = useState(DROPDOWN_POSITION.BELOW); + const dropdownRef = useRef(null); + const itemRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + if (itemRef.current) { + const rect = itemRef.current.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + const menuHeight = 200; + + if (spaceBelow < menuHeight) { + setDropdownPosition(DROPDOWN_POSITION.ABOVE); + } else { + setDropdownPosition(DROPDOWN_POSITION.BELOW); + } + } + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + return { + isOpen, + setIsOpen, + dropdownPosition, + dropdownRef, + itemRef, + }; +}; diff --git a/src/views/Drive/hooks/useVersionHistoryMenuConfig.test.ts b/src/views/Drive/hooks/useVersionHistoryMenuConfig.test.ts new file mode 100644 index 0000000000..f8989ab120 --- /dev/null +++ b/src/views/Drive/hooks/useVersionHistoryMenuConfig.test.ts @@ -0,0 +1,56 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi, Mock } from 'vitest'; +import { useSelector } from 'react-redux'; +import { useVersionHistoryMenuConfig } from './useVersionHistoryMenuConfig'; +import { FileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; + +vi.mock('react-redux', () => ({ + useSelector: vi.fn(), +})); + +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 mockState = (limits: FileLimitsResponse | null) => + ({ + fileVersions: { limits }, + }) as any; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('when versioning is locked, then the menu is marked as locked', () => { + 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); + }); + + 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/hooks/useVersionHistoryMenuConfig.ts b/src/views/Drive/hooks/useVersionHistoryMenuConfig.ts new file mode 100644 index 0000000000..215b94093d --- /dev/null +++ b/src/views/Drive/hooks/useVersionHistoryMenuConfig.ts @@ -0,0 +1,16 @@ +import { useSelector } from 'react-redux'; +import { fileVersionsSelectors } from 'app/store/slices/fileVersions'; +import { DriveItemData } from 'app/drive/types'; +import { isVersioningExtensionAllowed } from '../components/VersionHistory/utils'; +import { VersionHistoryMenuConfig } from '../components/DriveExplorer/components/DriveItemContextMenu'; + +export const useVersionHistoryMenuConfig = (selectedItem?: DriveItemData): VersionHistoryMenuConfig => { + const limits = useSelector(fileVersionsSelectors.getLimits); + const isVersioningEnabled = limits?.versioning?.enabled ?? false; + const isExtensionAllowed = selectedItem ? isVersioningExtensionAllowed(selectedItem) : true; + + return { + isLocked: !isVersioningEnabled, + isExtensionAllowed, + }; +}; diff --git a/src/views/Drive/hooks/useVersionItemActions.test.ts b/src/views/Drive/hooks/useVersionItemActions.test.ts new file mode 100644 index 0000000000..0739f87ddc --- /dev/null +++ b/src/views/Drive/hooks/useVersionItemActions.test.ts @@ -0,0 +1,172 @@ +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/services/fileVersion.service', () => ({ + default: { + downloadVersion: vi.fn(), + }, +})); + +vi.mock('app/notifications/services/notifications.service', () => ({ + default: { + show: vi.fn(), + }, + ToastType: { + Error: 'error', + }, +})); + +vi.mock('services/date.service', () => ({ + default: { + format: vi.fn(() => '10-01-2026 at 14:30'), + }, +})); + +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', + createdAt: '2026-01-10T14:30:00.000Z', + } 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, + '(10-01-2026 at 14:30) pretty-name', + 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/hooks/useVersionItemActions.ts b/src/views/Drive/hooks/useVersionItemActions.ts new file mode 100644 index 0000000000..6d8e0b1d1f --- /dev/null +++ b/src/views/Drive/hooks/useVersionItemActions.ts @@ -0,0 +1,81 @@ +import { Trash, ClockCounterClockwise, DownloadSimple } from '@phosphor-icons/react'; +import { MenuItemType } from '@internxt/ui'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +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'; +import dateService from 'services/date.service'; +import { items as itemsUtils } from '@internxt/lib'; + +interface UseVersionItemActionsParams { + version: FileVersion; + onDropdownClose: () => void; +} + +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 handleRestoreClick = () => { + onDropdownClose(); + dispatch(uiActions.setVersionToRestore(version)); + dispatch(uiActions.setIsRestoreVersionDialogOpen(true)); + }; + + const handleDownload = async () => { + onDropdownClose(); + + if (!item) { + notificationsService.show({ + text: translate('modals.versionHistory.downloadError'), + type: ToastType.Error, + }); + return; + } + + const entireFilename = item.plainName || item.name; + const formattedDate = dateService.format(version.createdAt, 'DD-MM-YYYY [at] HH:mm'); + const { filename, extension } = itemsUtils.getFilenameAndExt(entireFilename); + + const fileExtension = extension || item.type ? `.${extension || item.type}` : ''; + const versionFileName = `(${formattedDate}) ${filename}${fileExtension}`; + + await fileVersionService.downloadVersion(version, item, versionFileName, selectedWorkspace, workspaceCredentials); + }; + + const handleDeleteClick = () => { + onDropdownClose(); + dispatch(uiActions.setVersionToDelete(version)); + dispatch(uiActions.setIsDeleteVersionDialogOpen(true)); + }; + + const menuItems: Array> = [ + { + name: translate('modals.versionHistory.restoreVersion'), + icon: ClockCounterClockwise, + action: handleRestoreClick, + }, + { + name: translate('modals.versionHistory.downloadVersion'), + icon: DownloadSimple, + action: handleDownload, + }, + { + separator: true, + }, + { + name: translate('modals.versionHistory.deleteVersion'), + icon: Trash, + action: handleDeleteClick, + }, + ]; + + return { menuItems }; +}; diff --git a/src/views/Drive/services/fileVersion.service.test.ts b/src/views/Drive/services/fileVersion.service.test.ts new file mode 100644 index 0000000000..53cf50a133 --- /dev/null +++ b/src/views/Drive/services/fileVersion.service.test.ts @@ -0,0 +1,114 @@ +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), + }, + ], + selectedWorkspace, + workspaceCredentials, + downloadOptions: { + downloadName: 'custom-name', + }, + }); + }); +}); diff --git a/src/views/Drive/services/fileVersion.service.ts b/src/views/Drive/services/fileVersion.service.ts new file mode 100644 index 0000000000..6a82e43d43 --- /dev/null +++ b/src/views/Drive/services/fileVersion.service.ts @@ -0,0 +1,56 @@ +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), + }; + + await DownloadManager.downloadItem({ + payload: [versionFileData], + selectedWorkspace, + workspaceCredentials, + downloadOptions: { + downloadName: fileName, + }, + }); +} + +const fileVersionService = { + getFileVersions, + deleteVersion, + restoreVersion, + downloadVersion, + getLimits, +}; + +export default fileVersionService; diff --git a/src/views/Drive/services/replaceFile.service.ts b/src/views/Drive/services/replaceFile.service.ts new file mode 100644 index 0000000000..b98b40ed63 --- /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/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx b/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx index d3f2aa7726..fc00965f46 100644 --- a/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx +++ b/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx @@ -13,6 +13,12 @@ import { paymentService } from 'views/Checkout/services'; import { RootState } from 'app/store'; import { useAppDispatch } from 'app/store/hooks'; import { PlanState, planThunks } from 'app/store/slices/plan'; +import { + fetchVersionLimitsThunk, + VERSION_LIMITS_POLL_MAX_ATTEMPTS, + VERSION_LIMITS_POLL_DELAYS, + fileVersionsSelectors, +} from 'app/store/slices/fileVersions'; import CancelSubscriptionModal from '../../Workspace/Billing/CancelSubscriptionModal'; import { fetchPlanPrices, getStripe } from '../../../../services/plansApi'; import ChangePlanDialog from './components/ChangePlanDialog'; @@ -46,6 +52,7 @@ const PlansSection = ({ changeSection, onClosePreferences }: PlansSectionProps) const selectedWorkspace = useSelector((state: RootState) => state.workspaces.selectedWorkspace); const plan = useSelector((state) => state.plan); const user = useSelector((state) => state.user.user); + const versionLimits = useSelector(fileVersionsSelectors.getLimits); const { individualSubscription, businessSubscription } = plan; let stripe; @@ -182,9 +189,34 @@ const PlansSection = ({ changeSection, onClosePreferences }: PlansSectionProps) [translate], ); + const pollVersionLimitsUntilChanged = useCallback( + async (attempt = 0, previousLimits = versionLimits) => { + if (attempt >= VERSION_LIMITS_POLL_MAX_ATTEMPTS) return; + + const delay = VERSION_LIMITS_POLL_DELAYS[attempt] || VERSION_LIMITS_POLL_DELAYS.at(-1); + await new Promise((resolve) => setTimeout(resolve, delay)); + const result = await dispatch(fetchVersionLimitsThunk({ isSilent: true })).unwrap(); + + const hasLimitsChanged = + previousLimits?.versioning.enabled !== result.limits.versioning.enabled || + previousLimits?.versioning.maxFileSize !== result.limits.versioning.maxFileSize || + previousLimits?.versioning.retentionDays !== result.limits.versioning.retentionDays || + previousLimits?.versioning.maxVersions !== result.limits.versioning.maxVersions; + + if (!hasLimitsChanged) { + pollVersionLimitsUntilChanged(attempt + 1, previousLimits); + } + }, + [dispatch, versionLimits], + ); + const handlePaymentSuccess = () => { showSuccessSubscriptionNotification(); - setTimeout(() => dispatch(planThunks.initializeThunk()).unwrap(), 2000); + setTimeout(() => { + dispatch(planThunks.initializeThunk()).unwrap(); + }, 2000); + + pollVersionLimitsUntilChanged(); }; const handleSubscriptionPayment = async (priceId: string) => { @@ -242,6 +274,8 @@ const PlansSection = ({ changeSection, onClosePreferences }: PlansSectionProps) setTimeout(() => { dispatch(planThunks.initializeThunk()).unwrap(); }, 1000); + + pollVersionLimitsUntilChanged(); } }