Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 243 additions & 0 deletions frontend/src/components/__tests__/FolderManagementCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="folder-picker" />,
}));

const SINGLE_TITLE = 'Delete this folder?';

const setup = () => {
const user = userEvent.setup();
render(<FolderManagementCard />);
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();
});
});
4 changes: 2 additions & 2 deletions frontend/src/hooks/__tests__/useFolderOperations.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down
28 changes: 14 additions & 14 deletions frontend/src/hooks/useFolderOperations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.',
});

/**
Expand All @@ -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 {
Expand All @@ -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,
};
};

Expand Down
Loading
Loading