From 6538c1783dd8a190c8771d8957540f8d4cadfffe Mon Sep 17 00:00:00 2001 From: Gabriel Sancho Date: Fri, 27 Mar 2026 00:48:14 -0300 Subject: [PATCH] feat(frontend): add task details edit flow Add a task details action from the entity page and route it to a prefilled edit task page. Extend the local task API with single-task read and update helpers, and cover the new flow with frontend tests. --- frontend/src/__tests__/api/tasksApi.test.ts | 76 ++++ .../src/__tests__/pages/EditTaskPage.test.tsx | 125 ++++++ .../__tests__/pages/EntityDetailPage.test.tsx | 4 + frontend/src/api/tasksApi.ts | 28 ++ frontend/src/pages/EditTaskPage.tsx | 408 ++++++++++++++++++ frontend/src/pages/EntityDetailPage.tsx | 8 + frontend/src/router/index.tsx | 9 + 7 files changed, 658 insertions(+) create mode 100644 frontend/src/__tests__/api/tasksApi.test.ts create mode 100644 frontend/src/__tests__/pages/EditTaskPage.test.tsx create mode 100644 frontend/src/pages/EditTaskPage.tsx diff --git a/frontend/src/__tests__/api/tasksApi.test.ts b/frontend/src/__tests__/api/tasksApi.test.ts new file mode 100644 index 0000000..0d7a4ff --- /dev/null +++ b/frontend/src/__tests__/api/tasksApi.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getTask, + getTasksByEntity, + updateTask, + type EntityTaskResponse, +} from '@/api/tasksApi' + +const taskOne: EntityTaskResponse = { + id: 'task-1', + entityId: 'entity-1', + name: 'Weekly Check-in', + prompt: 'Summarize jokes', + scheduleCron: '0 9 * * 1', + emailLookback: 'last_week', + createdAt: '2026-03-26T10:00:00Z', +} + +const taskTwo: EntityTaskResponse = { + id: 'task-2', + entityId: 'entity-2', + name: 'Monthly Memo', + prompt: 'Escalate sandwich policy', + scheduleCron: '0 11 1 * *', + emailLookback: 'last_month', + createdAt: '2026-03-26T11:00:00Z', +} + +describe('tasksApi', () => { + beforeEach(() => { + localStorage.clear() + vi.restoreAllMocks() + }) + + it('should_returnTask_when_getTaskCalledWithExistingId', async () => { + localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) + + await expect(getTask('task-1')).resolves.toEqual(taskOne) + }) + + 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_updateStoredTask_when_updateTaskCalled', async () => { + localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) + + const updatedTask = await updateTask('task-1', { + entityId: 'entity-1', + name: 'Daily Check-in', + prompt: 'Ask about ceremonial coffee', + scheduleCron: '0 8 * * 1-5', + emailLookback: 'last_day', + }) + + expect(updatedTask).toEqual({ + ...taskOne, + name: 'Daily Check-in', + prompt: 'Ask about ceremonial coffee', + scheduleCron: '0 8 * * 1-5', + emailLookback: 'last_day', + }) + expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([ + { + ...taskOne, + name: 'Daily Check-in', + prompt: 'Ask about ceremonial coffee', + scheduleCron: '0 8 * * 1-5', + emailLookback: 'last_day', + }, + taskTwo, + ]) + }) +}) \ No newline at end of file diff --git a/frontend/src/__tests__/pages/EditTaskPage.test.tsx b/frontend/src/__tests__/pages/EditTaskPage.test.tsx new file mode 100644 index 0000000..7bd9f63 --- /dev/null +++ b/frontend/src/__tests__/pages/EditTaskPage.test.tsx @@ -0,0 +1,125 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import EditTaskPage from '@/pages/EditTaskPage' +import * as entitiesApi from '@/api/entitiesApi' +import * as tasksApi from '@/api/tasksApi' + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { ...actual, useNavigate: () => mockNavigate } +}) + +vi.mock('@/api/entitiesApi') +vi.mock('@/api/tasksApi') + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + + + +) + +const mockEntity = { + id: 'entity-1', + name: 'Entity A', + email: 'a@a.com', + jobTitle: 'Ops', + personality: 'Formal', + scheduleCron: '0 9 * * 1', + contextWindowDays: 3, + active: true, + createdAt: '', +} + +const mockTask = { + id: 'task-1', + entityId: 'entity-1', + name: 'Weekly Check-in', + prompt: 'Summarize jokes', + scheduleCron: '0 9 * * 1', + emailLookback: 'last_week' as const, + createdAt: '2026-03-26T10:00:00Z', +} + +describe('EditTaskPage', () => { + beforeEach(() => { + vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity) + vi.mocked(tasksApi.getTask).mockResolvedValue(mockTask) + mockNavigate.mockClear() + }) + + it('should_renderPrefilledTaskForm_when_pageLoads', async () => { + render(, { wrapper }) + + await screen.findByRole('link', { name: /back to entity a/i }) + + expect(screen.getByRole('heading', { name: /edit task/i })).toBeInTheDocument() + expect(screen.getByLabelText(/task name/i)).toHaveValue('Weekly Check-in') + expect(screen.getByLabelText(/task prompt/i)).toHaveValue('Summarize jokes') + expect(screen.getByLabelText(/^Email Period$/i)).toHaveValue('last_week') + expect(screen.getByLabelText(/^Minute$/i)).toHaveValue('0') + expect(screen.getByLabelText(/^Hour$/i)).toHaveValue('9') + expect(screen.getByLabelText(/^Day of Month$/i)).toHaveValue('*') + expect(screen.getByLabelText(/^Month$/i)).toHaveValue('*') + expect(screen.getByLabelText(/^Day of Week$/i)).toHaveValue('1') + }) + + it('should_generatePreviewAndUpdateTask_when_formSubmitted', async () => { + vi.mocked(tasksApi.generateTaskPreview).mockResolvedValue('SUBJECT: Preview\nBODY:\nFormal nonsense') + vi.mocked(tasksApi.updateTask).mockResolvedValue({ + ...mockTask, + name: 'Daily Check-in', + prompt: 'Ask about ceremonial coffee', + scheduleCron: '0 8 * * 1-5', + emailLookback: 'last_day', + }) + + render(, { wrapper }) + await screen.findByRole('link', { name: /back to entity a/i }) + + fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Daily Check-in' } }) + fireEvent.change(screen.getByLabelText(/task prompt/i), { + target: { value: 'Ask about ceremonial coffee' }, + }) + fireEvent.change(screen.getByLabelText(/^Email Period$/i), { + target: { value: 'last_day' }, + }) + fireEvent.click(screen.getByRole('button', { name: /Weekdays/i })) + fireEvent.change(screen.getByLabelText(/^Hour$/i), { target: { value: '8' } }) + + fireEvent.click(screen.getByRole('button', { name: /generate test message/i })) + + await waitFor(() => { + expect(tasksApi.generateTaskPreview).toHaveBeenCalled() + expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual( + expect.objectContaining({ + entityId: 'entity-1', + name: 'Daily Check-in', + prompt: 'Ask about ceremonial coffee', + scheduleCron: '0 8 * * 1-5', + emailLookback: 'last_day', + }) + ) + expect(screen.getByText(/Formal nonsense/i)).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: /save changes/i })) + + await waitFor(() => { + expect(tasksApi.updateTask).toHaveBeenCalledWith('task-1', { + entityId: 'entity-1', + name: 'Daily Check-in', + prompt: 'Ask about ceremonial coffee', + scheduleCron: '0 8 * * 1-5', + emailLookback: 'last_day', + }) + expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1') + }) + }) +}) \ No newline at end of file diff --git a/frontend/src/__tests__/pages/EntityDetailPage.test.tsx b/frontend/src/__tests__/pages/EntityDetailPage.test.tsx index 0cea8f5..c4dd081 100644 --- a/frontend/src/__tests__/pages/EntityDetailPage.test.tsx +++ b/frontend/src/__tests__/pages/EntityDetailPage.test.tsx @@ -57,6 +57,10 @@ describe('EntityDetailPage', () => { 'href', '/entities/entity-1/tasks/new' ) + expect(screen.getByRole('link', { name: /details/i })).toHaveAttribute( + 'href', + '/entities/entity-1/tasks/task-1' + ) }) }) }) diff --git a/frontend/src/api/tasksApi.ts b/frontend/src/api/tasksApi.ts index ced8104..958af7b 100644 --- a/frontend/src/api/tasksApi.ts +++ b/frontend/src/api/tasksApi.ts @@ -20,6 +20,8 @@ export interface EntityTaskCreateDto { emailLookback: EmailLookback } +export type EntityTaskUpdateDto = EntityTaskCreateDto + function readTasks(): EntityTaskResponse[] { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) return [] @@ -67,6 +69,11 @@ export async function getTasksByEntity(entityId: string): Promise task.entityId === entityId) } +/** Returns one scheduled task by identifier. */ +export async function getTask(taskId: string): Promise { + return readTasks().find((task) => task.id === taskId) ?? null +} + /** Creates a scheduled task in local storage. */ export async function createTask(data: EntityTaskCreateDto): Promise { const current = readTasks() @@ -80,3 +87,24 @@ export async function createTask(data: EntityTaskCreateDto): Promise { + const current = readTasks() + const existingTask = current.find((task) => task.id === taskId) + + if (!existingTask) { + return null + } + + const updatedTask: EntityTaskResponse = { + ...existingTask, + ...data, + } + + writeTasks(current.map((task) => (task.id === taskId ? updatedTask : task))) + return updatedTask +} diff --git a/frontend/src/pages/EditTaskPage.tsx b/frontend/src/pages/EditTaskPage.tsx new file mode 100644 index 0000000..a02761e --- /dev/null +++ b/frontend/src/pages/EditTaskPage.tsx @@ -0,0 +1,408 @@ +import { useEffect, useMemo, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { getEntity } from '../api/entitiesApi' +import { + generateTaskPreview, + getTask, + updateTask, + type EmailLookback, +} from '../api/tasksApi' + +interface TaskFormState { + name: string + prompt: string + scheduleCron: string + emailLookback: EmailLookback +} + +interface CronParts { + minute: string + hour: string + dayOfMonth: string + month: string + dayOfWeek: string +} + +interface RegularitySuggestion { + id: string + label: string + description: string + cronParts: CronParts +} + +const DEFAULT_CRON_PARTS: CronParts = { + minute: '0', + hour: '9', + dayOfMonth: '*', + month: '*', + dayOfWeek: '1-5', +} + +const REGULARITY_SUGGESTIONS: RegularitySuggestion[] = [ + { + id: 'daily', + label: 'Daily', + description: 'Every day at 09:00', + cronParts: { minute: '0', hour: '9', dayOfMonth: '*', month: '*', dayOfWeek: '*' }, + }, + { + id: 'weekdays', + label: 'Weekdays', + description: 'Monday to Friday at 09:00', + cronParts: { minute: '0', hour: '9', dayOfMonth: '*', month: '*', dayOfWeek: '1-5' }, + }, + { + id: 'weekly', + label: 'Weekly', + description: 'Every Monday at 09:00', + cronParts: { minute: '0', hour: '9', dayOfMonth: '*', month: '*', dayOfWeek: '1' }, + }, + { + id: 'monthly', + label: 'Monthly', + description: 'Day 1 of each month at 09:00', + cronParts: { minute: '0', hour: '9', dayOfMonth: '1', month: '*', dayOfWeek: '*' }, + }, +] + +function buildCron(parts: CronParts): string { + return [parts.minute, parts.hour, parts.dayOfMonth, parts.month, parts.dayOfWeek].join(' ') +} + +function parseCron(scheduleCron: string): CronParts { + const parts = scheduleCron.split(' ') + + if (parts.length !== 5) { + return DEFAULT_CRON_PARTS + } + + return { + minute: parts[0], + hour: parts[1], + dayOfMonth: parts[2], + month: parts[3], + dayOfWeek: parts[4], + } +} + +const DEFAULT_TASK_FORM: TaskFormState = { + name: '', + prompt: '', + scheduleCron: buildCron(DEFAULT_CRON_PARTS), + emailLookback: 'last_week', +} + +export default function EditTaskPage() { + const { entityId = '', taskId = '' } = useParams() + const navigate = useNavigate() + const queryClient = useQueryClient() + const [cronParts, setCronParts] = useState(DEFAULT_CRON_PARTS) + const [taskForm, setTaskForm] = useState(DEFAULT_TASK_FORM) + const [preview, setPreview] = useState('') + + const { data: entity, isLoading: isLoadingEntity } = useQuery({ + queryKey: ['entity', entityId], + queryFn: () => getEntity(entityId), + enabled: Boolean(entityId), + }) + + const { data: task, isLoading: isLoadingTask } = useQuery({ + queryKey: ['entity-task', taskId], + queryFn: () => getTask(taskId), + enabled: Boolean(taskId), + }) + + useEffect(() => { + if (!task) { + return + } + + const nextCronParts = parseCron(task.scheduleCron) + setCronParts(nextCronParts) + setTaskForm({ + name: task.name, + prompt: task.prompt, + scheduleCron: task.scheduleCron, + emailLookback: task.emailLookback, + }) + }, [task]) + + const updateTaskMutation = useMutation({ + mutationFn: (data: TaskFormState) => + updateTask(taskId, { + entityId, + name: data.name, + prompt: data.prompt, + scheduleCron: data.scheduleCron, + emailLookback: data.emailLookback, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['entity-tasks', entityId] }) + await queryClient.invalidateQueries({ queryKey: ['entity-task', taskId] }) + navigate(`/entities/${entityId}`) + }, + }) + + const previewMutation = useMutation({ + mutationFn: generateTaskPreview, + onSuccess: (value) => setPreview(value), + }) + + const canSubmit = useMemo(() => { + const hasFilledCronParts = Object.values(cronParts).every((value) => value.trim().length > 0) + return Boolean(taskForm.name.trim() && taskForm.prompt.trim() && hasFilledCronParts) + }, [cronParts, taskForm.name, taskForm.prompt]) + + const applyCronParts = (nextCronParts: CronParts) => { + setCronParts(nextCronParts) + setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) })) + } + + const regularitySummary = useMemo( + () => + `At ${cronParts.hour.padStart(2, '0')}:${cronParts.minute.padStart(2, '0')} - day ${cronParts.dayOfMonth}, month ${cronParts.month}, week day ${cronParts.dayOfWeek}`, + [cronParts.dayOfMonth, cronParts.dayOfWeek, cronParts.hour, cronParts.minute, cronParts.month] + ) + + if (!entityId || !taskId) { + return
Task identifier is missing.
+ } + + if (isLoadingEntity || isLoadingTask) { + return
Loading...
+ } + + if (!task) { + return ( +
+

Task not found.

+ + Back to {entity?.name ?? 'Entity'} + +
+ ) + } + + return ( +
+
+ + +

+ Edit Task + {entity && ( + — {entity.name} + )} +

+ +
{ + event.preventDefault() + if (!canSubmit) return + updateTaskMutation.mutate(taskForm) + }} + > +
+ + setTaskForm((prev) => ({ ...prev, name: event.target.value }))} + className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100" + required + /> +
+ +
+ +