From 766b13fbb296833da2263dbc9a7e531afbd1ce11 Mon Sep 17 00:00:00 2001 From: Gabriel Sancho Date: Fri, 27 Mar 2026 00:58:58 -0300 Subject: [PATCH] feat(frontend): add task inactivate and delete actions Extend the local task store with active state, inactivation, and hard delete support. Update the edit task page and tests so inactive tasks are hidden from normal lists and task lifecycle actions are available from the details view. --- frontend/src/__tests__/api/tasksApi.test.ts | 86 +++++++++++++++++++ .../__tests__/pages/CreateTaskPage.test.tsx | 1 + .../src/__tests__/pages/EditTaskPage.test.tsx | 68 ++++++++++++--- .../__tests__/pages/EntityDetailPage.test.tsx | 1 + frontend/src/api/tasksApi.ts | 36 +++++++- frontend/src/pages/EditTaskPage.tsx | 49 ++++++++++- 6 files changed, 225 insertions(+), 16 deletions(-) diff --git a/frontend/src/__tests__/api/tasksApi.test.ts b/frontend/src/__tests__/api/tasksApi.test.ts index 0d7a4ff..1a530e4 100644 --- a/frontend/src/__tests__/api/tasksApi.test.ts +++ b/frontend/src/__tests__/api/tasksApi.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { + createTask, + deleteTask, + getAllTasks, getTask, + inactivateTask, getTasksByEntity, updateTask, type EntityTaskResponse, @@ -13,6 +17,7 @@ const taskOne: EntityTaskResponse = { prompt: 'Summarize jokes', scheduleCron: '0 9 * * 1', emailLookback: 'last_week', + active: true, createdAt: '2026-03-26T10:00:00Z', } @@ -23,6 +28,7 @@ const taskTwo: EntityTaskResponse = { prompt: 'Escalate sandwich policy', scheduleCron: '0 11 1 * *', emailLookback: 'last_month', + active: false, createdAt: '2026-03-26T11:00:00Z', } @@ -38,12 +44,66 @@ describe('tasksApi', () => { await expect(getTask('task-1')).resolves.toEqual(taskOne) }) + it('should_returnInactiveTask_when_getTaskCalledWithInactiveId', async () => { + localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) + + await expect(getTask('task-2')).resolves.toEqual(taskTwo) + }) + it('should_returnTasksForEntity_when_getTasksByEntityCalled', async () => { localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) await expect(getTasksByEntity('entity-1')).resolves.toEqual([taskOne]) }) + it('should_hideInactiveTasks_when_getAllTasksCalled', async () => { + localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) + + await expect(getAllTasks()).resolves.toEqual([taskOne]) + }) + + it('should_hideInactiveTasksForEntity_when_getTasksByEntityCalled', async () => { + localStorage.setItem( + 'condado:entity-tasks', + JSON.stringify([ + taskOne, + taskTwo, + { + ...taskOne, + id: 'task-3', + active: false, + }, + ]) + ) + + await expect(getTasksByEntity('entity-1')).resolves.toEqual([taskOne]) + }) + + it('should_storeActiveTaskByDefault_when_createTaskCalled', async () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue('00000000-0000-0000-0000-000000000003') + + const createdTask = await createTask({ + entityId: 'entity-1', + name: 'Daily Check-in', + prompt: 'Ask about ceremonial coffee', + scheduleCron: '0 8 * * 1-5', + emailLookback: 'last_day', + }) + + expect(createdTask).toEqual( + expect.objectContaining({ + id: '00000000-0000-0000-0000-000000000003', + active: true, + }) + ) + expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([ + expect.objectContaining({ + id: '00000000-0000-0000-0000-000000000003', + active: true, + }), + ]) + }) + it('should_updateStoredTask_when_updateTaskCalled', async () => { localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) @@ -73,4 +133,30 @@ describe('tasksApi', () => { taskTwo, ]) }) + + it('should_markTaskInactive_when_inactivateTaskCalled', async () => { + localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) + + const updatedTask = await inactivateTask('task-1') + + expect(updatedTask).toEqual({ + ...taskOne, + active: false, + }) + expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([ + { + ...taskOne, + active: false, + }, + taskTwo, + ]) + }) + + it('should_removeTask_when_deleteTaskCalled', async () => { + localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) + + await deleteTask('task-1') + + expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([taskTwo]) + }) }) \ No newline at end of file diff --git a/frontend/src/__tests__/pages/CreateTaskPage.test.tsx b/frontend/src/__tests__/pages/CreateTaskPage.test.tsx index a35c556..651fc37 100644 --- a/frontend/src/__tests__/pages/CreateTaskPage.test.tsx +++ b/frontend/src/__tests__/pages/CreateTaskPage.test.tsx @@ -68,6 +68,7 @@ describe('CreateTaskPage', () => { prompt: 'Talk about coffee', scheduleCron: '0 8 * * 1-5', emailLookback: 'last_week', + active: true, createdAt: '2026-03-26T10:00:00Z', }) diff --git a/frontend/src/__tests__/pages/EditTaskPage.test.tsx b/frontend/src/__tests__/pages/EditTaskPage.test.tsx index 7bd9f63..786b687 100644 --- a/frontend/src/__tests__/pages/EditTaskPage.test.tsx +++ b/frontend/src/__tests__/pages/EditTaskPage.test.tsx @@ -15,15 +15,22 @@ vi.mock('react-router-dom', async () => { vi.mock('@/api/entitiesApi') vi.mock('@/api/tasksApi') -const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - - - - - -) +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + + + + ) + + render(, { wrapper }) + + return { queryClient } +} const mockEntity = { id: 'entity-1', @@ -44,6 +51,7 @@ const mockTask = { prompt: 'Summarize jokes', scheduleCron: '0 9 * * 1', emailLookback: 'last_week' as const, + active: true, createdAt: '2026-03-26T10:00:00Z', } @@ -51,11 +59,13 @@ describe('EditTaskPage', () => { beforeEach(() => { vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity) vi.mocked(tasksApi.getTask).mockResolvedValue(mockTask) + vi.mocked(tasksApi.inactivateTask).mockResolvedValue({ ...mockTask, active: false }) + vi.mocked(tasksApi.deleteTask).mockResolvedValue(undefined) mockNavigate.mockClear() }) it('should_renderPrefilledTaskForm_when_pageLoads', async () => { - render(, { wrapper }) + renderPage() await screen.findByRole('link', { name: /back to entity a/i }) @@ -80,7 +90,8 @@ describe('EditTaskPage', () => { emailLookback: 'last_day', }) - render(, { wrapper }) + const { queryClient } = renderPage() + const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries') await screen.findByRole('link', { name: /back to entity a/i }) fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Daily Check-in' } }) @@ -119,6 +130,41 @@ describe('EditTaskPage', () => { scheduleCron: '0 8 * * 1-5', emailLookback: 'last_day', }) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks', 'entity-1'] }) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-task', 'task-1'] }) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks'] }) + expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1') + }) + }) + + it('should_inactivateTaskAndNavigateBack_when_inactivateClicked', async () => { + const { queryClient } = renderPage() + const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries') + + await screen.findByRole('link', { name: /back to entity a/i }) + fireEvent.click(screen.getByRole('button', { name: /inactivate/i })) + + await waitFor(() => { + expect(tasksApi.inactivateTask).toHaveBeenCalledWith('task-1') + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks', 'entity-1'] }) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-task', 'task-1'] }) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks'] }) + expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1') + }) + }) + + it('should_deleteTaskAndNavigateBack_when_deleteClicked', async () => { + const { queryClient } = renderPage() + const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries') + + await screen.findByRole('link', { name: /back to entity a/i }) + fireEvent.click(screen.getByRole('button', { name: /delete/i })) + + await waitFor(() => { + expect(tasksApi.deleteTask).toHaveBeenCalledWith('task-1') + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks', 'entity-1'] }) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-task', 'task-1'] }) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks'] }) expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1') }) }) diff --git a/frontend/src/__tests__/pages/EntityDetailPage.test.tsx b/frontend/src/__tests__/pages/EntityDetailPage.test.tsx index c4dd081..b9d6961 100644 --- a/frontend/src/__tests__/pages/EntityDetailPage.test.tsx +++ b/frontend/src/__tests__/pages/EntityDetailPage.test.tsx @@ -42,6 +42,7 @@ describe('EntityDetailPage', () => { prompt: 'Summarize jokes', scheduleCron: '0 9 * * 1', emailLookback: 'last_week', + active: true, createdAt: '2026-03-26T10:00:00Z', }, ]) diff --git a/frontend/src/api/tasksApi.ts b/frontend/src/api/tasksApi.ts index 958af7b..101b36c 100644 --- a/frontend/src/api/tasksApi.ts +++ b/frontend/src/api/tasksApi.ts @@ -9,6 +9,7 @@ export interface EntityTaskResponse { prompt: string scheduleCron: string emailLookback: EmailLookback + active: boolean createdAt: string } @@ -27,7 +28,12 @@ function readTasks(): EntityTaskResponse[] { if (!raw) return [] try { - return JSON.parse(raw) as EntityTaskResponse[] + return (JSON.parse(raw) as Array & { active?: boolean }>).map( + (task) => ({ + ...task, + active: task.active ?? true, + }) + ) } catch { return [] } @@ -61,12 +67,12 @@ export async function generateTaskPreview(data: EntityTaskCreateDto): Promise { - return readTasks() + return readTasks().filter((task) => task.active) } /** Returns scheduled tasks for a specific entity. */ export async function getTasksByEntity(entityId: string): Promise { - return readTasks().filter((task) => task.entityId === entityId) + return readTasks().filter((task) => task.entityId === entityId && task.active) } /** Returns one scheduled task by identifier. */ @@ -80,6 +86,7 @@ export async function createTask(data: EntityTaskCreateDto): Promise (task.id === taskId ? updatedTask : task))) return updatedTask } + +/** Marks one scheduled task as inactive in local storage. */ +export async function inactivateTask(taskId: string): Promise { + const current = readTasks() + const existingTask = current.find((task) => task.id === taskId) + + if (!existingTask) { + return null + } + + const updatedTask: EntityTaskResponse = { + ...existingTask, + active: false, + } + + writeTasks(current.map((task) => (task.id === taskId ? updatedTask : task))) + return updatedTask +} + +/** Deletes one scheduled task from local storage. */ +export async function deleteTask(taskId: string): Promise { + writeTasks(readTasks().filter((task) => task.id !== taskId)) +} diff --git a/frontend/src/pages/EditTaskPage.tsx b/frontend/src/pages/EditTaskPage.tsx index a02761e..901e72a 100644 --- a/frontend/src/pages/EditTaskPage.tsx +++ b/frontend/src/pages/EditTaskPage.tsx @@ -3,8 +3,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link, useNavigate, useParams } from 'react-router-dom' import { getEntity } from '../api/entitiesApi' import { + deleteTask, generateTaskPreview, getTask, + inactivateTask, updateTask, type EmailLookback, } from '../api/tasksApi' @@ -93,6 +95,18 @@ const DEFAULT_TASK_FORM: TaskFormState = { emailLookback: 'last_week', } +async function invalidateTaskQueries( + queryClient: ReturnType, + entityId: string, + taskId: string +) { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['entity-tasks', entityId] }), + queryClient.invalidateQueries({ queryKey: ['entity-task', taskId] }), + queryClient.invalidateQueries({ queryKey: ['entity-tasks'] }), + ]) +} + export default function EditTaskPage() { const { entityId = '', taskId = '' } = useParams() const navigate = useNavigate() @@ -138,8 +152,23 @@ export default function EditTaskPage() { emailLookback: data.emailLookback, }), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ['entity-tasks', entityId] }) - await queryClient.invalidateQueries({ queryKey: ['entity-task', taskId] }) + await invalidateTaskQueries(queryClient, entityId, taskId) + navigate(`/entities/${entityId}`) + }, + }) + + const inactivateTaskMutation = useMutation({ + mutationFn: () => inactivateTask(taskId), + onSuccess: async () => { + await invalidateTaskQueries(queryClient, entityId, taskId) + navigate(`/entities/${entityId}`) + }, + }) + + const deleteTaskMutation = useMutation({ + mutationFn: () => deleteTask(taskId), + onSuccess: async () => { + await invalidateTaskQueries(queryClient, entityId, taskId) navigate(`/entities/${entityId}`) }, }) @@ -386,6 +415,22 @@ export default function EditTaskPage() {
+ +