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.
This commit is contained in:
165
frontend/src/__tests__/pages/CreateTaskPage.test.tsx
Normal file
165
frontend/src/__tests__/pages/CreateTaskPage.test.tsx
Normal file
@@ -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 }) => (
|
||||||
|
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||||
|
<MemoryRouter initialEntries={['/entities/entity-1/tasks/new']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/entities/:entityId/tasks/new" element={children} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
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(<CreateTaskPage />, { 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(<CreateTaskPage />, { 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(<CreateTaskPage />, { 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(<CreateTaskPage />, { 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(<CreateTaskPage />, { 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -53,72 +53,9 @@ describe('EntityDetailPage', () => {
|
|||||||
expect(screen.getByText(/Weekly Check-in/i)).toBeInTheDocument()
|
expect(screen.getByText(/Weekly Check-in/i)).toBeInTheDocument()
|
||||||
expect(screen.getByText(/Last week/i)).toBeInTheDocument()
|
expect(screen.getByText(/Last week/i)).toBeInTheDocument()
|
||||||
expect(screen.queryByText(/Default scheduler:/i)).not.toBeInTheDocument()
|
expect(screen.queryByText(/Default scheduler:/i)).not.toBeInTheDocument()
|
||||||
})
|
expect(screen.getByRole('link', { name: /new task/i })).toHaveAttribute(
|
||||||
})
|
'href',
|
||||||
|
'/entities/entity-1/tasks/new'
|
||||||
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(<EntityDetailPage />, { 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',
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
357
frontend/src/pages/CreateTaskPage.tsx
Normal file
357
frontend/src/pages/CreateTaskPage.tsx
Normal file
@@ -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<CronParts>(DEFAULT_CRON_PARTS)
|
||||||
|
const [taskForm, setTaskForm] = useState<TaskFormState>(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 <div className="p-8 text-sm text-slate-300">Entity identifier is missing.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="p-8 text-sm text-slate-300">Loading...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen overflow-y-auto bg-slate-950 py-8">
|
||||||
|
<div className="mx-auto max-w-2xl px-4 sm:px-6">
|
||||||
|
<nav className="mb-6">
|
||||||
|
<Link
|
||||||
|
to={`/entities/${entityId}`}
|
||||||
|
className="text-sm text-cyan-400 hover:text-cyan-300"
|
||||||
|
>
|
||||||
|
← Back to {entity?.name ?? 'Entity'}
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-slate-100">
|
||||||
|
New Task
|
||||||
|
{entity && (
|
||||||
|
<span className="ml-2 text-base font-normal text-slate-400">— {entity.name}</span>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="mt-6 space-y-6"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!canSubmit) return
|
||||||
|
createTaskMutation.mutate({
|
||||||
|
entityId,
|
||||||
|
name: taskForm.name,
|
||||||
|
prompt: taskForm.prompt,
|
||||||
|
scheduleCron: taskForm.scheduleCron,
|
||||||
|
emailLookback: taskForm.emailLookback,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="task-name" className="text-sm font-medium text-slate-200">
|
||||||
|
Task Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="task-name"
|
||||||
|
value={taskForm.name}
|
||||||
|
onChange={(event) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="task-prompt" className="text-sm font-medium text-slate-200">
|
||||||
|
Task Prompt
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="task-prompt"
|
||||||
|
value={taskForm.prompt}
|
||||||
|
onChange={(event) =>
|
||||||
|
setTaskForm((prev) => ({ ...prev, prompt: event.target.value }))
|
||||||
|
}
|
||||||
|
className="mt-1 min-h-36 w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="task-lookback" className="text-sm font-medium text-slate-200">
|
||||||
|
Email Period
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="task-lookback"
|
||||||
|
value={taskForm.emailLookback}
|
||||||
|
onChange={(event) =>
|
||||||
|
setTaskForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
emailLookback: event.target.value as EmailLookback,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||||
|
>
|
||||||
|
<option value="last_day">Last 24 hours</option>
|
||||||
|
<option value="last_week">Last week</option>
|
||||||
|
<option value="last_month">Last month</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-slate-800 bg-slate-900/60 p-4">
|
||||||
|
<p className="text-sm font-medium text-slate-200">Task Schedule</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
|
Pick a regularity then fine-tune each cron field. Use <code className="text-slate-300">*</code> for "every".
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||||
|
{REGULARITY_SUGGESTIONS.map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion.id}
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-left text-slate-200 hover:border-cyan-500/70"
|
||||||
|
onClick={() => applyCronParts(suggestion.cronParts)}
|
||||||
|
>
|
||||||
|
<span className="block text-sm font-semibold">{suggestion.label}</span>
|
||||||
|
<span className="block text-xs text-slate-400">{suggestion.description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="cron-minute" className="text-xs font-medium text-slate-300">
|
||||||
|
Minute
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cron-minute"
|
||||||
|
type="text"
|
||||||
|
value={cronParts.minute}
|
||||||
|
onChange={(event) =>
|
||||||
|
applyCronParts({ ...cronParts, minute: event.target.value })
|
||||||
|
}
|
||||||
|
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-2 py-2 text-sm text-slate-100"
|
||||||
|
placeholder="0-59 or *"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="cron-hour" className="text-xs font-medium text-slate-300">
|
||||||
|
Hour
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cron-hour"
|
||||||
|
type="text"
|
||||||
|
value={cronParts.hour}
|
||||||
|
onChange={(event) =>
|
||||||
|
applyCronParts({ ...cronParts, hour: event.target.value })
|
||||||
|
}
|
||||||
|
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-2 py-2 text-sm text-slate-100"
|
||||||
|
placeholder="0-23 or *"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="cron-day-of-month" className="text-xs font-medium text-slate-300">
|
||||||
|
Day of Month
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cron-day-of-month"
|
||||||
|
type="text"
|
||||||
|
value={cronParts.dayOfMonth}
|
||||||
|
onChange={(event) =>
|
||||||
|
applyCronParts({ ...cronParts, dayOfMonth: event.target.value })
|
||||||
|
}
|
||||||
|
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-2 py-2 text-sm text-slate-100"
|
||||||
|
placeholder="1-31 or *"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="cron-month" className="text-xs font-medium text-slate-300">
|
||||||
|
Month
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cron-month"
|
||||||
|
type="text"
|
||||||
|
value={cronParts.month}
|
||||||
|
onChange={(event) =>
|
||||||
|
applyCronParts({ ...cronParts, month: event.target.value })
|
||||||
|
}
|
||||||
|
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-2 py-2 text-sm text-slate-100"
|
||||||
|
placeholder="1-12 or *"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="cron-day-of-week" className="text-xs font-medium text-slate-300">
|
||||||
|
Day of Week
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cron-day-of-week"
|
||||||
|
type="text"
|
||||||
|
value={cronParts.dayOfWeek}
|
||||||
|
onChange={(event) =>
|
||||||
|
applyCronParts({ ...cronParts, dayOfWeek: event.target.value })
|
||||||
|
}
|
||||||
|
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-2 py-2 text-sm text-slate-100"
|
||||||
|
placeholder="0-7 or *"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-slate-400">{regularitySummary}</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">Cron: {taskForm.scheduleCron}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!canSubmit) return
|
||||||
|
previewMutation.mutate({
|
||||||
|
entityId,
|
||||||
|
name: taskForm.name,
|
||||||
|
prompt: taskForm.prompt,
|
||||||
|
scheduleCron: taskForm.scheduleCron,
|
||||||
|
emailLookback: taskForm.emailLookback,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={!canSubmit || previewMutation.isPending}
|
||||||
|
className="rounded-md border border-cyan-500 px-3 py-2 text-sm font-medium text-cyan-300 hover:bg-cyan-500/10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{previewMutation.isPending ? 'Generating…' : 'Generate Test Message'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<pre className="mt-4 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
||||||
|
{preview}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pb-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(`/entities/${entityId}`)}
|
||||||
|
className="rounded-md border border-slate-700 px-4 py-2 text-sm text-slate-200 hover:border-slate-500"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400 disabled:opacity-50"
|
||||||
|
disabled={!canSubmit || createTaskMutation.isPending}
|
||||||
|
>
|
||||||
|
{createTaskMutation.isPending ? 'Creating…' : 'Create Task'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,35 +1,10 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { getEntity } from '../api/entitiesApi'
|
import { getEntity } from '../api/entitiesApi'
|
||||||
import {
|
import { getEmailLookbackLabel, getTasksByEntity } from '../api/tasksApi'
|
||||||
createTask,
|
|
||||||
generateTaskPreview,
|
|
||||||
getEmailLookbackLabel,
|
|
||||||
getTasksByEntity,
|
|
||||||
type EmailLookback,
|
|
||||||
} from '../api/tasksApi'
|
|
||||||
|
|
||||||
interface TaskFormState {
|
|
||||||
name: string
|
|
||||||
prompt: string
|
|
||||||
scheduleCron: string
|
|
||||||
emailLookback: EmailLookback
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_TASK_FORM: TaskFormState = {
|
|
||||||
name: '',
|
|
||||||
prompt: '',
|
|
||||||
scheduleCron: '',
|
|
||||||
emailLookback: 'last_week',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EntityDetailPage() {
|
export default function EntityDetailPage() {
|
||||||
const { entityId = '' } = useParams()
|
const { entityId = '' } = useParams()
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
|
||||||
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
|
|
||||||
const [preview, setPreview] = useState('')
|
|
||||||
|
|
||||||
const { data: entity, isLoading: isLoadingEntity } = useQuery({
|
const { data: entity, isLoading: isLoadingEntity } = useQuery({
|
||||||
queryKey: ['entity', entityId],
|
queryKey: ['entity', entityId],
|
||||||
@@ -43,26 +18,6 @@ export default function EntityDetailPage() {
|
|||||||
enabled: Boolean(entityId),
|
enabled: Boolean(entityId),
|
||||||
})
|
})
|
||||||
|
|
||||||
const createTaskMutation = useMutation({
|
|
||||||
mutationFn: createTask,
|
|
||||||
onSuccess: async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['entity-tasks', entityId] })
|
|
||||||
setDialogOpen(false)
|
|
||||||
setTaskForm(DEFAULT_TASK_FORM)
|
|
||||||
setPreview('')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const previewMutation = useMutation({
|
|
||||||
mutationFn: generateTaskPreview,
|
|
||||||
onSuccess: (value) => setPreview(value),
|
|
||||||
})
|
|
||||||
|
|
||||||
const canSubmit = useMemo(
|
|
||||||
() => Boolean(taskForm.name.trim() && taskForm.prompt.trim() && taskForm.scheduleCron.trim()),
|
|
||||||
[taskForm]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!entityId) {
|
if (!entityId) {
|
||||||
return <div className="p-8 text-sm text-slate-300">Entity identifier is missing.</div>
|
return <div className="p-8 text-sm text-slate-300">Entity identifier is missing.</div>
|
||||||
}
|
}
|
||||||
@@ -75,7 +30,10 @@ export default function EntityDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<p className="text-sm text-red-300">Entity not found.</p>
|
<p className="text-sm text-red-300">Entity not found.</p>
|
||||||
<Link to="/entities" className="mt-4 inline-block text-sm text-cyan-300 hover:text-cyan-200">
|
<Link
|
||||||
|
to="/entities"
|
||||||
|
className="mt-4 inline-block text-sm text-cyan-300 hover:text-cyan-200"
|
||||||
|
>
|
||||||
Back to Entities
|
Back to Entities
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,14 +45,16 @@ export default function EntityDetailPage() {
|
|||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-slate-100">{entity.name}</h1>
|
<h1 className="text-3xl font-bold text-slate-100">{entity.name}</h1>
|
||||||
<p className="mt-2 text-sm text-slate-300">{entity.jobTitle} - {entity.email}</p>
|
<p className="mt-2 text-sm text-slate-300">
|
||||||
|
{entity.jobTitle} - {entity.email}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Link
|
||||||
onClick={() => setDialogOpen(true)}
|
to={`/entities/${entityId}/tasks/new`}
|
||||||
className="rounded-md bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400"
|
className="rounded-md bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400"
|
||||||
>
|
>
|
||||||
New Task
|
New Task
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -104,126 +64,17 @@ export default function EntityDetailPage() {
|
|||||||
<li key={task.id} className="space-y-1 px-4 py-3">
|
<li key={task.id} className="space-y-1 px-4 py-3">
|
||||||
<p className="font-medium text-slate-100">{task.name}</p>
|
<p className="font-medium text-slate-100">{task.name}</p>
|
||||||
<p className="text-sm text-slate-300">Schedule: {task.scheduleCron}</p>
|
<p className="text-sm text-slate-300">Schedule: {task.scheduleCron}</p>
|
||||||
<p className="text-sm text-slate-400">Email context: {getEmailLookbackLabel(task.emailLookback)}</p>
|
<p className="text-sm text-slate-400">
|
||||||
|
Email context: {getEmailLookbackLabel(task.emailLookback)}
|
||||||
|
</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{tasks.length === 0 && <li className="px-4 py-4 text-sm text-slate-400">No scheduled tasks yet.</li>}
|
{tasks.length === 0 && (
|
||||||
|
<li className="px-4 py-4 text-sm text-slate-400">No scheduled tasks yet.</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{dialogOpen && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 p-4" role="dialog" aria-modal="true">
|
|
||||||
<div className="w-full max-w-2xl rounded-xl border border-slate-800 bg-slate-950 p-6 shadow-2xl">
|
|
||||||
<h3 className="text-xl font-semibold text-slate-100">Create New Task</h3>
|
|
||||||
<form
|
|
||||||
className="mt-4 space-y-4"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (!canSubmit) return
|
|
||||||
createTaskMutation.mutate({
|
|
||||||
entityId,
|
|
||||||
name: taskForm.name,
|
|
||||||
prompt: taskForm.prompt,
|
|
||||||
scheduleCron: taskForm.scheduleCron,
|
|
||||||
emailLookback: taskForm.emailLookback,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="task-name" className="text-sm font-medium text-slate-200">Task Name</label>
|
|
||||||
<input
|
|
||||||
id="task-name"
|
|
||||||
value={taskForm.name}
|
|
||||||
onChange={(event) => 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
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="task-prompt" className="text-sm font-medium text-slate-200">Task Prompt</label>
|
|
||||||
<textarea
|
|
||||||
id="task-prompt"
|
|
||||||
value={taskForm.prompt}
|
|
||||||
onChange={(event) => setTaskForm((prev) => ({ ...prev, prompt: event.target.value }))}
|
|
||||||
className="mt-1 min-h-28 w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="task-schedule" className="text-sm font-medium text-slate-200">Task Schedule</label>
|
|
||||||
<input
|
|
||||||
id="task-schedule"
|
|
||||||
value={taskForm.scheduleCron}
|
|
||||||
onChange={(event) => setTaskForm((prev) => ({ ...prev, scheduleCron: 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"
|
|
||||||
placeholder="0 9 * * 1"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="task-lookback" className="text-sm font-medium text-slate-200">Email Period</label>
|
|
||||||
<select
|
|
||||||
id="task-lookback"
|
|
||||||
value={taskForm.emailLookback}
|
|
||||||
onChange={(event) => setTaskForm((prev) => ({ ...prev, emailLookback: event.target.value as EmailLookback }))}
|
|
||||||
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
|
||||||
>
|
|
||||||
<option value="last_day">Last 24 hours</option>
|
|
||||||
<option value="last_week">Last week</option>
|
|
||||||
<option value="last_month">Last month</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md border border-slate-800 bg-slate-900 p-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (!canSubmit) return
|
|
||||||
previewMutation.mutate({
|
|
||||||
entityId,
|
|
||||||
name: taskForm.name,
|
|
||||||
prompt: taskForm.prompt,
|
|
||||||
scheduleCron: taskForm.scheduleCron,
|
|
||||||
emailLookback: taskForm.emailLookback,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
className="rounded-md border border-cyan-500 px-3 py-2 text-sm font-medium text-cyan-300 hover:bg-cyan-500/10"
|
|
||||||
>
|
|
||||||
Generate Test Message
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{preview && (
|
|
||||||
<pre className="mt-3 whitespace-pre-wrap rounded-md bg-slate-950 p-3 text-xs text-slate-200">{preview}</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setDialogOpen(false)
|
|
||||||
setPreview('')
|
|
||||||
}}
|
|
||||||
className="rounded-md border border-slate-700 px-4 py-2 text-sm text-slate-200"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="rounded-md bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400 disabled:opacity-50"
|
|
||||||
disabled={!canSubmit}
|
|
||||||
>
|
|
||||||
Create Task
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const LoginPage = lazy(() => import('../pages/LoginPage'))
|
|||||||
const DashboardPage = lazy(() => import('../pages/DashboardPage'))
|
const DashboardPage = lazy(() => import('../pages/DashboardPage'))
|
||||||
const EntitiesPage = lazy(() => import('../pages/EntitiesPage'))
|
const EntitiesPage = lazy(() => import('../pages/EntitiesPage'))
|
||||||
const EntityDetailPage = lazy(() => import('../pages/EntityDetailPage'))
|
const EntityDetailPage = lazy(() => import('../pages/EntityDetailPage'))
|
||||||
|
const CreateTaskPage = lazy(() => import('../pages/CreateTaskPage'))
|
||||||
const LogsPage = lazy(() => import('../pages/LogsPage'))
|
const LogsPage = lazy(() => import('../pages/LogsPage'))
|
||||||
|
|
||||||
function Protected({ children }: { children: ReactNode }) {
|
function Protected({ children }: { children: ReactNode }) {
|
||||||
@@ -55,6 +56,14 @@ export const router = createBrowserRouter([
|
|||||||
</Protected>
|
</Protected>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/entities/:entityId/tasks/new',
|
||||||
|
element: (
|
||||||
|
<Protected>
|
||||||
|
<CreateTaskPage />
|
||||||
|
</Protected>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/logs',
|
path: '/logs',
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
Reference in New Issue
Block a user