From 10c83d4e5a39f1a68a82670a09c4f25b6280019a Mon Sep 17 00:00:00 2001 From: Gabriel Sancho Date: Fri, 27 Mar 2026 01:09:43 -0300 Subject: [PATCH] feat(frontend): toggle task activation state Add task reactivation support to the local task API and update the edit task page to switch between Activate and Inactivate based on the current task state. Keep the separate entity-page inactive-visibility changes out of this commit so they can be reviewed independently. --- frontend/src/__tests__/api/tasksApi.test.ts | 19 ++++++++ .../src/__tests__/pages/EditTaskPage.test.tsx | 46 +++++++++++++++++-- frontend/src/api/tasksApi.ts | 18 ++++++++ frontend/src/pages/EditTaskPage.tsx | 44 ++++++++++++++++-- 4 files changed, 119 insertions(+), 8 deletions(-) diff --git a/frontend/src/__tests__/api/tasksApi.test.ts b/frontend/src/__tests__/api/tasksApi.test.ts index 1a530e4..dd66772 100644 --- a/frontend/src/__tests__/api/tasksApi.test.ts +++ b/frontend/src/__tests__/api/tasksApi.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { + activateTask, createTask, deleteTask, getAllTasks, @@ -152,6 +153,24 @@ describe('tasksApi', () => { ]) }) + it('should_markTaskActive_when_activateTaskCalled', async () => { + localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) + + const updatedTask = await activateTask('task-2') + + expect(updatedTask).toEqual({ + ...taskTwo, + active: true, + }) + expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([ + taskOne, + { + ...taskTwo, + active: true, + }, + ]) + }) + it('should_removeTask_when_deleteTaskCalled', async () => { localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) diff --git a/frontend/src/__tests__/pages/EditTaskPage.test.tsx b/frontend/src/__tests__/pages/EditTaskPage.test.tsx index 786b687..2929334 100644 --- a/frontend/src/__tests__/pages/EditTaskPage.test.tsx +++ b/frontend/src/__tests__/pages/EditTaskPage.test.tsx @@ -6,6 +6,10 @@ import EditTaskPage from '@/pages/EditTaskPage' import * as entitiesApi from '@/api/entitiesApi' import * as tasksApi from '@/api/tasksApi' +type RenderPageOptions = { + task?: typeof mockTask +} + const mockNavigate = vi.fn() vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom') @@ -15,7 +19,7 @@ vi.mock('react-router-dom', async () => { vi.mock('@/api/entitiesApi') vi.mock('@/api/tasksApi') -function renderPage() { +function renderPage(options: RenderPageOptions = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const wrapper = ({ children }: { children: React.ReactNode }) => ( @@ -27,6 +31,8 @@ function renderPage() { ) + vi.mocked(tasksApi.getTask).mockResolvedValue(options.task ?? mockTask) + render(, { wrapper }) return { queryClient } @@ -57,8 +63,10 @@ const mockTask = { describe('EditTaskPage', () => { beforeEach(() => { + vi.clearAllMocks() vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity) vi.mocked(tasksApi.getTask).mockResolvedValue(mockTask) + vi.mocked(tasksApi.activateTask).mockResolvedValue({ ...mockTask, active: true }) vi.mocked(tasksApi.inactivateTask).mockResolvedValue({ ...mockTask, active: false }) vi.mocked(tasksApi.deleteTask).mockResolvedValue(undefined) mockNavigate.mockClear() @@ -137,15 +145,47 @@ describe('EditTaskPage', () => { }) }) + it('should_renderActivateButton_when_taskIsInactive', async () => { + renderPage({ task: { ...mockTask, active: false } }) + + const activateButton = await screen.findByRole('button', { name: /activate/i }) + + expect(activateButton).toHaveClass('border-emerald-700') + expect(activateButton).toHaveClass('text-emerald-200') + expect(screen.queryByRole('button', { name: /^inactivate$/i })).not.toBeInTheDocument() + }) + 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 })) + const inactivateButton = await screen.findByRole('button', { name: /^inactivate$/i }) + + expect(inactivateButton).toHaveClass('border-amber-700') + expect(inactivateButton).toHaveClass('text-amber-200') + + fireEvent.click(inactivateButton) await waitFor(() => { expect(tasksApi.inactivateTask).toHaveBeenCalledWith('task-1') + expect(tasksApi.activateTask).not.toHaveBeenCalled() + 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_activateTaskAndNavigateBack_when_activateClicked', async () => { + const { queryClient } = renderPage({ task: { ...mockTask, active: false } }) + const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries') + + const activateButton = await screen.findByRole('button', { name: /^activate$/i }) + fireEvent.click(activateButton) + + await waitFor(() => { + expect(tasksApi.activateTask).toHaveBeenCalledWith('task-1') + expect(tasksApi.inactivateTask).not.toHaveBeenCalled() expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks', 'entity-1'] }) expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-task', 'task-1'] }) expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks'] }) diff --git a/frontend/src/api/tasksApi.ts b/frontend/src/api/tasksApi.ts index 101b36c..92c5621 100644 --- a/frontend/src/api/tasksApi.ts +++ b/frontend/src/api/tasksApi.ts @@ -134,6 +134,24 @@ 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: true, + } + + 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 901e72a..d3f8a24 100644 --- a/frontend/src/pages/EditTaskPage.tsx +++ b/frontend/src/pages/EditTaskPage.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link, useNavigate, useParams } from 'react-router-dom' import { getEntity } from '../api/entitiesApi' import { + activateTask, deleteTask, generateTaskPreview, getTask, @@ -165,6 +166,14 @@ export default function EditTaskPage() { }, }) + const activateTaskMutation = useMutation({ + mutationFn: () => activateTask(taskId), + onSuccess: async () => { + await invalidateTaskQueries(queryClient, entityId, taskId) + navigate(`/entities/${entityId}`) + }, + }) + const deleteTaskMutation = useMutation({ mutationFn: () => deleteTask(taskId), onSuccess: async () => { @@ -417,16 +426,41 @@ export default function EditTaskPage() {