From 46274a933cf456fb9b8a90c2bd6ec47a2742a2ae Mon Sep 17 00:00:00 2001 From: Shiva Gupta Date: Wed, 26 Nov 2025 05:09:10 +0530 Subject: [PATCH] feat(tasks): implement bulk task actions (complete/delete) - Added checkboxes to task rows and "Select All" in header - Implemented floating action panel for selected tasks - Added backend endpoints for bulk complete and delete - Updated frontend state to track selected UUIDs - Added confirmation dialogs and loading states - Added comprehensive tests for bulk selection and actions - Fixes: #178 --- backend/controllers/complete_tasks.go | 76 ++ backend/controllers/delete_tasks.go | 75 ++ backend/main.go | 2 + backend/models/request_body.go | 12 + .../HomeComponents/Tasks/TaskDialog.tsx | 13 + .../components/HomeComponents/Tasks/Tasks.tsx | 173 ++++- .../Tasks/__tests__/TaskDialog.test.tsx | 2 + .../Tasks/__tests__/Tasks.test.tsx | 658 ++++++++++++++---- .../Tasks/__tests__/tasks-utils.test.ts | 199 ++++++ .../HomeComponents/Tasks/tasks-utils.ts | 81 ++- frontend/src/components/utils/types.ts | 2 + 11 files changed, 1163 insertions(+), 130 deletions(-) create mode 100644 backend/controllers/complete_tasks.go create mode 100644 backend/controllers/delete_tasks.go diff --git a/backend/controllers/complete_tasks.go b/backend/controllers/complete_tasks.go new file mode 100644 index 00000000..1dbb7a2b --- /dev/null +++ b/backend/controllers/complete_tasks.go @@ -0,0 +1,76 @@ +package controllers + +import ( + "ccsync_backend/models" + "ccsync_backend/utils/tw" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// BulkCompleteTaskHandler godoc +// @Summary Bulk complete tasks +// @Description Mark multiple tasks as completed in Taskwarrior +// @Tags Tasks +// @Accept json +// @Produce json +// @Param task body models.BulkCompleteTaskRequestBody true "Bulk task completion details" +// @Success 202 {string} string "Bulk task completion accepted for processing" +// @Failure 400 {string} string "Invalid request - missing or empty taskuuids" +// @Failure 405 {string} string "Method not allowed" +// @Router /complete-tasks [post] +func BulkCompleteTaskHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Invalid request method", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("error reading request body: %v", err), http.StatusBadRequest) + return + } + defer r.Body.Close() + + var requestBody models.BulkCompleteTaskRequestBody + + if err := json.Unmarshal(body, &requestBody); err != nil { + http.Error(w, fmt.Sprintf("error decoding request body: %v", err), http.StatusBadRequest) + return + } + + email := requestBody.Email + encryptionSecret := requestBody.EncryptionSecret + uuid := requestBody.UUID + taskUUIDs := requestBody.TaskUUIDs + + if len(taskUUIDs) == 0 { + http.Error(w, "taskuuids is required and cannot be empty", http.StatusBadRequest) + return + } + + logStore := models.GetLogStore() + + // Create a *single* job for all UUIDs + job := Job{ + Name: "Bulk Complete Tasks", + Execute: func() error { + for _, tu := range taskUUIDs { + logStore.AddLog("INFO", fmt.Sprintf("[Bulk Complete] Starting: %s", tu), uuid, "Bulk Complete Task") + + err := tw.CompleteTaskInTaskwarrior(email, encryptionSecret, uuid, tu) + if err != nil { + logStore.AddLog("ERROR", fmt.Sprintf("[Bulk Complete] Failed: %s (%v)", tu, err), uuid, "Bulk Complete Task") + continue + } + + logStore.AddLog("INFO", fmt.Sprintf("[Bulk Complete] Completed: %s", tu), uuid, "Bulk Complete Task") + } + return nil + }, + } + + GlobalJobQueue.AddJob(job) + w.WriteHeader(http.StatusAccepted) +} diff --git a/backend/controllers/delete_tasks.go b/backend/controllers/delete_tasks.go new file mode 100644 index 00000000..a0438641 --- /dev/null +++ b/backend/controllers/delete_tasks.go @@ -0,0 +1,75 @@ +package controllers + +import ( + "ccsync_backend/models" + "ccsync_backend/utils/tw" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// BulkDeleteTaskHandler godoc +// @Summary Bulk delete tasks +// @Description Delete multiple tasks in Taskwarrior +// @Tags Tasks +// @Accept json +// @Produce json +// @Param task body models.BulkDeleteTaskRequestBody true "Bulk task deletion details" +// @Success 202 {string} string "Bulk task deletion accepted for processing" +// @Failure 400 {string} string "Invalid request - missing or empty taskuuids" +// @Failure 405 {string} string "Method not allowed" +// @Router /delete-tasks [post] +func BulkDeleteTaskHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Invalid request method", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("error reading request body: %v", err), http.StatusBadRequest) + return + } + defer r.Body.Close() + + var requestBody models.BulkDeleteTaskRequestBody + + if err := json.Unmarshal(body, &requestBody); err != nil { + http.Error(w, fmt.Sprintf("error decoding request body: %v", err), http.StatusBadRequest) + return + } + + email := requestBody.Email + encryptionSecret := requestBody.EncryptionSecret + uuid := requestBody.UUID + taskUUIDs := requestBody.TaskUUIDs + + if len(taskUUIDs) == 0 { + http.Error(w, "taskuuids is required and cannot be empty", http.StatusBadRequest) + return + } + + logStore := models.GetLogStore() + + job := Job{ + Name: "Bulk Delete Tasks", + Execute: func() error { + for _, tu := range taskUUIDs { + logStore.AddLog("INFO", fmt.Sprintf("[Bulk Delete] Starting: %s", tu), uuid, "Bulk Delete Task") + + err := tw.DeleteTaskInTaskwarrior(email, encryptionSecret, uuid, tu) + if err != nil { + logStore.AddLog("ERROR", fmt.Sprintf("[Bulk Delete] Failed: %s (%v)", tu, err), uuid, "Bulk Delete Task") + continue + } + + logStore.AddLog("INFO", fmt.Sprintf("[Bulk Delete] Deleted: %s", tu), uuid, "Bulk Delete Task") + } + return nil + }, + } + + GlobalJobQueue.AddJob(job) + w.WriteHeader(http.StatusAccepted) +} diff --git a/backend/main.go b/backend/main.go index 298f5193..cf22e457 100644 --- a/backend/main.go +++ b/backend/main.go @@ -92,6 +92,8 @@ func main() { mux.Handle("/complete-task", rateLimitedHandler(http.HandlerFunc(controllers.CompleteTaskHandler))) mux.Handle("/delete-task", rateLimitedHandler(http.HandlerFunc(controllers.DeleteTaskHandler))) mux.Handle("/sync/logs", rateLimitedHandler(http.HandlerFunc(controllers.SyncLogsHandler))) + mux.Handle("/complete-tasks", rateLimitedHandler(http.HandlerFunc(controllers.BulkCompleteTaskHandler))) + mux.Handle("/delete-tasks", rateLimitedHandler(http.HandlerFunc(controllers.BulkDeleteTaskHandler))) mux.HandleFunc("/ws", controllers.WebSocketHandler) diff --git a/backend/models/request_body.go b/backend/models/request_body.go index 7f281c12..fd551ae7 100644 --- a/backend/models/request_body.go +++ b/backend/models/request_body.go @@ -51,3 +51,15 @@ type DeleteTaskRequestBody struct { UUID string `json:"UUID"` TaskUUID string `json:"taskuuid"` } +type BulkCompleteTaskRequestBody struct { + Email string `json:"email"` + EncryptionSecret string `json:"encryptionSecret"` + UUID string `json:"UUID"` + TaskUUIDs []string `json:"taskuuids"` +} +type BulkDeleteTaskRequestBody struct { + Email string `json:"email"` + EncryptionSecret string `json:"encryptionSecret"` + UUID string `json:"UUID"` + TaskUUIDs []string `json:"taskuuids"` +} diff --git a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx index 80a0c500..331464e6 100644 --- a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx @@ -42,6 +42,8 @@ export const TaskDialog = ({ selectedIndex, onOpenChange, onSelectTask, + selectedTaskUUIDs, + onCheckboxChange, editState, onUpdateState, allTasks, @@ -98,6 +100,17 @@ export const TaskDialog = ({ onSelectTask(task, index); }} > + e.stopPropagation()}> + { + e.stopPropagation(); + onCheckboxChange(task.uuid, e.target.checked); + }} + /> + {/* Display task details */} (null); + const [selectedTaskUUIDs, setSelectedTaskUUIDs] = useState([]); const tableRef = useRef(null); const [hotkeysEnabled, setHotkeysEnabled] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); @@ -367,6 +378,36 @@ export const Tasks = ( } } + const handleBulkComplete = async () => { + if (selectedTaskUUIDs.length === 0) return; + + const success = await bulkMarkTasksAsCompleted( + props.email, + props.encryptionSecret, + props.UUID, + selectedTaskUUIDs + ); + + if (success) { + setSelectedTaskUUIDs([]); + } + }; + + const handleBulkDelete = async () => { + if (selectedTaskUUIDs.length === 0) return; + + const success = await bulkMarkTasksAsDeleted( + props.email, + props.encryptionSecret, + props.UUID, + selectedTaskUUIDs + ); + + if (success) { + setSelectedTaskUUIDs([]); + } + }; + const handleIdSort = () => { const newOrder = idSortOrder === 'asc' ? 'desc' : 'asc'; setIdSortOrder(newOrder); @@ -870,7 +911,7 @@ export const Tasks = ( > {tasks.length != 0 ? ( <> -
+
{/* 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'; + }) && ( + + + + + + + + + Are you + {' '} + sure? + + + + + + + + + + + + + + )} + + {/* Bulk Delete Dialog */} + + + + + + + + + Are you + {' '} + sure? + + + + + + + + + + + + + +
+ )} ) : ( 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[];