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/__tests__/useFolderOperations.test.tsx b/frontend/src/hooks/__tests__/useFolderOperations.test.tsx index 80c101b23..2b49df009 100644 --- a/frontend/src/hooks/__tests__/useFolderOperations.test.tsx +++ b/frontend/src/hooks/__tests__/useFolderOperations.test.tsx @@ -56,7 +56,7 @@ describe('useFolderOperations - delete folder cache invalidation', () => { deleteFolders.mockResolvedValueOnce({ success: true, data: {} }); const { result, invalidateSpy } = renderUseFolderOperations(); - result.current.deleteFolder('folder-1'); + result.current.deleteFolders(['folder-1']); await waitFor(() => { expect(clustersKeyCalls(invalidateSpy).length).toBeGreaterThan(0); @@ -70,7 +70,7 @@ describe('useFolderOperations - delete folder cache invalidation', () => { deleteFolders.mockRejectedValue(new Error('delete failed')); const { result, invalidateSpy } = renderUseFolderOperations(); - result.current.deleteFolder('folder-1'); + result.current.deleteFolders(['folder-1']); // autoInvalidateTags still fires ['folders'] on settle regardless of // outcome, so wait for that instead of an arbitrary timeout to know the diff --git a/frontend/src/hooks/useFolderOperations.tsx b/frontend/src/hooks/useFolderOperations.tsx index cdd0f2c6a..61976382e 100644 --- a/frontend/src/hooks/useFolderOperations.tsx +++ b/frontend/src/hooks/useFolderOperations.tsx @@ -131,10 +131,9 @@ 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'], // Deleting a folder cascades to its images and faces, so any cluster built from // them is now stale. This has to be a separate call: autoInvalidateTags is passed @@ -145,15 +144,15 @@ export const useFolderOperations = () => { }, }); - // 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.', }); /** @@ -168,10 +167,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 { @@ -181,12 +181,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 25b836d93..5948c112d 100644 --- a/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx +++ b/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx @@ -22,6 +22,7 @@ import { Badge } from '@/components/ui/badge'; import { useFolderOperations } from '@/hooks/useFolderOperations'; import { useLibraryProcessingStatus } from '@/hooks/useLibraryProcessingStatus'; import { FolderDetails, isIndexingPending } from '@/types/Folder'; +import { ConfirmDialog } from '@/components/ConfirmDialog/ConfirmDialog'; import SettingsCard from './SettingsCard'; type TaggingStatus = RootState['folders']['taggingStatus']; @@ -131,10 +132,10 @@ const FolderManagementCard: React.FC = () => { const { folders, toggleAITagging, - deleteFolder, + deleteFolders, enableAITaggingPending, disableAITaggingPending, - deleteFolderPending, + deleteFoldersPending, } = useFolderOperations(); const taggingStatus = useSelector( @@ -147,6 +148,24 @@ const FolderManagementCard: React.FC = () => { const [expandedFolders, setExpandedFolders] = useState>( new Set(), ); + 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 someVisibleSelected = visibleFolders.some((folder: FolderDetails) => + selectedFolderIds.has(folder.folder_id), + ); // --- NEW: Force data refresh when window regains focus or visibility --- useEffect(() => { @@ -188,6 +207,50 @@ const FolderManagementCard: React.FC = () => { }); }; + 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) => ( -
-
-
-
- - - {folder.folder_path} - -
-
+
+ -
-
- - AI Tagging - - toggleAITagging(folder)} - disabled={ - enableAITaggingPending || disableAITaggingPending - } - /> -
+ {selectedFolders.length > 0 && ( + + )} +
- + {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} +
- {folder.AI_Tagging && ( -
- {isIndexingPending(folder.indexing_status) ? ( -
- - - Indexing Folder... - -
- ) : folder.indexing_status === 'interrupted' ? ( - // A previous session died mid-walk, so nothing is - // running and the folder is only partly indexed. -
- - - Indexing was interrupted - sync to finish - -
- ) : !folder.image_count && !folder.video_count ? ( -
- Folder is empty -
- ) : ( - - toggleFolderExpanded(folder.folder_id) - } - /> - )} +
+
+ + AI Tagging + + toggleAITagging(folder)} + disabled={ + enableAITaggingPending || disableAITaggingPending + } + />
- )} + + +
- ))} + + {folder.AI_Tagging && ( +
+ {isIndexingPending(folder.indexing_status) ? ( +
+ + + Indexing Folder... + +
+ ) : folder.indexing_status === 'interrupted' ? ( + // A previous session died mid-walk, so nothing is + // running and the folder is only partly indexed. +
+ + + Indexing was interrupted - sync to finish + +
+ ) : !folder.image_count && !folder.video_count ? ( +
+ Folder is empty +
+ ) : ( + + toggleFolderExpanded(folder.folder_id) + } + /> + )} +
+ )} +
+ ))}
) : (
@@ -303,6 +403,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} + /> ); };