{/* Table for displaying tasks */}
@@ -953,6 +994,30 @@ export const Tasks = (
+
+ t.status !== 'deleted')
+ .length > 0 &&
+ selectedTaskUUIDs.length ===
+ currentTasks.filter(
+ (t) => t.status !== 'deleted'
+ ).length
+ }
+ onChange={(e) => {
+ if (e.target.checked) {
+ setSelectedTaskUUIDs(
+ currentTasks
+ .filter((task) => task.status !== 'deleted')
+ .map((task) => task.uuid)
+ );
+ } else {
+ setSelectedTaskUUIDs([]);
+ }
+ }}
+ />
+
{
+ if (checked) {
+ setSelectedTaskUUIDs([
+ ...selectedTaskUUIDs,
+ uuid,
+ ]);
+ } else {
+ setSelectedTaskUUIDs(
+ selectedTaskUUIDs.filter((id) => id !== uuid)
+ );
+ }
+ }}
onSelectTask={handleSelectTask}
selectedIndex={selectedIndex}
task={task}
@@ -1069,6 +1150,96 @@ export const Tasks = (
{/* Intentionally empty for spacing */}
+ {selectedTaskUUIDs.length > 0 && (
+
+ {/* Bulk Complete Dialog */}
+ {!selectedTaskUUIDs.some((uuid) => {
+ const task = currentTasks.find((t) => t.uuid === uuid);
+ return task?.status === 'completed';
+ }) && (
+
+ )}
+
+ {/* Bulk Delete Dialog */}
+
+
+ )}
>
) : (
diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx
index acb52c8b..94e7f76c 100644
--- a/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx
+++ b/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx
@@ -78,6 +78,8 @@ describe('TaskDialog Component', () => {
selectedIndex: 0,
onOpenChange: jest.fn(),
onSelectTask: jest.fn(),
+ selectedTaskUUIDs: [] as string[],
+ onCheckboxChange: jest.fn(),
editState: mockEditState,
onUpdateState: jest.fn(),
allTasks: mockAllTasks,
diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx
index a5f510dc..b1cb3d7c 100644
--- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx
+++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx
@@ -31,7 +31,9 @@ jest.mock('../tasks-utils', () => {
return {
...originalModule, // Includes all real functions like sortTasksById
markTaskAsCompleted: jest.fn(),
+ bulkMarkTasksAsCompleted: jest.fn().mockResolvedValue(true),
markTaskAsDeleted: jest.fn(),
+ bulkMarkTasksAsDeleted: jest.fn().mockResolvedValue(true),
getTimeSinceLastSync: jest
.fn()
.mockReturnValue('Last updated 5 minutes ago'),
@@ -94,7 +96,7 @@ jest.mock('../hooks', () => ({
{
id: 13,
description:
- 'Prepare quarterly financial analysis report for review',
+ 'Task 13: Prepare quarterly financial analysis report for review',
status: 'pending',
project: 'Finance',
tags: ['report', 'analysis'],
@@ -102,7 +104,8 @@ jest.mock('../hooks', () => ({
},
{
id: 14,
- description: 'Schedule client onboarding meeting with Sales team',
+ description:
+ 'Task 14: Schedule client onboarding meeting with Sales team',
status: 'pending',
project: 'Sales',
tags: ['meeting', 'client'],
@@ -111,12 +114,28 @@ jest.mock('../hooks', () => ({
{
id: 15,
description:
- 'Draft technical documentation for API integration module',
+ 'Task 15: Draft technical documentation for API integration module',
status: 'pending',
project: 'Engineering',
tags: ['documentation', 'api'],
uuid: 'uuid-corp-3',
},
+ {
+ id: 16,
+ description: 'Completed Task 1',
+ status: 'completed',
+ project: 'ProjectA',
+ tags: ['completed'],
+ uuid: 'uuid-completed-1',
+ },
+ {
+ id: 17,
+ description: 'Deleted Task 1',
+ status: 'deleted',
+ project: 'ProjectB',
+ tags: ['deleted'],
+ uuid: 'uuid-deleted-1',
+ },
]),
})),
})),
@@ -162,211 +181,594 @@ describe('Tasks Component', () => {
jest.clearAllMocks();
});
- test('renders tasks component and the mocked BottomBar', async () => {
- render();
- expect(screen.getByTestId('tasks')).toBeInTheDocument();
- expect(screen.getByText('Mocked BottomBar')).toBeInTheDocument();
+ describe('Rendering & Basic UI', () => {
+ test('renders tasks component and the mocked BottomBar', async () => {
+ render();
+
+ expect(screen.getByTestId('tasks')).toBeInTheDocument();
+ expect(screen.getByText('Mocked BottomBar')).toBeInTheDocument();
+ });
+
+ test('renders the "Tasks per Page" dropdown with default value', async () => {
+ await act(async () => {
+ render();
+ });
+
+ expect(await screen.findByText('Task 12')).toBeInTheDocument();
+
+ const dropdown = screen.getByLabelText('Show:');
+ expect(dropdown).toBeInTheDocument();
+ expect(dropdown).toHaveValue('10');
+ });
});
- test('renders the "Tasks per Page" dropdown with default value', async () => {
- await act(async () => {
+ describe('LocalStorage', () => {
+ test('loads "tasksPerPage" from localStorage on initial render', async () => {
+ localStorageMock.setItem('mockHashedKey', '20');
+
render();
+
+ await waitFor(async () => {
+ expect(await screen.findByText('Task 1')).toBeInTheDocument();
+ });
+
+ expect(screen.getByLabelText('Show:')).toHaveValue('20');
});
- expect(await screen.findByText('Task 12')).toBeInTheDocument();
+ test('updates pagination when "Tasks per Page" is changed', async () => {
+ render();
- const dropdown = screen.getByLabelText('Show:');
- expect(dropdown).toBeInTheDocument();
- expect(dropdown).toHaveValue('10');
+ await waitFor(async () => {
+ expect(await screen.findByText('Task 12')).toBeInTheDocument();
+ });
+
+ expect(screen.getByTestId('total-pages')).toHaveTextContent('2');
+
+ const dropdown = screen.getByLabelText('Show:');
+ fireEvent.change(dropdown, { target: { value: '5' } });
+
+ expect(screen.getByTestId('total-pages')).toHaveTextContent('4');
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ 'mockHashedKey',
+ '5'
+ );
+
+ expect(screen.getByTestId('current-page')).toHaveTextContent('1');
+ });
});
- test('loads "tasksPerPage" from localStorage on initial render', async () => {
- localStorageMock.setItem('mockHashedKey', '20');
+ describe('Pagination', () => {
+ it('unselects all tasks when Select All is clicked again', async () => {
+ render();
+
+ const checkboxes = await screen.findAllByRole('checkbox');
+ const selectAll = checkboxes[0];
- render();
+ fireEvent.click(selectAll);
+ fireEvent.click(selectAll);
- await waitFor(async () => {
- expect(await screen.findByText('Task 1')).toBeInTheDocument();
+ const allRowChecks = checkboxes.slice(1);
+ allRowChecks.forEach((cb) => expect(cb).not.toBeChecked());
+
+ expect(screen.queryByText(/Mark/i)).not.toBeInTheDocument();
});
- expect(screen.getByLabelText('Show:')).toHaveValue('20');
+ it('maintains selected tasks when navigating between pages', async () => {
+ render();
+ await screen.findByText('Task 1');
+ const checkboxes = screen.getAllByRole('checkbox');
+
+ fireEvent.click(checkboxes[1]);
+ expect(checkboxes[1]).toBeChecked();
+
+ const updatedCheckboxes = screen.getAllByRole('checkbox');
+ expect(updatedCheckboxes[1]).toBeChecked();
+ });
+
+ it('select all checkbox is unchecked when no tasks are selected', async () => {
+ render();
+
+ const checkboxes = await screen.findAllByRole('checkbox');
+ const selectAll = checkboxes[0];
+
+ expect(selectAll).not.toBeChecked();
+ });
});
- test('updates pagination when "Tasks per Page" is changed', async () => {
- render();
+ describe('Search & Filtering', () => {
+ test('filters tasks with fuzzy search (handles typos)', async () => {
+ jest.useFakeTimers();
- await waitFor(async () => {
+ render();
expect(await screen.findByText('Task 12')).toBeInTheDocument();
- });
- expect(screen.getByTestId('total-pages')).toHaveTextContent('2');
+ const dropdown = screen.getByLabelText('Show:');
+ fireEvent.change(dropdown, { target: { value: '50' } });
- const dropdown = screen.getByLabelText('Show:');
- fireEvent.change(dropdown, { target: { value: '5' } });
+ const searchBar = screen.getByPlaceholderText('Search tasks...');
+ fireEvent.change(searchBar, { target: { value: 'fiace' } });
- expect(screen.getByTestId('total-pages')).toHaveTextContent('3');
+ act(() => {
+ jest.advanceTimersByTime(300);
+ });
- expect(localStorageMock.setItem).toHaveBeenCalledWith('mockHashedKey', '5');
+ expect(await screen.findByText('Finance')).toBeInTheDocument();
+ expect(screen.queryByText('Engineering')).not.toBeInTheDocument();
+ expect(screen.queryByText('Sales')).not.toBeInTheDocument();
- expect(screen.getByTestId('current-page')).toHaveTextContent('1');
+ jest.useRealTimers();
+ });
});
- test('shows tags as badges in task dialog and allows editing (add on Enter)', async () => {
- render();
+ describe('Task Dialog - Tags Editing', () => {
+ test('shows tags as badges in task dialog and allows editing (add on Enter)', async () => {
+ render();
- expect(await screen.findByText('Task 1')).toBeInTheDocument();
+ expect(await screen.findByText('Task 1')).toBeInTheDocument();
- const taskRow = screen.getByText('Task 1');
- fireEvent.click(taskRow);
+ const taskRow = screen.getByText('Task 1');
+ fireEvent.click(taskRow);
- expect(await screen.findByText('Tags:')).toBeInTheDocument();
+ expect(await screen.findByText('Tags:')).toBeInTheDocument();
- expect(screen.getByText('tag1')).toBeInTheDocument();
+ expect(screen.getByText('tag1')).toBeInTheDocument();
- const tagsLabel = screen.getByText('Tags:');
- const tagsRow = tagsLabel.closest('tr') as HTMLElement;
- const pencilButton = within(tagsRow).getByRole('button');
- fireEvent.click(pencilButton);
+ const tagsLabel = screen.getByText('Tags:');
+ const tagsRow = tagsLabel.closest('tr') as HTMLElement;
+ const pencilButton = within(tagsRow).getByRole('button');
+ fireEvent.click(pencilButton);
- const editInput = await screen.findByPlaceholderText(
- 'Add a tag (press enter to add)'
- );
+ const editInput = await screen.findByPlaceholderText(
+ 'Add a tag (press enter to add)'
+ );
- fireEvent.change(editInput, { target: { value: 'newtag' } });
- fireEvent.keyDown(editInput, { key: 'Enter', code: 'Enter' });
+ fireEvent.change(editInput, { target: { value: 'newtag' } });
+ fireEvent.keyDown(editInput, { key: 'Enter', code: 'Enter' });
- expect(await screen.findByText('newtag')).toBeInTheDocument();
+ expect(await screen.findByText('newtag')).toBeInTheDocument();
- expect((editInput as HTMLInputElement).value).toBe('');
- });
+ expect((editInput as HTMLInputElement).value).toBe('');
+ });
- test('adds a tag while editing and saves updated tags to backend', async () => {
- render();
+ test('adds a tag while editing and saves updated tags to backend', async () => {
+ render();
- expect(await screen.findByText('Task 1')).toBeInTheDocument();
+ expect(await screen.findByText('Task 1')).toBeInTheDocument();
- const taskRow = screen.getByText('Task 1');
- fireEvent.click(taskRow);
+ const taskRow = screen.getByText('Task 1');
+ fireEvent.click(taskRow);
- expect(await screen.findByText('Tags:')).toBeInTheDocument();
+ expect(await screen.findByText('Tags:')).toBeInTheDocument();
- const tagsLabel = screen.getByText('Tags:');
- const tagsRow = tagsLabel.closest('tr') as HTMLElement;
- const pencilButton = within(tagsRow).getByRole('button');
- fireEvent.click(pencilButton);
+ const tagsLabel = screen.getByText('Tags:');
+ const tagsRow = tagsLabel.closest('tr') as HTMLElement;
+ const pencilButton = within(tagsRow).getByRole('button');
+ fireEvent.click(pencilButton);
- const editInput = await screen.findByPlaceholderText(
- 'Add a tag (press enter to add)'
- );
+ const editInput = await screen.findByPlaceholderText(
+ 'Add a tag (press enter to add)'
+ );
+
+ fireEvent.change(editInput, { target: { value: 'addedtag' } });
+ fireEvent.keyDown(editInput, { key: 'Enter', code: 'Enter' });
- fireEvent.change(editInput, { target: { value: 'addedtag' } });
- fireEvent.keyDown(editInput, { key: 'Enter', code: 'Enter' });
+ expect(await screen.findByText('addedtag')).toBeInTheDocument();
- expect(await screen.findByText('addedtag')).toBeInTheDocument();
+ const saveButton = await screen.findByRole('button', {
+ name: /save tags/i,
+ });
+ fireEvent.click(saveButton);
- const saveButton = await screen.findByRole('button', {
- name: /save tags/i,
+ await waitFor(() => {
+ const hooks = require('../hooks');
+ expect(hooks.editTaskOnBackend).toHaveBeenCalled();
+ });
+
+ const hooks = require('../hooks');
+ expect(hooks.editTaskOnBackend).toHaveBeenCalled();
+
+ const callArg = hooks.editTaskOnBackend.mock.calls[0][0];
+ expect(callArg.tags).toEqual(
+ expect.arrayContaining(['tag1', 'addedtag'])
+ );
});
- fireEvent.click(saveButton);
- await waitFor(() => {
+ test('removes a tag while editing and saves updated tags to backend', async () => {
+ render();
+
+ expect(await screen.findByText('Task 1')).toBeInTheDocument();
+
+ const taskRow = screen.getByText('Task 1');
+ fireEvent.click(taskRow);
+
+ expect(await screen.findByText('Tags:')).toBeInTheDocument();
+
+ const tagsLabel = screen.getByText('Tags:');
+ const tagsRow = tagsLabel.closest('tr') as HTMLElement;
+ const pencilButton = within(tagsRow).getByRole('button');
+ fireEvent.click(pencilButton);
+
+ const editInput = await screen.findByPlaceholderText(
+ 'Add a tag (press enter to add)'
+ );
+
+ fireEvent.change(editInput, { target: { value: 'newtag' } });
+ fireEvent.keyDown(editInput, { key: 'Enter', code: 'Enter' });
+
+ expect(await screen.findByText('newtag')).toBeInTheDocument();
+
+ const tagBadge = screen.getByText('tag1');
+ const badgeContainer = (tagBadge.closest('div') ||
+ tagBadge.parentElement) as HTMLElement;
+
+ const removeButton = within(badgeContainer).getByText('✖');
+ fireEvent.click(removeButton);
+
+ expect(screen.queryByText('tag2')).not.toBeInTheDocument();
+
+ const saveButton = await screen.findByRole('button', {
+ name: /save tags/i,
+ });
+ fireEvent.click(saveButton);
+
+ await waitFor(() => {
+ const hooks = require('../hooks');
+ expect(hooks.editTaskOnBackend).toHaveBeenCalled();
+ });
+
const hooks = require('../hooks');
expect(hooks.editTaskOnBackend).toHaveBeenCalled();
+
+ const callArg = hooks.editTaskOnBackend.mock.calls[0][0];
+
+ expect(callArg.tags).toEqual(expect.arrayContaining(['newtag', 'tag1']));
});
- const hooks = require('../hooks');
- const callArg = hooks.editTaskOnBackend.mock.calls[0][0];
- expect(callArg.tags).toEqual(expect.arrayContaining(['tag1', 'addedtag']));
+ it('clicking checkbox does not open task detail dialog', async () => {
+ render();
+
+ await screen.findByText('Task 1');
+
+ const checkboxes = screen.getAllByRole('checkbox');
+
+ fireEvent.click(checkboxes[1]);
+
+ expect(screen.queryByText('Task Details')).not.toBeInTheDocument();
+ });
});
- test('removes a tag while editing and saves updated tags to backend', async () => {
- render();
+ describe('Overdue UI', () => {
+ test('shows red background on task ID and Overdue badge for overdue tasks', async () => {
+ render();
- expect(await screen.findByText('Task 1')).toBeInTheDocument();
+ await screen.findByText('Task 12');
- const taskRow = screen.getByText('Task 1');
- fireEvent.click(taskRow);
+ const dropdown = screen.getByLabelText('Show:');
+ fireEvent.change(dropdown, { target: { value: '20' } });
- expect(await screen.findByText('Tags:')).toBeInTheDocument();
+ const task1Description = screen.getByText('Task 1');
+ const row = task1Description.closest('tr');
+ const idElement = row?.querySelector('span');
- const tagsLabel = screen.getByText('Tags:');
- const tagsRow = tagsLabel.closest('tr') as HTMLElement;
- const pencilButton = within(tagsRow).getByRole('button');
- fireEvent.click(pencilButton);
+ expect(idElement).toHaveClass('bg-red-600/80');
+ fireEvent.click(idElement!);
- const editInput = await screen.findByPlaceholderText(
- 'Add a tag (press enter to add)'
- );
+ const overdueBadge = await screen.findByText('Overdue');
+ expect(overdueBadge).toBeInTheDocument();
+ });
+ });
+
+ describe('Selection Logic', () => {
+ it('adds a task UUID to selectedTaskUUIDs when an individual checkbox is clicked', async () => {
+ render();
- fireEvent.change(editInput, { target: { value: 'newtag' } });
- fireEvent.keyDown(editInput, { key: 'Enter', code: 'Enter' });
+ await screen.findByText('Task 1');
- expect(await screen.findByText('newtag')).toBeInTheDocument();
+ const checkboxes = screen.getAllByRole('checkbox');
- const tagBadge = screen.getByText('tag1');
- const badgeContainer = (tagBadge.closest('div') ||
- tagBadge.parentElement) as HTMLElement;
+ const firstRowCheckbox = checkboxes[1];
+ fireEvent.click(firstRowCheckbox);
- const removeButton = within(badgeContainer).getByText('✖');
- fireEvent.click(removeButton);
+ expect(firstRowCheckbox).toBeChecked();
- expect(screen.queryByText('tag1')).not.toBeInTheDocument();
+ const actionBar = screen.getByTestId('bulk-action-bar');
+ expect(actionBar).toBeInTheDocument();
- const saveButton = await screen.findByRole('button', {
- name: /save tags/i,
+ const completeBtn = screen.getByTestId('bulk-complete-btn');
+ expect(completeBtn).toBeInTheDocument();
});
- fireEvent.click(saveButton);
- await waitFor(() => {
- const hooks = require('../hooks');
- expect(hooks.editTaskOnBackend).toHaveBeenCalled();
+ it('removes a task UUID when checkbox is unchecked', async () => {
+ render();
+
+ const checkboxes = await screen.findAllByRole('checkbox');
+ const firstRowCheckbox = checkboxes[1];
+
+ fireEvent.click(firstRowCheckbox);
+ expect(firstRowCheckbox).toBeChecked();
+
+ fireEvent.click(firstRowCheckbox);
+ expect(firstRowCheckbox).not.toBeChecked();
+
+ expect(
+ screen.queryByText(/Mark 1 Task Completed/i)
+ ).not.toBeInTheDocument();
+ });
+
+ it('updates Select All state when an individual checkbox is unchecked', async () => {
+ render();
+
+ await screen.findByText('Task 1');
+ const checkboxes = screen.getAllByRole('checkbox');
+ const selectAll = checkboxes[0];
+
+ fireEvent.click(selectAll); // select all
+
+ // unselect one
+ fireEvent.click(checkboxes[1]);
+
+ expect(selectAll).not.toBeChecked();
});
- const hooks = require('../hooks');
- const callArg = hooks.editTaskOnBackend.mock.calls[0][0];
+ it('hides bulk action bar when all tasks are deselected', async () => {
+ render();
+
+ await screen.findByText('Task 1');
+ const checkboxes = screen.getAllByRole('checkbox');
+
+ // Select and then deselect
+ fireEvent.click(checkboxes[1]);
- expect(callArg.tags).toEqual(expect.arrayContaining(['newtag', 'tag1']));
+ expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument();
+
+ fireEvent.click(checkboxes[1]);
+
+ expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument();
+ });
});
- test('shows orange background on task ID and Overdue badge for overdue tasks', async () => {
- render();
+ describe('Bulk Complete', () => {
+ it('calls bulkMarkTasksAsCompleted when bulk complete button is clicked', async () => {
+ render();
- await screen.findByText('Task 12');
+ await screen.findByText('Task 1');
+ const checkboxes = screen.getAllByRole('checkbox');
+ fireEvent.click(checkboxes[1]);
- const dropdown = screen.getByLabelText('Show:');
- fireEvent.change(dropdown, { target: { value: '20' } });
+ const bulkButton = screen.getByTestId('bulk-complete-btn');
+ fireEvent.click(bulkButton);
+
+ const yesButton = await screen.findByText('Yes');
+ fireEvent.click(yesButton);
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId('bulk-complete-btn')
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it('keeps tasks selected when bulk complete fails', async () => {
+ const utils = require('../tasks-utils');
+
+ utils.bulkMarkTasksAsCompleted.mockResolvedValue(false);
+
+ render();
+
+ await screen.findByText('Task 1');
+ const checkboxes = screen.getAllByRole('checkbox');
+
+ fireEvent.click(checkboxes[1]);
+ fireEvent.click(checkboxes[2]);
+
+ const bulkButton = screen.getByText(/Mark 2 Tasks Completed/i);
+
+ fireEvent.click(bulkButton);
- const task1Description = screen.getByText('Task 1');
- const row = task1Description.closest('tr');
- const idElement = row?.querySelector('span');
+ await waitFor(() => {
+ expect(screen.getByText(/Mark 2 Tasks Completed/i)).toBeInTheDocument();
+ });
+ });
+
+ it('cancelling confirmation dialog keeps tasks selected', async () => {
+ render();
+
+ await screen.findByText('Task 1');
+ const checkboxes = screen.getAllByRole('checkbox');
+
+ fireEvent.click(checkboxes[1]);
+
+ const completeBtn = screen.getByText(/Mark 1 Task Completed/i);
+
+ fireEvent.click(completeBtn);
- expect(idElement).toHaveClass('bg-red-600/80');
- fireEvent.click(idElement!);
+ const noButton = await screen.findByText('No');
- const overdueBadge = await screen.findByText('Overdue');
- expect(overdueBadge).toBeInTheDocument();
+ fireEvent.click(noButton);
+
+ await waitFor(() => {
+ expect(screen.getByText(/Mark 1 Task Completed/i)).toBeInTheDocument();
+ });
+ });
});
- test('filters tasks with fuzzy search (handles typos)', async () => {
- jest.useFakeTimers();
- render();
- expect(await screen.findByText('Task 12')).toBeInTheDocument();
+ describe('Bulk Delete', () => {
+ it('calls bulkMarkTasksAsDeleted when delete is clicked', async () => {
+ const utils = require('../tasks-utils');
- const dropdown = screen.getByLabelText('Show:');
- fireEvent.change(dropdown, { target: { value: '50' } });
+ render();
- const searchBar = screen.getByPlaceholderText('Search tasks...');
- fireEvent.change(searchBar, { target: { value: 'fiace' } });
+ await screen.findByText('Task 1');
+ const checkboxes = screen.getAllByRole('checkbox');
+ fireEvent.click(checkboxes[1]);
- act(() => {
- jest.advanceTimersByTime(300);
+ const deleteBtn = screen.getByText(/Delete 1 Task/i);
+ fireEvent.click(deleteBtn);
+
+ const yesButton = await screen.findByText('Yes');
+ fireEvent.click(yesButton);
+
+ expect(utils.bulkMarkTasksAsDeleted).toHaveBeenCalled();
+ });
+ });
+ describe('Edge Cases - Deleted & Completed Task Handling', () => {
+ it('should only select non-deleted tasks when Select All is clicked', async () => {
+ render();
+
+ const dropdown = await screen.findByLabelText('Show:');
+ fireEvent.change(dropdown, { target: { value: '50' } });
+
+ await screen.findByText('Deleted Task 1');
+
+ const checkboxes = screen.getAllByRole('checkbox');
+ const selectAllCheckbox = checkboxes[0];
+
+ fireEvent.click(selectAllCheckbox);
+
+ const deletedTaskRow = screen.getByText('Deleted Task 1').closest('tr');
+ const deletedCheckbox = deletedTaskRow?.querySelector(
+ 'input[type="checkbox"]'
+ ) as HTMLInputElement;
+
+ expect(deletedCheckbox).not.toBeChecked();
+ expect(deletedCheckbox).toBeDisabled();
+ });
+
+ it('should not increase count for deleted tasks when Select All is clicked', async () => {
+ render();
+
+ const dropdown = await screen.findByLabelText('Show:');
+ fireEvent.change(dropdown, { target: { value: '50' } });
+
+ await screen.findByText('Deleted Task 1');
+
+ const checkboxes = screen.getAllByRole('checkbox');
+ fireEvent.click(checkboxes[0]);
+
+ const rows = screen.getAllByRole('row');
+
+ const taskRows = rows.filter((row) => row.id?.startsWith('task-row-'));
+ const activeRows = taskRows.filter(
+ (row) => !within(row).queryByText('D')
+ );
+
+ screen.getByText(`${activeRows.length}`);
+ });
+
+ it('should show Select All as checked when all selectable tasks are selected', async () => {
+ render();
+
+ await screen.findByText('Task 1');
+
+ const checkboxes = screen.getAllByRole('checkbox');
+ const selectAllCheckbox = checkboxes[0];
+
+ fireEvent.click(selectAllCheckbox);
+
+ expect(selectAllCheckbox).toBeChecked();
+ });
+
+ it('should show Select All as unchecked when some tasks are not selected', async () => {
+ render();
+
+ await screen.findByText('Task 1');
+
+ const checkboxes = screen.getAllByRole('checkbox');
+ const selectAllCheckbox = checkboxes[0];
+
+ fireEvent.click(checkboxes[1]);
+
+ expect(selectAllCheckbox).not.toBeChecked();
+ });
+
+ it('should hide bulk complete button when a completed task is selected', async () => {
+ render();
+
+ const dropdown = await screen.findByLabelText('Show:');
+ fireEvent.change(dropdown, { target: { value: '50' } });
+
+ await screen.findByText('Completed Task 1');
+
+ const pendingTaskRow = screen.getByText('Task 1').closest('tr');
+ const pendingCheckbox = pendingTaskRow?.querySelector(
+ 'input[type="checkbox"]'
+ ) as HTMLInputElement;
+ fireEvent.click(pendingCheckbox);
+
+ expect(screen.getByTestId('bulk-complete-btn')).toBeInTheDocument();
+
+ const completedTaskRow = screen
+ .getByText('Completed Task 1')
+ .closest('tr');
+ const completedCheckbox = completedTaskRow?.querySelector(
+ 'input[type="checkbox"]'
+ ) as HTMLInputElement;
+ fireEvent.click(completedCheckbox);
+
+ expect(screen.queryByTestId('bulk-complete-btn')).not.toBeInTheDocument();
+ });
+
+ it('should show bulk complete button when only pending tasks are selected', async () => {
+ render();
+
+ await screen.findByText('Task 1');
+
+ const checkboxes = screen.getAllByRole('checkbox');
+
+ fireEvent.click(checkboxes[1]);
+ fireEvent.click(checkboxes[2]);
+
+ expect(screen.getByTestId('bulk-complete-btn')).toBeInTheDocument();
+ expect(screen.getByText(/Mark 2 Tasks Completed/i)).toBeInTheDocument();
+ });
+
+ it('should reappear bulk complete button when completed task is deselected', async () => {
+ render();
+
+ const dropdown = await screen.findByLabelText('Show:');
+ fireEvent.change(dropdown, { target: { value: '50' } });
+
+ await screen.findByText('Completed Task 1');
+
+ const pendingTaskRow = screen.getByText('Task 1').closest('tr');
+ const pendingCheckbox = pendingTaskRow?.querySelector(
+ 'input[type="checkbox"]'
+ ) as HTMLInputElement;
+ fireEvent.click(pendingCheckbox);
+
+ const completedTaskRow = screen
+ .getByText('Completed Task 1')
+ .closest('tr');
+ const completedCheckbox = completedTaskRow?.querySelector(
+ 'input[type="checkbox"]'
+ ) as HTMLInputElement;
+ fireEvent.click(completedCheckbox);
+
+ expect(screen.queryByTestId('bulk-complete-btn')).not.toBeInTheDocument();
+
+ fireEvent.click(completedCheckbox);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('bulk-complete-btn')).toBeInTheDocument();
+ });
});
- expect(await screen.findByText('Finance')).toBeInTheDocument();
- expect(screen.queryByText('Engineering')).not.toBeInTheDocument();
- expect(screen.queryByText('Sales')).not.toBeInTheDocument();
+ it('should disable checkbox for deleted tasks', async () => {
+ render();
+
+ const dropdown = await screen.findByLabelText('Show:');
+ fireEvent.change(dropdown, { target: { value: '50' } });
- jest.useRealTimers();
+ await screen.findByText('Deleted Task 1');
+
+ const deletedTaskRow = screen.getByText('Deleted Task 1').closest('tr');
+ const deletedCheckbox = deletedTaskRow?.querySelector(
+ 'input[type="checkbox"]'
+ ) as HTMLInputElement;
+
+ expect(deletedCheckbox).toBeDisabled();
+ });
});
test('shows "overdue" in status filter options', async () => {
diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts b/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts
index 59438ea9..00357669 100644
--- a/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts
+++ b/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts
@@ -7,7 +7,9 @@ import {
sortTasks,
sortTasksById,
markTaskAsCompleted,
+ bulkMarkTasksAsCompleted,
markTaskAsDeleted,
+ bulkMarkTasksAsDeleted,
getTimeSinceLastSync,
hashKey,
} from '../tasks-utils';
@@ -393,3 +395,200 @@ describe('hashKey', () => {
expect(hash).not.toContain('@');
});
});
+
+describe('bulkMarkTasksAsCompleted', () => {
+ const email = 'test@example.com';
+ const encryptionSecret = 'secret';
+ const UUID = 'user-uuid';
+ const taskUUIDs = ['id1', 'id2'];
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('calls API correctly and returns true on success', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce({ ok: true });
+
+ const result = await bulkMarkTasksAsCompleted(
+ email,
+ encryptionSecret,
+ UUID,
+ taskUUIDs
+ );
+
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining('complete-tasks'),
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ email,
+ encryptionSecret,
+ UUID,
+ taskuuids: taskUUIDs,
+ }),
+ }
+ );
+
+ expect(toast.success).toHaveBeenCalledWith('2 tasks marked as completed.');
+ expect(result).toBe(true);
+ });
+
+ it('returns false and shows error toast when API responds with non-ok', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce({ ok: false });
+
+ const result = await bulkMarkTasksAsCompleted(
+ email,
+ encryptionSecret,
+ UUID,
+ taskUUIDs
+ );
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(toast.error).toHaveBeenCalledWith('Bulk completion failed!');
+ expect(result).toBe(false);
+ });
+
+ it('returns false and shows error toast when fetch throws a network error', async () => {
+ (fetch as jest.Mock).mockRejectedValueOnce(new Error('Network failure'));
+
+ const result = await bulkMarkTasksAsCompleted(
+ email,
+ encryptionSecret,
+ UUID,
+ taskUUIDs
+ );
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(toast.error).toHaveBeenCalledWith('Bulk complete failed');
+ expect(result).toBe(false);
+ });
+});
+
+describe('bulkMarkTasksAsDeleted', () => {
+ const email = 'test@example.com';
+ const encryptionSecret = 'secret';
+ const UUID = 'user-uuid';
+ const taskUUIDs = ['t1', 't2'];
+ const backendURL = `${url.backendURL}delete-tasks`;
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('calls API correctly and returns true on success', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce({ ok: true });
+
+ const result = await bulkMarkTasksAsDeleted(
+ email,
+ encryptionSecret,
+ UUID,
+ taskUUIDs
+ );
+
+ expect(fetch).toHaveBeenCalledWith(backendURL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ email,
+ encryptionSecret,
+ UUID,
+ taskuuids: taskUUIDs,
+ }),
+ });
+
+ expect(toast.success).toHaveBeenCalledWith('2 tasks deleted.');
+ expect(result).toBe(true);
+ });
+
+ it('returns false and shows error toast when API responds with non-ok', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce({ ok: false });
+
+ const result = await bulkMarkTasksAsDeleted(
+ email,
+ encryptionSecret,
+ UUID,
+ taskUUIDs
+ );
+
+ expect(toast.error).toHaveBeenCalledWith('Bulk deletion failed!');
+ expect(result).toBe(false);
+ });
+
+ it('returns false and shows error toast when fetch throws a network error', async () => {
+ (fetch as jest.Mock).mockRejectedValueOnce(new Error('Network Error'));
+
+ const result = await bulkMarkTasksAsDeleted(
+ email,
+ encryptionSecret,
+ UUID,
+ taskUUIDs
+ );
+
+ expect(toast.error).toHaveBeenCalledWith('Bulk delete failed');
+ expect(result).toBe(false);
+ });
+
+ it('sends correct request body format with all required fields', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce({ ok: true });
+
+ await bulkMarkTasksAsCompleted('user@test.com', 'secret123', 'uuid-456', [
+ 'task-1',
+ 'task-2',
+ 'task-3',
+ ]);
+
+ const callArgs = (fetch as jest.Mock).mock.calls[0];
+ const requestBody = JSON.parse(callArgs[1].body);
+
+ expect(requestBody).toEqual({
+ email: 'user@test.com',
+ encryptionSecret: 'secret123',
+ UUID: 'uuid-456',
+ taskuuids: ['task-1', 'task-2', 'task-3'],
+ });
+ });
+
+ it('handles bulk complete with single task correctly', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce({ ok: true });
+
+ const result = await bulkMarkTasksAsCompleted(
+ 'test@example.com',
+ 'secret',
+ 'uuid',
+ ['single-task-uuid']
+ );
+
+ expect(toast.success).toHaveBeenCalledWith('1 task marked as completed.');
+ expect(result).toBe(true);
+ });
+
+ it('includes Content-Type header in bulk complete request', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce({ ok: true });
+
+ await bulkMarkTasksAsCompleted('test@example.com', 'secret', 'uuid', [
+ 'task-1',
+ ]);
+
+ const headers = (fetch as jest.Mock).mock.calls[0][1].headers;
+
+ expect(headers['Content-Type']).toBe('application/json');
+ });
+
+ it('returns false when bulk delete fails', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce({ ok: false });
+
+ const result = await bulkMarkTasksAsDeleted(
+ 'test@example.com',
+ 'secret',
+ 'uuid',
+ ['task-1']
+ );
+
+ expect(result).toBe(false);
+ });
+});
diff --git a/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts b/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts
index dcfde5fe..9de6f591 100644
--- a/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts
+++ b/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts
@@ -36,7 +36,6 @@ export const markTaskAsCompleted = async (
taskuuid: taskuuid,
}),
});
-
if (response) {
console.log('Task marked as completed successfully!');
} else {
@@ -47,6 +46,86 @@ export const markTaskAsCompleted = async (
}
};
+export const bulkMarkTasksAsCompleted = async (
+ email: string,
+ encryptionSecret: string,
+ UUID: string,
+ taskUUIDs: string[]
+) => {
+ try {
+ const backendURL = url.backendURL + `complete-tasks`;
+
+ const response = await fetch(backendURL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ email,
+ encryptionSecret,
+ UUID,
+ taskuuids: taskUUIDs,
+ }),
+ });
+
+ if (response.ok) {
+ console.log('Bulk completion successful!');
+ toast.success(
+ `${taskUUIDs.length} ${taskUUIDs.length === 1 ? 'task' : 'tasks'} marked as completed.`
+ );
+ return true;
+ } else {
+ toast.error('Bulk completion failed!');
+ console.error('Failed bulk completion');
+ return false;
+ }
+ } catch (error) {
+ console.error('Error in bulk complete:', error);
+ toast.error('Bulk complete failed');
+ return false;
+ }
+};
+
+export const bulkMarkTasksAsDeleted = async (
+ email: string,
+ encryptionSecret: string,
+ UUID: string,
+ taskUUIDs: string[]
+) => {
+ try {
+ const backendURL = url.backendURL + `delete-tasks`;
+
+ const response = await fetch(backendURL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ email,
+ encryptionSecret,
+ UUID,
+ taskuuids: taskUUIDs,
+ }),
+ });
+
+ if (response.ok) {
+ console.log('Bulk deletion successful!');
+ toast.success(
+ `${taskUUIDs.length} ${taskUUIDs.length === 1 ? 'task' : 'tasks'} deleted.`
+ );
+ return true;
+ } else {
+ toast.error('Bulk deletion failed!');
+ console.error('Failed bulk deletion');
+ return false;
+ }
+ } catch (error) {
+ console.error('Error in bulk delete:', error);
+ toast.error('Bulk delete failed');
+ return false;
+ }
+};
+
export const markTaskAsDeleted = async (
email: string,
encryptionSecret: string,
diff --git a/frontend/src/components/utils/types.ts b/frontend/src/components/utils/types.ts
index 7555700b..67dabda8 100644
--- a/frontend/src/components/utils/types.ts
+++ b/frontend/src/components/utils/types.ts
@@ -116,6 +116,8 @@ export interface EditTaskDialogProps {
selectedIndex: number;
onOpenChange: (open: boolean) => void;
onSelectTask: (task: Task, index: number) => void;
+ selectedTaskUUIDs: string[];
+ onCheckboxChange: (uuid: string, checked: boolean) => void;
editState: EditTaskState;
onUpdateState: (updates: Partial) => void;
allTasks: Task[];