From 73d4261aa287913916e634e5c8979b1bbefadd17 Mon Sep 17 00:00:00 2001 From: Gabriel Sancho Date: Fri, 27 Mar 2026 00:00:13 -0300 Subject: [PATCH] feat(frontend): move task creation to dedicated page with scrollable layout Replace the EntityDetailPage modal flow with a route-based CreateTaskPage for better accessibility and long-form usability. Add route /entities/:entityId/tasks/new and update tests for both entity detail navigation and create-task page behavior. --- .../__tests__/pages/CreateTaskPage.test.tsx | 165 ++++++++ .../__tests__/pages/EntityDetailPage.test.tsx | 69 +--- frontend/src/pages/CreateTaskPage.tsx | 357 ++++++++++++++++++ frontend/src/pages/EntityDetailPage.tsx | 187 +-------- frontend/src/router/index.tsx | 9 + 5 files changed, 553 insertions(+), 234 deletions(-) create mode 100644 frontend/src/__tests__/pages/CreateTaskPage.test.tsx create mode 100644 frontend/src/pages/CreateTaskPage.tsx diff --git a/frontend/src/__tests__/pages/CreateTaskPage.test.tsx b/frontend/src/__tests__/pages/CreateTaskPage.test.tsx new file mode 100644 index 0000000..a35c556 --- /dev/null +++ b/frontend/src/__tests__/pages/CreateTaskPage.test.tsx @@ -0,0 +1,165 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import CreateTaskPage from '@/pages/CreateTaskPage' +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: '', +} + +describe('CreateTaskPage', () => { + beforeEach(() => { + vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week') + vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity) + mockNavigate.mockClear() + }) + + it('should_renderFormFields_when_pageLoads', async () => { + render(, { wrapper }) + + await screen.findByRole('link', { name: /back to entity a/i }) + + expect(screen.getByLabelText(/task name/i)).toBeInTheDocument() + expect(screen.getByLabelText(/task prompt/i)).toBeInTheDocument() + expect(screen.getByLabelText(/^Email Period$/i)).toBeInTheDocument() + expect(screen.getByLabelText(/^Minute$/i)).toBeInTheDocument() + expect(screen.getByLabelText(/^Hour$/i)).toBeInTheDocument() + expect(screen.getByLabelText(/^Day of Month$/i)).toBeInTheDocument() + expect(screen.getByLabelText(/^Month$/i)).toBeInTheDocument() + expect(screen.getByLabelText(/^Day of Week$/i)).toBeInTheDocument() + }) + + it('should_generatePreviewAndCreateTask_when_formSubmitted', async () => { + vi.mocked(tasksApi.generateTaskPreview).mockResolvedValue('SUBJECT: Preview\nBODY:\nFormal nonsense') + vi.mocked(tasksApi.createTask).mockResolvedValue({ + id: 'task-2', + entityId: 'entity-1', + name: 'Morning Blast', + prompt: 'Talk about coffee', + scheduleCron: '0 8 * * 1-5', + emailLookback: 'last_week', + createdAt: '2026-03-26T10:00:00Z', + }) + + render(, { wrapper }) + await screen.findByRole('link', { name: /back to entity a/i }) + + const minuteField = screen.getByLabelText(/^Minute$/i) + const hourField = screen.getByLabelText(/^Hour$/i) + const dayOfMonthField = screen.getByLabelText(/^Day of Month$/i) + const monthField = screen.getByLabelText(/^Month$/i) + const dayOfWeekField = screen.getByLabelText(/^Day of Week$/i) + + expect(minuteField.tagName).toBe('INPUT') + expect(hourField.tagName).toBe('INPUT') + expect(dayOfMonthField.tagName).toBe('INPUT') + expect(monthField.tagName).toBe('INPUT') + expect(dayOfWeekField.tagName).toBe('INPUT') + + expect(screen.getByRole('button', { name: /Daily/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Weekdays/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Weekly/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Monthly/i })).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Morning Blast' } }) + fireEvent.change(screen.getByLabelText(/task prompt/i), { target: { value: 'Talk about coffee' } }) + + 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: 'Morning Blast', + prompt: 'Talk about coffee', + scheduleCron: '0 8 * * 1-5', + emailLookback: 'last_week', + }) + ) + expect(screen.getByText(/Formal nonsense/i)).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: /create task/i })) + + await waitFor(() => { + expect(tasksApi.createTask).toHaveBeenCalled() + expect(vi.mocked(tasksApi.createTask).mock.calls[0][0]).toEqual( + expect.objectContaining({ + entityId: 'entity-1', + name: 'Morning Blast', + prompt: 'Talk about coffee', + scheduleCron: '0 8 * * 1-5', + emailLookback: 'last_week', + }) + ) + expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1') + }) + }) + + it('should_applyDailySuggestion_when_suggestionClicked', async () => { + render(, { wrapper }) + await screen.findByRole('link', { name: /back to entity a/i }) + + fireEvent.click(screen.getByRole('button', { name: /Daily/i })) + + 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('*') + }) + + it('should_renderEmailPeriodBeforeTaskSchedule_when_pageLoads', async () => { + render(, { wrapper }) + await screen.findByRole('link', { name: /back to entity a/i }) + + const emailPeriodLabel = screen.getByText(/^Email Period$/i) + const taskScheduleLabel = screen.getByText(/^Task Schedule$/i) + + const order = emailPeriodLabel.compareDocumentPosition(taskScheduleLabel) + expect(order & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it('should_navigateToEntityPage_when_cancelClicked', async () => { + render(, { wrapper }) + await screen.findByRole('link', { name: /back to entity a/i }) + + fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + + 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 7191de8..d7c23e7 100644 --- a/frontend/src/__tests__/pages/EntityDetailPage.test.tsx +++ b/frontend/src/__tests__/pages/EntityDetailPage.test.tsx @@ -53,72 +53,9 @@ describe('EntityDetailPage', () => { expect(screen.getByText(/Weekly Check-in/i)).toBeInTheDocument() expect(screen.getByText(/Last week/i)).toBeInTheDocument() expect(screen.queryByText(/Default scheduler:/i)).not.toBeInTheDocument() - }) - }) - - it('should_generatePreviewAndCreateTask_when_formSubmitted', async () => { - vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week') - - vi.mocked(entitiesApi.getEntity).mockResolvedValue({ - id: 'entity-1', - name: 'Entity A', - email: 'a@a.com', - jobTitle: 'Ops', - personality: 'Formal', - scheduleCron: '0 9 * * 1', - contextWindowDays: 3, - active: true, - createdAt: '', - }) - vi.mocked(tasksApi.getTasksByEntity).mockResolvedValue([]) - vi.mocked(tasksApi.generateTaskPreview).mockResolvedValue('SUBJECT: Preview\nBODY:\nFormal nonsense') - vi.mocked(tasksApi.createTask).mockResolvedValue({ - id: 'task-2', - entityId: 'entity-1', - name: 'Morning Blast', - prompt: 'Talk about coffee', - scheduleCron: '0 8 * * 1-5', - emailLookback: 'last_week', - createdAt: '2026-03-26T10:00:00Z', - }) - - render(, { wrapper }) - - await screen.findByRole('button', { name: /new task/i }) - fireEvent.click(screen.getByRole('button', { name: /new task/i })) - - fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Morning Blast' } }) - fireEvent.change(screen.getByLabelText(/task prompt/i), { target: { value: 'Talk about coffee' } }) - fireEvent.change(screen.getByLabelText(/task schedule/i), { target: { value: '0 8 * * 1-5' } }) - - 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: 'Morning Blast', - prompt: 'Talk about coffee', - scheduleCron: '0 8 * * 1-5', - emailLookback: 'last_week', - }) - ) - expect(screen.getByText(/Formal nonsense/i)).toBeInTheDocument() - }) - - fireEvent.click(screen.getByRole('button', { name: /create task/i })) - - await waitFor(() => { - expect(tasksApi.createTask).toHaveBeenCalled() - expect(vi.mocked(tasksApi.createTask).mock.calls[0][0]).toEqual( - expect.objectContaining({ - entityId: 'entity-1', - name: 'Morning Blast', - prompt: 'Talk about coffee', - scheduleCron: '0 8 * * 1-5', - emailLookback: 'last_week', - }) + expect(screen.getByRole('link', { name: /new task/i })).toHaveAttribute( + 'href', + '/entities/entity-1/tasks/new' ) }) }) diff --git a/frontend/src/pages/CreateTaskPage.tsx b/frontend/src/pages/CreateTaskPage.tsx new file mode 100644 index 0000000..dc91199 --- /dev/null +++ b/frontend/src/pages/CreateTaskPage.tsx @@ -0,0 +1,357 @@ +import { 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 { + createTask, + generateTaskPreview, + 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(' ') +} + +const DEFAULT_TASK_FORM: TaskFormState = { + name: '', + prompt: '', + scheduleCron: buildCron(DEFAULT_CRON_PARTS), + emailLookback: 'last_week', +} + +export default function CreateTaskPage() { + const { entityId = '' } = 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 } = useQuery({ + queryKey: ['entity', entityId], + queryFn: () => getEntity(entityId), + enabled: Boolean(entityId), + }) + + const createTaskMutation = useMutation({ + mutationFn: createTask, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['entity-tasks', entityId] }) + 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) { + return
Entity identifier is missing.
+ } + + if (isLoading) { + return
Loading...
+ } + + return ( +
+
+ + +

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

+ +
{ + event.preventDefault() + if (!canSubmit) return + createTaskMutation.mutate({ + entityId, + name: taskForm.name, + prompt: taskForm.prompt, + scheduleCron: taskForm.scheduleCron, + emailLookback: taskForm.emailLookback, + }) + }} + > +
+ + 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 + /> +
+ +
+ +