From 1410020cb46647e0b52727d66d267ea61df93798 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 4 Dec 2025 00:06:18 -0400 Subject: [PATCH 01/47] feat: add version history menu item to drive context menu Add "Version history" option to drive item context menu for files across all drive views (personal, shared, workspace). Menu item is disabled for folders and includes placeholder implementation in useDriveItemActions hook. --- src/app/i18n/locales/en.json | 1 + .../components/DriveItemContextMenu.tsx | 27 +++++++++++++++++++ .../components/DriveItemDropdownActions.tsx | 5 ++++ src/views/Drive/hooks/useDriveItemActions.tsx | 7 +++++ 4 files changed, 40 insertions(+) diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index a76f386ebe..5faeca1a17 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -1538,6 +1538,7 @@ "share": "Share", "manageAccess": "Manage access", "download": "Download", + "versionHistory": "Version history", "moveToTrash": "Move to trash", "copyLink": "Copy link", "linkSettings": "Link settings", diff --git a/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx b/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx index 3467b1b69e..b0b70a47f7 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx @@ -93,6 +93,15 @@ const getDownloadMenuItem = (downloadItems: (target?) => void) => ({ }, }); +const getVersionHistoryMenuItem = (viewVersionHistory: (target?) => void) => ({ + name: t('drive.dropdown.versionHistory'), + icon: ClockCounterClockwise, + action: viewVersionHistory, + disabled: (item) => { + return item.isFolder; + }, +}); + const getMoveToTrashMenuItem = (moveToTrash: (target?) => void) => ({ name: t('drive.dropdown.moveToTrash'), icon: Trash, @@ -165,6 +174,7 @@ const contextMenuDriveNotSharedLink = ({ renameItem, moveItem, downloadItem, + viewVersionHistory, moveToTrash, }: { shareLink: (item: DriveItemData) => void; @@ -174,6 +184,7 @@ const contextMenuDriveNotSharedLink = ({ renameItem: (item: DriveItemData) => void; moveItem: (item: DriveItemData) => void; downloadItem: (item: DriveItemData) => void; + viewVersionHistory: (item: DriveItemData) => void; moveToTrash: (item: DriveItemData) => void; }): Array> => [ @@ -185,6 +196,7 @@ const contextMenuDriveNotSharedLink = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), + getVersionHistoryMenuItem(viewVersionHistory), { separator: true }, getMoveToTrashMenuItem(moveToTrash), ].filter(Boolean) as MenuItemType[]; @@ -196,6 +208,7 @@ const contextMenuDriveFolderNotSharedLink = ({ renameItem, moveItem, downloadItem, + viewVersionHistory, moveToTrash, }: { shareLink: (item: DriveItemData) => void; @@ -204,6 +217,7 @@ const contextMenuDriveFolderNotSharedLink = ({ renameItem: (item: DriveItemData) => void; moveItem: (item: DriveItemData) => void; downloadItem: (item: DriveItemData) => void; + viewVersionHistory: (item: DriveItemData) => void; moveToTrash: (item: DriveItemData) => void; }): Array> => [ shareLinkMenuItem(shareLink), @@ -213,6 +227,7 @@ const contextMenuDriveFolderNotSharedLink = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), + getVersionHistoryMenuItem(viewVersionHistory), { separator: true }, getMoveToTrashMenuItem(moveToTrash), ]; @@ -225,6 +240,7 @@ const contextMenuDriveItemShared = ({ renameItem, moveItem, downloadItem, + viewVersionHistory, moveToTrash, }: { openPreview?: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; @@ -234,6 +250,7 @@ const contextMenuDriveItemShared = ({ renameItem: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; moveItem: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; downloadItem: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; + viewVersionHistory: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; moveToTrash: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; }): Array> => { const shareLinkItems = [manageLinkAccessMenuItem(openShareAccessSettings), getCopyLinkMenuItem(copyLink)]; @@ -245,6 +262,7 @@ const contextMenuDriveItemShared = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), + getVersionHistoryMenuItem(viewVersionHistory), { separator: true }, getMoveToTrashMenuItem(moveToTrash), ].filter(Boolean) as MenuItemType[]; @@ -257,6 +275,7 @@ const contextMenuDriveFolderShared = ({ renameItem, moveItem, downloadItem, + viewVersionHistory, moveToTrash, }: { copyLink: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; @@ -265,6 +284,7 @@ const contextMenuDriveFolderShared = ({ renameItem: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; moveItem: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; downloadItem: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; + viewVersionHistory: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; moveToTrash: (item: DriveItemData | (ListShareLinksItem & { code: string })) => void; }): Array> => { const shareLinkItems = [manageLinkAccessMenuItem(openShareAccessSettings), getCopyLinkMenuItem(copyLink)]; @@ -275,6 +295,7 @@ const contextMenuDriveFolderShared = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), + getVersionHistoryMenuItem(viewVersionHistory), { separator: true }, getMoveToTrashMenuItem(moveToTrash), ]; @@ -423,6 +444,7 @@ const contextMenuWorkspaceFolder = ({ renameItem, moveItem, downloadItem, + viewVersionHistory, moveToTrash, }: { shareLink: (item: DriveItemData) => void; @@ -432,6 +454,7 @@ const contextMenuWorkspaceFolder = ({ renameItem: (item: DriveItemData) => void; moveItem: (item: DriveItemData) => void; downloadItem: (item: DriveItemData) => void; + viewVersionHistory: (item: DriveItemData) => void; moveToTrash: (item: DriveItemData) => void; }): Array> => [ shareLinkMenuItem(shareLink), @@ -442,6 +465,7 @@ const contextMenuWorkspaceFolder = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), + getVersionHistoryMenuItem(viewVersionHistory), { separator: true }, getMoveToTrashMenuItem(moveToTrash), ]; @@ -455,6 +479,7 @@ const contextMenuWorkspaceFile = ({ renameItem, moveItem, downloadItem, + viewVersionHistory, moveToTrash, }: { shareLink: (item: DriveItemData) => void; @@ -465,6 +490,7 @@ const contextMenuWorkspaceFile = ({ renameItem: (item: DriveItemData) => void; moveItem: (item: DriveItemData) => void; downloadItem: (item: DriveItemData) => void; + viewVersionHistory: (item: DriveItemData) => void; moveToTrash: (item: DriveItemData) => void; }): Array> => [ @@ -477,6 +503,7 @@ const contextMenuWorkspaceFile = ({ getRenameMenuItem(renameItem), getMoveItemMenuItem(moveItem), getDownloadMenuItem(downloadItem), + getVersionHistoryMenuItem(viewVersionHistory), { separator: true }, getMoveToTrashMenuItem(moveToTrash), ].filter(Boolean) as MenuItemType[]; diff --git a/src/views/Drive/components/DriveItemDropdownActions.tsx b/src/views/Drive/components/DriveItemDropdownActions.tsx index 863ebdb8b8..43c4257771 100644 --- a/src/views/Drive/components/DriveItemDropdownActions.tsx +++ b/src/views/Drive/components/DriveItemDropdownActions.tsx @@ -25,6 +25,7 @@ const FileDropdownActions = (props: FileDropdownActionsProps) => { onMoveToTrashButtonClicked, onShowDetailsButtonClicked, onDownloadItemButtonClicked, + onViewVersionHistoryButtonClicked, onLinkSettingsButtonClicked, onRenameItemButtonClicked, onOpenPreviewButtonClicked, @@ -50,6 +51,7 @@ const FileDropdownActions = (props: FileDropdownActionsProps) => { renameItem: onRenameItemButtonClicked, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadItemButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onMoveToTrashButtonClicked, }); } @@ -61,6 +63,7 @@ const FileDropdownActions = (props: FileDropdownActionsProps) => { renameItem: onRenameItemButtonClicked, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadItemButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onMoveToTrashButtonClicked, }); } else if (item?.isFolder) { @@ -71,6 +74,7 @@ const FileDropdownActions = (props: FileDropdownActionsProps) => { renameItem: onRenameItemButtonClicked, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadItemButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onMoveToTrashButtonClicked, }); } @@ -82,6 +86,7 @@ const FileDropdownActions = (props: FileDropdownActionsProps) => { renameItem: onRenameItemButtonClicked, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadItemButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onMoveToTrashButtonClicked, }); }; diff --git a/src/views/Drive/hooks/useDriveItemActions.tsx b/src/views/Drive/hooks/useDriveItemActions.tsx index ae8edecd00..ecb63bb663 100644 --- a/src/views/Drive/hooks/useDriveItemActions.tsx +++ b/src/views/Drive/hooks/useDriveItemActions.tsx @@ -23,6 +23,7 @@ export interface DriveItemActions { onCopyLinkButtonClicked: () => void; onLinkSettingsButtonClicked: () => void; onDownloadItemButtonClicked: () => void; + onViewVersionHistoryButtonClicked: () => void; onShowDetailsButtonClicked: () => void; onMoveToTrashButtonClicked: () => void; onNameClicked: (e) => void; @@ -100,6 +101,11 @@ const useDriveItemActions = (item): DriveItemActions => { }); }; + const onViewVersionHistoryButtonClicked = () => { + // TODO: Implement version history dialog + console.log('View version history for item:', item); + }; + const onMoveToTrashButtonClicked = () => { moveItemsToTrash([item as DriveItemData]); }; @@ -159,6 +165,7 @@ const useDriveItemActions = (item): DriveItemActions => { onCopyLinkButtonClicked, onLinkSettingsButtonClicked, onDownloadItemButtonClicked, + onViewVersionHistoryButtonClicked, onShowDetailsButtonClicked, onMoveToTrashButtonClicked, onNameClicked, From 1090978212137c3eafc396754747ff96e24015b0 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 4 Dec 2025 00:07:57 -0400 Subject: [PATCH 02/47] feat: implement version history sidebar for drive items Add version history sidebar with UI for viewing, restoring, and deleting file versions. Includes state management in UI slice, integration with drive explorer context menus, and mock data structure for version display. --- src/app/i18n/locales/en.json | 3 + src/app/store/slices/ui/index.ts | 10 + .../DriveExplorer/DriveExplorer.tsx | 2 + .../components/DriveExplorerList.tsx | 14 + .../components/DriveTopBarActions.tsx | 11 + .../VersionHistorySidebar.tsx | 346 ++++++++++++++++++ src/views/Drive/hooks/useDriveItemActions.tsx | 4 +- 7 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 src/views/Drive/components/VersionHistorySidebar/VersionHistorySidebar.tsx diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 5faeca1a17..42e905855c 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -890,6 +890,9 @@ "location": "Location" } }, + "versionHistory": { + "title": "Version history" + }, "shareModal": { "title": "Share \"{{name}}\"", "list": { diff --git a/src/app/store/slices/ui/index.ts b/src/app/store/slices/ui/index.ts index e1e3f8e96e..a30cd04fce 100644 --- a/src/app/store/slices/ui/index.ts +++ b/src/app/store/slices/ui/index.ts @@ -9,6 +9,7 @@ interface UISliceState { isShareDialogOpen: boolean; isInvitationsDialogOpen: boolean; isItemDetailsDialogOpen: boolean; + isVersionHistoryDialogOpen: boolean; isCreateFolderDialogOpen: boolean; isDeleteItemsDialogOpen: boolean; isMoveItemsDialogOpen: boolean; @@ -27,6 +28,7 @@ interface UISliceState { isFileViewerOpen: boolean; fileViewerItem: PreviewFileItem | null; itemDetails: DriveItemDetails | null; + versionHistoryItem: DriveItemData | null; currentFileInfoMenuItem: FileInfoMenuItem | null; currentEditingNameDriveItem: DriveItemData | null; currentEditingNameDirty: string; @@ -41,6 +43,7 @@ const initialState: UISliceState = { isShareDialogOpen: false, isInvitationsDialogOpen: false, isItemDetailsDialogOpen: false, + isVersionHistoryDialogOpen: false, isCreateFolderDialogOpen: false, isDeleteItemsDialogOpen: false, isMoveItemsDialogOpen: false, @@ -59,6 +62,7 @@ const initialState: UISliceState = { isFileViewerOpen: false, fileViewerItem: null, itemDetails: null, + versionHistoryItem: null, currentFileInfoMenuItem: null, currentEditingNameDriveItem: null, currentEditingNameDirty: '', @@ -88,6 +92,12 @@ export const uiSlice = createSlice({ setIsItemDetailsDialogOpen(state: UISliceState, action: PayloadAction) { state.isItemDetailsDialogOpen = action.payload; }, + setIsVersionHistoryDialogOpen(state: UISliceState, action: PayloadAction) { + state.isVersionHistoryDialogOpen = action.payload; + }, + setVersionHistoryItem: (state: UISliceState, action: PayloadAction) => { + state.versionHistoryItem = action.payload; + }, setIsCreateFolderDialogOpen: (state: UISliceState, action: PayloadAction) => { state.isCreateFolderDialogOpen = action.payload; }, diff --git a/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx b/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx index d93a3c9264..cf8f0d839f 100644 --- a/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx +++ b/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx @@ -63,6 +63,7 @@ import { DriveTopBarItems } from './DriveTopBarItems'; import { ShareDialogWrapper } from 'app/drive/components/ShareDialog/ShareDialogWrapper'; import RealtimeService from 'services/sockets/socket.service'; import { eventHandler } from 'services/sockets/event-handler.service'; +import VersionHistorySidebar from '../VersionHistorySidebar/VersionHistorySidebar'; const MenuItemToGetSize = ({ isTrash, @@ -532,6 +533,7 @@ const DriveExplorer = (props: DriveExplorerProps): JSX.Element => { /> + {editNameItem && ( diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx index b3749e34ba..92c866d74d 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx @@ -269,6 +269,14 @@ const DriveExplorerList: React.FC = memo((props) => { [selectedWorkspace, workspaceCredentials], ); + const viewVersionHistory = useCallback( + (item: ContextMenuDriveItem) => { + dispatch(uiActions.setVersionHistoryItem(item as DriveItemData)); + dispatch(uiActions.setIsVersionHistoryDialogOpen(true)); + }, + [dispatch, uiActions], + ); + const moveToTrash = useCallback( (item: ContextMenuDriveItem) => { const driveItem = item as DriveItemData; @@ -331,6 +339,7 @@ const DriveExplorerList: React.FC = memo((props) => { renameItem: renameItem, moveItem: moveItem, downloadItem: downloadItem, + viewVersionHistory: viewVersionHistory, moveToTrash: props.onOpenStopSharingAndMoveToTrashDialog, }); @@ -344,6 +353,7 @@ const DriveExplorerList: React.FC = memo((props) => { renameItem: renameItem, moveItem: moveItem, downloadItem: downloadItem, + viewVersionHistory: viewVersionHistory, moveToTrash: props.onOpenStopSharingAndMoveToTrashDialog, }); @@ -354,6 +364,7 @@ const DriveExplorerList: React.FC = memo((props) => { renameItem: renameItem, moveItem: moveItem, downloadItem: downloadItem, + viewVersionHistory: viewVersionHistory, moveToTrash: moveToTrash, }); @@ -365,6 +376,7 @@ const DriveExplorerList: React.FC = memo((props) => { renameItem: renameItem, moveItem: moveItem, downloadItem: downloadItem, + viewVersionHistory: viewVersionHistory, moveToTrash: moveToTrash, }); @@ -381,6 +393,7 @@ const DriveExplorerList: React.FC = memo((props) => { renameItem: renameItem, moveItem: moveItem, downloadItem: downloadItem, + viewVersionHistory: viewVersionHistory, moveToTrash: moveToTrash, }); @@ -392,6 +405,7 @@ const DriveExplorerList: React.FC = memo((props) => { renameItem: renameItem, moveItem: moveItem, downloadItem: downloadItem, + viewVersionHistory: viewVersionHistory, moveToTrash: moveToTrash, }); diff --git a/src/views/Drive/components/DriveExplorer/components/DriveTopBarActions.tsx b/src/views/Drive/components/DriveExplorer/components/DriveTopBarActions.tsx index be6990ef77..c10a147efd 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveTopBarActions.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveTopBarActions.tsx @@ -98,6 +98,11 @@ const DriveTopBarActions = ({ }); }; + const onViewVersionHistoryButtonClicked = (): void => { + dispatch(uiActions.setVersionHistoryItem(selectedItems[0])); + dispatch(uiActions.setIsVersionHistoryDialogOpen(true)); + }; + const onBulkDeleteButtonClicked = (): void => { moveItemsToTrash(selectedItems); }; @@ -176,6 +181,7 @@ const DriveTopBarActions = ({ renameItem: onSelectedOneItemRename, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, }); @@ -187,6 +193,7 @@ const DriveTopBarActions = ({ renameItem: onSelectedOneItemRename, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, }); @@ -208,6 +215,7 @@ const DriveTopBarActions = ({ renameItem: onSelectedOneItemRename, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, }) : contextMenuDriveItemShared({ @@ -218,6 +226,7 @@ const DriveTopBarActions = ({ renameItem: onSelectedOneItemRename, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, }); } else { @@ -229,6 +238,7 @@ const DriveTopBarActions = ({ renameItem: onSelectedOneItemRename, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, }) : contextMenuDriveNotSharedLink({ @@ -239,6 +249,7 @@ const DriveTopBarActions = ({ renameItem: onSelectedOneItemRename, moveItem: onMoveItemButtonClicked, downloadItem: onDownloadButtonClicked, + viewVersionHistory: onViewVersionHistoryButtonClicked, moveToTrash: onBulkDeleteButtonClicked, }); } diff --git a/src/views/Drive/components/VersionHistorySidebar/VersionHistorySidebar.tsx b/src/views/Drive/components/VersionHistorySidebar/VersionHistorySidebar.tsx new file mode 100644 index 0000000000..32a00423f2 --- /dev/null +++ b/src/views/Drive/components/VersionHistorySidebar/VersionHistorySidebar.tsx @@ -0,0 +1,346 @@ +import { useEffect, useState, useRef } from 'react'; +import { RootState } from 'app/store'; +import { useAppDispatch, useAppSelector } from 'app/store/hooks'; +import { uiActions } from 'app/store/slices/ui'; +import { X, Trash, Info, DotsThree, ClockCounterClockwise, DownloadSimple } from '@phosphor-icons/react'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { Checkbox, Dropdown, MenuItemType } from '@internxt/ui'; +import { DriveItemData } from 'app/drive/types'; +import dateService from 'services/date.service'; + +interface FileVersion { + id: string; + date: Date; + userName: string; + expiresInDays?: number; + isAutosave?: boolean; + isCurrent?: boolean; +} + +const Header = ({ title, onClose }: { title: string; onClose: () => void }) => { + return ( +
+ {title} + +
+ ); +}; + +const CurrentVersionItem = ({ version }: { version: FileVersion }) => { + return ( +
+
+
+ + {dateService.format(version.date, 'MMM D, h:mm A')} + + + Current + +
+
+
+ {version.userName} +
+
+
+ ); +}; + +const VersionItem = ({ version, onDelete }: { version: FileVersion; onDelete: (id: string) => void }) => { + const [isChecked, setIsChecked] = useState(false); + const [keepButtonVisible, setKeepButtonVisible] = useState(false); + const [dropdownDirection, setDropdownDirection] = useState<'right' | 'left'>('right'); + const dropdownRef = useRef(null); + const itemRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setKeepButtonVisible(false); + } + }; + + if (keepButtonVisible) { + document.addEventListener('mousedown', handleClickOutside); + + // Calculate if dropdown fits below + if (itemRef.current) { + const rect = itemRef.current.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + const menuHeight = 200; // Approximate dropdown menu height + + if (spaceBelow < menuHeight) { + setDropdownDirection('left'); // Actually means "up" in this context + } else { + setDropdownDirection('right'); // Means "down" + } + } + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [keepButtonVisible]); + + const handleRestore = () => { + // TODO: Implement restore functionality + setKeepButtonVisible(false); + }; + + const handleDownload = () => { + // TODO: Implement download functionality + setKeepButtonVisible(false); + }; + + const handleDelete = () => { + onDelete(version.id); + setKeepButtonVisible(false); + }; + + const handleItemClick = () => { + setIsChecked(!isChecked); + }; + + const menuItems: Array> = [ + { + name: 'Restore version', + icon: ClockCounterClockwise, + action: handleRestore, + }, + { + name: 'Download version', + icon: DownloadSimple, + action: handleDownload, + }, + { + separator: true, + }, + { + name: 'Delete version', + icon: Trash, + action: handleDelete, + }, + ]; + + return ( +
+
+
+ +
+
+ + {dateService.format(version.date, 'MMM D, h:mm A')} + +
+ {version.expiresInDays !== undefined && ( +
+ + Expires in {version.expiresInDays} days +
+ )} +
+
+ {version.userName} +
+
+
+
{ + e.stopPropagation(); + if (!isChecked) { + setIsChecked(true); + } + setKeepButtonVisible(true); + }} + > + + + +
+
+
+ ); +}; + +const VersionHistorySidebar = () => { + const dispatch = useAppDispatch(); + const isOpen = useAppSelector((state: RootState) => state.ui.isVersionHistoryDialogOpen); + const item = useAppSelector((state: RootState) => state.ui.versionHistoryItem); + const { translate } = useTranslationContext(); + + // Mock data for now - this should come from API + const [versions, setVersions] = useState([ + { + id: '1', + date: new Date(), + userName: 'Name', + isCurrent: true, + }, + { + id: '2', + date: new Date(Date.now() - 1000 * 60 * 60 * 24), + userName: 'Name', + expiresInDays: 4, + isAutosave: true, + }, + { + id: '3', + date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2), + userName: 'Name', + expiresInDays: 3, + }, + { + id: '4', + date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3), + userName: 'Name', + expiresInDays: 2, + }, + { + id: '5', + date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 4), + userName: 'Name', + expiresInDays: 1, + }, + { + id: '6', + date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5), + userName: 'Name', + expiresInDays: 4, + }, + { + id: '7', + date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 6), + userName: 'Name', + expiresInDays: 3, + }, + { + id: '8', + date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5), + userName: 'Name', + expiresInDays: 4, + }, + { + id: '10', + date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 6), + userName: 'Name', + expiresInDays: 3, + }, + ]); + + const [selectAllAutosave, setSelectAllAutosave] = useState(false); + const autosaveVersions = versions.filter((v) => v.isAutosave); + const totalAutosaveCount = autosaveVersions.length; + + const onClose = () => { + dispatch(uiActions.setIsVersionHistoryDialogOpen(false)); + }; + + const handleDeleteVersion = (versionId: string) => { + setVersions((prev) => prev.filter((v) => v.id !== versionId)); + }; + + const handleDeleteAllAutosave = () => { + setVersions((prev) => prev.filter((v) => !v.isAutosave)); + setSelectAllAutosave(false); + }; + + useEffect(() => { + if (!isOpen) { + // Reset state when dialog closes + } + }, [isOpen]); + + if (!item) return null; + + return ( + <> + {/* Backdrop */} + {isOpen &&
} + + {/* Side Panel */} +
+
+
+ +
+ {/* Current Version */} + {versions + .filter((v) => v.isCurrent) + .map((version) => ( + + ))} + + {/* Autosave versions section */} + {totalAutosaveCount > 0 && ( +
+
+ setSelectAllAutosave(e.target.checked)} + className="h-4 w-4" + /> +
+ + {totalAutosaveCount}/{totalAutosaveCount} autosave versions + + +
+
+ +
+ )} + + {/* Other versions */} + {versions + .filter((v) => !v.isCurrent) + .map((version) => ( + + ))} +
+
+
+ + ); +}; + +export default VersionHistorySidebar; diff --git a/src/views/Drive/hooks/useDriveItemActions.tsx b/src/views/Drive/hooks/useDriveItemActions.tsx index ecb63bb663..c3932284a8 100644 --- a/src/views/Drive/hooks/useDriveItemActions.tsx +++ b/src/views/Drive/hooks/useDriveItemActions.tsx @@ -102,8 +102,8 @@ const useDriveItemActions = (item): DriveItemActions => { }; const onViewVersionHistoryButtonClicked = () => { - // TODO: Implement version history dialog - console.log('View version history for item:', item); + dispatch(uiActions.setVersionHistoryItem(item as DriveItemData)); + dispatch(uiActions.setIsVersionHistoryDialogOpen(true)); }; const onMoveToTrashButtonClicked = () => { From b4b5565357d08e438d0369f6bf20bb3f56009132 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 5 Dec 2025 01:20:16 -0400 Subject: [PATCH 03/47] refactor: restructure version history sidebar with modular components Refactors the version history sidebar by extracting components into separate files for better maintainability and code organization. Replaces magic strings with constants for dropdown positioning. --- .../HeaderAndSidenavLayout.tsx | 5 +- .../DriveExplorer/DriveExplorer.tsx | 2 - .../components/VersionHistory/Sidebar.tsx | 88 +++++ .../components/AutosaveSection.tsx | 36 ++ .../components/CurrentVersionItem.tsx | 28 ++ .../VersionHistory/components/Header.tsx | 20 + .../VersionHistory/components/VersionItem.tsx | 98 +++++ .../VersionHistory/components/index.ts | 4 + .../components/VersionHistory/hooks/index.ts | 2 + .../hooks/useDropdownPositioning.ts | 58 +++ .../hooks/useVersionItemActions.ts | 47 +++ .../Drive/components/VersionHistory/index.ts | 1 + .../Drive/components/VersionHistory/types.ts | 8 + .../VersionHistorySidebar.tsx | 346 ------------------ 14 files changed, 394 insertions(+), 349 deletions(-) create mode 100644 src/views/Drive/components/VersionHistory/Sidebar.tsx create mode 100644 src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx create mode 100644 src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx create mode 100644 src/views/Drive/components/VersionHistory/components/Header.tsx create mode 100644 src/views/Drive/components/VersionHistory/components/VersionItem.tsx create mode 100644 src/views/Drive/components/VersionHistory/components/index.ts create mode 100644 src/views/Drive/components/VersionHistory/hooks/index.ts create mode 100644 src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.ts create mode 100644 src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts create mode 100644 src/views/Drive/components/VersionHistory/index.ts create mode 100644 src/views/Drive/components/VersionHistory/types.ts delete mode 100644 src/views/Drive/components/VersionHistorySidebar/VersionHistorySidebar.tsx diff --git a/src/app/core/layouts/HeaderAndSidenavLayout/HeaderAndSidenavLayout.tsx b/src/app/core/layouts/HeaderAndSidenavLayout/HeaderAndSidenavLayout.tsx index 57bf04e565..79bdf94b4c 100644 --- a/src/app/core/layouts/HeaderAndSidenavLayout/HeaderAndSidenavLayout.tsx +++ b/src/app/core/layouts/HeaderAndSidenavLayout/HeaderAndSidenavLayout.tsx @@ -11,6 +11,7 @@ import DriveItemInfoMenu from 'app/drive/components/DriveItemInfoMenu/DriveItemI import SharedFolderTooBigDialog from '../../../drive/components/SharedFolderTooBigDialog/SharedFolderTooBigDialog'; import { getAppConfig } from 'services/config.service'; import ShareItemDialog from '../../../../views/Shared/components/ShareItemDialog/ShareItemDialog'; +import { Sidebar as VersionHistorySidebar } from '../../../../views/Drive/components/VersionHistory'; export interface HeaderAndSidenavLayoutProps { children: JSX.Element; @@ -48,12 +49,14 @@ export default function HeaderAndSidenavLayout(props: HeaderAndSidenavLayoutProp
-
+
{children} {isDriveItemInfoMenuOpen && driveItemInfo && ( )} + +
diff --git a/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx b/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx index cf8f0d839f..d93a3c9264 100644 --- a/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx +++ b/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx @@ -63,7 +63,6 @@ import { DriveTopBarItems } from './DriveTopBarItems'; import { ShareDialogWrapper } from 'app/drive/components/ShareDialog/ShareDialogWrapper'; import RealtimeService from 'services/sockets/socket.service'; import { eventHandler } from 'services/sockets/event-handler.service'; -import VersionHistorySidebar from '../VersionHistorySidebar/VersionHistorySidebar'; const MenuItemToGetSize = ({ isTrash, @@ -533,7 +532,6 @@ const DriveExplorer = (props: DriveExplorerProps): JSX.Element => { /> - {editNameItem && ( diff --git a/src/views/Drive/components/VersionHistory/Sidebar.tsx b/src/views/Drive/components/VersionHistory/Sidebar.tsx new file mode 100644 index 0000000000..8788dcc94a --- /dev/null +++ b/src/views/Drive/components/VersionHistory/Sidebar.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { RootState } from 'app/store'; +import { useAppDispatch, useAppSelector } from 'app/store/hooks'; +import { uiActions } from 'app/store/slices/ui'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { Header, CurrentVersionItem, VersionItem, AutosaveSection } from './components'; +import { FileVersion } from './types'; + +const Sidebar = () => { + const dispatch = useAppDispatch(); + const isOpen = useAppSelector((state: RootState) => state.ui.isVersionHistoryDialogOpen); + const item = useAppSelector((state: RootState) => state.ui.versionHistoryItem); + const { translate } = useTranslationContext(); + + const [versions, setVersions] = useState([ + { + id: '1', + date: new Date(), + userName: 'Name', + isCurrent: true, + }, + { + id: '2', + date: new Date(Date.now() - 1000 * 60 * 60 * 24), + userName: 'Name', + expiresInDays: 4, + isAutosave: true, + }, + ]); + + const [selectAllAutosave, setSelectAllAutosave] = useState(false); + const autosaveVersions = versions.filter((v) => v.isAutosave); + const totalAutosaveCount = autosaveVersions.length; + + const onClose = () => { + dispatch(uiActions.setIsVersionHistoryDialogOpen(false)); + }; + + const handleDeleteVersion = (versionId: string) => { + setVersions((prev) => prev.filter((v) => v.id !== versionId)); + }; + + const handleDeleteAllAutosave = () => { + setVersions((prev) => prev.filter((v) => !v.isAutosave)); + setSelectAllAutosave(false); + }; + + if (!item) return null; + + return ( + <> + {isOpen &&
} +
+
+
+ +
+ {versions + .filter((v) => v.isCurrent) + .map((version) => ( + + ))} + + + + {versions + .filter((v) => !v.isCurrent) + .map((version) => ( + + ))} +
+
+
+ + ); +}; + +export default Sidebar; diff --git a/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx new file mode 100644 index 0000000000..21715ea8e6 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx @@ -0,0 +1,36 @@ +import { Trash } from '@phosphor-icons/react'; +import { Checkbox } from '@internxt/ui'; + +interface AutosaveSectionProps { + totalAutosaveCount: number; + selectAllAutosave: boolean; + onSelectAllChange: (checked: boolean) => void; + onDeleteAll: () => void; +} + +export const AutosaveSection = ({ + totalAutosaveCount, + selectAllAutosave, + onSelectAllChange, + onDeleteAll, +}: AutosaveSectionProps) => { + if (totalAutosaveCount === 0) return null; + + return ( +
+
+ onSelectAllChange(!selectAllAutosave)} + className="h-4 w-4" + /> + + {totalAutosaveCount}/{totalAutosaveCount} autosave versions + +
+ +
+ ); +}; diff --git a/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx b/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx new file mode 100644 index 0000000000..8f10ea3d29 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/components/CurrentVersionItem.tsx @@ -0,0 +1,28 @@ +import dateService from 'services/date.service'; +import { Avatar } from '@internxt/ui'; +import { FileVersion } from '../types'; + +interface CurrentVersionItemProps { + version: FileVersion; +} + +export const CurrentVersionItem = ({ version }: CurrentVersionItemProps) => { + return ( +
+
+
+ + {dateService.format(version.date, 'MMM D, h:mm A')} + + + Current + +
+
+ + {version.userName} +
+
+
+ ); +}; diff --git a/src/views/Drive/components/VersionHistory/components/Header.tsx b/src/views/Drive/components/VersionHistory/components/Header.tsx new file mode 100644 index 0000000000..2ea53a1e0a --- /dev/null +++ b/src/views/Drive/components/VersionHistory/components/Header.tsx @@ -0,0 +1,20 @@ +import { X } from '@phosphor-icons/react'; + +interface HeaderProps { + title: string; + onClose: () => void; +} + +export const Header = ({ title, onClose }: HeaderProps) => { + return ( +
+ {title} + +
+ ); +}; diff --git a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx new file mode 100644 index 0000000000..7cbd0c8e52 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx @@ -0,0 +1,98 @@ +import { useState } from 'react'; +import { Info, DotsThree } from '@phosphor-icons/react'; +import { Checkbox, Dropdown, Avatar } from '@internxt/ui'; +import dateService from 'services/date.service'; +import { FileVersion } from '../types'; +import { useDropdownPositioning, useVersionItemActions } from '../hooks'; + +interface VersionItemProps { + version: FileVersion; + onDelete: (id: string) => void; +} + +export const VersionItem = ({ version, onDelete }: VersionItemProps) => { + const [isSelected, setIsSelected] = useState(true); + const { isOpen, setIsOpen, dropdownPosition, dropdownRef, itemRef } = useDropdownPositioning(); + const { menuItems } = useVersionItemActions({ + version, + onDelete, + onDropdownClose: () => setIsOpen(false), + }); + + const handleItemClick = () => { + setIsSelected(!isSelected); + }; + + const dropdownOpenDirection = dropdownPosition === 'above' ? 'left' : 'right'; + + return ( + + ); +}; diff --git a/src/views/Drive/components/VersionHistory/components/index.ts b/src/views/Drive/components/VersionHistory/components/index.ts new file mode 100644 index 0000000000..58e0f09ed5 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/components/index.ts @@ -0,0 +1,4 @@ +export { Header } from './Header'; +export { CurrentVersionItem } from './CurrentVersionItem'; +export { VersionItem } from './VersionItem'; +export { AutosaveSection } from './AutosaveSection'; diff --git a/src/views/Drive/components/VersionHistory/hooks/index.ts b/src/views/Drive/components/VersionHistory/hooks/index.ts new file mode 100644 index 0000000000..cb2aeceda4 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/hooks/index.ts @@ -0,0 +1,2 @@ +export { useDropdownPositioning } from './useDropdownPositioning'; +export { useVersionItemActions } from './useVersionItemActions'; diff --git a/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.ts b/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.ts new file mode 100644 index 0000000000..70ba700b9e --- /dev/null +++ b/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.ts @@ -0,0 +1,58 @@ +import { useEffect, useState, useRef, RefObject } from 'react'; + +type DropdownPosition = 'above' | 'below'; + +const DROPDOWN_POSITION = { + ABOVE: 'above' as const, + BELOW: 'below' as const, +}; + +interface UseDropdownPositioningReturn { + isOpen: boolean; + setIsOpen: (open: boolean) => void; + dropdownPosition: DropdownPosition; + dropdownRef: RefObject; + itemRef: RefObject; +} + +export const useDropdownPositioning = (): UseDropdownPositioningReturn => { + const [isOpen, setIsOpen] = useState(false); + const [dropdownPosition, setDropdownPosition] = useState(DROPDOWN_POSITION.BELOW); + const dropdownRef = useRef(null); + const itemRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + if (itemRef.current) { + const rect = itemRef.current.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + const menuHeight = 200; + + if (spaceBelow < menuHeight) { + setDropdownPosition(DROPDOWN_POSITION.ABOVE); + } else { + setDropdownPosition(DROPDOWN_POSITION.BELOW); + } + } + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + return { + isOpen, + setIsOpen, + dropdownPosition, + dropdownRef, + itemRef, + }; +}; diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts b/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts new file mode 100644 index 0000000000..81c2d3af41 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts @@ -0,0 +1,47 @@ +import { Trash, ClockCounterClockwise, DownloadSimple } from '@phosphor-icons/react'; +import { MenuItemType } from '@internxt/ui'; +import { FileVersion } from '../types'; + +interface UseVersionItemActionsParams { + version: FileVersion; + onDelete: (id: string) => void; + onDropdownClose: () => void; +} + +export const useVersionItemActions = ({ version, onDelete, onDropdownClose }: UseVersionItemActionsParams) => { + const handleRestore = () => { + onDropdownClose(); + }; + + const handleDownload = () => { + onDropdownClose(); + }; + + const handleDelete = () => { + onDelete(version.id); + onDropdownClose(); + }; + + const menuItems: Array> = [ + { + name: 'Restore version', + icon: ClockCounterClockwise, + action: handleRestore, + }, + { + name: 'Download version', + icon: DownloadSimple, + action: handleDownload, + }, + { + separator: true, + }, + { + name: 'Delete version', + icon: Trash, + action: handleDelete, + }, + ]; + + return { menuItems }; +}; diff --git a/src/views/Drive/components/VersionHistory/index.ts b/src/views/Drive/components/VersionHistory/index.ts new file mode 100644 index 0000000000..871a4d9d0a --- /dev/null +++ b/src/views/Drive/components/VersionHistory/index.ts @@ -0,0 +1 @@ +export { default as Sidebar } from './Sidebar'; diff --git a/src/views/Drive/components/VersionHistory/types.ts b/src/views/Drive/components/VersionHistory/types.ts new file mode 100644 index 0000000000..9492b17ffc --- /dev/null +++ b/src/views/Drive/components/VersionHistory/types.ts @@ -0,0 +1,8 @@ +export interface FileVersion { + id: string; + date: Date; + userName: string; + expiresInDays?: number; + isAutosave?: boolean; + isCurrent?: boolean; +} diff --git a/src/views/Drive/components/VersionHistorySidebar/VersionHistorySidebar.tsx b/src/views/Drive/components/VersionHistorySidebar/VersionHistorySidebar.tsx deleted file mode 100644 index 32a00423f2..0000000000 --- a/src/views/Drive/components/VersionHistorySidebar/VersionHistorySidebar.tsx +++ /dev/null @@ -1,346 +0,0 @@ -import { useEffect, useState, useRef } from 'react'; -import { RootState } from 'app/store'; -import { useAppDispatch, useAppSelector } from 'app/store/hooks'; -import { uiActions } from 'app/store/slices/ui'; -import { X, Trash, Info, DotsThree, ClockCounterClockwise, DownloadSimple } from '@phosphor-icons/react'; -import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { Checkbox, Dropdown, MenuItemType } from '@internxt/ui'; -import { DriveItemData } from 'app/drive/types'; -import dateService from 'services/date.service'; - -interface FileVersion { - id: string; - date: Date; - userName: string; - expiresInDays?: number; - isAutosave?: boolean; - isCurrent?: boolean; -} - -const Header = ({ title, onClose }: { title: string; onClose: () => void }) => { - return ( -
- {title} - -
- ); -}; - -const CurrentVersionItem = ({ version }: { version: FileVersion }) => { - return ( -
-
-
- - {dateService.format(version.date, 'MMM D, h:mm A')} - - - Current - -
-
-
- {version.userName} -
-
-
- ); -}; - -const VersionItem = ({ version, onDelete }: { version: FileVersion; onDelete: (id: string) => void }) => { - const [isChecked, setIsChecked] = useState(false); - const [keepButtonVisible, setKeepButtonVisible] = useState(false); - const [dropdownDirection, setDropdownDirection] = useState<'right' | 'left'>('right'); - const dropdownRef = useRef(null); - const itemRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setKeepButtonVisible(false); - } - }; - - if (keepButtonVisible) { - document.addEventListener('mousedown', handleClickOutside); - - // Calculate if dropdown fits below - if (itemRef.current) { - const rect = itemRef.current.getBoundingClientRect(); - const spaceBelow = window.innerHeight - rect.bottom; - const menuHeight = 200; // Approximate dropdown menu height - - if (spaceBelow < menuHeight) { - setDropdownDirection('left'); // Actually means "up" in this context - } else { - setDropdownDirection('right'); // Means "down" - } - } - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [keepButtonVisible]); - - const handleRestore = () => { - // TODO: Implement restore functionality - setKeepButtonVisible(false); - }; - - const handleDownload = () => { - // TODO: Implement download functionality - setKeepButtonVisible(false); - }; - - const handleDelete = () => { - onDelete(version.id); - setKeepButtonVisible(false); - }; - - const handleItemClick = () => { - setIsChecked(!isChecked); - }; - - const menuItems: Array> = [ - { - name: 'Restore version', - icon: ClockCounterClockwise, - action: handleRestore, - }, - { - name: 'Download version', - icon: DownloadSimple, - action: handleDownload, - }, - { - separator: true, - }, - { - name: 'Delete version', - icon: Trash, - action: handleDelete, - }, - ]; - - return ( -
-
-
- -
-
- - {dateService.format(version.date, 'MMM D, h:mm A')} - -
- {version.expiresInDays !== undefined && ( -
- - Expires in {version.expiresInDays} days -
- )} -
-
- {version.userName} -
-
-
-
{ - e.stopPropagation(); - if (!isChecked) { - setIsChecked(true); - } - setKeepButtonVisible(true); - }} - > - - - -
-
-
- ); -}; - -const VersionHistorySidebar = () => { - const dispatch = useAppDispatch(); - const isOpen = useAppSelector((state: RootState) => state.ui.isVersionHistoryDialogOpen); - const item = useAppSelector((state: RootState) => state.ui.versionHistoryItem); - const { translate } = useTranslationContext(); - - // Mock data for now - this should come from API - const [versions, setVersions] = useState([ - { - id: '1', - date: new Date(), - userName: 'Name', - isCurrent: true, - }, - { - id: '2', - date: new Date(Date.now() - 1000 * 60 * 60 * 24), - userName: 'Name', - expiresInDays: 4, - isAutosave: true, - }, - { - id: '3', - date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2), - userName: 'Name', - expiresInDays: 3, - }, - { - id: '4', - date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3), - userName: 'Name', - expiresInDays: 2, - }, - { - id: '5', - date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 4), - userName: 'Name', - expiresInDays: 1, - }, - { - id: '6', - date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5), - userName: 'Name', - expiresInDays: 4, - }, - { - id: '7', - date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 6), - userName: 'Name', - expiresInDays: 3, - }, - { - id: '8', - date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5), - userName: 'Name', - expiresInDays: 4, - }, - { - id: '10', - date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 6), - userName: 'Name', - expiresInDays: 3, - }, - ]); - - const [selectAllAutosave, setSelectAllAutosave] = useState(false); - const autosaveVersions = versions.filter((v) => v.isAutosave); - const totalAutosaveCount = autosaveVersions.length; - - const onClose = () => { - dispatch(uiActions.setIsVersionHistoryDialogOpen(false)); - }; - - const handleDeleteVersion = (versionId: string) => { - setVersions((prev) => prev.filter((v) => v.id !== versionId)); - }; - - const handleDeleteAllAutosave = () => { - setVersions((prev) => prev.filter((v) => !v.isAutosave)); - setSelectAllAutosave(false); - }; - - useEffect(() => { - if (!isOpen) { - // Reset state when dialog closes - } - }, [isOpen]); - - if (!item) return null; - - return ( - <> - {/* Backdrop */} - {isOpen &&
} - - {/* Side Panel */} -
-
-
- -
- {/* Current Version */} - {versions - .filter((v) => v.isCurrent) - .map((version) => ( - - ))} - - {/* Autosave versions section */} - {totalAutosaveCount > 0 && ( -
-
- setSelectAllAutosave(e.target.checked)} - className="h-4 w-4" - /> -
- - {totalAutosaveCount}/{totalAutosaveCount} autosave versions - - -
-
- -
- )} - - {/* Other versions */} - {versions - .filter((v) => !v.isCurrent) - .map((version) => ( - - ))} -
-
-
- - ); -}; - -export default VersionHistorySidebar; From b1d81a366558776e93b28e23c22207eaf0dc0276 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 5 Dec 2025 01:26:15 -0400 Subject: [PATCH 04/47] refactor: rename version history dialog state and actions to sidebar --- src/app/store/slices/ui/index.ts | 8 ++++---- .../DriveExplorer/components/DriveExplorerList.tsx | 2 +- .../DriveExplorer/components/DriveTopBarActions.tsx | 2 +- src/views/Drive/components/VersionHistory/Sidebar.tsx | 4 ++-- src/views/Drive/hooks/useDriveItemActions.tsx | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/store/slices/ui/index.ts b/src/app/store/slices/ui/index.ts index a30cd04fce..7c2ab076db 100644 --- a/src/app/store/slices/ui/index.ts +++ b/src/app/store/slices/ui/index.ts @@ -9,7 +9,7 @@ interface UISliceState { isShareDialogOpen: boolean; isInvitationsDialogOpen: boolean; isItemDetailsDialogOpen: boolean; - isVersionHistoryDialogOpen: boolean; + isVersionHistorySidebarOpen: boolean; isCreateFolderDialogOpen: boolean; isDeleteItemsDialogOpen: boolean; isMoveItemsDialogOpen: boolean; @@ -43,7 +43,7 @@ const initialState: UISliceState = { isShareDialogOpen: false, isInvitationsDialogOpen: false, isItemDetailsDialogOpen: false, - isVersionHistoryDialogOpen: false, + isVersionHistorySidebarOpen: false, isCreateFolderDialogOpen: false, isDeleteItemsDialogOpen: false, isMoveItemsDialogOpen: false, @@ -92,8 +92,8 @@ export const uiSlice = createSlice({ setIsItemDetailsDialogOpen(state: UISliceState, action: PayloadAction) { state.isItemDetailsDialogOpen = action.payload; }, - setIsVersionHistoryDialogOpen(state: UISliceState, action: PayloadAction) { - state.isVersionHistoryDialogOpen = action.payload; + setIsVersionHistorySidebarOpen(state: UISliceState, action: PayloadAction) { + state.isVersionHistorySidebarOpen = action.payload; }, setVersionHistoryItem: (state: UISliceState, action: PayloadAction) => { state.versionHistoryItem = action.payload; diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx index 92c866d74d..091b72a3e4 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx @@ -272,7 +272,7 @@ const DriveExplorerList: React.FC = memo((props) => { const viewVersionHistory = useCallback( (item: ContextMenuDriveItem) => { dispatch(uiActions.setVersionHistoryItem(item as DriveItemData)); - dispatch(uiActions.setIsVersionHistoryDialogOpen(true)); + dispatch(uiActions.setIsVersionHistorySidebarOpen(true)); }, [dispatch, uiActions], ); diff --git a/src/views/Drive/components/DriveExplorer/components/DriveTopBarActions.tsx b/src/views/Drive/components/DriveExplorer/components/DriveTopBarActions.tsx index c10a147efd..0b71ab5743 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveTopBarActions.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveTopBarActions.tsx @@ -100,7 +100,7 @@ const DriveTopBarActions = ({ const onViewVersionHistoryButtonClicked = (): void => { dispatch(uiActions.setVersionHistoryItem(selectedItems[0])); - dispatch(uiActions.setIsVersionHistoryDialogOpen(true)); + dispatch(uiActions.setIsVersionHistorySidebarOpen(true)); }; const onBulkDeleteButtonClicked = (): void => { diff --git a/src/views/Drive/components/VersionHistory/Sidebar.tsx b/src/views/Drive/components/VersionHistory/Sidebar.tsx index 8788dcc94a..f4ad5b847c 100644 --- a/src/views/Drive/components/VersionHistory/Sidebar.tsx +++ b/src/views/Drive/components/VersionHistory/Sidebar.tsx @@ -8,7 +8,7 @@ import { FileVersion } from './types'; const Sidebar = () => { const dispatch = useAppDispatch(); - const isOpen = useAppSelector((state: RootState) => state.ui.isVersionHistoryDialogOpen); + const isOpen = useAppSelector((state: RootState) => state.ui.isVersionHistorySidebarOpen); const item = useAppSelector((state: RootState) => state.ui.versionHistoryItem); const { translate } = useTranslationContext(); @@ -33,7 +33,7 @@ const Sidebar = () => { const totalAutosaveCount = autosaveVersions.length; const onClose = () => { - dispatch(uiActions.setIsVersionHistoryDialogOpen(false)); + dispatch(uiActions.setIsVersionHistorySidebarOpen(false)); }; const handleDeleteVersion = (versionId: string) => { diff --git a/src/views/Drive/hooks/useDriveItemActions.tsx b/src/views/Drive/hooks/useDriveItemActions.tsx index c3932284a8..a64eb0c718 100644 --- a/src/views/Drive/hooks/useDriveItemActions.tsx +++ b/src/views/Drive/hooks/useDriveItemActions.tsx @@ -103,7 +103,7 @@ const useDriveItemActions = (item): DriveItemActions => { const onViewVersionHistoryButtonClicked = () => { dispatch(uiActions.setVersionHistoryItem(item as DriveItemData)); - dispatch(uiActions.setIsVersionHistoryDialogOpen(true)); + dispatch(uiActions.setIsVersionHistorySidebarOpen(true)); }; const onMoveToTrashButtonClicked = () => { From aea78d9dc7cdf971a52fc6575be8c38b5cb6bf82 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 5 Dec 2025 10:34:02 -0400 Subject: [PATCH 05/47] feat: add internationalization support to version history module --- src/app/i18n/locales/de.json | 12 +++++++++++- src/app/i18n/locales/en.json | 8 +++++++- src/app/i18n/locales/es.json | 10 ++++++++++ src/app/i18n/locales/fr.json | 10 ++++++++++ src/app/i18n/locales/it.json | 12 +++++++++++- src/app/i18n/locales/ru.json | 10 ++++++++++ src/app/i18n/locales/tw.json | 10 ++++++++++ src/app/i18n/locales/zh.json | 10 ++++++++++ .../VersionHistory/components/AutosaveSection.tsx | 8 +++++++- .../VersionHistory/components/CurrentVersionItem.tsx | 11 ++++++----- .../VersionHistory/components/VersionItem.tsx | 12 ++++++------ .../VersionHistory/hooks/useVersionItemActions.ts | 9 ++++++--- .../Drive/components/VersionHistory/utils/index.ts | 3 +++ 13 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 src/views/Drive/components/VersionHistory/utils/index.ts diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index a24474fdb4..3452603dcc 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -793,6 +793,15 @@ "Ort": "Ort" } }, + "versionHistory": { + "title": "Versionsverlauf", + "current": "Aktuell", + "expiresInDays": "Läuft in {{days}} Tagen ab", + "autosaveVersions": "{{count}}/{{total}} automatisch gespeicherte Versionen", + "restoreVersion": "Version wiederherstellen", + "downloadVersion": "Version herunterladen", + "deleteVersion": "Version löschen" + }, "shareModal": { "title": "\"{{name}}\" teilen", "list": { @@ -1462,7 +1471,8 @@ "restore": "Wiederherstellen", "deletePermanently": "Lösche dauerhaft", "move": "Verschiebe", - "delete": "Lösche" + "delete": "Lösche", + "versionHistory": "Versionsverlauf" }, "deleteItems": { "title": "Dauerhaft löschen?", diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 42e905855c..4f911784ed 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -891,7 +891,13 @@ } }, "versionHistory": { - "title": "Version history" + "title": "Version history", + "current": "Current", + "expiresInDays": "Expires in {{days}} days", + "autosaveVersions": "{{count}}/{{total}} autosave versions", + "restoreVersion": "Restore version", + "downloadVersion": "Download version", + "deleteVersion": "Delete version" }, "shareModal": { "title": "Share \"{{name}}\"", diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 9a97d9cfe0..4299340373 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -872,6 +872,15 @@ "location": "Ubicación" } }, + "versionHistory": { + "title": "Historial de versiones", + "current": "Actual", + "expiresInDays": "Expira en {{days}} días", + "autosaveVersions": "{{count}}/{{total}} versiones de autoguardado", + "restoreVersion": "Restaurar versión", + "downloadVersion": "Descargar versión", + "deleteVersion": "Eliminar versión" + }, "shareModal": { "title": "Compartir \"{{name}}\"", "list": { @@ -1515,6 +1524,7 @@ "manageLinkAccess": "Gestionar acceso", "manageAccess": "Gestionar acceso", "download": "Descargar", + "versionHistory": "Historial de versiones", "moveToTrash": "Mover a la papelera", "copyLink": "Copiar enlace", "linkSettings": "Ajustes del enlace", diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index 4c05922941..ca072644f6 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -814,6 +814,15 @@ "location": "Emplacement" } }, + "versionHistory": { + "title": "Historique des versions", + "current": "Actuelle", + "expiresInDays": "Expire dans {{days}} jours", + "autosaveVersions": "{{count}}/{{total}} versions de sauvegarde automatique", + "restoreVersion": "Restaurer la version", + "downloadVersion": "Télécharger la version", + "deleteVersion": "Supprimer la version" + }, "newFolderModal": { "title": "Nouveau dossier", "label": "Nom", @@ -1461,6 +1470,7 @@ "manageLinkAccess": "Gestion de l'accès", "manageAccess": "Gestion de l'accès", "download": "Télécharger", + "versionHistory": "Historique des versions", "moveToTrash": "Déplacer vers la corbeille", "copyLink": "Copier le lien", "linkSettings": "Paramètres du lien", diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 2aaa139db4..3b298b6f4f 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -926,6 +926,15 @@ "location": "Posizione" } }, + "versionHistory": { + "title": "Cronologia versioni", + "current": "Attuale", + "expiresInDays": "Scade tra {{days}} giorni", + "autosaveVersions": "{{count}}/{{total}} versioni di salvataggio automatico", + "restoreVersion": "Ripristina versione", + "downloadVersion": "Scarica versione", + "deleteVersion": "Elimina versione" + }, "shareModal": { "title": "Condividi \"{{name}}\"", "list": { @@ -1575,7 +1584,8 @@ "restore": "Ripristinare", "deletePermanently": "Eliminare definitivamente", "move": "Sposta", - "delete": "Elimina" + "delete": "Elimina", + "versionHistory": "Cronologia versioni" }, "deleteItems": { "title": "Eliminare definitivamente?", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index b5197f3087..584ef9efe0 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -833,6 +833,15 @@ "location": "Расположение" } }, + "versionHistory": { + "title": "История версий", + "current": "Текущая", + "expiresInDays": "Истекает через {{days}} дней", + "autosaveVersions": "{{count}}/{{total}} версий автосохранения", + "restoreVersion": "Восстановить версию", + "downloadVersion": "Скачать версию", + "deleteVersion": "Удалить версию" + }, "shareModal": { "title": "Поделиться \"{{name}}\"", "list": { @@ -1474,6 +1483,7 @@ "manageLinkAccess": "Управление доступом", "manageAccess": "Управление доступом", "download": "Загрузить", + "versionHistory": "История версий", "moveToTrash": "Переместить в корзину", "copyLink": "Копировать ссылку", "linkSettings": "Настройки ссылки", diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index 41d3201328..7b529e0dc9 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -820,6 +820,15 @@ "location": "位置" } }, + "versionHistory": { + "title": "版本歷史", + "current": "目前", + "expiresInDays": "{{days}} 天後過期", + "autosaveVersions": "{{count}}/{{total}} 自動儲存版本", + "restoreVersion": "復原版本", + "downloadVersion": "下載版本", + "deleteVersion": "刪除版本" + }, "shareModal": { "title": "分享“{{name}}”", "list": { @@ -1458,6 +1467,7 @@ }, "dropdown": { "openPreview": "打開預覽", + "versionHistory": "版本歷史", "details": "詳細信息", "shareLink": "分享", "shareTeam": "与团队分享", diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index e6cc7677eb..fa31fca700 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -856,6 +856,15 @@ "location": "位置" } }, + "versionHistory": { + "title": "版本历史", + "current": "当前", + "expiresInDays": "{{days}} 天后到期", + "autosaveVersions": "{{count}}/{{total}} 自动保存版本", + "restoreVersion": "恢复版本", + "downloadVersion": "下载版本", + "deleteVersion": "删除版本" + }, "shareModal": { "title": "分享 \"{{name}}\"", "list": { @@ -1503,6 +1512,7 @@ "share": "分享", "manageAccess": "管理访问权限", "download": "下载", + "versionHistory": "版本历史", "moveToTrash": "移入垃圾箱", "copyLink": "复制链接", "linkSettings": "链接设置", diff --git a/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx index 21715ea8e6..b072ba2f08 100644 --- a/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx +++ b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx @@ -1,5 +1,6 @@ import { Trash } from '@phosphor-icons/react'; import { Checkbox } from '@internxt/ui'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; interface AutosaveSectionProps { totalAutosaveCount: number; @@ -14,6 +15,8 @@ export const AutosaveSection = ({ onSelectAllChange, onDeleteAll, }: AutosaveSectionProps) => { + const { translate } = useTranslationContext(); + if (totalAutosaveCount === 0) return null; return ( @@ -25,7 +28,10 @@ export const AutosaveSection = ({ className="h-4 w-4" /> - {totalAutosaveCount}/{totalAutosaveCount} autosave versions + {translate('modals.versionHistory.autosaveVersions', { + count: totalAutosaveCount, + total: totalAutosaveCount, + })}
+ +
+
+ + ); +}; diff --git a/src/views/Drive/components/VersionHistory/components/VersionHistorySkeleton.tsx b/src/views/Drive/components/VersionHistory/components/VersionHistorySkeleton.tsx new file mode 100644 index 0000000000..3919e24ecc --- /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 6f7c866074..e993602a99 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 58e0f09ed5..9bc3ebe779 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 c4ce61ae1c..984b0c53ad 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 0000000000..b23b92f2a4 --- /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 9492b17ffc..54a8f87029 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 0000000000..b98b40ed63 --- /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; From 5d54b7e7d1a68ccd5063b0979e38ea6651939273 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 10 Dec 2025 01:30:06 -0400 Subject: [PATCH 08/47] 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 3919e24ecc..d0c1bef1bd 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 ac309af39f50cc9f50e3910bbc087f4c34c3cbb4 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 10 Dec 2025 10:57:12 -0400 Subject: [PATCH 09/47] 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 79bdf94b4c..63c4b05ab6 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 75d25c387f..c7ad12b777 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 b6fc723044..a07376ef8b 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 984b0c53ad..f4e9bdd149 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 b23b92f2a4..1144966b67 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 8c1579cceeeaf68377f27967d6e1c558d61e61cc Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 10 Dec 2025 11:21:37 -0400 Subject: [PATCH 10/47] 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 f4e9bdd149..de3f34e425 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 c5ba49e221877d918483ca77aa2eb34cab48afd0 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 10 Dec 2025 00:59:31 -0400 Subject: [PATCH 11/47] 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 3452603dcc..d9e3730e2c 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -800,7 +800,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": "\"{{name}}\" teilen", diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 4299340373..124c052ee8 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -879,7 +879,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 ca072644f6..daec745a2d 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -821,7 +821,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 3b298b6f4f..a0ef63ea20 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -933,7 +933,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 584ef9efe0..1912cf38b9 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -840,7 +840,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 7b529e0dc9..dd50b7a781 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -827,7 +827,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 fa31fca700..c941907e31 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -863,7 +863,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 3e31c8e61aa8e21dffb1d434e3e1c161ca1c2483 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 12 Dec 2025 01:53:36 -0400 Subject: [PATCH 12/47] 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 --- .../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 +- 10 files changed, 188 insertions(+), 78 deletions(-) delete mode 100644 src/views/Drive/components/VersionHistory/types.ts diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index c7ad12b777..8a2a0be11a 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 a07376ef8b..7012d72278 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 b072ba2f08..8bea3504e5 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 28bd8cef12..c7e91ca7dc 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 d0c1bef1bd..a7a445a72b 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 e993602a99..3ef4f741fd 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 f63da93617..0000000000 --- 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 3b8db44420..5fb50562cd 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 baf5f41008..948225d68d 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 d8bb1433f791bb8d8747bd9b521892e08589e07b Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 16 Dec 2025 19:13:23 -0400 Subject: [PATCH 18/47] 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 626e96d540..18d38fc65a 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 ba4ec35598a2c414d20f99b711f1714ec97fff43 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 17 Dec 2025 12:09:12 -0400 Subject: [PATCH 19/47] 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 --- .../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 ++--- 7 files changed, 89 insertions(+), 61 deletions(-) create mode 100644 src/app/store/slices/fileVersions/fileVersions.selectors.ts diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index eb33f8be71..36bab0719d 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 0000000000..585e2b58d1 --- /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 3672f0724c..70e099e007 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 a0a2fe2819..2e2213ffd9 100644 --- a/src/services/date.service.ts +++ b/src/services/date.service.ts @@ -24,12 +24,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 18d38fc65a..ef5821df00 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 20cc246feb..a620285b80 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 948225d68d..9b612a67d5 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', From 636c509fd0233745f4645fec74956e4a65824392 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 17 Dec 2025 12:15:06 -0400 Subject: [PATCH 20/47] 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 585e2b58d1..0b6511c2c3 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 4e0af53d4903db52a8e68b84003784fc09a00921 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 18 Dec 2025 08:41:22 -0400 Subject: [PATCH 21/47] 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 0b6511c2c3..03c545460f 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 a620285b80..9ecbcd191c 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 1eaed50b570f40117d080440490a4f583a02bba2 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 8 Jan 2026 09:23:25 -0400 Subject: [PATCH 22/47] 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 36bab0719d..3d465ea21b 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 8bea3504e5..cba9e14898 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 054cf3fc0e9c4fb9954c60ea19071fb0831e5574 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 8 Jan 2026 21:17:26 -0400 Subject: [PATCH 23/47] 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 9ecbcd191c..0d1ba45778 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 cba9e14898..321a936375 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 2ea53a1e0a..ed9578fdda 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 802d5a93011bf787722040b07fcefa7c8654031d Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 8 Jan 2026 22:47:46 -0400 Subject: [PATCH 24/47] 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 e33df36e80..6f8e776a9f 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 6c178b72d7c22b00e16870cbf2902c199c56ea09 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 9 Jan 2026 00:07:46 -0400 Subject: [PATCH 25/47] 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 6f8e776a9f..8415d0cb01 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 && (
From 08a4c954a2ef16c66884ea0ac37c854ec4a7fe0d Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 18 Dec 2025 00:37:37 -0400 Subject: [PATCH 26/47] 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 6b5791aa87..e73231d9cb 100644 --- a/src/services/date.service.test.ts +++ b/src/services/date.service.test.ts @@ -1,5 +1,5 @@ import dayjs from 'dayjs'; -import { describe, expect, test } from 'vitest'; +import { beforeEach, afterEach, describe, expect, test, vi } from 'vitest'; import dateService from './date.service'; describe('dateService', () => { @@ -27,4 +27,30 @@ describe('dateService', () => { expect(isBefore).toBe(false); }); + + describe('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 0000000000..48f33b5513 --- /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 0000000000..2957014575 --- /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 0000000000..021c7d0183 --- /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 034170cf71..a5d085091f 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 b6beafe1437d92525f402a99bb1aa9fae54aafd3 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 22 Dec 2025 19:14:09 -0400 Subject: [PATCH 27/47] 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 e73231d9cb..334d2117e4 100644 --- a/src/services/date.service.test.ts +++ b/src/services/date.service.test.ts @@ -28,7 +28,7 @@ describe('dateService', () => { expect(isBefore).toBe(false); }); - describe('getDaysUntilExpiration', () => { + describe('Expiration countdown', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2023-01-01T00:00:00Z')); @@ -38,17 +38,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 48f33b5513..930020276b 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 021c7d0183..8740e9b140 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 52bc06b4e8573ffb09a92bc30ae8f9c69f11ecf0 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 23 Dec 2025 09:17:06 -0400 Subject: [PATCH 28/47] 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 930020276b..d5ab8abffa 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 2957014575..d45bb6e596 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 7966f010dcaf1f453fe89806671e3fa698861808 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 18 Dec 2025 00:50:10 -0400 Subject: [PATCH 29/47] 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 0000000000..2db76cd583 --- /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 0000000000..ce0d970153 --- /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 b9f976b79307b656974319daa519e862798956f9 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 18 Dec 2025 00:57:26 -0400 Subject: [PATCH 30/47] 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 2db76cd583..1937dff96f 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 1c1f529a0aef2550b6addd48013e6e5b7243d7ae Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 22 Dec 2025 19:23:44 -0400 Subject: [PATCH 31/47] 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 1937dff96f..6ed3ea378f 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 ce0d970153..f055c78c9d 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 365e798ac4b618ba355732af8e42ccff9c6a12a4 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sat, 10 Jan 2026 00:00:06 -0400 Subject: [PATCH 32/47] feat: add locked feature modal and versioning enhancements - Added a locked feature modal to inform users about version restoration capabilities in paid plans. - Updated i18n translations for the new locked feature. - Refactored file version service imports and related tests. - Enhanced version history menu configuration to handle locked states. - Implemented dropdown positioning hook for better UI interactions. - Added tests for version item actions and dropdown positioning. - Integrated version limits fetching in the PlansSection for better user experience. --- src/app/i18n/locales/en.json | 8 ++- .../store/slices/fileVersions/index.test.ts | 4 +- src/app/store/slices/fileVersions/index.ts | 2 +- .../components/DriveExplorerList.tsx | 2 +- .../components/DriveItemContextMenu.tsx | 3 +- .../components/DriveTopBarActions.tsx | 6 +- .../components/VersionHistory/Sidebar.tsx | 42 +++++++++++-- .../components/LockedFeatureModal.tsx | 62 +++++++++++++++++++ .../VersionHistory/components/VersionItem.tsx | 3 +- .../VersionHistory/components/index.ts | 1 + .../components/VersionHistory/hooks/index.ts | 3 - .../hooks/useVersionHistoryMenuConfig.ts | 30 --------- src/views/Drive/hooks/index.ts | 3 + src/views/Drive/hooks/useDriveItemActions.tsx | 6 +- .../hooks/useDropdownPositioning.test.ts | 0 .../hooks/useDropdownPositioning.ts | 0 .../hooks/useVersionHistoryMenuConfig.test.ts | 47 +------------- .../hooks/useVersionHistoryMenuConfig.ts | 16 +++++ .../hooks/useVersionItemActions.test.ts | 2 +- .../hooks/useVersionItemActions.ts | 0 .../services/fileVersion.service.test.ts | 0 .../services/fileVersion.service.ts | 0 .../Sections/Account/Plans/PlansSection.tsx | 7 ++- 23 files changed, 144 insertions(+), 103 deletions(-) create mode 100644 src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx delete mode 100644 src/views/Drive/components/VersionHistory/hooks/index.ts delete mode 100644 src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts rename src/views/Drive/{components/VersionHistory => }/hooks/useDropdownPositioning.test.ts (100%) rename src/views/Drive/{components/VersionHistory => }/hooks/useDropdownPositioning.ts (100%) rename src/views/Drive/{components/VersionHistory => }/hooks/useVersionHistoryMenuConfig.test.ts (57%) create mode 100644 src/views/Drive/hooks/useVersionHistoryMenuConfig.ts rename src/views/Drive/{components/VersionHistory => }/hooks/useVersionItemActions.test.ts (98%) rename src/views/Drive/{components/VersionHistory => }/hooks/useVersionItemActions.ts (100%) rename src/views/Drive/{components/VersionHistory => }/services/fileVersion.service.test.ts (100%) rename src/views/Drive/{components/VersionHistory => }/services/fileVersion.service.ts (100%) diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 84278de79c..aad8880b64 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -910,7 +910,13 @@ "restoreSuccess": "Version restored successfully", "restoreError": "Failed to restore version", "deleteSuccess": "Version deleted successfully", - "deleteError": "Failed to delete version" + "deleteError": "Failed to delete version", + "lockedFeature": { + "title": "Locked feature", + "description": "Restore previous versions of your files and track changes over time.", + "supportedFormats": "Available for PDF, Word, Excel, and CSV files in paid plans.", + "upgradeButton": "Upgrade" + } }, "shareModal": { "title": "Share \"{{name}}\"", diff --git a/src/app/store/slices/fileVersions/index.test.ts b/src/app/store/slices/fileVersions/index.test.ts index 6ed3ea378f..ce4f7dc2a7 100644 --- a/src/app/store/slices/fileVersions/index.test.ts +++ b/src/app/store/slices/fileVersions/index.test.ts @@ -1,10 +1,10 @@ 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 fileVersionService from 'views/Drive/services/fileVersion.service'; import { fileVersionsActions, fileVersionsReducer, fetchFileVersionsThunk, fetchVersionLimitsThunk } from './index'; import { RootState } from '../..'; -vi.mock('views/Drive/components/VersionHistory/services/fileVersion.service', () => ({ +vi.mock('views/Drive/services/fileVersion.service', () => ({ default: { getFileVersions: vi.fn(), getLimits: vi.fn(), diff --git a/src/app/store/slices/fileVersions/index.ts b/src/app/store/slices/fileVersions/index.ts index 70e099e007..821015f2d0 100644 --- a/src/app/store/slices/fileVersions/index.ts +++ b/src/app/store/slices/fileVersions/index.ts @@ -1,6 +1,6 @@ import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; import { FileVersion, FileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; -import fileVersionService from 'views/Drive/components/VersionHistory/services/fileVersion.service'; +import fileVersionService from 'views/Drive/services/fileVersion.service'; interface FileVersionsState { versionsByFileId: Record, FileVersion[]>; diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx index cf1b4cb277..0cf66c4be1 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx @@ -34,7 +34,7 @@ import { } from './DriveItemContextMenu'; import { List } from '@internxt/ui'; import { DownloadManager } from 'app/network/DownloadManager'; -import { useVersionHistoryMenuConfig } from '../../VersionHistory/hooks'; +import { useVersionHistoryMenuConfig } from 'views/Drive/hooks/useVersionHistoryMenuConfig'; interface DriveExplorerListProps { folderId: string; diff --git a/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx b/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx index ef5821df00..1666039545 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx @@ -106,13 +106,12 @@ const getVersionHistoryMenuItem = ( ): MenuItemType => { const isLocked = config?.isLocked ?? false; const isExtensionAllowed = config?.isExtensionAllowed ?? true; - const onUpgradeClick = config?.onUpgradeClick; if (isLocked) { return { name: t('drive.dropdown.versionHistory') as string, icon: LockSimple, - action: onUpgradeClick, + action: viewVersionHistory, disabled: () => false, node: (
{ - if (versionHistoryMenuConfig.locked) { - versionHistoryMenuConfig.onLockedClick?.(); - return; - } dispatch(uiActions.setVersionHistoryItem(selectedItems[0])); dispatch(uiActions.setIsVersionHistorySidebarOpen(true)); }; diff --git a/src/views/Drive/components/VersionHistory/Sidebar.tsx b/src/views/Drive/components/VersionHistory/Sidebar.tsx index 0d1ba45778..20604f2173 100644 --- a/src/views/Drive/components/VersionHistory/Sidebar.tsx +++ b/src/views/Drive/components/VersionHistory/Sidebar.tsx @@ -5,6 +5,8 @@ import { uiActions } from 'app/store/slices/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import storageSelectors from 'app/store/slices/storage/storage.selectors'; import { fetchSortedFolderContentThunk } from 'app/store/slices/storage/storage.thunks/fetchSortedFolderContentThunk'; +import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; +import navigationService from 'services/navigation.service'; import { Header, CurrentVersionItem, @@ -12,8 +14,9 @@ import { AutosaveSection, VersionActionDialog, VersionHistorySkeleton, + LockedFeatureModal, } from './components'; -import fileVersionService from 'views/Drive/components/VersionHistory/services/fileVersion.service'; +import fileVersionService from 'views/Drive/services/fileVersion.service'; import errorService from 'services/error.service'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; import { @@ -35,6 +38,7 @@ const Sidebar = () => { const isRestoreVersionDialogOpen = useAppSelector((state: RootState) => state.ui.isRestoreVersionDialogOpen); const versionToRestore = useAppSelector((state: RootState) => state.ui.versionToRestore); const currentFolderId = useAppSelector(storageSelectors.currentFolderId); + const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); const { translate } = useTranslationContext(); const limits = useAppSelector(fileVersionsSelectors.getLimits); @@ -51,6 +55,24 @@ const Sidebar = () => { updatedAt: '', }); + const isVersioningEnabled = limits?.versioning?.enabled ?? false; + + const blurredBackgroundVersions = useMemo(() => { + if (isVersioningEnabled) return []; + + const fixedDate = new Date('2024-05-01').toISOString(); + return Array.from({ length: 10 }, (_, i) => ({ + id: `mock-${i}`, + fileId: '', + size: '1000000', + createdAt: fixedDate, + updatedAt: fixedDate, + type: 'file', + })); + }, [isVersioningEnabled]); + + const displayVersions = isVersioningEnabled ? versions : blurredBackgroundVersions; + useEffect(() => { if (item) { setCurrentVersion({ @@ -59,7 +81,7 @@ const Sidebar = () => { }); } }, [item]); - const totalVersionsCount = versions.length; + const totalVersionsCount = displayVersions.length; const selectedCount = selectedAutosaveVersions.size; const selectAllAutosave = selectedCount === totalVersionsCount && totalVersionsCount > 0; const totalAllowedVersions = limits?.versioning.maxVersions ?? 0; @@ -201,6 +223,15 @@ const Sidebar = () => { dispatch(uiActions.setIsDeleteVersionDialogOpen(true)); }, [item, selectedCount, dispatch]); + const handleUpgrade = useCallback(() => { + dispatch(uiActions.setIsPreferencesDialogOpen(true)); + navigationService.openPreferencesDialog({ + section: 'account', + subsection: 'plans', + workspaceUuid: selectedWorkspace?.workspaceUser.workspaceId, + }); + }, [dispatch, selectedWorkspace]); + if (!item) return null; return ( <> @@ -214,8 +245,8 @@ const Sidebar = () => {
-
- {isLoading ? ( +
+ {isVersioningEnabled && isLoading ? ( ) : ( <> @@ -235,7 +266,7 @@ const Sidebar = () => { onDeleteAll={handleDeleteSelectedVersions} /> - {versions.map((version) => ( + {displayVersions.map((version) => ( { ))} )} + {!isVersioningEnabled && }
diff --git a/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx b/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx new file mode 100644 index 0000000000..ab2ccb66ca --- /dev/null +++ b/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx @@ -0,0 +1,62 @@ +import { Button } from '@internxt/ui'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { ClockCounterClockwise, LockSimple } from '@phosphor-icons/react'; + +interface LockedFeatureModalProps { + onUpgrade: () => void; +} + +const MODAL_DIMENSIONS = { + width: '282px', + height: '333px', +} as const; + +const ICON_SIZES = { + clock: 64, + lock: 35, +} as const; + +const COLORS = { + border: '#474747', + lock: '#737373', +} as const; + +export const LockedFeatureModal = ({ onUpgrade }: LockedFeatureModalProps) => { + const { translate } = useTranslationContext(); + + return ( +
+
+
+
+ +
+
+ +
+
+ +
+

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

+

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

+

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

+
+ +
+ +
+
+
+ ); +}; diff --git a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx index 8415d0cb01..728976d792 100644 --- a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx +++ b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx @@ -1,7 +1,8 @@ import { Info, DotsThree } from '@phosphor-icons/react'; import { Checkbox, Dropdown, Avatar } from '@internxt/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { useDropdownPositioning, useVersionItemActions } from '../hooks'; +import { useDropdownPositioning } from 'views/Drive/hooks/useDropdownPositioning'; +import { useVersionItemActions } from 'views/Drive/hooks/useVersionItemActions'; import { formatVersionDate, getDaysUntilExpiration } from '../utils'; import { FileVersion } from '@internxt/sdk/dist/drive/storage/types'; import { memo } from 'react'; diff --git a/src/views/Drive/components/VersionHistory/components/index.ts b/src/views/Drive/components/VersionHistory/components/index.ts index 9bc3ebe779..f63f5c3aee 100644 --- a/src/views/Drive/components/VersionHistory/components/index.ts +++ b/src/views/Drive/components/VersionHistory/components/index.ts @@ -4,3 +4,4 @@ export { VersionItem } from './VersionItem'; export { AutosaveSection } from './AutosaveSection'; export { VersionActionDialog } from './VersionActionDialog'; export { VersionHistorySkeleton } from './VersionHistorySkeleton'; +export { LockedFeatureModal } from './LockedFeatureModal'; diff --git a/src/views/Drive/components/VersionHistory/hooks/index.ts b/src/views/Drive/components/VersionHistory/hooks/index.ts deleted file mode 100644 index 5fb50562cd..0000000000 --- a/src/views/Drive/components/VersionHistory/hooks/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { useDropdownPositioning } from './useDropdownPositioning'; -export { useVersionItemActions } from './useVersionItemActions'; -export { useVersionHistoryMenuConfig } from './useVersionHistoryMenuConfig'; diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts b/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts deleted file mode 100644 index 9b612a67d5..0000000000 --- a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useSelector } from 'react-redux'; -import { useAppDispatch } from 'app/store/hooks'; -import { uiActions } from 'app/store/slices/ui'; -import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; -import { fileVersionsSelectors } from 'app/store/slices/fileVersions'; -import navigationService from 'services/navigation.service'; -import { DriveItemData } from 'app/drive/types'; -import { isVersioningExtensionAllowed } from '../utils'; -import { VersionHistoryMenuConfig } from '../../DriveExplorer/components/DriveItemContextMenu'; - -export const useVersionHistoryMenuConfig = (selectedItem?: DriveItemData): VersionHistoryMenuConfig => { - const dispatch = useAppDispatch(); - const selectedWorkspace = useSelector(workspacesSelectors.getSelectedWorkspace); - const limits = useSelector(fileVersionsSelectors.getLimits); - const isVersioningEnabled = limits?.versioning?.enabled ?? false; - const isExtensionAllowed = selectedItem ? isVersioningExtensionAllowed(selectedItem) : true; - - return { - isLocked: !isVersioningEnabled, - isExtensionAllowed, - onUpgradeClick: () => { - dispatch(uiActions.setIsPreferencesDialogOpen(true)); - navigationService.openPreferencesDialog({ - section: 'account', - subsection: 'plans', - workspaceUuid: selectedWorkspace?.workspaceUser.workspaceId, - }); - }, - }; -}; diff --git a/src/views/Drive/hooks/index.ts b/src/views/Drive/hooks/index.ts index ae8b20a932..8a207b8786 100644 --- a/src/views/Drive/hooks/index.ts +++ b/src/views/Drive/hooks/index.ts @@ -3,4 +3,7 @@ export { useDriveItemDrag, useDriveItemDrop } from './useDriveItemDragAndDrop'; export { default as useDriveItemStoreProps } from './useDriveStoreProps'; export { usePaginationState } from './usePaginationState'; export { useTutorialState } from './useTutorialState'; +export { useVersionHistoryMenuConfig } from './useVersionHistoryMenuConfig'; +export { useVersionItemActions } from './useVersionItemActions'; +export { useDropdownPositioning } from './useDropdownPositioning'; export type { DriveItemActions } from './useDriveItemActions'; diff --git a/src/views/Drive/hooks/useDriveItemActions.tsx b/src/views/Drive/hooks/useDriveItemActions.tsx index 6460615b7e..30b5c1a432 100644 --- a/src/views/Drive/hooks/useDriveItemActions.tsx +++ b/src/views/Drive/hooks/useDriveItemActions.tsx @@ -11,7 +11,7 @@ import { storageActions } from 'app/store/slices/storage'; import { uiActions } from 'app/store/slices/ui'; import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; import { DownloadManager } from 'app/network/DownloadManager'; -import { useVersionHistoryMenuConfig } from '../components/VersionHistory/hooks'; +import { useVersionHistoryMenuConfig } from './useVersionHistoryMenuConfig'; export interface DriveItemActions { nameInputRef: React.RefObject; @@ -104,10 +104,6 @@ const useDriveItemActions = (item): DriveItemActions => { }; const onViewVersionHistoryButtonClicked = () => { - if (versionHistoryConfig.locked) { - versionHistoryConfig.onLockedClick?.(); - return; - } dispatch(uiActions.setVersionHistoryItem(item as DriveItemData)); dispatch(uiActions.setIsVersionHistorySidebarOpen(true)); }; diff --git a/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts b/src/views/Drive/hooks/useDropdownPositioning.test.ts similarity index 100% rename from src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts rename to src/views/Drive/hooks/useDropdownPositioning.test.ts diff --git a/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.ts b/src/views/Drive/hooks/useDropdownPositioning.ts similarity index 100% rename from src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.ts rename to src/views/Drive/hooks/useDropdownPositioning.ts diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts b/src/views/Drive/hooks/useVersionHistoryMenuConfig.test.ts similarity index 57% rename from src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts rename to src/views/Drive/hooks/useVersionHistoryMenuConfig.test.ts index d45bb6e596..f8989ab120 100644 --- a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts +++ b/src/views/Drive/hooks/useVersionHistoryMenuConfig.test.ts @@ -1,41 +1,13 @@ -import { renderHook, act } from '@testing-library/react'; +import { renderHook } 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 }, }; @@ -46,38 +18,23 @@ const disabledLimits: FileLimitsResponse = { describe('Version history menu', () => { const mockUseSelector = useSelector as unknown as Mock; - const mockUseAppDispatch = useAppDispatch as unknown as Mock; - const dispatch = vi.fn(); const mockState = (limits: FileLimitsResponse | null) => ({ - workspaces: { selectedWorkspace: workspaceMock }, fileVersions: { limits }, }) as any; beforeEach(() => { vi.clearAllMocks(); - mockUseAppDispatch.mockReturnValue(dispatch); }); - it('when versioning is locked, then the upgrade flow opens', () => { + it('when versioning is locked, then the menu is marked as locked', () => { mockUseSelector.mockImplementation((selector: (state: any) => unknown) => selector(mockState(disabledLimits))); const { result } = renderHook(() => useVersionHistoryMenuConfig({ type: 'pdf' } as any)); expect(result.current.isLocked).toBe(true); expect(result.current.isExtensionAllowed).toBe(true); - - act(() => { - result.current.onUpgradeClick?.(); - }); - - expect(dispatch).toHaveBeenCalledWith({ type: 'setIsPreferencesDialogOpen', payload: true }); - expect(navigationService.openPreferencesDialog).toHaveBeenCalledWith({ - section: 'account', - subsection: 'plans', - workspaceUuid: 'workspace-1', - }); }); it('when versioning is enabled and the file is supported, then the menu is unlocked', () => { diff --git a/src/views/Drive/hooks/useVersionHistoryMenuConfig.ts b/src/views/Drive/hooks/useVersionHistoryMenuConfig.ts new file mode 100644 index 0000000000..215b94093d --- /dev/null +++ b/src/views/Drive/hooks/useVersionHistoryMenuConfig.ts @@ -0,0 +1,16 @@ +import { useSelector } from 'react-redux'; +import { fileVersionsSelectors } from 'app/store/slices/fileVersions'; +import { DriveItemData } from 'app/drive/types'; +import { isVersioningExtensionAllowed } from '../components/VersionHistory/utils'; +import { VersionHistoryMenuConfig } from '../components/DriveExplorer/components/DriveItemContextMenu'; + +export const useVersionHistoryMenuConfig = (selectedItem?: DriveItemData): VersionHistoryMenuConfig => { + const limits = useSelector(fileVersionsSelectors.getLimits); + const isVersioningEnabled = limits?.versioning?.enabled ?? false; + const isExtensionAllowed = selectedItem ? isVersioningExtensionAllowed(selectedItem) : true; + + return { + isLocked: !isVersioningEnabled, + isExtensionAllowed, + }; +}; diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts b/src/views/Drive/hooks/useVersionItemActions.test.ts similarity index 98% rename from src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts rename to src/views/Drive/hooks/useVersionItemActions.test.ts index f055c78c9d..5db7da8ab3 100644 --- a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts +++ b/src/views/Drive/hooks/useVersionItemActions.test.ts @@ -20,7 +20,7 @@ vi.mock('app/store/hooks', () => ({ useAppSelector: vi.fn(), })); -vi.mock('views/Drive/components/VersionHistory/services/fileVersion.service', () => ({ +vi.mock('views/Drive/services/fileVersion.service', () => ({ default: { downloadVersion: vi.fn(), }, diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts b/src/views/Drive/hooks/useVersionItemActions.ts similarity index 100% rename from src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts rename to src/views/Drive/hooks/useVersionItemActions.ts diff --git a/src/views/Drive/components/VersionHistory/services/fileVersion.service.test.ts b/src/views/Drive/services/fileVersion.service.test.ts similarity index 100% rename from src/views/Drive/components/VersionHistory/services/fileVersion.service.test.ts rename to src/views/Drive/services/fileVersion.service.test.ts diff --git a/src/views/Drive/components/VersionHistory/services/fileVersion.service.ts b/src/views/Drive/services/fileVersion.service.ts similarity index 100% rename from src/views/Drive/components/VersionHistory/services/fileVersion.service.ts rename to src/views/Drive/services/fileVersion.service.ts diff --git a/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx b/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx index d3f2aa7726..8b261eae5b 100644 --- a/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx +++ b/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx @@ -13,6 +13,7 @@ import { paymentService } from 'views/Checkout/services'; import { RootState } from 'app/store'; import { useAppDispatch } from 'app/store/hooks'; import { PlanState, planThunks } from 'app/store/slices/plan'; +import { fetchVersionLimitsThunk } from 'app/store/slices/fileVersions'; import CancelSubscriptionModal from '../../Workspace/Billing/CancelSubscriptionModal'; import { fetchPlanPrices, getStripe } from '../../../../services/plansApi'; import ChangePlanDialog from './components/ChangePlanDialog'; @@ -184,7 +185,10 @@ const PlansSection = ({ changeSection, onClosePreferences }: PlansSectionProps) const handlePaymentSuccess = () => { showSuccessSubscriptionNotification(); - setTimeout(() => dispatch(planThunks.initializeThunk()).unwrap(), 2000); + setTimeout(() => { + dispatch(planThunks.initializeThunk()).unwrap(); + dispatch(fetchVersionLimitsThunk()); + }, 2000); }; const handleSubscriptionPayment = async (priceId: string) => { @@ -241,6 +245,7 @@ const PlansSection = ({ changeSection, onClosePreferences }: PlansSectionProps) setCancellingSubscription(false); setTimeout(() => { dispatch(planThunks.initializeThunk()).unwrap(); + dispatch(fetchVersionLimitsThunk()); }, 1000); } } From 1708f3e53e5092dfc37a802d7c75933d7b758b98 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sat, 10 Jan 2026 00:01:43 -0400 Subject: [PATCH 33/47] feat: add locked feature localization for multiple languages --- src/app/i18n/locales/de.json | 8 +++++++- src/app/i18n/locales/es.json | 8 +++++++- src/app/i18n/locales/fr.json | 8 +++++++- src/app/i18n/locales/it.json | 8 +++++++- src/app/i18n/locales/ru.json | 8 +++++++- src/app/i18n/locales/tw.json | 8 +++++++- src/app/i18n/locales/zh.json | 8 +++++++- 7 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index d9e3730e2c..f68ecc0684 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -813,7 +813,13 @@ "restoreSuccess": "Version erfolgreich wiederhergestellt", "restoreError": "Fehler beim Wiederherstellen der Version", "deleteSuccess": "Version erfolgreich gelöscht", - "deleteError": "Fehler beim Löschen der Version" + "deleteError": "Fehler beim Löschen der Version", + "lockedFeature": { + "title": "Gesperrte Funktion", + "description": "Stellen Sie frühere Versionen Ihrer Dateien wieder her und verfolgen Sie Änderungen im Laufe der Zeit.", + "supportedFormats": "Verfügbar für PDF-, Word-, Excel- und CSV-Dateien in kostenpflichtigen Plänen.", + "upgradeButton": "Upgrade" + } }, "shareModal": { "title": "\"{{name}}\" teilen", diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 124c052ee8..3bd311b36a 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -892,7 +892,13 @@ "restoreSuccess": "Versión restaurada exitosamente", "restoreError": "Error al restaurar la versión", "deleteSuccess": "Versión eliminada exitosamente", - "deleteError": "Error al eliminar la versión" + "deleteError": "Error al eliminar la versión", + "lockedFeature": { + "title": "Función bloqueada", + "description": "Restaura versiones anteriores de tus archivos y realiza un seguimiento de los cambios a lo largo del tiempo.", + "supportedFormats": "Disponible para archivos PDF, Word, Excel y CSV en planes de pago.", + "upgradeButton": "Mejorar plan" + } }, "shareModal": { "title": "Compartir \"{{name}}\"", diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index daec745a2d..83289d1cbb 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -834,7 +834,13 @@ "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" + "deleteError": "Échec de la suppression de la version", + "lockedFeature": { + "title": "Fonctionnalité verrouillée", + "description": "Restaurez les versions précédentes de vos fichiers et suivez les modifications au fil du temps.", + "supportedFormats": "Disponible pour les fichiers PDF, Word, Excel et CSV dans les forfaits payants.", + "upgradeButton": "Mettre à niveau" + } }, "newFolderModal": { "title": "Nouveau dossier", diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index a0ef63ea20..0b0172a886 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -946,7 +946,13 @@ "restoreSuccess": "Versione ripristinata con successo", "restoreError": "Impossibile ripristinare la versione", "deleteSuccess": "Versione eliminata con successo", - "deleteError": "Impossibile eliminare la versione" + "deleteError": "Impossibile eliminare la versione", + "lockedFeature": { + "title": "Funzione bloccata", + "description": "Ripristina le versioni precedenti dei tuoi file e monitora le modifiche nel tempo.", + "supportedFormats": "Disponibile per file PDF, Word, Excel e CSV nei piani a pagamento.", + "upgradeButton": "Aggiorna" + } }, "shareModal": { "title": "Condividi \"{{name}}\"", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 1912cf38b9..ef7b329ee2 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -853,7 +853,13 @@ "restoreSuccess": "Версия успешно восстановлена", "restoreError": "Не удалось восстановить версию", "deleteSuccess": "Версия успешно удалена", - "deleteError": "Не удалось удалить версию" + "deleteError": "Не удалось удалить версию", + "lockedFeature": { + "title": "Заблокированная функция", + "description": "Восстанавливайте предыдущие версии файлов и отслеживайте изменения со временем.", + "supportedFormats": "Доступно для файлов PDF, Word, Excel и CSV в платных тарифах.", + "upgradeButton": "Обновить" + } }, "shareModal": { "title": "Поделиться \"{{name}}\"", diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index dd50b7a781..77c1f31d44 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -840,7 +840,13 @@ "restoreSuccess": "版本復原成功", "restoreError": "復原版本失敗", "deleteSuccess": "版本刪除成功", - "deleteError": "刪除版本失敗" + "deleteError": "刪除版本失敗", + "lockedFeature": { + "title": "功能已鎖定", + "description": "復原檔案的先前版本並追蹤隨時間的變化。", + "supportedFormats": "適用於付費方案中的PDF、Word、Excel和CSV檔案。", + "upgradeButton": "升級" + } }, "shareModal": { "title": "分享“{{name}}”", diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index c941907e31..00ba95f8b1 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -876,7 +876,13 @@ "restoreSuccess": "版本恢复成功", "restoreError": "恢复版本失败", "deleteSuccess": "版本删除成功", - "deleteError": "删除版本失败" + "deleteError": "删除版本失败", + "lockedFeature": { + "title": "功能已锁定", + "description": "恢复文件的以前版本并跟踪随时间的变化。", + "supportedFormats": "适用于付费计划中的PDF、Word、Excel和CSV文件。", + "upgradeButton": "升级" + } }, "shareModal": { "title": "分享 \"{{name}}\"", From 1e3e929b43ae087323aff1a3b9e8fcbcafbb705a Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sat, 10 Jan 2026 14:24:18 -0400 Subject: [PATCH 34/47] feature: update LockedFeatureModal styles and improve accessibility --- .../components/LockedFeatureModal.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx b/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx index ab2ccb66ca..f7e118277c 100644 --- a/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx +++ b/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx @@ -16,26 +16,28 @@ const ICON_SIZES = { lock: 35, } as const; -const COLORS = { - border: '#474747', - lock: '#737373', -} as const; +const LIGHT_MODE_ICON_BG = '#F9F9FC'; export const LockedFeatureModal = ({ onUpgrade }: LockedFeatureModalProps) => { const { translate } = useTranslationContext(); return ( -
+
-
+
- +
@@ -43,10 +45,10 @@ export const LockedFeatureModal = ({ onUpgrade }: LockedFeatureModalProps) => {

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

-

+

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

-

+

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

From c611ef54da41472d2642e830b7cb314712f64f3c Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sat, 10 Jan 2026 14:55:30 -0400 Subject: [PATCH 35/47] feat: add timestamped filename format for version downloads - Add downloadName option to DownloadItem type to support custom filenames - Update downloadWorkerHandler to accept and use custom download names - Format version download filenames as "(DD-MM-YYYY at HH:mm) filename.ext" - Use dateService and itemsUtils from @internxt/lib for proper formatting - Pass custom filename through downloadOptions instead of modifying item name --- .../services/downloadManager.service.test.ts | 1 + .../drive/services/downloadManager.service.ts | 22 +++++++++++++------ .../worker.service/downloadWorkerHandler.ts | 4 +++- .../Drive/hooks/useVersionItemActions.test.ts | 3 ++- .../Drive/hooks/useVersionItemActions.ts | 12 ++++++++-- .../services/fileVersion.service.test.ts | 4 +++- .../Drive/services/fileVersion.service.ts | 4 +++- 7 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/app/drive/services/downloadManager.service.test.ts b/src/app/drive/services/downloadManager.service.test.ts index 9246b469c5..fe4d426cf4 100644 --- a/src/app/drive/services/downloadManager.service.test.ts +++ b/src/app/drive/services/downloadManager.service.test.ts @@ -892,6 +892,7 @@ describe('downloadManagerService', () => { updateProgressCallback: mockUpdateProgress, abortController: mockTask.abortController, sharingOptions: mockTask.credentials, + downloadName: mockTask.options.downloadName, }); }); diff --git a/src/app/drive/services/downloadManager.service.ts b/src/app/drive/services/downloadManager.service.ts index 82ec911bcc..00a6da492f 100644 --- a/src/app/drive/services/downloadManager.service.ts +++ b/src/app/drive/services/downloadManager.service.ts @@ -47,6 +47,7 @@ export type DownloadItem = { downloadOptions?: { areSharedItems?: boolean; showErrors?: boolean; + downloadName?: string; }; createFoldersIterator?: FolderIterator | SharedFolderIterator; createFilesIterator?: FileIterator | SharedFileIterator; @@ -119,13 +120,17 @@ export class DownloadManagerService { const abort = () => Promise.resolve(uploadFolderAbortController.abort('Download cancelled')); const formattedDate = date.format(new Date(), 'YYYY-MM-DD_HHmmss'); - let downloadName = `Internxt (${formattedDate})`; - if (itemsPayload.length === 1) { - const item = itemsPayload[0]; - if (itemsPayload[0].isFolder) { - downloadName = item.name; - } else { - downloadName = item.type ? `${item.name}.${item.type}` : item.name; + let downloadName = downloadItem.downloadOptions?.downloadName; + + if (!downloadName) { + downloadName = `Internxt (${formattedDate})`; + if (itemsPayload.length === 1) { + const item = itemsPayload[0]; + if (itemsPayload[0].isFolder) { + downloadName = item.name; + } else { + downloadName = item.type ? `${item.name}.${item.type}` : item.name; + } } } @@ -295,6 +300,7 @@ export class DownloadManagerService { updateProgressCallback, abortController, sharingOptions: credentials, + downloadName: options.downloadName, }); console.timeEnd(`download-file-${file.uuid}`); @@ -508,6 +514,7 @@ export class DownloadManagerService { updateProgressCallback: (progress: number) => void; abortController?: AbortController; sharingOptions: { credentials: { user: string; pass: string }; mnemonic: string }; + downloadName?: string; }) => { const shouldDownloadUsingBlob = !!(navigator.brave && (await navigator.brave.isBrave())) || @@ -531,6 +538,7 @@ export class DownloadManagerService { itemData: payload.file, updateProgressCallback: payload.updateProgressCallback, abortController: payload.abortController, + downloadName: payload.downloadName, }); }; } diff --git a/src/app/drive/services/worker.service/downloadWorkerHandler.ts b/src/app/drive/services/worker.service/downloadWorkerHandler.ts index 14cc3e9a95..bbdf5f8b34 100644 --- a/src/app/drive/services/worker.service/downloadWorkerHandler.ts +++ b/src/app/drive/services/worker.service/downloadWorkerHandler.ts @@ -11,6 +11,7 @@ interface HandleWorkerMessagesPayload { abortController?: AbortController; itemData: DriveFileData; updateProgressCallback: (progress: number) => void; + downloadName?: string; } interface HandleMessagesPayload { @@ -37,9 +38,10 @@ export class DownloadWorkerHandler { abortController, itemData, updateProgressCallback, + downloadName, }: HandleWorkerMessagesPayload) { const fileName = itemData.plainName ?? itemData.name; - const completeFilename = itemData.type ? `${fileName}.${itemData.type}` : fileName; + const completeFilename = downloadName || (itemData.type ? `${fileName}.${itemData.type}` : fileName); const downloadId = itemData.fileId; const fileSize = itemData.size; diff --git a/src/views/Drive/hooks/useVersionItemActions.test.ts b/src/views/Drive/hooks/useVersionItemActions.test.ts index 5db7da8ab3..a943f0922d 100644 --- a/src/views/Drive/hooks/useVersionItemActions.test.ts +++ b/src/views/Drive/hooks/useVersionItemActions.test.ts @@ -60,6 +60,7 @@ describe('Version item menu', () => { fileId: 'file-uuid', networkFileId: 'network-file-id', size: '5', + createdAt: '2026-01-10T14:30:00.000Z', } as FileVersion; const fileItem = { id: 'file-id', @@ -142,7 +143,7 @@ describe('Version item menu', () => { expect(downloadVersionSpy).toHaveBeenCalledWith( version, fileItem, - fileItem.plainName, + '(10-01-2026 at 10:30) pretty-name', selectedWorkspace, workspaceCredentials, ); diff --git a/src/views/Drive/hooks/useVersionItemActions.ts b/src/views/Drive/hooks/useVersionItemActions.ts index 0400bdd476..4e1af1e039 100644 --- a/src/views/Drive/hooks/useVersionItemActions.ts +++ b/src/views/Drive/hooks/useVersionItemActions.ts @@ -8,6 +8,8 @@ import { uiActions } from 'app/store/slices/ui'; import { RootState } from 'app/store'; import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; import { FileVersion } from '@internxt/sdk/dist/drive/storage/types'; +import dateService from 'services/date.service'; +import { items as itemsUtils } from '@internxt/lib'; interface UseVersionItemActionsParams { version: FileVersion; @@ -38,8 +40,14 @@ export const useVersionItemActions = ({ version, onDropdownClose }: UseVersionIt return; } - const fileName = item.plainName || item.name; - await fileVersionService.downloadVersion(version, item, fileName, selectedWorkspace, workspaceCredentials); + const entireFilename = item.plainName || item.name; + const formattedDate = dateService.format(version.createdAt, 'DD-MM-YYYY [at] HH:mm'); + const { filename, extension } = itemsUtils.getFilenameAndExt(entireFilename); + + const fileExtension = extension ? `.${extension}` : ''; + const versionFileName = `(${formattedDate}) ${filename}${fileExtension}`; + + await fileVersionService.downloadVersion(version, item, versionFileName, selectedWorkspace, workspaceCredentials); }; const handleDeleteClick = () => { diff --git a/src/views/Drive/services/fileVersion.service.test.ts b/src/views/Drive/services/fileVersion.service.test.ts index 8740e9b140..53cf50a133 100644 --- a/src/views/Drive/services/fileVersion.service.test.ts +++ b/src/views/Drive/services/fileVersion.service.test.ts @@ -102,11 +102,13 @@ describe('File version actions', () => { ...fileItem, fileId: version.networkFileId, size: Number(version.size), - name: 'custom-name', }, ], selectedWorkspace, workspaceCredentials, + downloadOptions: { + downloadName: 'custom-name', + }, }); }); }); diff --git a/src/views/Drive/services/fileVersion.service.ts b/src/views/Drive/services/fileVersion.service.ts index a5d085091f..6a82e43d43 100644 --- a/src/views/Drive/services/fileVersion.service.ts +++ b/src/views/Drive/services/fileVersion.service.ts @@ -33,13 +33,15 @@ export async function downloadVersion( ...fileItem, fileId: version.networkFileId, size: Number(version.size), - name: fileName, }; await DownloadManager.downloadItem({ payload: [versionFileData], selectedWorkspace, workspaceCredentials, + downloadOptions: { + downloadName: fileName, + }, }); } From 3a2dea6b0071cc0a56fa005269e3dc745d69ffad Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 12 Jan 2026 09:49:30 -0400 Subject: [PATCH 36/47] test: update date format in version item menu tests --- src/views/Drive/hooks/useVersionItemActions.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/views/Drive/hooks/useVersionItemActions.test.ts b/src/views/Drive/hooks/useVersionItemActions.test.ts index a943f0922d..0739f87ddc 100644 --- a/src/views/Drive/hooks/useVersionItemActions.test.ts +++ b/src/views/Drive/hooks/useVersionItemActions.test.ts @@ -35,6 +35,12 @@ vi.mock('app/notifications/services/notifications.service', () => ({ }, })); +vi.mock('services/date.service', () => ({ + default: { + format: vi.fn(() => '10-01-2026 at 14:30'), + }, +})); + const mockSetVersionToRestore = vi.hoisted(() => vi.fn((payload) => ({ type: 'setVersionToRestore', payload }))); const mockSetIsRestoreVersionDialogOpen = vi.hoisted(() => vi.fn((payload) => ({ type: 'setIsRestoreVersionDialogOpen', payload })), @@ -143,7 +149,7 @@ describe('Version item menu', () => { expect(downloadVersionSpy).toHaveBeenCalledWith( version, fileItem, - '(10-01-2026 at 10:30) pretty-name', + '(10-01-2026 at 14:30) pretty-name', selectedWorkspace, workspaceCredentials, ); From 39fe918d8962050e17f7bf391f5085815bb5d8f7 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 12 Jan 2026 10:26:12 -0400 Subject: [PATCH 37/47] feature: simplify download name generation for single items --- .../drive/services/downloadManager.service.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/drive/services/downloadManager.service.ts b/src/app/drive/services/downloadManager.service.ts index 00a6da492f..6c32e54145 100644 --- a/src/app/drive/services/downloadManager.service.ts +++ b/src/app/drive/services/downloadManager.service.ts @@ -93,6 +93,13 @@ export type DownloadTask = { export class DownloadManagerService { public static readonly instance: DownloadManagerService = new DownloadManagerService(); + private getSingleItemName(item: DownloadItem['payload'][0]): string { + if (item.isFolder) { + return item.name; + } + return item.type ? `${item.name}.${item.type}` : item.name; + } + readonly getDownloadCredentialsFromWorkspace = ( selectedWorkspace: WorkspaceData | null, workspaceCredentials: WorkspaceCredentialsDetails | null, @@ -123,15 +130,8 @@ export class DownloadManagerService { let downloadName = downloadItem.downloadOptions?.downloadName; if (!downloadName) { - downloadName = `Internxt (${formattedDate})`; - if (itemsPayload.length === 1) { - const item = itemsPayload[0]; - if (itemsPayload[0].isFolder) { - downloadName = item.name; - } else { - downloadName = item.type ? `${item.name}.${item.type}` : item.name; - } - } + downloadName = + itemsPayload.length === 1 ? this.getSingleItemName(itemsPayload[0]) : `Internxt (${formattedDate})`; } let taskId = downloadItem.taskId; From aa07cc5ae4aa8a4d8588babd61788bf078793368 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 12 Jan 2026 11:22:33 -0400 Subject: [PATCH 38/47] refactor: simplify handleUpgrade function in Sidebar component --- src/views/Drive/components/VersionHistory/Sidebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/Drive/components/VersionHistory/Sidebar.tsx b/src/views/Drive/components/VersionHistory/Sidebar.tsx index 20604f2173..2c5554db2b 100644 --- a/src/views/Drive/components/VersionHistory/Sidebar.tsx +++ b/src/views/Drive/components/VersionHistory/Sidebar.tsx @@ -223,14 +223,14 @@ const Sidebar = () => { dispatch(uiActions.setIsDeleteVersionDialogOpen(true)); }, [item, selectedCount, dispatch]); - const handleUpgrade = useCallback(() => { + const handleUpgrade = () => { dispatch(uiActions.setIsPreferencesDialogOpen(true)); navigationService.openPreferencesDialog({ section: 'account', subsection: 'plans', workspaceUuid: selectedWorkspace?.workspaceUser.workspaceId, }); - }, [dispatch, selectedWorkspace]); + }; if (!item) return null; return ( From 3bc8d3f6fb2986815e56db0314d6c8996ed1b3fa Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 12 Jan 2026 23:49:25 -0400 Subject: [PATCH 39/47] refactor: streamline LockedFeatureModal layout and remove unused styles --- .../components/LockedFeatureModal.tsx | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx b/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx index f7e118277c..09e18fd678 100644 --- a/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx +++ b/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx @@ -6,34 +6,19 @@ interface LockedFeatureModalProps { onUpgrade: () => void; } -const MODAL_DIMENSIONS = { - width: '282px', - height: '333px', -} as const; - const ICON_SIZES = { clock: 64, lock: 35, } as const; -const LIGHT_MODE_ICON_BG = '#F9F9FC'; - export const LockedFeatureModal = ({ onUpgrade }: LockedFeatureModalProps) => { const { translate } = useTranslationContext(); return (
-
+
-
+
From 469c2916f523fd096be10f6632e89a3fe9b9f5e1 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 13 Jan 2026 10:16:16 -0400 Subject: [PATCH 40/47] style: enhance hover effect for unselected version item in dark mode --- .../components/VersionHistory/components/AutosaveSection.tsx | 2 +- .../components/VersionHistory/components/CurrentVersionItem.tsx | 2 +- .../Drive/components/VersionHistory/components/VersionItem.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx index 321a936375..4d404ae975 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 ( -
+
+
{formatVersionDate(createdAt)} diff --git a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx index 728976d792..f4a53c0ee6 100644 --- a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx +++ b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx @@ -53,7 +53,7 @@ export const VersionItem = memo( type="button" aria-pressed={isSelected} aria-label={`Version from ${formatVersionDate(version.createdAt)}`} - className={`group relative w-full px-6 cursor-pointer text-left ${isSelected ? 'bg-primary/10' : 'hover:bg-gray-1'}`} + className={`group relative w-full px-6 cursor-pointer text-left ${isSelected ? 'bg-primary/10' : 'hover:bg-gray-1 dark:hover:bg-white/3'}`} onClick={handleItemClick} >
From 7e2608c9c961de98811193b9dc7df0c1fe461ac8 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 13 Jan 2026 17:05:46 -0400 Subject: [PATCH 41/47] style: adjust padding and text styles in LockedFeatureModal for improved layout --- .../VersionHistory/components/LockedFeatureModal.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx b/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx index 09e18fd678..4150619b44 100644 --- a/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx +++ b/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx @@ -16,7 +16,7 @@ export const LockedFeatureModal = ({ onUpgrade }: LockedFeatureModalProps) => { return (
-
+
@@ -26,14 +26,14 @@ export const LockedFeatureModal = ({ onUpgrade }: LockedFeatureModalProps) => {
-
-

+
+

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

-

+

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

-

+

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

From 08e496b673ac74030cf79650232dea390eb2e2f9 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 15 Jan 2026 10:37:09 -0400 Subject: [PATCH 42/47] fix: improve sidebar visibility logic by consolidating open state checks --- src/views/Drive/components/VersionHistory/Sidebar.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/views/Drive/components/VersionHistory/Sidebar.tsx b/src/views/Drive/components/VersionHistory/Sidebar.tsx index 2c5554db2b..4932ca4073 100644 --- a/src/views/Drive/components/VersionHistory/Sidebar.tsx +++ b/src/views/Drive/components/VersionHistory/Sidebar.tsx @@ -232,13 +232,14 @@ const Sidebar = () => { }); }; - if (!item) return null; + const shouldShowSidebar = isOpen && item; + return ( <> - {isOpen &&
} + {shouldShowSidebar &&
}
From 565ee0734160350574b8967aa1d485a4f82b1728 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 20 Jan 2026 11:12:24 -0400 Subject: [PATCH 43/47] feat: add loading state for file limits in Sidebar component --- src/app/store/slices/fileVersions/fileVersions.selectors.ts | 3 +++ src/views/Drive/components/VersionHistory/Sidebar.tsx | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/store/slices/fileVersions/fileVersions.selectors.ts b/src/app/store/slices/fileVersions/fileVersions.selectors.ts index 03c545460f..55b0d24b2e 100644 --- a/src/app/store/slices/fileVersions/fileVersions.selectors.ts +++ b/src/app/store/slices/fileVersions/fileVersions.selectors.ts @@ -5,6 +5,9 @@ const fileVersionsSelectors = { getLimits(state: RootState): FileLimitsResponse | null { return state.fileVersions.limits; }, + isLimitsLoading(state: RootState): boolean { + return state.fileVersions.isLimitsLoading; + }, getVersionsByFileId(state: RootState, fileId: NonNullable): FileVersion[] { return state.fileVersions.versionsByFileId[fileId] ?? []; }, diff --git a/src/views/Drive/components/VersionHistory/Sidebar.tsx b/src/views/Drive/components/VersionHistory/Sidebar.tsx index 4932ca4073..ff0f501379 100644 --- a/src/views/Drive/components/VersionHistory/Sidebar.tsx +++ b/src/views/Drive/components/VersionHistory/Sidebar.tsx @@ -42,6 +42,7 @@ const Sidebar = () => { const { translate } = useTranslationContext(); const limits = useAppSelector(fileVersionsSelectors.getLimits); + const isLimitsLoading = useAppSelector(fileVersionsSelectors.isLimitsLoading); const versions = useAppSelector((state: RootState) => item ? fileVersionsSelectors.getVersionsByFileId(state, item.uuid) : [], ); @@ -56,6 +57,7 @@ const Sidebar = () => { }); const isVersioningEnabled = limits?.versioning?.enabled ?? false; + const isLoadingContent = (isVersioningEnabled && isLoading) || isLimitsLoading; const blurredBackgroundVersions = useMemo(() => { if (isVersioningEnabled) return []; @@ -247,7 +249,7 @@ const Sidebar = () => {
- {isVersioningEnabled && isLoading ? ( + {isLoadingContent ? ( ) : ( <> @@ -279,7 +281,7 @@ const Sidebar = () => { ))} )} - {!isVersioningEnabled && } + {!isVersioningEnabled && !isLimitsLoading && }
From 330c14db3f774311c26695addd15caea2707d25a Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sat, 31 Jan 2026 22:39:53 -0400 Subject: [PATCH 44/47] feat: implement smart polling for version limits after plan changes Add intelligent exponential backoff polling that stops early when version limits are updated after subscription changes. Version limits are updated via webhook, so polling with change detection ensures the frontend receives updated state efficiently. - Add VERSION_LIMITS_POLL_MAX_ATTEMPTS (3) and VERSION_LIMITS_POLL_DELAYS (2s, 4s, 6s) - Add isSilent parameter to fetchVersionLimitsThunk to suppress loading skeleton during background polling - Implement pollVersionLimitsUntilChanged with limit comparison to detect when webhook has processed - Compare all versioning properties (enabled, maxFileSize, retentionDays, maxVersions) to detect changes - Stop polling early if limits change, continue until max attempts if unchanged - Apply polling to both payment success and subscription cancellation flows - Update tests to handle new payload structure { limits, isSilent } --- .../store/slices/fileVersions/index.test.ts | 14 ++++---- src/app/store/slices/fileVersions/index.ts | 31 ++++++++++------ .../Sections/Account/Plans/PlansSection.tsx | 35 +++++++++++++++++-- 3 files changed, 59 insertions(+), 21 deletions(-) diff --git a/src/app/store/slices/fileVersions/index.test.ts b/src/app/store/slices/fileVersions/index.test.ts index ce4f7dc2a7..c935495416 100644 --- a/src/app/store/slices/fileVersions/index.test.ts +++ b/src/app/store/slices/fileVersions/index.test.ts @@ -54,11 +54,11 @@ describe('File history state', () => { const getLimitsSpy = vi.spyOn(fileVersionService, 'getLimits').mockResolvedValueOnce(limits); const dispatch = vi.fn(); - const action = await fetchVersionLimitsThunk()(dispatch, () => ({}) as RootState, undefined); + const action = await fetchVersionLimitsThunk({})(dispatch, () => ({}) as RootState, undefined); expect(getLimitsSpy).toHaveBeenCalled(); expect(action.meta.requestStatus).toBe('fulfilled'); - expect(action.payload).toBe(limits); + expect(action.payload).toEqual({ limits, isSilent: false }); }); it('when version limits fail to load, then the error is reported', async () => { @@ -67,7 +67,7 @@ describe('File history state', () => { .mockRejectedValueOnce(new Error('limits unavailable')); const dispatch = vi.fn(); - const action = await fetchVersionLimitsThunk()(dispatch, () => ({}) as RootState, undefined); + const action = await fetchVersionLimitsThunk({})(dispatch, () => ({}) as RootState, undefined); expect(getLimitsSpy).toHaveBeenCalled(); expect(action.meta.requestStatus).toBe('rejected'); @@ -138,7 +138,7 @@ describe('File history state', () => { }); it('when limits are loading or finished, then the loading state updates', () => { - const pendingState = fileVersionsReducer(undefined, fetchVersionLimitsThunk.pending('', undefined)); + const pendingState = fileVersionsReducer(undefined, fetchVersionLimitsThunk.pending('', {})); expect(pendingState.isLimitsLoading).toBe(true); const limits: FileLimitsResponse = { @@ -146,14 +146,14 @@ describe('File history state', () => { }; const fulfilledState = fileVersionsReducer( pendingState, - fetchVersionLimitsThunk.fulfilled(limits, '', undefined), + fetchVersionLimitsThunk.fulfilled({ limits, isSilent: false }, '', {}), ); expect(fulfilledState.isLimitsLoading).toBe(false); - expect(fulfilledState.limits).toBe(limits); + expect(fulfilledState.limits).toEqual(limits); const rejectedState = fileVersionsReducer( pendingState, - fetchVersionLimitsThunk.rejected(new Error('err'), '', undefined), + fetchVersionLimitsThunk.rejected(new Error('err'), '', {}), ); expect(rejectedState.isLimitsLoading).toBe(false); }); diff --git a/src/app/store/slices/fileVersions/index.ts b/src/app/store/slices/fileVersions/index.ts index 821015f2d0..cee9190397 100644 --- a/src/app/store/slices/fileVersions/index.ts +++ b/src/app/store/slices/fileVersions/index.ts @@ -2,6 +2,9 @@ import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; import { FileVersion, FileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; import fileVersionService from 'views/Drive/services/fileVersion.service'; +export const VERSION_LIMITS_POLL_MAX_ATTEMPTS = 3; +export const VERSION_LIMITS_POLL_DELAYS = [2000, 4000, 6000]; + interface FileVersionsState { versionsByFileId: Record, FileVersion[]>; isLoadingByFileId: Record, boolean>; @@ -30,14 +33,17 @@ export const fetchFileVersionsThunk = createAsyncThunk( }, ); -export const fetchVersionLimitsThunk = createAsyncThunk('fileVersions/fetchLimits', async (_, { rejectWithValue }) => { - try { - const limits = await fileVersionService.getLimits(); - return limits; - } catch (error) { - return rejectWithValue((error as Error).message); - } -}); +export const fetchVersionLimitsThunk = createAsyncThunk( + 'fileVersions/fetchLimits', + async ({ isSilent = false }: { isSilent?: boolean } = {}, { rejectWithValue }) => { + try { + const limits = await fileVersionService.getLimits(); + return { limits, isSilent }; + } catch (error) { + return rejectWithValue((error as Error).message); + } + }, +); export const fileVersionsSlice = createSlice({ name: 'fileVersions', @@ -75,11 +81,14 @@ export const fileVersionsSlice = createSlice({ state.isLoadingByFileId[action.meta.arg] = false; state.errorsByFileId[action.meta.arg] = action.payload as string; }) - .addCase(fetchVersionLimitsThunk.pending, (state) => { - state.isLimitsLoading = true; + .addCase(fetchVersionLimitsThunk.pending, (state, action) => { + const isSilent = action.meta.arg?.isSilent || false; + if (!isSilent) { + state.isLimitsLoading = true; + } }) .addCase(fetchVersionLimitsThunk.fulfilled, (state, action) => { - state.limits = action.payload; + state.limits = action.payload.limits; state.isLimitsLoading = false; }) .addCase(fetchVersionLimitsThunk.rejected, (state) => { diff --git a/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx b/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx index 8b261eae5b..fc00965f46 100644 --- a/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx +++ b/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx @@ -13,7 +13,12 @@ import { paymentService } from 'views/Checkout/services'; import { RootState } from 'app/store'; import { useAppDispatch } from 'app/store/hooks'; import { PlanState, planThunks } from 'app/store/slices/plan'; -import { fetchVersionLimitsThunk } from 'app/store/slices/fileVersions'; +import { + fetchVersionLimitsThunk, + VERSION_LIMITS_POLL_MAX_ATTEMPTS, + VERSION_LIMITS_POLL_DELAYS, + fileVersionsSelectors, +} from 'app/store/slices/fileVersions'; import CancelSubscriptionModal from '../../Workspace/Billing/CancelSubscriptionModal'; import { fetchPlanPrices, getStripe } from '../../../../services/plansApi'; import ChangePlanDialog from './components/ChangePlanDialog'; @@ -47,6 +52,7 @@ const PlansSection = ({ changeSection, onClosePreferences }: PlansSectionProps) const selectedWorkspace = useSelector((state: RootState) => state.workspaces.selectedWorkspace); const plan = useSelector((state) => state.plan); const user = useSelector((state) => state.user.user); + const versionLimits = useSelector(fileVersionsSelectors.getLimits); const { individualSubscription, businessSubscription } = plan; let stripe; @@ -183,12 +189,34 @@ const PlansSection = ({ changeSection, onClosePreferences }: PlansSectionProps) [translate], ); + const pollVersionLimitsUntilChanged = useCallback( + async (attempt = 0, previousLimits = versionLimits) => { + if (attempt >= VERSION_LIMITS_POLL_MAX_ATTEMPTS) return; + + const delay = VERSION_LIMITS_POLL_DELAYS[attempt] || VERSION_LIMITS_POLL_DELAYS.at(-1); + await new Promise((resolve) => setTimeout(resolve, delay)); + const result = await dispatch(fetchVersionLimitsThunk({ isSilent: true })).unwrap(); + + const hasLimitsChanged = + previousLimits?.versioning.enabled !== result.limits.versioning.enabled || + previousLimits?.versioning.maxFileSize !== result.limits.versioning.maxFileSize || + previousLimits?.versioning.retentionDays !== result.limits.versioning.retentionDays || + previousLimits?.versioning.maxVersions !== result.limits.versioning.maxVersions; + + if (!hasLimitsChanged) { + pollVersionLimitsUntilChanged(attempt + 1, previousLimits); + } + }, + [dispatch, versionLimits], + ); + const handlePaymentSuccess = () => { showSuccessSubscriptionNotification(); setTimeout(() => { dispatch(planThunks.initializeThunk()).unwrap(); - dispatch(fetchVersionLimitsThunk()); }, 2000); + + pollVersionLimitsUntilChanged(); }; const handleSubscriptionPayment = async (priceId: string) => { @@ -245,8 +273,9 @@ const PlansSection = ({ changeSection, onClosePreferences }: PlansSectionProps) setCancellingSubscription(false); setTimeout(() => { dispatch(planThunks.initializeThunk()).unwrap(); - dispatch(fetchVersionLimitsThunk()); }, 1000); + + pollVersionLimitsUntilChanged(); } } From b6c7ee73f44ff44a72c71dbbc46bd8d3289e8311 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 4 Feb 2026 11:42:49 -0400 Subject: [PATCH 45/47] fix: handle file extension correctly in version download filename --- src/views/Drive/hooks/useVersionItemActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/Drive/hooks/useVersionItemActions.ts b/src/views/Drive/hooks/useVersionItemActions.ts index 4e1af1e039..6d8e0b1d1f 100644 --- a/src/views/Drive/hooks/useVersionItemActions.ts +++ b/src/views/Drive/hooks/useVersionItemActions.ts @@ -44,7 +44,7 @@ export const useVersionItemActions = ({ version, onDropdownClose }: UseVersionIt const formattedDate = dateService.format(version.createdAt, 'DD-MM-YYYY [at] HH:mm'); const { filename, extension } = itemsUtils.getFilenameAndExt(entireFilename); - const fileExtension = extension ? `.${extension}` : ''; + const fileExtension = extension || item.type ? `.${extension || item.type}` : ''; const versionFileName = `(${formattedDate}) ${filename}${fileExtension}`; await fileVersionService.downloadVersion(version, item, versionFileName, selectedWorkspace, workspaceCredentials); From 808ae2702069497f85b895140024f2369ca74c26 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 11 Feb 2026 13:05:05 -0400 Subject: [PATCH 46/47] refactor: optimize version extension checking and clean up imports --- .../components/VersionHistory/components/VersionItem.tsx | 3 ++- src/views/Drive/components/VersionHistory/utils/index.ts | 8 +++----- src/views/Drive/hooks/useDriveItemActions.tsx | 1 - 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx index f4a53c0ee6..4439d4c1da 100644 --- a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx +++ b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx @@ -3,7 +3,8 @@ import { Checkbox, Dropdown, Avatar } from '@internxt/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { useDropdownPositioning } from 'views/Drive/hooks/useDropdownPositioning'; import { useVersionItemActions } from 'views/Drive/hooks/useVersionItemActions'; -import { formatVersionDate, getDaysUntilExpiration } from '../utils'; +import { formatVersionDate } from '../utils'; +import { getDaysUntilExpiration } from 'services/date.service'; import { FileVersion } from '@internxt/sdk/dist/drive/storage/types'; import { memo } from 'react'; import sizeService from 'app/drive/services/size.service'; diff --git a/src/views/Drive/components/VersionHistory/utils/index.ts b/src/views/Drive/components/VersionHistory/utils/index.ts index 0178c4433a..6c461b18c2 100644 --- a/src/views/Drive/components/VersionHistory/utils/index.ts +++ b/src/views/Drive/components/VersionHistory/utils/index.ts @@ -3,14 +3,12 @@ import { DriveItemData } from 'app/drive/types'; export const formatVersionDate = (date: string): string => dateService.format(date, 'MMM D, h:mm A'); -const ALLOWED_VERSIONING_EXTENSIONS = ['pdf', 'docx', 'xlsx', 'csv']; +const ALLOWED_VERSIONING_EXTENSIONS = new Set(['pdf', 'docx', 'xlsx', 'csv']); export const isVersioningExtensionAllowed = (item?: Pick | null): boolean => { - if (!item || !item.type) { + if (!item?.type) { return false; } const extension = item.type.toLowerCase(); - return ALLOWED_VERSIONING_EXTENSIONS.includes(extension); + return ALLOWED_VERSIONING_EXTENSIONS.has(extension); }; - -export const getDaysUntilExpiration = (expiresAt: string): number => dateService.getDaysUntilExpiration(expiresAt); diff --git a/src/views/Drive/hooks/useDriveItemActions.tsx b/src/views/Drive/hooks/useDriveItemActions.tsx index 30b5c1a432..bd93a6a77b 100644 --- a/src/views/Drive/hooks/useDriveItemActions.tsx +++ b/src/views/Drive/hooks/useDriveItemActions.tsx @@ -39,7 +39,6 @@ const useDriveItemActions = (item): DriveItemActions => { const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); const workspaceCredentials = useAppSelector(workspacesSelectors.getWorkspaceCredentials); const isWorkspace = !!selectedWorkspace; - const versionHistoryConfig = useVersionHistoryMenuConfig(item); const onRenameItemButtonClicked = () => { dispatch(storageActions.setItemToRename(item as DriveItemData)); From 23b70f832f9f59afbe7673daa39b6903b5c033dc Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 11 Feb 2026 13:07:10 -0400 Subject: [PATCH 47/47] feature: filter expired versions and optimize re-renders in version history --- .../Drive/components/VersionHistory/Sidebar.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/views/Drive/components/VersionHistory/Sidebar.tsx b/src/views/Drive/components/VersionHistory/Sidebar.tsx index ff0f501379..68c6266e75 100644 --- a/src/views/Drive/components/VersionHistory/Sidebar.tsx +++ b/src/views/Drive/components/VersionHistory/Sidebar.tsx @@ -25,9 +25,13 @@ import { fileVersionsActions, fileVersionsSelectors, } from 'app/store/slices/fileVersions'; +import { getDaysUntilExpiration } from 'services/date.service'; +import { FileVersion } from '@internxt/sdk/dist/drive/storage/types'; type VersionInfo = { id: string; updatedAt: string }; +const EMPTY_ARRAY: FileVersion[] = []; + const Sidebar = () => { const dispatch = useAppDispatch(); const isOpen = useAppSelector((state: RootState) => state.ui.isVersionHistorySidebarOpen); @@ -44,7 +48,7 @@ const Sidebar = () => { const limits = useAppSelector(fileVersionsSelectors.getLimits); const isLimitsLoading = useAppSelector(fileVersionsSelectors.isLimitsLoading); const versions = useAppSelector((state: RootState) => - item ? fileVersionsSelectors.getVersionsByFileId(state, item.uuid) : [], + item ? fileVersionsSelectors.getVersionsByFileId(state, item.uuid) : EMPTY_ARRAY, ); const isLoading = useAppSelector((state: RootState) => item ? fileVersionsSelectors.isLoadingByFileId(state, item.uuid) : false, @@ -73,7 +77,13 @@ const Sidebar = () => { })); }, [isVersioningEnabled]); - const displayVersions = isVersioningEnabled ? versions : blurredBackgroundVersions; + const displayVersions = useMemo(() => { + if (!isVersioningEnabled) return blurredBackgroundVersions; + return versions.filter((version) => { + if (!version.expiresAt) return true; + return getDaysUntilExpiration(version.expiresAt) > 0; + }); + }, [isVersioningEnabled, versions]); useEffect(() => { if (item) {