From 1612affbe69204f803c7822c067838cb4e2db628 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 10 Dec 2025 00:49:17 -0400 Subject: [PATCH 01/27] feature: implement file version history with restore, delete, and download functionality Adds complete file version management system with API integration for viewing, restoring, deleting, and downloading file versions. Updates name collision dialog to use replaceFile endpoint instead of delete-and-reupload for file replacements. --- package.json | 2 +- .../NameCollisionContainer.tsx | 24 ++- src/app/i18n/locales/en.json | 15 +- src/app/store/slices/ui/index.ts | 21 +++ .../components/VersionHistory/Sidebar.tsx | 153 ++++++++++++++---- .../components/CurrentVersionItem.tsx | 15 +- .../components/VersionActionDialog.tsx | 83 ++++++++++ .../components/VersionHistorySkeleton.tsx | 30 ++++ .../VersionHistory/components/VersionItem.tsx | 12 +- .../VersionHistory/components/index.ts | 2 + .../hooks/useVersionItemActions.ts | 42 ++++- .../services/file-version.service.ts | 61 +++++++ .../Drive/components/VersionHistory/types.ts | 9 +- .../Drive/services/replace-file.service.ts | 18 +++ yarn.lock | 8 +- 15 files changed, 428 insertions(+), 67 deletions(-) create mode 100644 src/views/Drive/components/VersionHistory/components/VersionActionDialog.tsx create mode 100644 src/views/Drive/components/VersionHistory/components/VersionHistorySkeleton.tsx create mode 100644 src/views/Drive/components/VersionHistory/services/file-version.service.ts create mode 100644 src/views/Drive/services/replace-file.service.ts diff --git a/package.json b/package.json index 76e44ebcb..a57a6f3ea 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@iconscout/react-unicons": "^1.1.6", "@internxt/css-config": "1.1.0", "@internxt/lib": "1.4.1", - "@internxt/sdk": "=1.11.17", + "@internxt/sdk": "=1.11.18", "@internxt/ui": "0.1.1", "@phosphor-icons/react": "^2.1.7", "@popperjs/core": "^2.11.6", diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index a7b1723a0..75d25c387 100644 --- a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx +++ b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx @@ -12,6 +12,7 @@ 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/replace-file.service'; type NameCollisionContainerProps = { currentFolderId: string; @@ -129,10 +130,12 @@ 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) { + await moveItemsToTrash([itemToReplace]); uploadFoldersWithManager({ payload: [ { @@ -146,19 +149,14 @@ const NameCollisionContainer: FC = ({ 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; + await replaceFileService.replaceFile(itemToReplace.uuid, { + fileId: itemToReplace.fileId, + size: file.size, }); + dispatch(fetchSortedFolderContentThunk(folderId)); } - }); + } }; const keepAndUploadItem = async (itemsToUpload: (IRoot | File)[]) => { diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 541adf8b3..3f61f38dd 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -892,7 +892,20 @@ "autosaveVersions": "{{count}}/{{total}} autosave versions", "restoreVersion": "Restore version", "downloadVersion": "Download version", - "deleteVersion": "Delete version" + "deleteVersion": "Delete version", + "deleteVersionTitle": "Delete version", + "deleteButton": "Delete", + "restoreButton": "Restore", + "downloadError": "Failed to download version", + "deleteVersionAdvice": "This version will be permanently deleted. \nThis action cannot be undone.", + "deletingVersion": "Deleting", + "restoreVersionTitle": "Restore version", + "restoreVersionAdvice": "Restoring this version will replace the current file and remove all newer versions. \nOnce restored, this action cannot be undone.\n\nYou can download a copy of newer versions before restoring.", + "restoringVersion": "Restoring", + "restoreSuccess": "Version restored successfully", + "restoreError": "Failed to restore version", + "deleteSuccess": "Version deleted successfully", + "deleteError": "Failed to delete version" }, "shareModal": { "title": "Share \"{{name}}\"", diff --git a/src/app/store/slices/ui/index.ts b/src/app/store/slices/ui/index.ts index 7c2ab076d..732f20632 100644 --- a/src/app/store/slices/ui/index.ts +++ b/src/app/store/slices/ui/index.ts @@ -1,6 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { DriveItemData, DriveItemDetails, FileInfoMenuItem, UpgradePlanDialogInfo } from 'app/drive/types'; import { PreviewFileItem } from '../../../share/types'; +import { FileVersion } from 'views/Drive/components/VersionHistory/types'; interface UISliceState { isSidenavCollapsed: boolean; @@ -12,6 +13,8 @@ interface UISliceState { isVersionHistorySidebarOpen: boolean; isCreateFolderDialogOpen: boolean; isDeleteItemsDialogOpen: boolean; + isDeleteVersionDialogOpen: boolean; + isRestoreVersionDialogOpen: boolean; isMoveItemsDialogOpen: boolean; isClearTrashDialogOpen: boolean; isEditFolderNameDialog: boolean; @@ -29,6 +32,8 @@ interface UISliceState { fileViewerItem: PreviewFileItem | null; itemDetails: DriveItemDetails | null; versionHistoryItem: DriveItemData | null; + versionToDelete: FileVersion | null; + versionToRestore: FileVersion | null; currentFileInfoMenuItem: FileInfoMenuItem | null; currentEditingNameDriveItem: DriveItemData | null; currentEditingNameDirty: string; @@ -46,6 +51,8 @@ const initialState: UISliceState = { isVersionHistorySidebarOpen: false, isCreateFolderDialogOpen: false, isDeleteItemsDialogOpen: false, + isDeleteVersionDialogOpen: false, + isRestoreVersionDialogOpen: false, isMoveItemsDialogOpen: false, isClearTrashDialogOpen: false, isEditFolderNameDialog: false, @@ -63,6 +70,8 @@ const initialState: UISliceState = { fileViewerItem: null, itemDetails: null, versionHistoryItem: null, + versionToDelete: null, + versionToRestore: null, currentFileInfoMenuItem: null, currentEditingNameDriveItem: null, currentEditingNameDirty: '', @@ -98,12 +107,24 @@ export const uiSlice = createSlice({ setVersionHistoryItem: (state: UISliceState, action: PayloadAction) => { state.versionHistoryItem = action.payload; }, + setVersionToDelete: (state: UISliceState, action: PayloadAction) => { + state.versionToDelete = action.payload; + }, + setVersionToRestore: (state: UISliceState, action: PayloadAction) => { + state.versionToRestore = action.payload; + }, setIsCreateFolderDialogOpen: (state: UISliceState, action: PayloadAction) => { state.isCreateFolderDialogOpen = action.payload; }, setIsDeleteItemsDialogOpen: (state: UISliceState, action: PayloadAction) => { state.isDeleteItemsDialogOpen = action.payload; }, + setIsDeleteVersionDialogOpen: (state: UISliceState, action: PayloadAction) => { + state.isDeleteVersionDialogOpen = action.payload; + }, + setIsRestoreVersionDialogOpen: (state: UISliceState, action: PayloadAction) => { + state.isRestoreVersionDialogOpen = action.payload; + }, setIsMoveItemsDialogOpen: (state: UISliceState, action: PayloadAction) => { state.isMoveItemsDialogOpen = action.payload; }, diff --git a/src/views/Drive/components/VersionHistory/Sidebar.tsx b/src/views/Drive/components/VersionHistory/Sidebar.tsx index a16fd504f..b6fc72304 100644 --- a/src/views/Drive/components/VersionHistory/Sidebar.tsx +++ b/src/views/Drive/components/VersionHistory/Sidebar.tsx @@ -1,36 +1,117 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { RootState } from 'app/store'; import { useAppDispatch, useAppSelector } from 'app/store/hooks'; import { uiActions } from 'app/store/slices/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { Header, CurrentVersionItem, VersionItem, AutosaveSection } from './components'; +import { + Header, + CurrentVersionItem, + VersionItem, + AutosaveSection, + VersionActionDialog, + VersionHistorySkeleton, +} from './components'; import { FileVersion } from './types'; +import fileVersionService from 'views/Drive/components/VersionHistory/services/file-version.service'; +import errorService from 'services/error.service'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; 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 { translate } = useTranslationContext(); - const [versions, setVersions] = useState([ - { - id: '1', - date: new Date('2024-06-20T10:00:00Z'), - userName: 'John Doe', - expiresInDays: 30, - isCurrent: true, - isAutosave: false, - }, - ]); + const [versions, setVersions] = useState([]); + const [isLoading, setIsLoading] = useState(false); const [selectAllAutosave, setSelectAllAutosave] = useState(false); const autosaveVersions = versions.filter((v) => v.isAutosave); const totalAutosaveCount = autosaveVersions.length; + const userName = user?.name && user?.lastname ? `${user.name} ${user.lastname}` : user?.email || 'Unknown User'; + + const fetchVersions = async () => { + if (!item || !isOpen) return; + + setIsLoading(true); + try { + const fileVersions = await fileVersionService.getFileVersions(item.uuid); + // TODO: Determine if autosave + setVersions(fileVersions); + } catch (error) { + const castedError = errorService.castError(error); + errorService.reportError(castedError); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchVersions(); + }, [item, isOpen]); + const onClose = () => { dispatch(uiActions.setIsVersionHistorySidebarOpen(false)); }; + const handleCloseDeleteDialog = () => { + dispatch(uiActions.setIsDeleteVersionDialogOpen(false)); + dispatch(uiActions.setVersionToDelete(null)); + }; + + const handleCloseRestoreDialog = () => { + dispatch(uiActions.setIsRestoreVersionDialogOpen(false)); + dispatch(uiActions.setVersionToRestore(null)); + }; + + const handleDeleteConfirm = async () => { + if (!versionToDelete || !item) return; + + try { + await fileVersionService.deleteVersion(item.uuid, versionToDelete.id.toString()); + notificationsService.show({ + text: translate('modals.versionHistory.deleteSuccess'), + type: ToastType.Success, + }); + await fetchVersions(); + } catch (error) { + const castedError = errorService.castError(error); + errorService.reportError(castedError); + notificationsService.show({ + text: translate('modals.versionHistory.deleteError'), + type: ToastType.Error, + }); + throw error; + } + }; + + const handleRestoreConfirm = async () => { + if (!versionToRestore || !item) return; + + try { + await fileVersionService.restoreVersion(item.uuid, versionToRestore.id.toString()); + notificationsService.show({ + text: translate('modals.versionHistory.restoreSuccess'), + type: ToastType.Success, + }); + await fetchVersions(); + } catch (error) { + const castedError = errorService.castError(error); + errorService.reportError(castedError); + notificationsService.show({ + text: translate('modals.versionHistory.restoreError'), + type: ToastType.Error, + }); + throw error; + } + }; + if (!item) return null; return ( @@ -46,27 +127,39 @@ const Sidebar = () => {
- {versions - .filter((v) => v.isCurrent) - .map((version) => ( - - ))} - - {}} - /> - - {versions - .filter((v) => !v.isCurrent) - .map((version) => ( - - ))} + {isLoading ? ( + + ) : ( + <> + + + {}} + /> + + {versions.map((version) => ( + + ))} + + )}
+ + ); }; diff --git a/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx b/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx index 95f611b36..28bd8cef1 100644 --- a/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx +++ b/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx @@ -1,27 +1,30 @@ import { Avatar } from '@internxt/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { FileVersion } from '../types'; import { formatVersionDate } from '../utils'; +import { DriveItemData } from 'app/drive/types'; interface CurrentVersionItemProps { - version: FileVersion; + version: DriveItemData; + userName: string; } -export const CurrentVersionItem = ({ version }: CurrentVersionItemProps) => { +export const CurrentVersionItem = ({ version, userName }: CurrentVersionItemProps) => { const { translate } = useTranslationContext(); return (
- {formatVersionDate(version.date)} + + {formatVersionDate(new Date(version.createdAt))} + {translate('modals.versionHistory.current')}
- - {version.userName} + + {userName}
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 000000000..c2f8f8d6a --- /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 000000000..3919e24ec --- /dev/null +++ b/src/views/Drive/components/VersionHistory/components/VersionHistorySkeleton.tsx @@ -0,0 +1,30 @@ +export const VersionHistorySkeleton = () => { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ + {[...Array(3)].map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ); +}; diff --git a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx index 6f7c86607..e993602a9 100644 --- a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx +++ b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx @@ -8,9 +8,10 @@ import { formatVersionDate } from '../utils'; interface VersionItemProps { version: FileVersion; + userName: string; } -export const VersionItem = ({ version }: VersionItemProps) => { +export const VersionItem = ({ version, userName }: VersionItemProps) => { const { translate } = useTranslationContext(); const [isSelected, setIsSelected] = useState(true); const { isOpen, setIsOpen, dropdownPosition, dropdownRef, itemRef } = useDropdownPositioning(); @@ -24,13 +25,14 @@ export const VersionItem = ({ version }: VersionItemProps) => { }; const dropdownOpenDirection = dropdownPosition === 'above' ? 'left' : 'right'; + const versionDate = new Date(version.createdAt); return (
diff --git a/src/views/Drive/components/VersionHistory/components/index.ts b/src/views/Drive/components/VersionHistory/components/index.ts index 58e0f09ed..9bc3ebe77 100644 --- a/src/views/Drive/components/VersionHistory/components/index.ts +++ b/src/views/Drive/components/VersionHistory/components/index.ts @@ -2,3 +2,5 @@ export { Header } from './Header'; export { CurrentVersionItem } from './CurrentVersionItem'; export { VersionItem } from './VersionItem'; export { AutosaveSection } from './AutosaveSection'; +export { VersionActionDialog } from './VersionActionDialog'; +export { VersionHistorySkeleton } from './VersionHistorySkeleton'; diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts b/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts index c4ce61ae1..984b0c53a 100644 --- a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts +++ b/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts @@ -2,6 +2,12 @@ import { Trash, ClockCounterClockwise, DownloadSimple } from '@phosphor-icons/re import { MenuItemType } from '@internxt/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { FileVersion } from '../types'; +import fileVersionService from '../services/file-version.service'; +import errorService from 'services/error.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'; interface UseVersionItemActionsParams { version: FileVersion; @@ -10,24 +16,50 @@ interface UseVersionItemActionsParams { export const useVersionItemActions = ({ version, onDropdownClose }: UseVersionItemActionsParams) => { const { translate } = useTranslationContext(); + const dispatch = useAppDispatch(); + const item = useAppSelector((state: RootState) => state.ui.versionHistoryItem); - const handleRestore = () => { + const handleRestoreClick = () => { onDropdownClose(); + dispatch(uiActions.setVersionToRestore(version)); + dispatch(uiActions.setIsRestoreVersionDialogOpen(true)); }; - const handleDownload = () => { + const handleDownload = async () => { onDropdownClose(); + + if (!item) { + notificationsService.show({ + text: translate('modals.versionHistory.downloadError'), + type: ToastType.Error, + }); + return; + } + + try { + const fileName = item.type ? `${item.plainName || item.name}.${item.type}` : item.plainName || item.name; + await fileVersionService.downloadVersion(version, fileName, item.bucket); + } catch (error) { + const castedError = errorService.castError(error); + errorService.reportError(castedError); + notificationsService.show({ + text: translate('modals.versionHistory.downloadError'), + type: ToastType.Error, + }); + } }; - const handleDelete = () => { + const handleDeleteClick = () => { onDropdownClose(); + dispatch(uiActions.setVersionToDelete(version)); + dispatch(uiActions.setIsDeleteVersionDialogOpen(true)); }; const menuItems: Array> = [ { name: translate('modals.versionHistory.restoreVersion'), icon: ClockCounterClockwise, - action: handleRestore, + action: handleRestoreClick, }, { name: translate('modals.versionHistory.downloadVersion'), @@ -40,7 +72,7 @@ export const useVersionItemActions = ({ version, onDropdownClose }: UseVersionIt { name: translate('modals.versionHistory.deleteVersion'), icon: Trash, - action: handleDelete, + action: handleDeleteClick, }, ]; diff --git a/src/views/Drive/components/VersionHistory/services/file-version.service.ts b/src/views/Drive/components/VersionHistory/services/file-version.service.ts new file mode 100644 index 000000000..b23b92f2a --- /dev/null +++ b/src/views/Drive/components/VersionHistory/services/file-version.service.ts @@ -0,0 +1,61 @@ +import { SdkFactory } from 'app/core/factory/sdk'; +import localStorageService from 'services/local-storage.service'; +import { downloadFile } from 'app/network/download'; +import { binaryStreamToBlob } from 'services/stream.service'; +import { saveAs } from 'file-saver'; +import { FileVersion } from '../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 downloadVersion( + version: FileVersion, + fileName: string, + bucketId: string, + updateProgressCallback?: (progress: number) => void, + abortController?: AbortController, +): Promise { + const user = localStorageService.getUser(); + if (!user) throw new Error('User not found'); + + const fileStream = await downloadFile({ + fileId: version.networkFileId, + bucketId, + creds: { + user: user.bridgeUser, + pass: user.userId, + }, + mnemonic: user.mnemonic, + options: { + notifyProgress: (totalBytes, downloadedBytes) => { + if (updateProgressCallback) { + updateProgressCallback(downloadedBytes / totalBytes); + } + }, + abortController, + }, + }); + + const blob = await binaryStreamToBlob(fileStream); + saveAs(blob, fileName); +} + +const fileVersionService = { + getFileVersions, + deleteVersion, + restoreVersion, + downloadVersion, +}; + +export default fileVersionService; diff --git a/src/views/Drive/components/VersionHistory/types.ts b/src/views/Drive/components/VersionHistory/types.ts index 9492b17ff..54a8f8702 100644 --- a/src/views/Drive/components/VersionHistory/types.ts +++ b/src/views/Drive/components/VersionHistory/types.ts @@ -1,7 +1,12 @@ export interface FileVersion { id: string; - date: Date; - userName: string; + fileId: string; + networkFileId: string; + size: bigint; + status: 'EXISTS' | 'DELETED'; + createdAt: Date; + updatedAt: Date; + userId?: number; expiresInDays?: number; isAutosave?: boolean; isCurrent?: boolean; diff --git a/src/views/Drive/services/replace-file.service.ts b/src/views/Drive/services/replace-file.service.ts new file mode 100644 index 000000000..b98b40ed6 --- /dev/null +++ b/src/views/Drive/services/replace-file.service.ts @@ -0,0 +1,18 @@ +import { SdkFactory } from 'app/core/factory/sdk'; +import { DriveFileData } from 'app/drive/types'; + +export interface ReplaceFilePayload { + fileId: string; + size: number; +} + +export async function replaceFile(fileUuid: string, payload: ReplaceFilePayload): Promise { + const storageClient = SdkFactory.getNewApiInstance().createNewStorageClient(); + return storageClient.replaceFile(fileUuid, payload); +} + +const replaceFileService = { + replaceFile, +}; + +export default replaceFileService; diff --git a/yarn.lock b/yarn.lock index 06da92d17..3c4f8297e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1906,10 +1906,10 @@ version "1.0.2" resolved "https://codeload.github.com/internxt/prettier-config/tar.gz/9fa74e9a2805e1538b50c3809324f1c9d0f3e4f9" -"@internxt/sdk@=1.11.17": - version "1.11.17" - resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.11.17.tgz#2f5bdada5d3cbf5cfc685a21c24b5df3ff51d8c8" - integrity sha512-91iEUvZizlwX6KBEFJ3JdFiGrhMBQ9R54sTc3Pei9QtV2FYTU8nTVEPYAg39tLOGzT/kVuplYOtBxfk6wFtSDA== +"@internxt/sdk@=1.11.18": + version "1.11.18" + resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.11.18/29d49dccb479892e0ce1affca01e4d73f70cb24c#29d49dccb479892e0ce1affca01e4d73f70cb24c" + integrity sha512-nZixage2HN/PVuoC412rHynFoSwFRG3Q1gV0TXIJwVcyW2eQg5pt7n9wwNuSkdQwdutieQuoJ2kwEa/PITR5Xw== dependencies: axios "1.13.2" uuid "11.1.0" From 4a2ab35e7785f6c7edc1664ebaaebdf887f26c7b Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 10 Dec 2025 00:59:31 -0400 Subject: [PATCH 02/27] feature: enhance internationalization for version management with additional messages and error handling --- src/app/i18n/locales/de.json | 15 ++++++++++++++- src/app/i18n/locales/es.json | 15 ++++++++++++++- src/app/i18n/locales/fr.json | 15 ++++++++++++++- src/app/i18n/locales/it.json | 15 ++++++++++++++- src/app/i18n/locales/ru.json | 15 ++++++++++++++- src/app/i18n/locales/tw.json | 15 ++++++++++++++- src/app/i18n/locales/zh.json | 15 ++++++++++++++- 7 files changed, 98 insertions(+), 7 deletions(-) diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 1bd6e5948..cb0f7c43d 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -795,7 +795,20 @@ "autosaveVersions": "{{count}}/{{total}} automatisch gespeicherte Versionen", "restoreVersion": "Version wiederherstellen", "downloadVersion": "Version herunterladen", - "deleteVersion": "Version löschen" + "deleteVersion": "Version löschen", + "deleteVersionTitle": "Version löschen", + "deleteButton": "Löschen", + "restoreButton": "Wiederherstellen", + "downloadError": "Fehler beim Herunterladen der Version", + "deleteVersionAdvice": "Diese Version wird dauerhaft gelöscht. \nDiese Aktion kann nicht rückgängig gemacht werden.", + "deletingVersion": "Lösche", + "restoreVersionTitle": "Version wiederherstellen", + "restoreVersionAdvice": "Das Wiederherstellen dieser Version ersetzt die aktuelle Datei und entfernt alle neueren Versionen. \nSobald wiederhergestellt, kann diese Aktion nicht rückgängig gemacht werden.\n\nSie können eine Kopie neuerer Versionen herunterladen, bevor Sie wiederherstellen.", + "restoringVersion": "Wiederherstellung", + "restoreSuccess": "Version erfolgreich wiederhergestellt", + "restoreError": "Fehler beim Wiederherstellen der Version", + "deleteSuccess": "Version erfolgreich gelöscht", + "deleteError": "Fehler beim Löschen der Version" }, "shareModal": { "title": "Aktie \"{{name}}\"", diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 81c54e751..18798f93e 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -874,7 +874,20 @@ "autosaveVersions": "{{count}}/{{total}} versiones de autoguardado", "restoreVersion": "Restaurar versión", "downloadVersion": "Descargar versión", - "deleteVersion": "Eliminar versión" + "deleteVersion": "Eliminar versión", + "deleteVersionTitle": "Eliminar versión", + "deleteButton": "Eliminar", + "restoreButton": "Restaurar", + "downloadError": "Error al descargar la versión", + "deleteVersionAdvice": "Esta versión será eliminada permanentemente. \nEsta acción no se puede deshacer.", + "deletingVersion": "Eliminando", + "restoreVersionTitle": "Restaurar versión", + "restoreVersionAdvice": "Restaurar esta versión reemplazará el archivo actual y eliminará todas las versiones más recientes. \nUna vez restaurada, esta acción no se puede deshacer.\n\nPuedes descargar una copia de las versiones más recientes antes de restaurar.", + "restoringVersion": "Restaurando", + "restoreSuccess": "Versión restaurada exitosamente", + "restoreError": "Error al restaurar la versión", + "deleteSuccess": "Versión eliminada exitosamente", + "deleteError": "Error al eliminar la versión" }, "shareModal": { "title": "Compartir \"{{name}}\"", diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index e6c643814..7fd403e32 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -816,7 +816,20 @@ "autosaveVersions": "{{count}}/{{total}} versions de sauvegarde automatique", "restoreVersion": "Restaurer la version", "downloadVersion": "Télécharger la version", - "deleteVersion": "Supprimer la version" + "deleteVersion": "Supprimer la version", + "deleteVersionTitle": "Supprimer la version", + "deleteButton": "Supprimer", + "restoreButton": "Restaurer", + "downloadError": "Échec du téléchargement de la version", + "deleteVersionAdvice": "Cette version sera définitivement supprimée. \nCette action ne peut pas être annulée.", + "deletingVersion": "Suppression", + "restoreVersionTitle": "Restaurer la version", + "restoreVersionAdvice": "La restauration de cette version remplacera le fichier actuel et supprimera toutes les versions plus récentes. \nUne fois restaurée, cette action ne peut pas être annulée.\n\nVous pouvez télécharger une copie des versions plus récentes avant de restaurer.", + "restoringVersion": "Restauration", + "restoreSuccess": "Version restaurée avec succès", + "restoreError": "Échec de la restauration de la version", + "deleteSuccess": "Version supprimée avec succès", + "deleteError": "Échec de la suppression de la version" }, "newFolderModal": { "title": "Nouveau dossier", diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index f5c8e3074..916622c36 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -928,7 +928,20 @@ "autosaveVersions": "{{count}}/{{total}} versioni di salvataggio automatico", "restoreVersion": "Ripristina versione", "downloadVersion": "Scarica versione", - "deleteVersion": "Elimina versione" + "deleteVersion": "Elimina versione", + "deleteVersionTitle": "Elimina versione", + "deleteButton": "Elimina", + "restoreButton": "Ripristina", + "downloadError": "Impossibile scaricare la versione", + "deleteVersionAdvice": "Questa versione sarà eliminata in modo permanente. \nQuesta azione non può essere annullata.", + "deletingVersion": "Eliminazione", + "restoreVersionTitle": "Ripristina versione", + "restoreVersionAdvice": "Il ripristino di questa versione sostituirà il file corrente e rimuoverà tutte le versioni più recenti. \nUna volta ripristinata, questa azione non può essere annullata.\n\nPuoi scaricare una copia delle versioni più recenti prima del ripristino.", + "restoringVersion": "Ripristino", + "restoreSuccess": "Versione ripristinata con successo", + "restoreError": "Impossibile ripristinare la versione", + "deleteSuccess": "Versione eliminata con successo", + "deleteError": "Impossibile eliminare la versione" }, "shareModal": { "title": "Condividi \"{{name}}\"", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 59d75dc36..e3d644be9 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -835,7 +835,20 @@ "autosaveVersions": "{{count}}/{{total}} версий автосохранения", "restoreVersion": "Восстановить версию", "downloadVersion": "Скачать версию", - "deleteVersion": "Удалить версию" + "deleteVersion": "Удалить версию", + "deleteVersionTitle": "Удалить версию", + "deleteButton": "Удалить", + "restoreButton": "Восстановить", + "downloadError": "Не удалось загрузить версию", + "deleteVersionAdvice": "Эта версия будет удалена навсегда. \nЭто действие нельзя отменить.", + "deletingVersion": "Удаление", + "restoreVersionTitle": "Восстановить версию", + "restoreVersionAdvice": "Восстановление этой версии заменит текущий файл и удалит все более новые версии. \nПосле восстановления это действие нельзя отменить.\n\nВы можете скачать копию более новых версий перед восстановлением.", + "restoringVersion": "Восстановление", + "restoreSuccess": "Версия успешно восстановлена", + "restoreError": "Не удалось восстановить версию", + "deleteSuccess": "Версия успешно удалена", + "deleteError": "Не удалось удалить версию" }, "shareModal": { "title": "Поделиться \"{{name}}\"", diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index bdf567a55..216d1e305 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -822,7 +822,20 @@ "autosaveVersions": "{{count}}/{{total}} 自動儲存版本", "restoreVersion": "復原版本", "downloadVersion": "下載版本", - "deleteVersion": "刪除版本" + "deleteVersion": "刪除版本", + "deleteVersionTitle": "刪除版本", + "deleteButton": "刪除", + "restoreButton": "復原", + "downloadError": "下載版本失敗", + "deleteVersionAdvice": "此版本將被永久刪除。\n此操作無法復原。", + "deletingVersion": "刪除中", + "restoreVersionTitle": "復原版本", + "restoreVersionAdvice": "復原此版本將取代目前檔案並刪除所有較新的版本。\n一旦復原,此操作無法撤銷。\n\n您可以在復原之前下載較新版本的副本。", + "restoringVersion": "復原中", + "restoreSuccess": "版本復原成功", + "restoreError": "復原版本失敗", + "deleteSuccess": "版本刪除成功", + "deleteError": "刪除版本失敗" }, "shareModal": { "title": "分享“{{name}}”", diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index b341cb2e5..9c448049b 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -858,7 +858,20 @@ "autosaveVersions": "{{count}}/{{total}} 自动保存版本", "restoreVersion": "恢复版本", "downloadVersion": "下载版本", - "deleteVersion": "删除版本" + "deleteVersion": "删除版本", + "deleteVersionTitle": "删除版本", + "deleteButton": "删除", + "restoreButton": "恢复", + "downloadError": "下载版本失败", + "deleteVersionAdvice": "此版本将被永久删除。\n此操作无法撤销。", + "deletingVersion": "删除中", + "restoreVersionTitle": "恢复版本", + "restoreVersionAdvice": "恢复此版本将替换当前文件并删除所有较新的版本。\n一旦恢复,此操作无法撤销。\n\n您可以在恢复之前下载较新版本的副本。", + "restoringVersion": "恢复中", + "restoreSuccess": "版本恢复成功", + "restoreError": "恢复版本失败", + "deleteSuccess": "版本删除成功", + "deleteError": "删除版本失败" }, "shareModal": { "title": "分享 \"{{name}}\"", From f59d2198209174db3f18d56489f29db1be1a3dc7 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 10 Dec 2025 01:30:06 -0400 Subject: [PATCH 03/27] refactor: update skeleton component to use Array.from for better readability --- .../VersionHistory/components/VersionHistorySkeleton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/Drive/components/VersionHistory/components/VersionHistorySkeleton.tsx b/src/views/Drive/components/VersionHistory/components/VersionHistorySkeleton.tsx index 3919e24ec..d0c1bef1b 100644 --- a/src/views/Drive/components/VersionHistory/components/VersionHistorySkeleton.tsx +++ b/src/views/Drive/components/VersionHistory/components/VersionHistorySkeleton.tsx @@ -14,8 +14,8 @@ export const VersionHistorySkeleton = () => {
- {[...Array(3)].map((_, index) => ( -
+ {Array.from({ length: 3 }, (_, index) => ( +
From e348210001a1205722cd849471d79a99e51653b4 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 10 Dec 2025 10:57:12 -0400 Subject: [PATCH 04/27] feature: improve file version downloads with multipart support and better UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace direct download with DownloadManager for efficient multipart downloads - Add workspace credentials support for version downloads - Position TaskLogger at bottom-right - Refactor: rename service files to camelCase convention (file-version.service.ts → fileVersion.service.ts, replace-file.service.ts → replaceFile.service.ts) - Pass file item and workspace context to downloadVersion for proper credential handling --- .../HeaderAndSidenavLayout.tsx | 4 +- .../NameCollisionContainer.tsx | 2 +- .../components/VersionHistory/Sidebar.tsx | 2 +- .../hooks/useVersionItemActions.ts | 7 ++- ...sion.service.ts => fileVersion.service.ts} | 44 +++++++------------ ...file.service.ts => replaceFile.service.ts} | 0 6 files changed, 26 insertions(+), 33 deletions(-) rename src/views/Drive/components/VersionHistory/services/{file-version.service.ts => fileVersion.service.ts} (51%) rename src/views/Drive/services/{replace-file.service.ts => replaceFile.service.ts} (100%) diff --git a/src/app/core/layouts/HeaderAndSidenavLayout/HeaderAndSidenavLayout.tsx b/src/app/core/layouts/HeaderAndSidenavLayout/HeaderAndSidenavLayout.tsx index 79bdf94b4..63c4b05ab 100644 --- a/src/app/core/layouts/HeaderAndSidenavLayout/HeaderAndSidenavLayout.tsx +++ b/src/app/core/layouts/HeaderAndSidenavLayout/HeaderAndSidenavLayout.tsx @@ -58,7 +58,9 @@ export default function HeaderAndSidenavLayout(props: HeaderAndSidenavLayoutProp
- +
+ +
diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index 75d25c387..c7ad12b77 100644 --- a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx +++ b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx @@ -12,7 +12,7 @@ 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/replace-file.service'; +import replaceFileService from 'views/Drive/services/replaceFile.service'; type NameCollisionContainerProps = { currentFolderId: string; diff --git a/src/views/Drive/components/VersionHistory/Sidebar.tsx b/src/views/Drive/components/VersionHistory/Sidebar.tsx index b6fc72304..a07376ef8 100644 --- a/src/views/Drive/components/VersionHistory/Sidebar.tsx +++ b/src/views/Drive/components/VersionHistory/Sidebar.tsx @@ -12,7 +12,7 @@ import { VersionHistorySkeleton, } from './components'; import { FileVersion } from './types'; -import fileVersionService from 'views/Drive/components/VersionHistory/services/file-version.service'; +import fileVersionService from 'views/Drive/components/VersionHistory/services/fileVersion.service'; import errorService from 'services/error.service'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts b/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts index 984b0c53a..f4e9bdd14 100644 --- a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts +++ b/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts @@ -2,12 +2,13 @@ import { Trash, ClockCounterClockwise, DownloadSimple } from '@phosphor-icons/re import { MenuItemType } from '@internxt/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { FileVersion } from '../types'; -import fileVersionService from '../services/file-version.service'; +import fileVersionService from '../services/fileVersion.service'; import errorService from 'services/error.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'; interface UseVersionItemActionsParams { version: FileVersion; @@ -18,6 +19,8 @@ export const useVersionItemActions = ({ version, onDropdownClose }: UseVersionIt 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(); @@ -38,7 +41,7 @@ export const useVersionItemActions = ({ version, onDropdownClose }: UseVersionIt try { const fileName = item.type ? `${item.plainName || item.name}.${item.type}` : item.plainName || item.name; - await fileVersionService.downloadVersion(version, fileName, item.bucket); + await fileVersionService.downloadVersion(version, item, fileName, selectedWorkspace, workspaceCredentials); } catch (error) { const castedError = errorService.castError(error); errorService.reportError(castedError); diff --git a/src/views/Drive/components/VersionHistory/services/file-version.service.ts b/src/views/Drive/components/VersionHistory/services/fileVersion.service.ts similarity index 51% rename from src/views/Drive/components/VersionHistory/services/file-version.service.ts rename to src/views/Drive/components/VersionHistory/services/fileVersion.service.ts index b23b92f2a..1144966b6 100644 --- a/src/views/Drive/components/VersionHistory/services/file-version.service.ts +++ b/src/views/Drive/components/VersionHistory/services/fileVersion.service.ts @@ -1,9 +1,8 @@ import { SdkFactory } from 'app/core/factory/sdk'; -import localStorageService from 'services/local-storage.service'; -import { downloadFile } from 'app/network/download'; -import { binaryStreamToBlob } from 'services/stream.service'; -import { saveAs } from 'file-saver'; +import { DownloadManager } from 'app/network/DownloadManager'; import { FileVersion } from '../types'; +import { DriveItemData } from 'app/drive/types'; +import { WorkspaceCredentialsDetails, WorkspaceData } from '@internxt/sdk/dist/workspaces'; const getStorageClient = () => SdkFactory.getNewApiInstance().createNewStorageClient(); @@ -21,34 +20,23 @@ export async function restoreVersion(fileUuid: string, versionId: string): Promi export async function downloadVersion( version: FileVersion, + fileItem: DriveItemData, fileName: string, - bucketId: string, - updateProgressCallback?: (progress: number) => void, - abortController?: AbortController, + selectedWorkspace: WorkspaceData | null, + workspaceCredentials: WorkspaceCredentialsDetails | null, ): Promise { - const user = localStorageService.getUser(); - if (!user) throw new Error('User not found'); - - const fileStream = await downloadFile({ + const versionFileData: DriveItemData = { + ...fileItem, fileId: version.networkFileId, - bucketId, - creds: { - user: user.bridgeUser, - pass: user.userId, - }, - mnemonic: user.mnemonic, - options: { - notifyProgress: (totalBytes, downloadedBytes) => { - if (updateProgressCallback) { - updateProgressCallback(downloadedBytes / totalBytes); - } - }, - abortController, - }, + size: Number(version.size), + name: fileName, + }; + + await DownloadManager.downloadItem({ + payload: [versionFileData], + selectedWorkspace, + workspaceCredentials, }); - - const blob = await binaryStreamToBlob(fileStream); - saveAs(blob, fileName); } const fileVersionService = { diff --git a/src/views/Drive/services/replace-file.service.ts b/src/views/Drive/services/replaceFile.service.ts similarity index 100% rename from src/views/Drive/services/replace-file.service.ts rename to src/views/Drive/services/replaceFile.service.ts From 71950054b9d725078419acbc55b8c896a68c3785 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 10 Dec 2025 11:10:05 -0400 Subject: [PATCH 05/27] fix: update @internxt/sdk version to 1.11.19 in package.json and yarn.lock --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a57a6f3ea..daeacaf6b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@iconscout/react-unicons": "^1.1.6", "@internxt/css-config": "1.1.0", "@internxt/lib": "1.4.1", - "@internxt/sdk": "=1.11.18", + "@internxt/sdk": "1.11.19", "@internxt/ui": "0.1.1", "@phosphor-icons/react": "^2.1.7", "@popperjs/core": "^2.11.6", diff --git a/yarn.lock b/yarn.lock index 3c4f8297e..47ba84d46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1906,10 +1906,10 @@ version "1.0.2" resolved "https://codeload.github.com/internxt/prettier-config/tar.gz/9fa74e9a2805e1538b50c3809324f1c9d0f3e4f9" -"@internxt/sdk@=1.11.18": - version "1.11.18" - resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.11.18/29d49dccb479892e0ce1affca01e4d73f70cb24c#29d49dccb479892e0ce1affca01e4d73f70cb24c" - integrity sha512-nZixage2HN/PVuoC412rHynFoSwFRG3Q1gV0TXIJwVcyW2eQg5pt7n9wwNuSkdQwdutieQuoJ2kwEa/PITR5Xw== +"@internxt/sdk@1.11.19": + version "1.11.19" + resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.11.19/f9033c8bf0849d414a3865806bacd79026a38769#f9033c8bf0849d414a3865806bacd79026a38769" + integrity sha512-cX4uf8QkU6InJj8wnclsmcsMW5TtUIlFu/Z2xnV3/JUojftIBOuIyLlDzl7uN2+ayB6aN50GLiTHRpK86MvcOg== dependencies: axios "1.13.2" uuid "11.1.0" From e2b34bb1c0d16d9368946a08e9ce2d631b7b2f24 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 10 Dec 2025 11:21:37 -0400 Subject: [PATCH 06/27] refactor: remove redundant error handling in version download Remove try-catch block and error handling from useVersionItemActions since DownloadManager already handles error reporting, notifications, and task status updates internally. This prevents duplicate error notifications from being shown to the user. --- .../VersionHistory/hooks/useVersionItemActions.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts b/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts index f4e9bdd14..de3f34e42 100644 --- a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts +++ b/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts @@ -3,7 +3,6 @@ import { MenuItemType } from '@internxt/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { FileVersion } from '../types'; import fileVersionService from '../services/fileVersion.service'; -import errorService from 'services/error.service'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; import { useAppDispatch, useAppSelector } from 'app/store/hooks'; import { uiActions } from 'app/store/slices/ui'; @@ -39,17 +38,8 @@ export const useVersionItemActions = ({ version, onDropdownClose }: UseVersionIt return; } - try { - const fileName = item.type ? `${item.plainName || item.name}.${item.type}` : item.plainName || item.name; - await fileVersionService.downloadVersion(version, item, fileName, selectedWorkspace, workspaceCredentials); - } catch (error) { - const castedError = errorService.castError(error); - errorService.reportError(castedError); - notificationsService.show({ - text: translate('modals.versionHistory.downloadError'), - type: ToastType.Error, - }); - } + const fileName = item.plainName || item.name; + await fileVersionService.downloadVersion(version, item, fileName, selectedWorkspace, workspaceCredentials); }; const handleDeleteClick = () => { From f3a74ba33752ac30db6f1e3dc008569bf8c6b801 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 12 Dec 2025 01:53:36 -0400 Subject: [PATCH 07/27] feature: enhance version history with batch operations and SDK integration - Update @internxt/sdk to 1.11.21 for improved version history support - Add batch delete functionality for multiple version selections - Implement version limits display showing used/total allowed versions - Fix file replace operation to properly upload new file content - Migrate from local FileVersion type to SDK types for better consistency - Add indeterminate checkbox state for partial selections - Improve version restoration to update current version state - Enhance skeleton loading to show more realistic version count - Add getLimits API call to fetch version constraints - Refactor CurrentVersionItem to use version info instead of full item data --- package.json | 2 +- .../NameCollisionContainer.tsx | 20 ++- .../components/VersionHistory/Sidebar.tsx | 160 ++++++++++++++---- .../components/AutosaveSection.tsx | 25 ++- .../components/CurrentVersionItem.tsx | 9 +- .../components/VersionHistorySkeleton.tsx | 2 +- .../VersionHistory/components/VersionItem.tsx | 26 +-- .../hooks/useVersionItemActions.ts | 2 +- .../services/fileVersion.service.ts | 7 +- .../Drive/components/VersionHistory/types.ts | 13 -- .../components/VersionHistory/utils/index.ts | 2 +- yarn.lock | 8 +- 12 files changed, 193 insertions(+), 83 deletions(-) delete mode 100644 src/views/Drive/components/VersionHistory/types.ts diff --git a/package.json b/package.json index daeacaf6b..d8add7718 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@iconscout/react-unicons": "^1.1.6", "@internxt/css-config": "1.1.0", "@internxt/lib": "1.4.1", - "@internxt/sdk": "1.11.19", + "@internxt/sdk": "=1.11.21", "@internxt/ui": "0.1.1", "@phosphor-icons/react": "^2.1.7", "@popperjs/core": "^2.11.6", diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index c7ad12b77..8a2a0be11 100644 --- a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx +++ b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx @@ -13,6 +13,7 @@ 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'; type NameCollisionContainerProps = { currentFolderId: string; @@ -150,10 +151,27 @@ const NameCollisionContainer: FC = ({ }); } else { const file = itemToUpload as File; + 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 }, + ); + + const newFileId = await uploadPromise; await replaceFileService.replaceFile(itemToReplace.uuid, { - fileId: itemToReplace.fileId, + fileId: newFileId, size: file.size, }); + dispatch(fetchSortedFolderContentThunk(folderId)); } } diff --git a/src/views/Drive/components/VersionHistory/Sidebar.tsx b/src/views/Drive/components/VersionHistory/Sidebar.tsx index a07376ef8..7012d7227 100644 --- a/src/views/Drive/components/VersionHistory/Sidebar.tsx +++ b/src/views/Drive/components/VersionHistory/Sidebar.tsx @@ -1,8 +1,10 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { RootState } from 'app/store'; import { useAppDispatch, useAppSelector } from 'app/store/hooks'; import { uiActions } from 'app/store/slices/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import storageSelectors from 'app/store/slices/storage/storage.selectors'; +import { fetchSortedFolderContentThunk } from 'app/store/slices/storage/storage.thunks/fetchSortedFolderContentThunk'; import { Header, CurrentVersionItem, @@ -11,10 +13,12 @@ import { VersionActionDialog, VersionHistorySkeleton, } from './components'; -import { FileVersion } from './types'; import fileVersionService from 'views/Drive/components/VersionHistory/services/fileVersion.service'; import errorService from 'services/error.service'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { FileVersion, GetFileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; + +type VersionInfo = { id: string; createdAt: string }; const Sidebar = () => { const dispatch = useAppDispatch(); @@ -25,44 +29,81 @@ const Sidebar = () => { const versionToDelete = useAppSelector((state: RootState) => state.ui.versionToDelete); const isRestoreVersionDialogOpen = useAppSelector((state: RootState) => state.ui.isRestoreVersionDialogOpen); const versionToRestore = useAppSelector((state: RootState) => state.ui.versionToRestore); + const currentFolderId = useAppSelector(storageSelectors.currentFolderId); const { translate } = useTranslationContext(); const [versions, setVersions] = useState([]); + const [limits, setLimits] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [selectedAutosaveVersions, setSelectedAutosaveVersions] = useState>(new Set()); + const [isBatchDeleteMode, setIsBatchDeleteMode] = useState(false); + const [currentVersion, setCurrentVersion] = useState(null); - const [selectAllAutosave, setSelectAllAutosave] = useState(false); - const autosaveVersions = versions.filter((v) => v.isAutosave); - const totalAutosaveCount = autosaveVersions.length; + const totalVersionsCount = versions.length; + const selectedCount = selectedAutosaveVersions.size; + const selectAllAutosave = selectedCount === totalVersionsCount && totalVersionsCount > 0; - const userName = user?.name && user?.lastname ? `${user.name} ${user.lastname}` : user?.email || 'Unknown User'; + const userName = useMemo( + () => (user?.name && user?.lastname ? `${user.name} ${user.lastname}` : user?.email || 'Unknown User'), + [user], + ); - const fetchVersions = async () => { + const fetchData = useCallback(async () => { if (!item || !isOpen) return; setIsLoading(true); try { - const fileVersions = await fileVersionService.getFileVersions(item.uuid); - // TODO: Determine if autosave + const [fileVersions, limits] = await Promise.all([ + fileVersionService.getFileVersions(item.uuid), + fileVersionService.getLimits(), + ]); setVersions(fileVersions); + setLimits(limits); } catch (error) { const castedError = errorService.castError(error); errorService.reportError(castedError); } finally { setIsLoading(false); } - }; + }, [item, isOpen]); useEffect(() => { - fetchVersions(); - }, [item, isOpen]); + if (item) { + setCurrentVersion({ id: item.fileId, createdAt: item.createdAt }); + } + void fetchData(); + }, [item, isOpen, fetchData]); - const onClose = () => { + const handleError = useCallback( + (error: unknown, messageKey: string) => { + const castedError = errorService.castError(error); + errorService.reportError(castedError); + notificationsService.show({ + text: translate(messageKey), + type: ToastType.Error, + }); + }, + [translate], + ); + + const onClose = useCallback(() => { dispatch(uiActions.setIsVersionHistorySidebarOpen(false)); + setSelectedAutosaveVersions(new Set()); + setIsBatchDeleteMode(false); + }, [dispatch]); + + const removeVersionsFromSelection = (versionIds: string[]) => { + setSelectedAutosaveVersions((prev) => { + const updated = new Set(prev); + versionIds.forEach((id) => updated.delete(id)); + return updated; + }); }; const handleCloseDeleteDialog = () => { dispatch(uiActions.setIsDeleteVersionDialogOpen(false)); dispatch(uiActions.setVersionToDelete(null)); + setIsBatchDeleteMode(false); }; const handleCloseRestoreDialog = () => { @@ -71,23 +112,31 @@ const Sidebar = () => { }; const handleDeleteConfirm = async () => { - if (!versionToDelete || !item) return; + 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 fileVersionService.deleteVersion(item.uuid, versionToDelete.id.toString()); + await Promise.all(versionIdsToDelete.map((versionId) => fileVersionService.deleteVersion(item.uuid, versionId))); + notificationsService.show({ text: translate('modals.versionHistory.deleteSuccess'), type: ToastType.Success, }); - await fetchVersions(); + await fetchData(); + removeVersionsFromSelection(versionIdsToDelete); } catch (error) { - const castedError = errorService.castError(error); - errorService.reportError(castedError); - notificationsService.show({ - text: translate('modals.versionHistory.deleteError'), - type: ToastType.Error, - }); - throw error; + handleError(error, 'modals.versionHistory.deleteError'); + } finally { + setIsBatchDeleteMode(false); } }; @@ -95,23 +144,48 @@ const Sidebar = () => { if (!versionToRestore || !item) return; try { - await fileVersionService.restoreVersion(item.uuid, versionToRestore.id.toString()); + const restoredVersion = await fileVersionService.restoreVersion(item.uuid, versionToRestore.id); + + setCurrentVersion({ + id: restoredVersion.id, + createdAt: restoredVersion.createdAt, + }); + notificationsService.show({ text: translate('modals.versionHistory.restoreSuccess'), type: ToastType.Success, }); - await fetchVersions(); + if (currentFolderId) { + await dispatch(fetchSortedFolderContentThunk(currentFolderId)); + } + await fetchData(); + removeVersionsFromSelection([versionToRestore.id]); } catch (error) { - const castedError = errorService.castError(error); - errorService.reportError(castedError); - notificationsService.show({ - text: translate('modals.versionHistory.restoreError'), - type: ToastType.Error, - }); - throw error; + handleError(error, 'modals.versionHistory.restoreError'); } }; + const handleSelectAllAutosave = useCallback( + (checked: boolean) => { + setSelectedAutosaveVersions(checked ? new Set(versions.map((v) => v.id)) : new Set()); + }, + [versions], + ); + + const handleVersionSelectionChange = useCallback((versionId: string, selected: boolean) => { + setSelectedAutosaveVersions((prev) => { + const newSelection = new Set(prev); + selected ? newSelection.add(versionId) : newSelection.delete(versionId); + return newSelection; + }); + }, []); + + const handleDeleteSelectedVersions = useCallback(() => { + if (!item || selectedCount === 0) return; + setIsBatchDeleteMode(true); + dispatch(uiActions.setIsDeleteVersionDialogOpen(true)); + }, [item, selectedCount, dispatch]); + if (!item) return null; return ( @@ -131,17 +205,29 @@ const Sidebar = () => { ) : ( <> - + {}} + onSelectAllChange={handleSelectAllAutosave} + onDeleteAll={handleDeleteSelectedVersions} /> {versions.map((version) => ( - + handleVersionSelectionChange(version.id, selected)} + /> ))} )} diff --git a/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx index b072ba2f0..8bea3504e 100644 --- a/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx +++ b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx @@ -3,38 +3,49 @@ import { Checkbox } from '@internxt/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; interface AutosaveSectionProps { - totalAutosaveCount: number; + totalVersionsCount: number; + totalAllowedVersions: number; + selectedCount: number; selectAllAutosave: boolean; onSelectAllChange: (checked: boolean) => void; onDeleteAll: () => void; } export const AutosaveSection = ({ - totalAutosaveCount, + totalVersionsCount, + totalAllowedVersions, + selectedCount, selectAllAutosave, onSelectAllChange, onDeleteAll, }: AutosaveSectionProps) => { const { translate } = useTranslationContext(); - - if (totalAutosaveCount === 0) return null; + const hasSelection = selectedCount > 0; + const isIndeterminate = hasSelection && !selectAllAutosave; return (
onSelectAllChange(!selectAllAutosave)} className="h-4 w-4" /> {translate('modals.versionHistory.autosaveVersions', { - count: totalAutosaveCount, - total: totalAutosaveCount, + count: totalVersionsCount, + total: totalAllowedVersions, })}
-
diff --git a/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx b/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx index 28bd8cef1..c7e91ca7d 100644 --- a/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx +++ b/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx @@ -1,23 +1,20 @@ import { Avatar } from '@internxt/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { formatVersionDate } from '../utils'; -import { DriveItemData } from 'app/drive/types'; interface CurrentVersionItemProps { - version: DriveItemData; + createdAt: string; userName: string; } -export const CurrentVersionItem = ({ version, userName }: CurrentVersionItemProps) => { +export const CurrentVersionItem = ({ createdAt, userName }: CurrentVersionItemProps) => { const { translate } = useTranslationContext(); return (
- - {formatVersionDate(new Date(version.createdAt))} - + {formatVersionDate(createdAt)} {translate('modals.versionHistory.current')} diff --git a/src/views/Drive/components/VersionHistory/components/VersionHistorySkeleton.tsx b/src/views/Drive/components/VersionHistory/components/VersionHistorySkeleton.tsx index d0c1bef1b..a7a445a72 100644 --- a/src/views/Drive/components/VersionHistory/components/VersionHistorySkeleton.tsx +++ b/src/views/Drive/components/VersionHistory/components/VersionHistorySkeleton.tsx @@ -14,7 +14,7 @@ export const VersionHistorySkeleton = () => {
- {Array.from({ length: 3 }, (_, index) => ( + {Array.from({ length: 6 }, (_, index) => (
diff --git a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx index e993602a9..3ef4f741f 100644 --- a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx +++ b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx @@ -1,38 +1,41 @@ -import { useState } from 'react'; import { Info, DotsThree } from '@phosphor-icons/react'; import { Checkbox, Dropdown, Avatar } from '@internxt/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { FileVersion } from '../types'; import { useDropdownPositioning, useVersionItemActions } from '../hooks'; import { formatVersionDate } from '../utils'; +import { FileVersion } from '@internxt/sdk/dist/drive/storage/types'; interface VersionItemProps { version: FileVersion; userName: string; + isSelected: boolean; + onSelectionChange: (selected: boolean) => void; } -export const VersionItem = ({ version, userName }: VersionItemProps) => { +export const VersionItem = ({ version, userName, isSelected, onSelectionChange }: VersionItemProps) => { const { translate } = useTranslationContext(); - const [isSelected, setIsSelected] = useState(true); const { isOpen, setIsOpen, dropdownPosition, dropdownRef, itemRef } = useDropdownPositioning(); const { menuItems } = useVersionItemActions({ version, onDropdownClose: () => setIsOpen(false), }); + const handleToggleSelection = () => { + onSelectionChange(!isSelected); + }; + const handleItemClick = () => { - setIsSelected(!isSelected); + handleToggleSelection(); }; const dropdownOpenDirection = dropdownPosition === 'above' ? 'left' : 'right'; - const versionDate = new Date(version.createdAt); return ( ); -}; +}); diff --git a/src/views/Drive/components/VersionHistory/context/VersioningLimitsContext.tsx b/src/views/Drive/components/VersionHistory/context/VersioningLimitsContext.tsx deleted file mode 100644 index f63da9361..000000000 --- a/src/views/Drive/components/VersionHistory/context/VersioningLimitsContext.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; -import { GetFileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; -import fileVersionService from '../services/fileVersion.service'; -import errorService from 'services/error.service'; - -interface VersioningLimitsContextValue { - limits: GetFileLimitsResponse | null; - isLoading: boolean; - refetch: () => Promise; -} - -export const VersioningLimitsContext = createContext(undefined); - -export const VersioningLimitsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [limits, setLimits] = useState(null); - const [isLoading, setIsLoading] = useState(false); - - const fetchLimits = useCallback(async () => { - setIsLoading(true); - try { - const response = await fileVersionService.getLimits(); - setLimits(response); - } catch (error) { - const castedError = errorService.castError(error); - errorService.reportError(castedError); - } finally { - setIsLoading(false); - } - }, []); - - useEffect(() => { - void fetchLimits(); - }, [fetchLimits]); - - return ( - - {children} - - ); -}; - -export const useVersioningLimits = (): VersioningLimitsContextValue => { - const context = useContext(VersioningLimitsContext); - if (!context) { - return { - limits: null, - isLoading: false, - refetch: async () => {}, - }; - } - return context; -}; diff --git a/src/views/Drive/components/VersionHistory/hooks/index.ts b/src/views/Drive/components/VersionHistory/hooks/index.ts index 3b8db4442..5fb50562c 100644 --- a/src/views/Drive/components/VersionHistory/hooks/index.ts +++ b/src/views/Drive/components/VersionHistory/hooks/index.ts @@ -1,4 +1,3 @@ export { useDropdownPositioning } from './useDropdownPositioning'; export { useVersionItemActions } from './useVersionItemActions'; -export { useVersioningLimits } from '../context/VersioningLimitsContext'; export { useVersionHistoryMenuConfig } from './useVersionHistoryMenuConfig'; diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts b/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts index baf5f4100..948225d68 100644 --- a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts +++ b/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts @@ -1,19 +1,18 @@ import { useSelector } from 'react-redux'; -import { useContext } from 'react'; import { useAppDispatch } from 'app/store/hooks'; import { uiActions } from 'app/store/slices/ui'; import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; import navigationService from 'services/navigation.service'; import { DriveItemData } from 'app/drive/types'; -import { VersioningLimitsContext } from '../context/VersioningLimitsContext'; import { isVersioningExtensionAllowed } from '../utils'; import { VersionHistoryMenuConfig } from '../../DriveExplorer/components/DriveItemContextMenu'; +import { RootState } from 'app/store'; export const useVersionHistoryMenuConfig = (selectedItem?: DriveItemData): VersionHistoryMenuConfig => { const dispatch = useAppDispatch(); const selectedWorkspace = useSelector(workspacesSelectors.getSelectedWorkspace); - const context = useContext(VersioningLimitsContext); - const isVersioningEnabled = context?.limits?.versioning?.enabled ?? false; + const limits = useSelector((state: RootState) => state.fileVersions.limits); + const isVersioningEnabled = limits?.versioning?.enabled ?? false; const allowedExtension = selectedItem ? isVersioningExtensionAllowed(selectedItem) : true; return { From 79b26db1faae47891124363672f8b375491766b1 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 16 Dec 2025 19:13:23 -0400 Subject: [PATCH 13/27] feature: enhance version history menu item to handle availability based on item state --- .../DriveExplorer/components/DriveItemContextMenu.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx b/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx index 27b13d864..7c7550164 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx @@ -109,11 +109,18 @@ const getVersionHistoryMenuItem = ( const action = isLocked ? (config?.onLockedClick ?? (() => undefined)) : viewVersionHistory; const IconComponent = isLocked ? LockSimple : ClockCounterClockwise; + const isVersionHistoryUnavailable = (item: DriveItemData) => { + const isFolder = item.isFolder; + const isUnsupportedExtension = !allowedExtension; + const isDisabledForUnlockedItem = !isLocked && (isFolder || isUnsupportedExtension); + return isDisabledForUnlockedItem; + }; + return { name: t('drive.dropdown.versionHistory'), icon: IconComponent, action, - disabled: (item) => !isLocked && (item.isFolder || !allowedExtension), + disabled: isVersionHistoryUnavailable, ...(isLocked && { locked: true, node: ( From e3bffd79a3d8f7f7756cdaf80c2e8fbd455e01ee Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 17 Dec 2025 12:09:12 -0400 Subject: [PATCH 14/27] refactor: improve file versions state management and naming conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create fileVersions selectors for centralized state access - Rename state properties for clarity: - loadingStates → isLoadingByFileId - errors → errorsByFileId - limitsLoading → isLimitsLoading - Update type imports to use FileLimitsResponse (SDK v1.11.24) - Use NonNullable for Record keys to prevent null index types - Refactor version history menu config: - Rename properties: locked → isLocked, allowedExtension → isExtensionAllowed, onLockedClick → onUpgradeClick - Simplify getVersionHistoryMenuItem with early return pattern - Replace direct state access with selectors across components - Export getDaysUntilExpiration utility function - Add type assertion for restored version fileId --- package.json | 2 +- .../NameCollisionContainer.tsx | 4 +- .../fileVersions/fileVersions.selectors.ts | 22 +++++++++ src/app/store/slices/fileVersions/index.ts | 43 +++++++++-------- src/services/date.service.ts | 4 +- .../components/DriveItemContextMenu.tsx | 48 +++++++++---------- .../components/VersionHistory/Sidebar.tsx | 17 +++++-- .../hooks/useVersionHistoryMenuConfig.ts | 12 ++--- yarn.lock | 8 ++-- 9 files changed, 94 insertions(+), 66 deletions(-) create mode 100644 src/app/store/slices/fileVersions/fileVersions.selectors.ts diff --git a/package.json b/package.json index 73f4c4026..947c46072 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@iconscout/react-unicons": "^1.1.6", "@internxt/css-config": "1.1.0", "@internxt/lib": "1.4.1", - "@internxt/sdk": "=1.11.22", + "@internxt/sdk": "=1.11.24", "@internxt/ui": "0.1.1", "@phosphor-icons/react": "^2.1.7", "@popperjs/core": "^2.11.6", diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index eb33f8be7..36bab0719 100644 --- a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx +++ b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx @@ -14,7 +14,7 @@ import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selector 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 } from 'app/store/slices/fileVersions'; +import { fileVersionsActions, fileVersionsSelectors } from 'app/store/slices/fileVersions'; type NameCollisionContainerProps = { currentFolderId: string; @@ -46,7 +46,7 @@ const NameCollisionContainer: FC = ({ () => moveDestinationFolderId ?? currentFolderId, [moveDestinationFolderId, currentFolderId], ); - const limits = useAppSelector((state: RootState) => state.fileVersions.limits); + const limits = useAppSelector(fileVersionsSelectors.getLimits); const isVersioningEnabled = limits?.versioning?.enabled ?? false; const handleNewItems = (files: (File | DriveItemData)[], folders: (IRoot | DriveItemData)[]) => [ diff --git a/src/app/store/slices/fileVersions/fileVersions.selectors.ts b/src/app/store/slices/fileVersions/fileVersions.selectors.ts new file mode 100644 index 000000000..585e2b58d --- /dev/null +++ b/src/app/store/slices/fileVersions/fileVersions.selectors.ts @@ -0,0 +1,22 @@ +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[] | undefined { + return state.fileVersions.versionsByFileId[fileId]; + }, + isLoadingByFileId(state: RootState, fileId: NonNullable): boolean { + return state.fileVersions.isLoadingByFileId[fileId] ?? false; + }, + getErrorByFileId(state: RootState, fileId: NonNullable): string | null | undefined { + return state.fileVersions.errorsByFileId[fileId]; + }, +}; + +export default fileVersionsSelectors; diff --git a/src/app/store/slices/fileVersions/index.ts b/src/app/store/slices/fileVersions/index.ts index 3672f0724..70e099e00 100644 --- a/src/app/store/slices/fileVersions/index.ts +++ b/src/app/store/slices/fileVersions/index.ts @@ -1,21 +1,21 @@ import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; -import { FileVersion, GetFileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; +import { FileVersion, FileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; import fileVersionService from 'views/Drive/components/VersionHistory/services/fileVersion.service'; interface FileVersionsState { - versionsByFileId: Record; - loadingStates: Record; - errors: Record; - limits: GetFileLimitsResponse | null; - limitsLoading: boolean; + versionsByFileId: Record, FileVersion[]>; + isLoadingByFileId: Record, boolean>; + errorsByFileId: Record, string | null>; + limits: FileLimitsResponse | null; + isLimitsLoading: boolean; } const initialState: FileVersionsState = { versionsByFileId: {}, - loadingStates: {}, - errors: {}, + isLoadingByFileId: {}, + errorsByFileId: {}, limits: null, - limitsLoading: false, + isLimitsLoading: false, }; export const fetchFileVersionsThunk = createAsyncThunk( @@ -45,13 +45,13 @@ export const fileVersionsSlice = createSlice({ reducers: { invalidateCache: (state, action: PayloadAction) => { delete state.versionsByFileId[action.payload]; - delete state.loadingStates[action.payload]; - delete state.errors[action.payload]; + delete state.isLoadingByFileId[action.payload]; + delete state.errorsByFileId[action.payload]; }, clearAllCache: (state) => { state.versionsByFileId = {}; - state.loadingStates = {}; - state.errors = {}; + state.isLoadingByFileId = {}; + state.errorsByFileId = {}; }, updateVersionsAfterDelete: (state, action: PayloadAction<{ fileUuid: string; versionId: string }>) => { const { fileUuid, versionId } = action.payload; @@ -63,30 +63,31 @@ export const fileVersionsSlice = createSlice({ extraReducers: (builder) => { builder .addCase(fetchFileVersionsThunk.pending, (state, action) => { - state.loadingStates[action.meta.arg] = true; - state.errors[action.meta.arg] = null; + 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.loadingStates[fileUuid] = false; + state.isLoadingByFileId[fileUuid] = false; }) .addCase(fetchFileVersionsThunk.rejected, (state, action) => { - state.loadingStates[action.meta.arg] = false; - state.errors[action.meta.arg] = action.payload as string; + state.isLoadingByFileId[action.meta.arg] = false; + state.errorsByFileId[action.meta.arg] = action.payload as string; }) .addCase(fetchVersionLimitsThunk.pending, (state) => { - state.limitsLoading = true; + state.isLimitsLoading = true; }) .addCase(fetchVersionLimitsThunk.fulfilled, (state, action) => { state.limits = action.payload; - state.limitsLoading = false; + state.isLimitsLoading = false; }) .addCase(fetchVersionLimitsThunk.rejected, (state) => { - state.limitsLoading = false; + state.isLimitsLoading = false; }); }, }); export const fileVersionsActions = fileVersionsSlice.actions; export const fileVersionsReducer = fileVersionsSlice.reducer; +export { default as fileVersionsSelectors } from './fileVersions.selectors'; diff --git a/src/services/date.service.ts b/src/services/date.service.ts index 292892da2..2c9377ab2 100644 --- a/src/services/date.service.ts +++ b/src/services/date.service.ts @@ -28,12 +28,12 @@ export const formatDefaultDate = (date: Date | string | number, translate: (key: return dayjs(date).format(`D MMM, YYYY [${translatedAt}] HH:mm`); }; -function getDaysUntilExpiration(expiresAt: Date | string): number { +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, diff --git a/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx b/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx index 7c7550164..81e5b6f96 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx @@ -95,46 +95,44 @@ const getDownloadMenuItem = (downloadItems: (target?) => void) => ({ }); export type VersionHistoryMenuConfig = { - locked?: boolean; - onLockedClick?: () => void; - allowedExtension?: boolean; + isLocked: boolean; + isExtensionAllowed: boolean; + onUpgradeClick?: () => void; }; const getVersionHistoryMenuItem = ( viewVersionHistory: (target?) => void, config?: VersionHistoryMenuConfig, ): MenuItemType => { - const isLocked = config?.locked ?? false; - const allowedExtension = config?.allowedExtension ?? true; - const action = isLocked ? (config?.onLockedClick ?? (() => undefined)) : viewVersionHistory; - const IconComponent = isLocked ? LockSimple : ClockCounterClockwise; - - const isVersionHistoryUnavailable = (item: DriveItemData) => { - const isFolder = item.isFolder; - const isUnsupportedExtension = !allowedExtension; - const isDisabledForUnlockedItem = !isLocked && (isFolder || isUnsupportedExtension); - return isDisabledForUnlockedItem; - }; - - return { - name: t('drive.dropdown.versionHistory'), - icon: IconComponent, - action, - disabled: isVersionHistoryUnavailable, - ...(isLocked && { - locked: true, + const isLocked = config?.isLocked ?? false; + const isExtensionAllowed = config?.isExtensionAllowed ?? true; + const onUpgradeClick = config?.onUpgradeClick; + + if (isLocked) { + return { + name: t('drive.dropdown.versionHistory') as string, + icon: LockSimple, + action: onUpgradeClick, + disabled: () => false, node: (
- + {t('drive.dropdown.versionHistory')}
), - }), - } as MenuItemType & { locked?: boolean }; + }; + } + + return { + name: t('drive.dropdown.versionHistory') as string, + icon: ClockCounterClockwise, + action: viewVersionHistory, + disabled: (item: DriveItemData) => item.isFolder || !isExtensionAllowed, + }; }; const getMoveToTrashMenuItem = (moveToTrash: (target?) => void) => ({ diff --git a/src/views/Drive/components/VersionHistory/Sidebar.tsx b/src/views/Drive/components/VersionHistory/Sidebar.tsx index 20cc246fe..a620285b8 100644 --- a/src/views/Drive/components/VersionHistory/Sidebar.tsx +++ b/src/views/Drive/components/VersionHistory/Sidebar.tsx @@ -16,7 +16,12 @@ import { import fileVersionService from 'views/Drive/components/VersionHistory/services/fileVersion.service'; import errorService from 'services/error.service'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; -import { fetchFileVersionsThunk, fetchVersionLimitsThunk, fileVersionsActions } from 'app/store/slices/fileVersions'; +import { + fetchFileVersionsThunk, + fetchVersionLimitsThunk, + fileVersionsActions, + fileVersionsSelectors, +} from 'app/store/slices/fileVersions'; type VersionInfo = { id: string; updatedAt: string }; @@ -32,11 +37,13 @@ const Sidebar = () => { const currentFolderId = useAppSelector(storageSelectors.currentFolderId); const { translate } = useTranslationContext(); - const limits = useAppSelector((state: RootState) => state.fileVersions.limits); + const limits = useAppSelector(fileVersionsSelectors.getLimits); const versions = - useAppSelector((state: RootState) => (item ? state.fileVersions.versionsByFileId[item.uuid] : [])) || []; + useAppSelector((state: RootState) => (item ? fileVersionsSelectors.getVersionsByFileId(state, item.uuid) : [])) || + []; const isLoading = - useAppSelector((state: RootState) => (item ? state.fileVersions.loadingStates[item.uuid] : false)) || false; + useAppSelector((state: RootState) => (item ? fileVersionsSelectors.isLoadingByFileId(state, item.uuid) : false)) || + false; const [selectedAutosaveVersions, setSelectedAutosaveVersions] = useState>(new Set()); const [isBatchDeleteMode, setIsBatchDeleteMode] = useState(false); const [currentVersion, setCurrentVersion] = useState({ @@ -151,7 +158,7 @@ const Sidebar = () => { const restoredVersion = await fileVersionService.restoreVersion(item.uuid, versionToRestore.id); setCurrentVersion({ - id: restoredVersion.fileId, + id: restoredVersion.fileId as string, updatedAt: new Date().toISOString(), }); diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts b/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts index 948225d68..9b612a67d 100644 --- a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts +++ b/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts @@ -2,23 +2,23 @@ import { useSelector } from 'react-redux'; import { useAppDispatch } from 'app/store/hooks'; import { uiActions } from 'app/store/slices/ui'; import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; +import { fileVersionsSelectors } from 'app/store/slices/fileVersions'; import navigationService from 'services/navigation.service'; import { DriveItemData } from 'app/drive/types'; import { isVersioningExtensionAllowed } from '../utils'; import { VersionHistoryMenuConfig } from '../../DriveExplorer/components/DriveItemContextMenu'; -import { RootState } from 'app/store'; export const useVersionHistoryMenuConfig = (selectedItem?: DriveItemData): VersionHistoryMenuConfig => { const dispatch = useAppDispatch(); const selectedWorkspace = useSelector(workspacesSelectors.getSelectedWorkspace); - const limits = useSelector((state: RootState) => state.fileVersions.limits); + const limits = useSelector(fileVersionsSelectors.getLimits); const isVersioningEnabled = limits?.versioning?.enabled ?? false; - const allowedExtension = selectedItem ? isVersioningExtensionAllowed(selectedItem) : true; + const isExtensionAllowed = selectedItem ? isVersioningExtensionAllowed(selectedItem) : true; return { - locked: !isVersioningEnabled, - allowedExtension, - onLockedClick: () => { + isLocked: !isVersioningEnabled, + isExtensionAllowed, + onUpgradeClick: () => { dispatch(uiActions.setIsPreferencesDialogOpen(true)); navigationService.openPreferencesDialog({ section: 'account', diff --git a/yarn.lock b/yarn.lock index aa223e12f..b901f2cc3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1906,10 +1906,10 @@ version "1.0.2" resolved "https://codeload.github.com/internxt/prettier-config/tar.gz/9fa74e9a2805e1538b50c3809324f1c9d0f3e4f9" -"@internxt/sdk@=1.11.22": - version "1.11.22" - resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.11.22/4809924a6eef5ec36c5fc47fe2778afc60404c7f#4809924a6eef5ec36c5fc47fe2778afc60404c7f" - integrity sha512-ds02BkmDzA8vrzVzsn9HXDgvjIKY2tmblqFypmvuBQ+GnFYMatilu/dMe3sNAhFpIAV5ucmOLZe/Goi8FUizIw== +"@internxt/sdk@=1.11.24": + version "1.11.24" + resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.11.24/69cc187e532bd225d77d4cef1de6ce9afa0d1063#69cc187e532bd225d77d4cef1de6ce9afa0d1063" + integrity sha512-2EzWSHRd9r2tjHyEHatm9LjvEeT+d+NwXvmOd4MKOgXSjtK2Je8tiHB+5QlrzWu8s6BybEfWBN02HRz5EMhn9g== dependencies: axios "1.13.2" uuid "11.1.0" From 02fe0fecddc38ee9676d7eb8fbab06872ae302bc Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 17 Dec 2025 12:15:06 -0400 Subject: [PATCH 15/27] refactor: remove unused selectors from fileVersionsSelectors --- src/app/store/slices/fileVersions/fileVersions.selectors.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/app/store/slices/fileVersions/fileVersions.selectors.ts b/src/app/store/slices/fileVersions/fileVersions.selectors.ts index 585e2b58d..0b6511c2c 100644 --- a/src/app/store/slices/fileVersions/fileVersions.selectors.ts +++ b/src/app/store/slices/fileVersions/fileVersions.selectors.ts @@ -5,18 +5,12 @@ 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[] | undefined { return state.fileVersions.versionsByFileId[fileId]; }, isLoadingByFileId(state: RootState, fileId: NonNullable): boolean { return state.fileVersions.isLoadingByFileId[fileId] ?? false; }, - getErrorByFileId(state: RootState, fileId: NonNullable): string | null | undefined { - return state.fileVersions.errorsByFileId[fileId]; - }, }; export default fileVersionsSelectors; From 700735af83c003b75cce48817a18b60a26a8162d Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 18 Dec 2025 00:37:37 -0400 Subject: [PATCH 16/27] test: add comprehensive test coverage for file version history feature Add unit tests for date service, version history hooks, and file version service to ensure reliability of the file versioning functionality. Tests cover dropdown positioning logic, version history menu configuration, file version operations, and date utility functions including expiration calculations. --- src/services/date.service.test.ts | 28 ++++- .../hooks/useDropdownPositioning.test.ts | 87 ++++++++++++++ .../hooks/useVersionHistoryMenuConfig.test.ts | 99 ++++++++++++++++ .../services/fileVersion.service.test.ts | 112 ++++++++++++++++++ .../services/fileVersion.service.ts | 4 +- 5 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts create mode 100644 src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts create mode 100644 src/views/Drive/components/VersionHistory/services/fileVersion.service.test.ts diff --git a/src/services/date.service.test.ts b/src/services/date.service.test.ts index d29e6215c..75ec470e0 100644 --- a/src/services/date.service.test.ts +++ b/src/services/date.service.test.ts @@ -1,5 +1,5 @@ import dayjs from 'dayjs'; -import { describe, expect, test } from 'vitest'; +import { beforeEach, afterEach, describe, expect, test, vi } from 'vitest'; import dateService from './date.service'; describe('dateService', () => { @@ -35,4 +35,30 @@ describe('dateService', () => { expect(isBefore).toBe(false); }); + + describe('getDaysUntilExpiration', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2023-01-01T00:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('returns remaining days rounded up for a future date', () => { + const expiresAt = '2023-01-02T06:00:00Z'; + expect(dateService.getDaysUntilExpiration(expiresAt)).toBe(2); + }); + + test('returns zero for past dates', () => { + const expiresAt = '2022-12-31T23:59:59Z'; + expect(dateService.getDaysUntilExpiration(expiresAt)).toBe(0); + }); + + test('counts partial same-day time as one day', () => { + const expiresAt = '2023-01-01T12:00:00Z'; + expect(dateService.getDaysUntilExpiration(expiresAt)).toBe(1); + }); + }); }); diff --git a/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts b/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts new file mode 100644 index 000000000..48f33b551 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts @@ -0,0 +1,87 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { MutableRefObject } from 'react'; +import { useDropdownPositioning } from './useDropdownPositioning'; + +const originalInnerHeight = window.innerHeight; +const setRefCurrent = (ref: React.RefObject, value: T) => { + (ref as MutableRefObject).current = value; +}; + +describe('useDropdownPositioning', () => { + beforeEach(() => { + Object.defineProperty(window, 'innerHeight', { value: originalInnerHeight, writable: true, configurable: true }); + }); + + afterEach(() => { + Object.defineProperty(window, 'innerHeight', { value: originalInnerHeight, writable: true, configurable: true }); + }); + + it('clicking inside keeps the menu 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('clicking outside closes the menu', () => { + 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('opens below when there is room', 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('opens above when space is tight', async () => { + Object.defineProperty(window, 'innerHeight', { value: 150, writable: true, configurable: true }); + const { result } = renderHook(() => useDropdownPositioning()); + const mockItem = { + getBoundingClientRect: () => ({ bottom: 20 }) as DOMRect, + } as unknown as HTMLElement; + setRefCurrent(result.current.itemRef, mockItem); + + act(() => { + result.current.setIsOpen(true); + }); + + await waitFor(() => { + expect(result.current.dropdownPosition).toBe('above'); + }); + }); +}); diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts b/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts new file mode 100644 index 000000000..295701457 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts @@ -0,0 +1,99 @@ +import { renderHook, act } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi, Mock } from 'vitest'; +import { useSelector } from 'react-redux'; +import { useAppDispatch } from 'app/store/hooks'; +import navigationService from 'services/navigation.service'; +import { useVersionHistoryMenuConfig } from './useVersionHistoryMenuConfig'; +import { FileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; +import { WorkspaceData } from '@internxt/sdk/dist/workspaces'; + +vi.mock('react-redux', () => ({ + useSelector: vi.fn(), +})); + +vi.mock('app/store/hooks', () => ({ + useAppDispatch: vi.fn(), +})); + +const mockSetIsPreferencesDialogOpen = vi.hoisted(() => + vi.fn((payload: boolean) => ({ type: 'setIsPreferencesDialogOpen', payload })), +); + +vi.mock('app/store/slices/ui', () => ({ + uiActions: { + setIsPreferencesDialogOpen: mockSetIsPreferencesDialogOpen, + }, +})); + +vi.mock('services/navigation.service', () => ({ + default: { + openPreferencesDialog: vi.fn(), + }, +})); + +const workspaceMock = { + workspaceUser: { workspaceId: 'workspace-1' }, + workspace: { id: 'workspace-1' }, +} as unknown as WorkspaceData; + +const enabledLimits: FileLimitsResponse = { + versioning: { enabled: true, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, +}; + +const disabledLimits: FileLimitsResponse = { + versioning: { enabled: false, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, +}; + +describe('useVersionHistoryMenuConfig', () => { + const mockUseSelector = useSelector as unknown as Mock; + const mockUseAppDispatch = useAppDispatch as unknown as Mock; + const dispatch = vi.fn(); + + const mockState = (limits: FileLimitsResponse | null) => + ({ + workspaces: { selectedWorkspace: workspaceMock }, + fileVersions: { limits }, + }) as any; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseAppDispatch.mockReturnValue(dispatch); + }); + + it('returns locked config and triggers upgrade flow', () => { + mockUseSelector.mockImplementation((selector: (state: any) => unknown) => selector(mockState(disabledLimits))); + + const { result } = renderHook(() => useVersionHistoryMenuConfig({ type: 'pdf' } as any)); + + expect(result.current.isLocked).toBe(true); + expect(result.current.isExtensionAllowed).toBe(true); + + act(() => { + result.current.onUpgradeClick?.(); + }); + + expect(dispatch).toHaveBeenCalledWith({ type: 'setIsPreferencesDialogOpen', payload: true }); + expect(navigationService.openPreferencesDialog).toHaveBeenCalledWith({ + section: 'account', + subsection: 'plans', + workspaceUuid: 'workspace-1', + }); + }); + + it('returns unlocked config when versioning is enabled and extension allowed', () => { + 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('flags unsupported extensions even when versioning is enabled', () => { + mockUseSelector.mockImplementation((selector: (state: any) => unknown) => selector(mockState(enabledLimits))); + + const { result } = renderHook(() => useVersionHistoryMenuConfig({ type: 'exe' } as any)); + + expect(result.current.isExtensionAllowed).toBe(false); + }); +}); diff --git a/src/views/Drive/components/VersionHistory/services/fileVersion.service.test.ts b/src/views/Drive/components/VersionHistory/services/fileVersion.service.test.ts new file mode 100644 index 000000000..021c7d018 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/services/fileVersion.service.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SdkFactory } from 'app/core/factory/sdk'; +import { DownloadManager } from 'app/network/DownloadManager'; +import fileVersionService from './fileVersion.service'; +import { FileVersion, RestoreFileVersionResponse } from '@internxt/sdk/dist/drive/storage/types'; +import { WorkspaceCredentialsDetails, WorkspaceData } from '@internxt/sdk/dist/workspaces'; + +vi.mock('app/core/factory/sdk', () => ({ + SdkFactory: { + getNewApiInstance: vi.fn(), + }, +})); + +vi.mock('app/network/DownloadManager', () => ({ + DownloadManager: { + downloadItem: vi.fn(), + }, +})); + +describe('fileVersion.service', () => { + 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('returns the versions list from the SDK', 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('asks the SDK to delete a version', async () => { + storageClientMock.deleteFileVersion.mockResolvedValueOnce(undefined); + + await fileVersionService.deleteVersion(fileUuid, versionId); + + expect(storageClientMock.deleteFileVersion).toHaveBeenCalledWith(fileUuid, versionId); + }); + + it('restores a version and returns the SDK reply', 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('reads version limits', 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('sends a download request with the version file data', async () => { + const version = { networkFileId: 'network-file-id', size: '42' } as FileVersion; + const fileItem = { fileId: 'file-id', size: 10, name: 'original-name' } as any; + const selectedWorkspace = { workspace: { id: 'workspace-id' }, workspaceUser: {} } as unknown as WorkspaceData; + const workspaceCredentials = { + workspaceId: 'workspace-id', + bucket: 'bucket', + workspaceUserId: 'workspace-user', + email: 'user@example.com', + credentials: {} as any, + tokenHeader: 'token-header', + } as WorkspaceCredentialsDetails; + const downloadItemSpy = vi.spyOn(DownloadManager, 'downloadItem').mockResolvedValueOnce(undefined); + + await fileVersionService.downloadVersion(version, fileItem, 'custom-name', selectedWorkspace, workspaceCredentials); + + expect(downloadItemSpy).toHaveBeenCalledWith({ + payload: [ + { + ...fileItem, + fileId: version.networkFileId, + size: Number(version.size), + name: 'custom-name', + }, + ], + selectedWorkspace, + workspaceCredentials, + }); + }); +}); diff --git a/src/views/Drive/components/VersionHistory/services/fileVersion.service.ts b/src/views/Drive/components/VersionHistory/services/fileVersion.service.ts index 034170cf7..a5d085091 100644 --- a/src/views/Drive/components/VersionHistory/services/fileVersion.service.ts +++ b/src/views/Drive/components/VersionHistory/services/fileVersion.service.ts @@ -2,7 +2,7 @@ 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 { GetFileLimitsResponse, FileVersion, RestoreFileVersionResponse } from '@internxt/sdk/dist/drive/storage/types'; +import { FileLimitsResponse, FileVersion, RestoreFileVersionResponse } from '@internxt/sdk/dist/drive/storage/types'; const getStorageClient = () => SdkFactory.getNewApiInstance().createNewStorageClient(); @@ -18,7 +18,7 @@ export async function restoreVersion(fileUuid: string, versionId: string): Promi return getStorageClient().restoreFileVersion(fileUuid, versionId); } -export async function getLimits(): Promise { +export async function getLimits(): Promise { return getStorageClient().getFileVersionLimits(); } From abe4124d111a384bb6e21e0030663aa139eb14c4 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 18 Dec 2025 00:50:10 -0400 Subject: [PATCH 17/27] test: add unit tests for Redux file versions slice and version item actions hook Add comprehensive test coverage for fileVersions Redux slice and useVersionItemActions hook. Tests include thunk operations (fetch versions, fetch limits), reducer state management (loading, errors, cache invalidation), and menu action handlers (restore, download, delete). Uses icon-based menu item selection for robust, maintainable tests. --- .../store/slices/fileVersions/index.test.ts | 161 +++++++++++++++++ .../hooks/useVersionItemActions.test.ts | 165 ++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 src/app/store/slices/fileVersions/index.test.ts create mode 100644 src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts diff --git a/src/app/store/slices/fileVersions/index.test.ts b/src/app/store/slices/fileVersions/index.test.ts new file mode 100644 index 000000000..2db76cd58 --- /dev/null +++ b/src/app/store/slices/fileVersions/index.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FileVersion, FileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; +import fileVersionService from 'views/Drive/components/VersionHistory/services/fileVersion.service'; +import { fileVersionsActions, fileVersionsReducer, fetchFileVersionsThunk, fetchVersionLimitsThunk } from './index'; +import { RootState } from '../..'; + +vi.mock('views/Drive/components/VersionHistory/services/fileVersion.service', () => ({ + default: { + getFileVersions: vi.fn(), + getLimits: vi.fn(), + }, +})); + +describe('fileVersions slice', () => { + const fileUuid = 'file-uuid'; + const versions: FileVersion[] = [ + { id: 'v1', fileId: fileUuid } as FileVersion, + { id: 'v2', fileId: fileUuid } as FileVersion, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('thunks', () => { + it('fetch file versions succeeds', 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('fetch file versions surfaces the error message', 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('fetch version limits succeeds', async () => { + const limits: FileLimitsResponse = { + versioning: { enabled: true, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, + }; + const getLimitsSpy = vi.spyOn(fileVersionService, 'getLimits').mockResolvedValueOnce(limits); + const dispatch = vi.fn(); + + const action = await fetchVersionLimitsThunk()(dispatch, () => ({}) as RootState, undefined); + + expect(getLimitsSpy).toHaveBeenCalled(); + expect(action.meta.requestStatus).toBe('fulfilled'); + expect(action.payload).toBe(limits); + }); + + it('fetch version limits surfaces the error message', 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('reducers', () => { + it('marks loading and error state while fetching versions', () => { + 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('clears caches selectively and globally', () => { + 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('removes deleted versions from the cached 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('tracks loading state for fetching limits', () => { + const pendingState = fileVersionsReducer(undefined, fetchVersionLimitsThunk.pending('', undefined)); + expect(pendingState.isLimitsLoading).toBe(true); + + const limits: FileLimitsResponse = { + versioning: { enabled: true, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, + }; + const fulfilledState = fileVersionsReducer( + pendingState, + fetchVersionLimitsThunk.fulfilled(limits, '', undefined), + ); + expect(fulfilledState.isLimitsLoading).toBe(false); + expect(fulfilledState.limits).toBe(limits); + + const rejectedState = fileVersionsReducer( + pendingState, + fetchVersionLimitsThunk.rejected(new Error('err'), '', undefined), + ); + expect(rejectedState.isLimitsLoading).toBe(false); + }); + }); +}); diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts b/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts new file mode 100644 index 000000000..ce0d97015 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts @@ -0,0 +1,165 @@ +import { renderHook, act } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi, Mock } from 'vitest'; +import { Trash, ClockCounterClockwise, DownloadSimple } from '@phosphor-icons/react'; +import { useVersionItemActions } from './useVersionItemActions'; +import fileVersionService from '../services/fileVersion.service'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { useAppDispatch, useAppSelector } from 'app/store/hooks'; +import { FileVersion } from '@internxt/sdk/dist/drive/storage/types'; +import { WorkspaceData } from '@internxt/sdk/dist/workspaces'; +import { RootState } from 'app/store'; +import { MenuItemType } from '@internxt/ui'; + +vi.mock('app/i18n/provider/TranslationProvider', () => ({ + useTranslationContext: vi.fn(), +})); + +vi.mock('app/store/hooks', () => ({ + useAppDispatch: vi.fn(), + useAppSelector: vi.fn(), +})); + +vi.mock('views/Drive/components/VersionHistory/services/fileVersion.service', () => ({ + default: { + downloadVersion: vi.fn(), + }, +})); + +vi.mock('app/notifications/services/notifications.service', () => ({ + default: { + show: vi.fn(), + }, + ToastType: { + Error: 'error', + }, +})); + +const mockSetVersionToRestore = vi.hoisted(() => vi.fn((payload) => ({ type: 'setVersionToRestore', payload }))); +const mockSetIsRestoreVersionDialogOpen = vi.hoisted(() => + vi.fn((payload) => ({ type: 'setIsRestoreVersionDialogOpen', payload })), +); +const mockSetVersionToDelete = vi.hoisted(() => vi.fn((payload) => ({ type: 'setVersionToDelete', payload }))); +const mockSetIsDeleteVersionDialogOpen = vi.hoisted(() => + vi.fn((payload) => ({ type: 'setIsDeleteVersionDialogOpen', payload })), +); + +vi.mock('app/store/slices/ui', () => ({ + uiActions: { + setVersionToRestore: mockSetVersionToRestore, + setIsRestoreVersionDialogOpen: mockSetIsRestoreVersionDialogOpen, + setVersionToDelete: mockSetVersionToDelete, + setIsDeleteVersionDialogOpen: mockSetIsDeleteVersionDialogOpen, + }, +})); + +describe('useVersionItemActions', () => { + const translateMock = vi.fn((key: string) => key); + const version = { + id: 'version-id', + fileId: 'file-uuid', + networkFileId: 'network-file-id', + size: '5', + } as FileVersion; + const fileItem = { + id: 'file-id', + name: 'fallback-name', + plainName: 'pretty-name', + fileId: 'file-id', + } as any; + const selectedWorkspace = { workspace: { id: 'workspace-id' } } as WorkspaceData; + const workspaceCredentials = { workspaceId: 'workspace-id', token: 'token' } as any; + const baseState = { + ui: { versionHistoryItem: fileItem }, + workspaces: { selectedWorkspace, workspaceCredentials }, + } as unknown as RootState; + + const mockDispatch = vi.fn(); + const mockUseAppDispatch = useAppDispatch as unknown as Mock; + const mockUseAppSelector = useAppSelector as unknown as Mock; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseAppDispatch.mockReturnValue(mockDispatch); + (useTranslationContext as Mock).mockReturnValue({ translate: translateMock }); + mockUseAppSelector.mockImplementation((selector: (state: RootState) => unknown) => selector(baseState)); + }); + + const getMenuActionByIcon = (items: Array>, icon: any) => { + const item = items.find((item) => 'icon' in item && item.icon === icon); + return item && 'action' in item ? (item as any).action : undefined; + }; + + it('opens the restore dialog when restore is chosen', () => { + 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('shows a toast when there is nothing selected to download', 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('downloads a version with the readable filename and workspace data', async () => { + const onDropdownClose = vi.fn(); + const downloadVersionSpy = vi.spyOn(fileVersionService, 'downloadVersion').mockResolvedValue(undefined as any); + const { result } = renderHook(() => useVersionItemActions({ version, onDropdownClose })); + const downloadAction = getMenuActionByIcon(result.current.menuItems, DownloadSimple); + + await act(async () => { + await downloadAction(); + }); + + expect(onDropdownClose).toHaveBeenCalled(); + expect(downloadVersionSpy).toHaveBeenCalledWith( + version, + fileItem, + fileItem.plainName, + selectedWorkspace, + workspaceCredentials, + ); + expect(notificationsService.show).not.toHaveBeenCalled(); + }); + + it('opens the delete dialog when delete is chosen', () => { + 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 }); + }); +}); From f3f12d16ef88ab46e0ce93d0befbd2eb4d29fcb5 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 18 Dec 2025 00:57:26 -0400 Subject: [PATCH 18/27] refactor: simplify test descriptions in file versions slice tests --- src/app/store/slices/fileVersions/index.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/store/slices/fileVersions/index.test.ts b/src/app/store/slices/fileVersions/index.test.ts index 2db76cd58..1937dff96 100644 --- a/src/app/store/slices/fileVersions/index.test.ts +++ b/src/app/store/slices/fileVersions/index.test.ts @@ -23,7 +23,7 @@ describe('fileVersions slice', () => { }); describe('thunks', () => { - it('fetch file versions succeeds', async () => { + it('should load file versions successfully', async () => { const getFileVersionsSpy = vi.spyOn(fileVersionService, 'getFileVersions').mockResolvedValueOnce(versions); const dispatch = vi.fn(); @@ -34,7 +34,7 @@ describe('fileVersions slice', () => { expect(action.payload).toEqual({ fileUuid, versions }); }); - it('fetch file versions surfaces the error message', async () => { + it('should handle errors when loading file versions', async () => { const getFileVersionsSpy = vi .spyOn(fileVersionService, 'getFileVersions') .mockRejectedValueOnce(new Error('failed to fetch')); @@ -47,7 +47,7 @@ describe('fileVersions slice', () => { expect(action.payload).toBe('failed to fetch'); }); - it('fetch version limits succeeds', async () => { + it('should load version limits successfully', async () => { const limits: FileLimitsResponse = { versioning: { enabled: true, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, }; @@ -61,7 +61,7 @@ describe('fileVersions slice', () => { expect(action.payload).toBe(limits); }); - it('fetch version limits surfaces the error message', async () => { + it('should handle errors when loading version limits', async () => { const getLimitsSpy = vi .spyOn(fileVersionService, 'getLimits') .mockRejectedValueOnce(new Error('limits unavailable')); @@ -76,7 +76,7 @@ describe('fileVersions slice', () => { }); describe('reducers', () => { - it('marks loading and error state while fetching versions', () => { + it('should update loading and error states when loading versions', () => { const pendingState = fileVersionsReducer(undefined, fetchFileVersionsThunk.pending('', fileUuid)); expect(pendingState.isLoadingByFileId[fileUuid]).toBe(true); @@ -100,7 +100,7 @@ describe('fileVersions slice', () => { expect(fulfilledState.versionsByFileId[fileUuid]).toEqual(versions); }); - it('clears caches selectively and globally', () => { + it('should clear cache for a specific file or all files', () => { const populatedState = { versionsByFileId: { [fileUuid]: versions }, isLoadingByFileId: { [fileUuid]: false }, @@ -120,7 +120,7 @@ describe('fileVersions slice', () => { expect(afterClearAll.errorsByFileId).toEqual({}); }); - it('removes deleted versions from the cached list', () => { + it('should remove a deleted version from the list', () => { const state = { versionsByFileId: { [fileUuid]: versions }, isLoadingByFileId: {}, @@ -137,7 +137,7 @@ describe('fileVersions slice', () => { expect(updatedState.versionsByFileId[fileUuid]).toEqual([versions[1]]); }); - it('tracks loading state for fetching limits', () => { + it('should update loading state when loading limits', () => { const pendingState = fileVersionsReducer(undefined, fetchVersionLimitsThunk.pending('', undefined)); expect(pendingState.isLimitsLoading).toBe(true); From d919cd2f572aee17c9fbc7f71d8768d4d8a78275 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 18 Dec 2025 08:41:22 -0400 Subject: [PATCH 19/27] refactor: add default empty array return to getVersionsByFileId selector --- .../slices/fileVersions/fileVersions.selectors.ts | 4 ++-- .../Drive/components/VersionHistory/Sidebar.tsx | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/store/slices/fileVersions/fileVersions.selectors.ts b/src/app/store/slices/fileVersions/fileVersions.selectors.ts index 0b6511c2c..03c545460 100644 --- a/src/app/store/slices/fileVersions/fileVersions.selectors.ts +++ b/src/app/store/slices/fileVersions/fileVersions.selectors.ts @@ -5,8 +5,8 @@ const fileVersionsSelectors = { getLimits(state: RootState): FileLimitsResponse | null { return state.fileVersions.limits; }, - getVersionsByFileId(state: RootState, fileId: NonNullable): FileVersion[] | undefined { - return state.fileVersions.versionsByFileId[fileId]; + getVersionsByFileId(state: RootState, fileId: NonNullable): FileVersion[] { + return state.fileVersions.versionsByFileId[fileId] ?? []; }, isLoadingByFileId(state: RootState, fileId: NonNullable): boolean { return state.fileVersions.isLoadingByFileId[fileId] ?? false; diff --git a/src/views/Drive/components/VersionHistory/Sidebar.tsx b/src/views/Drive/components/VersionHistory/Sidebar.tsx index a620285b8..9ecbcd191 100644 --- a/src/views/Drive/components/VersionHistory/Sidebar.tsx +++ b/src/views/Drive/components/VersionHistory/Sidebar.tsx @@ -38,12 +38,12 @@ const Sidebar = () => { const { translate } = useTranslationContext(); const limits = useAppSelector(fileVersionsSelectors.getLimits); - const versions = - useAppSelector((state: RootState) => (item ? fileVersionsSelectors.getVersionsByFileId(state, item.uuid) : [])) || - []; - const isLoading = - useAppSelector((state: RootState) => (item ? fileVersionsSelectors.isLoadingByFileId(state, item.uuid) : false)) || - false; + const versions = useAppSelector((state: RootState) => + item ? fileVersionsSelectors.getVersionsByFileId(state, item.uuid) : [], + ); + const isLoading = useAppSelector((state: RootState) => + item ? fileVersionsSelectors.isLoadingByFileId(state, item.uuid) : false, + ); const [selectedAutosaveVersions, setSelectedAutosaveVersions] = useState>(new Set()); const [isBatchDeleteMode, setIsBatchDeleteMode] = useState(false); const [currentVersion, setCurrentVersion] = useState({ From ea961f723f14251df5d39ed3190b6012c971e69b Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 18 Dec 2025 09:00:47 -0400 Subject: [PATCH 20/27] chore: update @internxt/sdk package registry source --- yarn.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index b901f2cc3..63686d20e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1908,8 +1908,8 @@ "@internxt/sdk@=1.11.24": version "1.11.24" - resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.11.24/69cc187e532bd225d77d4cef1de6ce9afa0d1063#69cc187e532bd225d77d4cef1de6ce9afa0d1063" - integrity sha512-2EzWSHRd9r2tjHyEHatm9LjvEeT+d+NwXvmOd4MKOgXSjtK2Je8tiHB+5QlrzWu8s6BybEfWBN02HRz5EMhn9g== + resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.11.24.tgz#c0d31f99329f7553a88533bc59ddf8c155cf550b" + integrity sha512-GppLvUA6MhyiL/DIYDRhY/YdddrCZYz/d5/lbTHYwTbqrcVZEyAu8YbCEr0LG7GqhRxjnh4m55VAo//VC0dZyg== dependencies: axios "1.13.2" uuid "11.1.0" From 564699ecf27f4c97e526a96a2650347245afc886 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 22 Dec 2025 19:14:09 -0400 Subject: [PATCH 21/27] test: improve test descriptions using Given-When-Then pattern --- src/services/date.service.test.ts | 8 ++++---- .../hooks/useDropdownPositioning.test.ts | 10 +++++----- .../services/fileVersion.service.test.ts | 12 ++++++------ 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/services/date.service.test.ts b/src/services/date.service.test.ts index 75ec470e0..2b1d86c05 100644 --- a/src/services/date.service.test.ts +++ b/src/services/date.service.test.ts @@ -36,7 +36,7 @@ describe('dateService', () => { expect(isBefore).toBe(false); }); - describe('getDaysUntilExpiration', () => { + describe('Expiration countdown', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2023-01-01T00:00:00Z')); @@ -46,17 +46,17 @@ describe('dateService', () => { vi.useRealTimers(); }); - test('returns remaining days rounded up for a future date', () => { + 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('returns zero for past dates', () => { + test('when the expiration has passed, then zero days remain', () => { const expiresAt = '2022-12-31T23:59:59Z'; expect(dateService.getDaysUntilExpiration(expiresAt)).toBe(0); }); - test('counts partial same-day time as one day', () => { + 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/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts b/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts index 48f33b551..930020276 100644 --- a/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts +++ b/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts @@ -8,7 +8,7 @@ const setRefCurrent = (ref: React.RefObject, value: T) => { (ref as MutableRefObject).current = value; }; -describe('useDropdownPositioning', () => { +describe('Version menu behavior', () => { beforeEach(() => { Object.defineProperty(window, 'innerHeight', { value: originalInnerHeight, writable: true, configurable: true }); }); @@ -17,7 +17,7 @@ describe('useDropdownPositioning', () => { Object.defineProperty(window, 'innerHeight', { value: originalInnerHeight, writable: true, configurable: true }); }); - it('clicking inside keeps the menu open', () => { + it('when clicking inside the menu, then it stays open', () => { const { result } = renderHook(() => useDropdownPositioning()); const dropdownElement = document.createElement('div'); const childElement = document.createElement('span'); @@ -35,7 +35,7 @@ describe('useDropdownPositioning', () => { expect(result.current.isOpen).toBe(true); }); - it('clicking outside closes the menu', () => { + it('when clicking outside the menu, then it closes', () => { const { result } = renderHook(() => useDropdownPositioning()); const dropdownElement = document.createElement('div'); setRefCurrent(result.current.dropdownRef, dropdownElement); @@ -51,7 +51,7 @@ describe('useDropdownPositioning', () => { expect(result.current.isOpen).toBe(false); }); - it('opens below when there is room', async () => { + it('when there is room 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 = { @@ -68,7 +68,7 @@ describe('useDropdownPositioning', () => { }); }); - it('opens above when space is tight', async () => { + 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 = { diff --git a/src/views/Drive/components/VersionHistory/services/fileVersion.service.test.ts b/src/views/Drive/components/VersionHistory/services/fileVersion.service.test.ts index 021c7d018..8740e9b14 100644 --- a/src/views/Drive/components/VersionHistory/services/fileVersion.service.test.ts +++ b/src/views/Drive/components/VersionHistory/services/fileVersion.service.test.ts @@ -17,7 +17,7 @@ vi.mock('app/network/DownloadManager', () => ({ }, })); -describe('fileVersion.service', () => { +describe('File version actions', () => { const fileUuid = 'file-uuid'; const versionId = 'version-id'; let storageClientMock: { @@ -42,7 +42,7 @@ describe('fileVersion.service', () => { } as any); }); - it('returns the versions list from the SDK', async () => { + it('when file versions are requested, then the available list is returned', async () => { const versions = [{ id: 'v1' } as FileVersion]; storageClientMock.getFileVersions.mockResolvedValueOnce(versions); @@ -52,7 +52,7 @@ describe('fileVersion.service', () => { expect(result).toBe(versions); }); - it('asks the SDK to delete a version', async () => { + it('when a version is deleted, then the delete request is sent', async () => { storageClientMock.deleteFileVersion.mockResolvedValueOnce(undefined); await fileVersionService.deleteVersion(fileUuid, versionId); @@ -60,7 +60,7 @@ describe('fileVersion.service', () => { expect(storageClientMock.deleteFileVersion).toHaveBeenCalledWith(fileUuid, versionId); }); - it('restores a version and returns the SDK reply', async () => { + 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); @@ -70,7 +70,7 @@ describe('fileVersion.service', () => { expect(result).toBe(restoreResponse); }); - it('reads version limits', async () => { + 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); @@ -80,7 +80,7 @@ describe('fileVersion.service', () => { expect(result).toBe(limits); }); - it('sends a download request with the version file data', async () => { + 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; From 70846464cabaf038a13bd655e7e2fe49c9a96b2d Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 22 Dec 2025 19:23:44 -0400 Subject: [PATCH 22/27] test: refactor test descriptions to use Given-When-Then pattern --- .../store/slices/fileVersions/index.test.ts | 22 +++++++++---------- .../hooks/useVersionItemActions.test.ts | 10 ++++----- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/app/store/slices/fileVersions/index.test.ts b/src/app/store/slices/fileVersions/index.test.ts index 1937dff96..6ed3ea378 100644 --- a/src/app/store/slices/fileVersions/index.test.ts +++ b/src/app/store/slices/fileVersions/index.test.ts @@ -11,7 +11,7 @@ vi.mock('views/Drive/components/VersionHistory/services/fileVersion.service', () }, })); -describe('fileVersions slice', () => { +describe('File history state', () => { const fileUuid = 'file-uuid'; const versions: FileVersion[] = [ { id: 'v1', fileId: fileUuid } as FileVersion, @@ -22,8 +22,8 @@ describe('fileVersions slice', () => { vi.clearAllMocks(); }); - describe('thunks', () => { - it('should load file versions successfully', async () => { + 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(); @@ -34,7 +34,7 @@ describe('fileVersions slice', () => { expect(action.payload).toEqual({ fileUuid, versions }); }); - it('should handle errors when loading file versions', async () => { + 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')); @@ -47,7 +47,7 @@ describe('fileVersions slice', () => { expect(action.payload).toBe('failed to fetch'); }); - it('should load version limits successfully', async () => { + it('when version limits are requested, then the limits load successfully', async () => { const limits: FileLimitsResponse = { versioning: { enabled: true, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, }; @@ -61,7 +61,7 @@ describe('fileVersions slice', () => { expect(action.payload).toBe(limits); }); - it('should handle errors when loading version limits', async () => { + it('when version limits fail to load, then the error is reported', async () => { const getLimitsSpy = vi .spyOn(fileVersionService, 'getLimits') .mockRejectedValueOnce(new Error('limits unavailable')); @@ -75,8 +75,8 @@ describe('fileVersions slice', () => { }); }); - describe('reducers', () => { - it('should update loading and error states when loading versions', () => { + 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); @@ -100,7 +100,7 @@ describe('fileVersions slice', () => { expect(fulfilledState.versionsByFileId[fileUuid]).toEqual(versions); }); - it('should clear cache for a specific file or all files', () => { + it('when cached history is cleared for a file or all files, then entries are removed', () => { const populatedState = { versionsByFileId: { [fileUuid]: versions }, isLoadingByFileId: { [fileUuid]: false }, @@ -120,7 +120,7 @@ describe('fileVersions slice', () => { expect(afterClearAll.errorsByFileId).toEqual({}); }); - it('should remove a deleted version from the list', () => { + it('when a version is deleted, then it is removed from the list', () => { const state = { versionsByFileId: { [fileUuid]: versions }, isLoadingByFileId: {}, @@ -137,7 +137,7 @@ describe('fileVersions slice', () => { expect(updatedState.versionsByFileId[fileUuid]).toEqual([versions[1]]); }); - it('should update loading state when loading limits', () => { + it('when limits are loading or finished, then the loading state updates', () => { const pendingState = fileVersionsReducer(undefined, fetchVersionLimitsThunk.pending('', undefined)); expect(pendingState.isLimitsLoading).toBe(true); diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts b/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts index ce0d97015..f055c78c9 100644 --- a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts +++ b/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts @@ -53,7 +53,7 @@ vi.mock('app/store/slices/ui', () => ({ }, })); -describe('useVersionItemActions', () => { +describe('Version item menu', () => { const translateMock = vi.fn((key: string) => key); const version = { id: 'version-id', @@ -90,7 +90,7 @@ describe('useVersionItemActions', () => { return item && 'action' in item ? (item as any).action : undefined; }; - it('opens the restore dialog when restore is chosen', () => { + 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); @@ -104,7 +104,7 @@ describe('useVersionItemActions', () => { expect(mockDispatch).toHaveBeenCalledWith({ type: 'setIsRestoreVersionDialogOpen', payload: true }); }); - it('shows a toast when there is nothing selected to download', async () => { + 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({ @@ -128,7 +128,7 @@ describe('useVersionItemActions', () => { expect(fileVersionService.downloadVersion).not.toHaveBeenCalled(); }); - it('downloads a version with the readable filename and workspace data', async () => { + 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 })); @@ -149,7 +149,7 @@ describe('useVersionItemActions', () => { expect(notificationsService.show).not.toHaveBeenCalled(); }); - it('opens the delete dialog when delete is chosen', () => { + 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); From 0a418b4290f8b6460cb539e02f71687a13408f8c Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 23 Dec 2025 09:17:06 -0400 Subject: [PATCH 23/27] test: refine test descriptions for consistency and clarity --- .../VersionHistory/hooks/useDropdownPositioning.test.ts | 2 +- .../hooks/useVersionHistoryMenuConfig.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts b/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts index 930020276..d5ab8abff 100644 --- a/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts +++ b/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts @@ -51,7 +51,7 @@ describe('Version menu behavior', () => { expect(result.current.isOpen).toBe(false); }); - it('when there is room below the item, then the menu opens below', async () => { + 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 = { diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts b/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts index 295701457..d45bb6e59 100644 --- a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts +++ b/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts @@ -44,7 +44,7 @@ const disabledLimits: FileLimitsResponse = { versioning: { enabled: false, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, }; -describe('useVersionHistoryMenuConfig', () => { +describe('Version history menu', () => { const mockUseSelector = useSelector as unknown as Mock; const mockUseAppDispatch = useAppDispatch as unknown as Mock; const dispatch = vi.fn(); @@ -60,7 +60,7 @@ describe('useVersionHistoryMenuConfig', () => { mockUseAppDispatch.mockReturnValue(dispatch); }); - it('returns locked config and triggers upgrade flow', () => { + it('when versioning is locked, then the upgrade flow opens', () => { mockUseSelector.mockImplementation((selector: (state: any) => unknown) => selector(mockState(disabledLimits))); const { result } = renderHook(() => useVersionHistoryMenuConfig({ type: 'pdf' } as any)); @@ -80,7 +80,7 @@ describe('useVersionHistoryMenuConfig', () => { }); }); - it('returns unlocked config when versioning is enabled and extension allowed', () => { + 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)); @@ -89,7 +89,7 @@ describe('useVersionHistoryMenuConfig', () => { expect(result.current.isExtensionAllowed).toBe(true); }); - it('flags unsupported extensions even when versioning is enabled', () => { + 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)); From 6906b8910319ede682401c9bdaf860733bdc37f2 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 8 Jan 2026 09:23:25 -0400 Subject: [PATCH 24/27] feature: add file extension validation for version replacement and improve autosave delete button styling --- .../NameCollisionDialog/NameCollisionContainer.tsx | 4 +++- .../VersionHistory/components/AutosaveSection.tsx | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index 36bab0719..3d465ea21 100644 --- a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx +++ b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx @@ -15,6 +15,7 @@ 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; @@ -193,7 +194,8 @@ const NameCollisionContainer: FC = ({ }); } else { const file = itemToUpload as File; - isVersioningEnabled ? await replaceFileVersion(file, itemToReplace) : await trashAndUpload(file, itemToReplace); + const canReplaceVersion = isVersioningEnabled && isVersioningExtensionAllowed(itemToReplace); + canReplaceVersion ? await replaceFileVersion(file, itemToReplace) : await trashAndUpload(file, itemToReplace); } dispatch(fetchSortedFolderContentThunk(folderId)); diff --git a/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx index 8bea3504e..cba9e1489 100644 --- a/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx +++ b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx @@ -42,11 +42,11 @@ export const AutosaveSection = ({
); From 2bf1abf6c7f09424ca79e4cb50e391e407448c38 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 8 Jan 2026 21:17:26 -0400 Subject: [PATCH 25/27] refactor: add user avatar support and update border styling in version history --- .../components/VersionHistory/Sidebar.tsx | 10 +- .../components/AutosaveSection.tsx | 2 +- .../components/CurrentVersionItem.tsx | 7 +- .../VersionHistory/components/Header.tsx | 2 +- .../VersionHistory/components/VersionItem.tsx | 167 +++++++++--------- 5 files changed, 100 insertions(+), 88 deletions(-) diff --git a/src/views/Drive/components/VersionHistory/Sidebar.tsx b/src/views/Drive/components/VersionHistory/Sidebar.tsx index 9ecbcd191..0d1ba4577 100644 --- a/src/views/Drive/components/VersionHistory/Sidebar.tsx +++ b/src/views/Drive/components/VersionHistory/Sidebar.tsx @@ -69,6 +69,8 @@ const Sidebar = () => { [user], ); + const userAvatar = user?.avatar ?? null; + useEffect(() => { if (!item || !isOpen) return; @@ -217,7 +219,12 @@ const Sidebar = () => { ) : ( <> - + { key={version.id} version={version} userName={userName} + userAvatar={userAvatar} isSelected={selectedAutosaveVersions.has(version.id)} onSelectionChange={(selected) => handleVersionSelectionChange(version.id, selected)} /> diff --git a/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx index cba9e1489..321a93637 100644 --- a/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx +++ b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx @@ -24,7 +24,7 @@ export const AutosaveSection = ({ const isIndeterminate = hasSelection && !selectAllAutosave; return ( -
+
{ +export const CurrentVersionItem = ({ createdAt, userName, userAvatar }: CurrentVersionItemProps) => { const { translate } = useTranslationContext(); return ( -
+
{formatVersionDate(createdAt)} @@ -20,7 +21,7 @@ export const CurrentVersionItem = ({ createdAt, userName }: CurrentVersionItemPr
- + {userName}
diff --git a/src/views/Drive/components/VersionHistory/components/Header.tsx b/src/views/Drive/components/VersionHistory/components/Header.tsx index 2ea53a1e0..ed9578fdd 100644 --- a/src/views/Drive/components/VersionHistory/components/Header.tsx +++ b/src/views/Drive/components/VersionHistory/components/Header.tsx @@ -7,7 +7,7 @@ interface HeaderProps { export const Header = ({ title, onClose }: HeaderProps) => { return ( -
+
{title}
- - ); -}); + + ); + }, +); From e361615f84dfbeb7581ef6b6f59c65efc248f9c9 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 8 Jan 2026 22:47:46 -0400 Subject: [PATCH 26/27] fix: add backdrop overlay to prevent dropdown hover bleed-through in version history --- .../VersionHistory/components/VersionItem.tsx | 142 ++++++++++-------- 1 file changed, 77 insertions(+), 65 deletions(-) diff --git a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx index e33df36e8..6f8e776a9 100644 --- a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx +++ b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx @@ -34,79 +34,91 @@ export const VersionItem = memo( const dropdownOpenDirection = dropdownPosition === 'above' ? 'left' : 'right'; return ( -
- + + ); }, ); From 467836cd99e96b7e6912758bcb537205cca00012 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 9 Jan 2026 00:07:46 -0400 Subject: [PATCH 27/27] fix: pin version size label to the right edge --- .../components/VersionHistory/components/VersionItem.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx index 6f8e776a9..8415d0cb0 100644 --- a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx +++ b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx @@ -5,6 +5,7 @@ import { useDropdownPositioning, useVersionItemActions } from '../hooks'; import { formatVersionDate, getDaysUntilExpiration } from '../utils'; import { FileVersion } from '@internxt/sdk/dist/drive/storage/types'; import { memo } from 'react'; +import sizeService from 'app/drive/services/size.service'; interface VersionItemProps { version: FileVersion; @@ -32,6 +33,7 @@ export const VersionItem = memo( }; const dropdownOpenDirection = dropdownPosition === 'above' ? 'left' : 'right'; + const versionSize = sizeService.bytesToString(Number.parseInt(version.size), false); return ( <> @@ -66,8 +68,11 @@ export const VersionItem = memo( }`} />
-
+
{formatVersionDate(version.updatedAt)} + + {versionSize} +
{version.expiresAt !== undefined && (