From 8921f971061ebc45c503c23a06b099925ce975e1 Mon Sep 17 00:00:00 2001 From: prawnsgupta Date: Mon, 27 Jul 2026 15:46:46 +0530 Subject: [PATCH 1/2] feat(frontend): confirm before deleting folders, with multi-select Deleting a folder from Folder Management happened the moment the trash icon was clicked, so a misclick permanently removed the folder along with its tags, faces and indexing data. Deletion now goes through the shared ConfirmDialog. The confirmation names the folder, states that it cannot be undone, and makes clear that the photos themselves stay on disk, since deletion only clears the library entry and its cascaded rows rather than touching the filesystem. Folders can also be selected with checkboxes and removed together through a single "Delete selected (N)" confirmation, so clearing out a directory tree does not mean dismissing one dialog per folder. Select all is scoped to the folders currently on screen so "View More" cannot silently widen the selection. The delete mutation now takes a list of ids and sends them in one request, which the /folders/delete-folders endpoint already supported. The success dialog that used to appear after a deletion is gone: the user has just confirmed the action and the folders disappear from the list, so a second modal only added a click. Errors are still reported. The icon-only delete button also gains an aria-label, so it has an accessible name and the confirmation can be reached by keyboard and screen reader. Adds tests covering both flows: that clicking delete confirms rather than deleting, that cancelling deletes nothing, that the right folder is removed, and that a bulk confirmation deletes every selected folder in a single call. --- .../__tests__/FolderManagementCard.test.tsx | 243 ++++++++++++ frontend/src/hooks/useFolderOperations.tsx | 28 +- .../components/FolderManagementCard.tsx | 363 ++++++++++++------ 3 files changed, 494 insertions(+), 140 deletions(-) create mode 100644 frontend/src/components/__tests__/FolderManagementCard.test.tsx diff --git a/frontend/src/components/__tests__/FolderManagementCard.test.tsx b/frontend/src/components/__tests__/FolderManagementCard.test.tsx new file mode 100644 index 000000000..d9640d562 --- /dev/null +++ b/frontend/src/components/__tests__/FolderManagementCard.test.tsx @@ -0,0 +1,243 @@ +import { render, screen } from '@/test-utils'; +import userEvent from '@testing-library/user-event'; +import FolderManagementCard from '@/pages/SettingsPage/components/FolderManagementCard'; +import { FolderDetails } from '@/types/Folder'; + +const mockDeleteFolders = jest.fn(); +const mockToggleAITagging = jest.fn(); + +const mockMakeFolder = (id: string, path: string): FolderDetails => ({ + folder_id: id, + folder_path: path, + last_modified_time: 0, + AI_Tagging: false, + indexing_status: 'completed', +}); + +const mockFolders: FolderDetails[] = [ + mockMakeFolder('folder-1', 'C:\\Users\\me\\Pictures\\Holiday'), + mockMakeFolder('folder-2', 'C:\\Users\\me\\Pictures\\Screenshots'), + mockMakeFolder('folder-3', 'C:\\Users\\me\\Pictures\\Camera'), +]; + +jest.mock('@/hooks/useFolderOperations', () => ({ + useFolderOperations: () => ({ + folders: mockFolders, + toggleAITagging: mockToggleAITagging, + deleteFolders: mockDeleteFolders, + enableAITaggingPending: false, + disableAITaggingPending: false, + deleteFoldersPending: false, + }), +})); + +jest.mock('@/hooks/useLibraryProcessingStatus', () => ({ + useLibraryProcessingStatus: () => ({ semanticAvailable: true }), +})); + +jest.mock('@/components/FolderPicker/FolderPicker', () => ({ + __esModule: true, + default: () =>
, +})); + +const SINGLE_TITLE = 'Delete this folder?'; + +const setup = () => { + const user = userEvent.setup(); + render(); + return { user }; +}; + +const deleteButtonFor = (folder: FolderDetails) => + screen.getByRole('button', { name: `Delete folder ${folder.folder_path}` }); + +const checkboxFor = (folder: FolderDetails) => + screen.getByRole('checkbox', { name: `Select folder ${folder.folder_path}` }); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('FolderManagementCard - single folder deletion', () => { + test('no confirmation is shown until a delete button is clicked', () => { + setup(); + + expect(screen.queryByText(SINGLE_TITLE)).not.toBeInTheDocument(); + }); + + test('clicking delete asks for confirmation instead of deleting straight away', async () => { + const { user } = setup(); + + await user.click(deleteButtonFor(mockFolders[0])); + + expect(screen.getByText(SINGLE_TITLE)).toBeInTheDocument(); + expect(mockDeleteFolders).not.toHaveBeenCalled(); + }); + + test('the confirmation names the folder and warns that it cannot be undone', async () => { + const { user } = setup(); + + await user.click(deleteButtonFor(mockFolders[0])); + + const description = screen.getByText(/cannot be undone/i); + expect(description).toHaveTextContent(mockFolders[0].folder_path); + expect(description).toHaveTextContent(/stay on your disk/i); + }); + + test('cancelling closes the confirmation and deletes nothing', async () => { + const { user } = setup(); + + await user.click(deleteButtonFor(mockFolders[0])); + await user.click(screen.getByRole('button', { name: /cancel/i })); + + expect(mockDeleteFolders).not.toHaveBeenCalled(); + expect(screen.queryByText(SINGLE_TITLE)).not.toBeInTheDocument(); + }); + + test('confirming deletes the folder and closes the confirmation', async () => { + const { user } = setup(); + + await user.click(deleteButtonFor(mockFolders[0])); + await user.click(screen.getByRole('button', { name: /^delete folder$/i })); + + expect(mockDeleteFolders).toHaveBeenCalledTimes(1); + expect(mockDeleteFolders).toHaveBeenCalledWith([mockFolders[0].folder_id]); + expect(screen.queryByText(SINGLE_TITLE)).not.toBeInTheDocument(); + }); + + test('confirming deletes the folder whose delete button was clicked', async () => { + const { user } = setup(); + + await user.click(deleteButtonFor(mockFolders[1])); + await user.click(screen.getByRole('button', { name: /^delete folder$/i })); + + expect(mockDeleteFolders).toHaveBeenCalledWith([mockFolders[1].folder_id]); + }); + + test('the confirmation can be reopened after cancelling', async () => { + const { user } = setup(); + + await user.click(deleteButtonFor(mockFolders[0])); + await user.click(screen.getByRole('button', { name: /cancel/i })); + await user.click(deleteButtonFor(mockFolders[0])); + + expect(screen.getByText(SINGLE_TITLE)).toBeInTheDocument(); + expect(mockDeleteFolders).not.toHaveBeenCalled(); + }); +}); + +describe('FolderManagementCard - bulk deletion', () => { + test('the bulk delete button only appears once something is selected', async () => { + const { user } = setup(); + + expect( + screen.queryByRole('button', { name: /delete selected/i }), + ).not.toBeInTheDocument(); + + await user.click(checkboxFor(mockFolders[0])); + + expect( + screen.getByRole('button', { name: /delete selected \(1\)/i }), + ).toBeInTheDocument(); + }); + + test('the bulk delete button counts the selected folders', async () => { + const { user } = setup(); + + await user.click(checkboxFor(mockFolders[0])); + await user.click(checkboxFor(mockFolders[2])); + + expect( + screen.getByRole('button', { name: /delete selected \(2\)/i }), + ).toBeInTheDocument(); + }); + + test('unselecting a folder updates the count and hides the button at zero', async () => { + const { user } = setup(); + + await user.click(checkboxFor(mockFolders[0])); + await user.click(checkboxFor(mockFolders[0])); + + expect( + screen.queryByRole('button', { name: /delete selected/i }), + ).not.toBeInTheDocument(); + }); + + test('bulk deletion asks for one confirmation naming the count', async () => { + const { user } = setup(); + + await user.click(checkboxFor(mockFolders[0])); + await user.click(checkboxFor(mockFolders[1])); + await user.click( + screen.getByRole('button', { name: /delete selected \(2\)/i }), + ); + + expect(screen.getByText('Delete 2 folders?')).toBeInTheDocument(); + expect(screen.getByText(/cannot be undone/i)).toHaveTextContent( + '2 folders will be removed', + ); + expect(mockDeleteFolders).not.toHaveBeenCalled(); + }); + + test('confirming a bulk deletion removes every selected folder in one call', async () => { + const { user } = setup(); + + await user.click(checkboxFor(mockFolders[0])); + await user.click(checkboxFor(mockFolders[2])); + await user.click( + screen.getByRole('button', { name: /delete selected \(2\)/i }), + ); + await user.click( + screen.getByRole('button', { name: /^delete 2 folders$/i }), + ); + + expect(mockDeleteFolders).toHaveBeenCalledTimes(1); + expect(mockDeleteFolders).toHaveBeenCalledWith([ + mockFolders[0].folder_id, + mockFolders[2].folder_id, + ]); + }); + + test('cancelling a bulk deletion keeps the selection and deletes nothing', async () => { + const { user } = setup(); + + await user.click(checkboxFor(mockFolders[0])); + await user.click(checkboxFor(mockFolders[1])); + await user.click( + screen.getByRole('button', { name: /delete selected \(2\)/i }), + ); + await user.click(screen.getByRole('button', { name: /cancel/i })); + + expect(mockDeleteFolders).not.toHaveBeenCalled(); + expect( + screen.getByRole('button', { name: /delete selected \(2\)/i }), + ).toBeInTheDocument(); + }); + + test('select all picks every folder, and unselects them again', async () => { + const { user } = setup(); + const selectAll = screen.getByRole('checkbox', { name: /select all/i }); + + await user.click(selectAll); + + expect( + screen.getByRole('button', { name: /delete selected \(3\)/i }), + ).toBeInTheDocument(); + + await user.click(selectAll); + + expect( + screen.queryByRole('button', { name: /delete selected/i }), + ).not.toBeInTheDocument(); + }); + + test('selecting every folder individually ticks the select all box', async () => { + const { user } = setup(); + + for (const folder of mockFolders) { + await user.click(checkboxFor(folder)); + } + + expect(screen.getByRole('checkbox', { name: /select all/i })).toBeChecked(); + }); +}); diff --git a/frontend/src/hooks/useFolderOperations.tsx b/frontend/src/hooks/useFolderOperations.tsx index fe436b97d..814701268 100644 --- a/frontend/src/hooks/useFolderOperations.tsx +++ b/frontend/src/hooks/useFolderOperations.tsx @@ -129,22 +129,21 @@ export const useFolderOperations = () => { errorMessage: 'Failed to disable AI tagging. Please try again.', }); - // Delete folder mutation + // Delete folders mutation - takes a list so one confirmation can remove a batch const deleteFolderMutation = usePictoMutation({ - mutationFn: async (folder_id: string) => - deleteFolders({ folder_ids: [folder_id] }), + mutationFn: async (folder_ids: string[]) => deleteFolders({ folder_ids }), autoInvalidateTags: ['folders'], }); - // Apply feedback to the delete folder mutation + // Apply feedback to the delete folder mutation. + // No success dialog here: the user has already confirmed the deletion and the + // folders disappear from the list, so a second modal only adds a click. useMutationFeedback(deleteFolderMutation, { showLoading: true, - loadingMessage: 'Deleting folder', - successTitle: 'Folder Deleted', - successMessage: - 'The folder has been successfully removed from your library.', + loadingMessage: 'Deleting folders', + showSuccess: false, errorTitle: 'Delete Error', - errorMessage: 'Failed to delete the folder. Please try again.', + errorMessage: 'Failed to delete the folders. Please try again.', }); /** @@ -159,10 +158,11 @@ export const useFolderOperations = () => { }; /** - * Delete a folder + * Delete one or more folders in a single request */ - const deleteFolder = (folderId: string) => { - deleteFolderMutation.mutate(folderId); + const handleDeleteFolders = (folderIds: string[]) => { + if (folderIds.length === 0) return; + deleteFolderMutation.mutate(folderIds); }; return { @@ -172,12 +172,12 @@ export const useFolderOperations = () => { // Operations toggleAITagging, - deleteFolder, + deleteFolders: handleDeleteFolders, // Mutation states (for use in UI, e.g., disabling buttons) enableAITaggingPending: enableAITaggingMutation.isPending, disableAITaggingPending: disableAITaggingMutation.isPending, - deleteFolderPending: deleteFolderMutation.isPending, + deleteFoldersPending: deleteFolderMutation.isPending, }; }; diff --git a/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx b/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx index 17b33cc0a..398af7f08 100644 --- a/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx +++ b/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx @@ -13,6 +13,7 @@ import { Badge } from '@/components/ui/badge'; import { useFolderOperations } from '@/hooks/useFolderOperations'; import { useLibraryProcessingStatus } from '@/hooks/useLibraryProcessingStatus'; import { FolderDetails } from '@/types/Folder'; +import { ConfirmDialog } from '@/components/ConfirmDialog/ConfirmDialog'; import SettingsCard from './SettingsCard'; /** @@ -22,10 +23,10 @@ const FolderManagementCard: React.FC = () => { const { folders, toggleAITagging, - deleteFolder, + deleteFolders, enableAITaggingPending, disableAITaggingPending, - deleteFolderPending, + deleteFoldersPending, } = useFolderOperations(); const taggingStatus = useSelector( @@ -35,11 +36,70 @@ const FolderManagementCard: React.FC = () => { const { semanticAvailable } = useLibraryProcessingStatus(); const [visibleFoldersCount, setVisibleFoldersCount] = useState(6); + const [selectedFolderIds, setSelectedFolderIds] = useState>( + new Set(), + ); + // Folders queued for deletion. Empty means the confirmation is closed. + const [foldersToDelete, setFoldersToDelete] = useState([]); + + const visibleFolders = folders.slice(0, visibleFoldersCount); + const selectedFolders = folders.filter((folder: FolderDetails) => + selectedFolderIds.has(folder.folder_id), + ); + const allVisibleSelected = + visibleFolders.length > 0 && + visibleFolders.every((folder: FolderDetails) => + selectedFolderIds.has(folder.folder_id), + ); const handleViewMore = () => { setVisibleFoldersCount((prevCount) => prevCount + 5); }; + const toggleFolderSelection = (folderId: string) => { + setSelectedFolderIds((previous) => { + const next = new Set(previous); + if (next.has(folderId)) { + next.delete(folderId); + } else { + next.add(folderId); + } + return next; + }); + }; + + // Only spans the folders currently on screen, so "View More" never pulls in + // folders the user has not seen. + const toggleSelectAllVisible = () => { + setSelectedFolderIds((previous) => { + const next = new Set(previous); + visibleFolders.forEach((folder: FolderDetails) => { + if (allVisibleSelected) { + next.delete(folder.folder_id); + } else { + next.add(folder.folder_id); + } + }); + return next; + }); + }; + + const confirmDeletion = () => { + deleteFolders(foldersToDelete.map((folder) => folder.folder_id)); + setSelectedFolderIds((previous) => { + const next = new Set(previous); + foldersToDelete.forEach((folder) => next.delete(folder.folder_id)); + return next; + }); + }; + + const deletionDescription = () => { + if (foldersToDelete.length === 1) { + return `"${foldersToDelete[0].folder_path}" will be removed from your library along with its tags and indexing data. This cannot be undone, though the photos themselves stay on your disk.`; + } + return `${foldersToDelete.length} folders will be removed from your library along with their tags and indexing data. This cannot be undone, though the photos themselves stay on your disk.`; + }; + return ( { > {folders.length > 0 ? (
- {folders - .slice(0, visibleFoldersCount) - .map((folder: FolderDetails) => ( -
+ + + {selectedFolders.length > 0 && ( + + )} +
-
-
- - AI Tagging - - toggleAITagging(folder)} - disabled={ - enableAITaggingPending || disableAITaggingPending - } - /> -
+ {visibleFolders.map((folder: FolderDetails) => ( +
+
+
+
+ toggleFolderSelection(folder.folder_id)} + aria-label={`Select folder ${folder.folder_path}`} + className="border-border h-4 w-4 shrink-0 cursor-pointer rounded" + /> + + + {folder.folder_path} + +
+
- +
+
+ + AI Tagging + + toggleAITagging(folder)} + disabled={ + enableAITaggingPending || disableAITaggingPending + } + />
+ +
+
- {folder.AI_Tagging && ( -
- {folder.indexing_status !== 'completed' ? ( -
- - - Indexing Folder... - -
- ) : !folder.image_count && !folder.video_count ? ( -
- Folder is empty -
- ) : ( - <> -
- AI Tagging Progress - = 100 - ? 'flex items-center gap-1 text-green-500' - : 'text-muted-foreground' - } - > - {(taggingStatus[folder.folder_id] - ?.tagging_percentage ?? 0) >= 100 && ( - - )} - {Math.round( - taggingStatus[folder.folder_id] - ?.tagging_percentage ?? 0, - )} - % - -
- + {folder.indexing_status !== 'completed' ? ( +
+ + + Indexing Folder... + +
+ ) : !folder.image_count && !folder.video_count ? ( +
+ Folder is empty +
+ ) : ( + <> +
+ AI Tagging Progress + = 100 - ? 'bg-green-500' - : 'bg-blue-500' + ? 'flex items-center gap-1 text-green-500' + : 'text-muted-foreground' } - /> - - {semanticAvailable && ( - <> -
- Semantic Indexing - = 100 - ? 'flex items-center gap-1 text-green-500' - : 'text-muted-foreground' - } - > - {(taggingStatus[folder.folder_id] - ?.embedding_percentage ?? 0) >= 100 && ( - - )} - {Math.round( - taggingStatus[folder.folder_id] - ?.embedding_percentage ?? 0, - )} - % - -
- + {(taggingStatus[folder.folder_id] + ?.tagging_percentage ?? 0) >= 100 && ( + + )} + {Math.round( + taggingStatus[folder.folder_id] + ?.tagging_percentage ?? 0, + )} + % +
+
+ = 100 + ? 'bg-green-500' + : 'bg-blue-500' + } + /> + + {semanticAvailable && ( + <> +
+ Semantic Indexing + = 100 - ? 'bg-green-500' - : 'bg-blue-500' + ? 'flex items-center gap-1 text-green-500' + : 'text-muted-foreground' } - /> - - )} - - )} -
- )} -
- ))} + > + {(taggingStatus[folder.folder_id] + ?.embedding_percentage ?? 0) >= 100 && ( + + )} + {Math.round( + taggingStatus[folder.folder_id] + ?.embedding_percentage ?? 0, + )} + % + +
+ = 100 + ? 'bg-green-500' + : 'bg-blue-500' + } + /> + + )} + + )} +
+ )} +
+ ))}
) : (
@@ -210,6 +301,26 @@ const FolderManagementCard: React.FC = () => {
+ + 0} + onOpenChange={(open) => { + if (!open) setFoldersToDelete([]); + }} + title={ + foldersToDelete.length > 1 + ? `Delete ${foldersToDelete.length} folders?` + : 'Delete this folder?' + } + description={foldersToDelete.length > 0 ? deletionDescription() : ''} + confirmLabel={ + foldersToDelete.length > 1 + ? `Delete ${foldersToDelete.length} Folders` + : 'Delete Folder' + } + destructive + onConfirm={confirmDeletion} + /> ); }; From 2180c9caa36cbdfcfa75479f0c358fc4839125dd Mon Sep 17 00:00:00 2001 From: prawnsgupta Date: Wed, 29 Jul 2026 19:50:56 +0530 Subject: [PATCH 2/2] Clarify the select-all control for partial selections It only spans folders currently on screen, so relabel it "Select all shown" and show an indeterminate state when some but not all visible folders are selected, instead of an empty checkbox. --- .../SettingsPage/components/FolderManagementCard.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx b/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx index e9f6116e8..27097f5d8 100644 --- a/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx +++ b/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx @@ -51,6 +51,9 @@ const FolderManagementCard: React.FC = () => { visibleFolders.every((folder: FolderDetails) => selectedFolderIds.has(folder.folder_id), ); + const someVisibleSelected = visibleFolders.some((folder: FolderDetails) => + selectedFolderIds.has(folder.folder_id), + ); const handleViewMore = () => { setVisibleFoldersCount((prevCount) => prevCount + 5); @@ -113,10 +116,16 @@ const FolderManagementCard: React.FC = () => { { + if (el) { + el.indeterminate = + someVisibleSelected && !allVisibleSelected; + } + }} onChange={toggleSelectAllVisible} className="border-border h-4 w-4 shrink-0 cursor-pointer rounded" /> - Select all + Select all shown {selectedFolders.length > 0 && (