- remove prompt and preview generation from task creation - create tasks as inactive and route directly to edit page - add generated message history UX to edit task - update entity/task views and related test coverage
605 lines
21 KiB
TypeScript
605 lines
21 KiB
TypeScript
import { useEffect, useMemo, useRef, 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 {
|
|
activateTask,
|
|
buildTaskPreviewPrompt,
|
|
deleteTask,
|
|
generateTaskPreview,
|
|
getTask,
|
|
inactivateTask,
|
|
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
|
|
}
|
|
|
|
interface GeneratedMessageItem {
|
|
id: string
|
|
label: string
|
|
content: string
|
|
}
|
|
|
|
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',
|
|
}
|
|
|
|
async function invalidateTaskQueries(
|
|
queryClient: ReturnType<typeof useQueryClient>,
|
|
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()
|
|
const queryClient = useQueryClient()
|
|
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
|
|
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
|
|
const [generatedMessages, setGeneratedMessages] = useState<GeneratedMessageItem[]>([])
|
|
const [selectedMessageId, setSelectedMessageId] = useState('')
|
|
const [previewError, setPreviewError] = useState('')
|
|
const generatedMessageCounter = useRef(0)
|
|
|
|
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 invalidateTaskQueries(queryClient, entityId, taskId)
|
|
navigate(`/entities/${entityId}`)
|
|
},
|
|
})
|
|
|
|
const inactivateTaskMutation = useMutation({
|
|
mutationFn: () => inactivateTask(taskId),
|
|
onSuccess: async () => {
|
|
await invalidateTaskQueries(queryClient, entityId, taskId)
|
|
navigate(`/entities/${entityId}`)
|
|
},
|
|
})
|
|
|
|
const activateTaskMutation = useMutation({
|
|
mutationFn: () => activateTask(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}`)
|
|
},
|
|
})
|
|
|
|
const previewMutation = useMutation({
|
|
mutationFn: generateTaskPreview,
|
|
onMutate: () => {
|
|
setPreviewError('')
|
|
},
|
|
onSuccess: (value) => {
|
|
generatedMessageCounter.current += 1
|
|
const nextMessage: GeneratedMessageItem = {
|
|
id: `message-${generatedMessageCounter.current}`,
|
|
label: `Message #${generatedMessageCounter.current}`,
|
|
content: value,
|
|
}
|
|
|
|
setGeneratedMessages((prev) => [nextMessage, ...prev])
|
|
setSelectedMessageId(nextMessage.id)
|
|
},
|
|
onError: (error) => {
|
|
setPreviewError(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Unable to generate a test message from the local model.'
|
|
)
|
|
},
|
|
})
|
|
|
|
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 currentTask = useMemo(
|
|
() => ({
|
|
entityId,
|
|
name: taskForm.name,
|
|
prompt: taskForm.prompt,
|
|
scheduleCron: taskForm.scheduleCron,
|
|
emailLookback: taskForm.emailLookback,
|
|
}),
|
|
[entityId, taskForm.emailLookback, taskForm.name, taskForm.prompt, taskForm.scheduleCron]
|
|
)
|
|
|
|
const finalPrompt = useMemo(() => {
|
|
if (!entity) {
|
|
return 'Entity details unavailable.'
|
|
}
|
|
|
|
return buildTaskPreviewPrompt(entity, currentTask)
|
|
}, [currentTask, entity])
|
|
|
|
const selectedMessage = useMemo(
|
|
() => generatedMessages.find((message) => message.id === selectedMessageId),
|
|
[generatedMessages, selectedMessageId]
|
|
)
|
|
|
|
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 <div className="p-8 text-sm text-slate-300">Task identifier is missing.</div>
|
|
}
|
|
|
|
if (isLoadingEntity || isLoadingTask) {
|
|
return <div className="p-8 text-sm text-slate-300">Loading...</div>
|
|
}
|
|
|
|
if (!task) {
|
|
return (
|
|
<div className="p-8 text-sm text-slate-300">
|
|
<p>Task not found.</p>
|
|
<Link to={`/entities/${entityId}`} className="mt-4 inline-block text-cyan-400 hover:text-cyan-300">
|
|
Back to {entity?.name ?? 'Entity'}
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen overflow-y-auto bg-slate-950 py-8">
|
|
<div className="mx-auto max-w-7xl 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">
|
|
Edit 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
|
|
updateTaskMutation.mutate(taskForm)
|
|
}}
|
|
>
|
|
<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
|
|
if (!entity) return
|
|
previewMutation.mutate({ entity, task: currentTask })
|
|
}}
|
|
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>
|
|
|
|
<div className="mt-4 grid gap-4 lg:grid-cols-3">
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
|
Final Prompt
|
|
</p>
|
|
<pre className="mt-2 h-full min-h-56 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
|
{finalPrompt}
|
|
</pre>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
|
Generated Message
|
|
</p>
|
|
<pre className="mt-2 min-h-56 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
|
{selectedMessage?.content ?? 'Generate a message and it will appear here.'}
|
|
</pre>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
|
Generated Message History
|
|
</p>
|
|
<ul
|
|
aria-label="Generated message history"
|
|
className="mt-2 space-y-2 rounded-md border border-slate-800 bg-slate-950 p-3"
|
|
>
|
|
{generatedMessages.length === 0 && (
|
|
<li className="text-xs text-slate-400">No generated messages yet.</li>
|
|
)}
|
|
{generatedMessages.map((message) => (
|
|
<li
|
|
key={message.id}
|
|
className="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-900 p-2"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedMessageId(message.id)}
|
|
className="flex-1 text-left text-xs text-slate-200 hover:text-cyan-300"
|
|
aria-label={message.label}
|
|
>
|
|
<span className="block font-medium">{message.label}</span>
|
|
<span className="mt-1 block line-clamp-2 text-slate-400">
|
|
{message.content.split('\n')[0]}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setGeneratedMessages((prev) => {
|
|
const nextMessages = prev.filter((item) => item.id !== message.id)
|
|
if (selectedMessageId === message.id) {
|
|
setSelectedMessageId(nextMessages[0]?.id ?? '')
|
|
}
|
|
return nextMessages
|
|
})
|
|
}}
|
|
className="rounded border border-red-500/40 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-red-300 hover:bg-red-500/10"
|
|
aria-label={`Delete ${message.label.toLowerCase()}`}
|
|
>
|
|
Delete
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
{previewError && (
|
|
<p role="alert" className="mt-4 text-sm text-red-300">
|
|
{previewError}
|
|
</p>
|
|
)}
|
|
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pb-8">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (task.active) {
|
|
inactivateTaskMutation.mutate()
|
|
return
|
|
}
|
|
|
|
activateTaskMutation.mutate()
|
|
}}
|
|
disabled={
|
|
inactivateTaskMutation.isPending ||
|
|
activateTaskMutation.isPending ||
|
|
deleteTaskMutation.isPending
|
|
}
|
|
className={`mr-auto rounded-md border px-4 py-2 text-sm disabled:opacity-50 ${
|
|
task.active
|
|
? 'border-amber-700 text-amber-200 hover:border-amber-500'
|
|
: 'border-emerald-700 text-emerald-200 hover:border-emerald-500'
|
|
}`}
|
|
>
|
|
{task.active
|
|
? inactivateTaskMutation.isPending
|
|
? 'Inactivating…'
|
|
: 'Inactivate'
|
|
: activateTaskMutation.isPending
|
|
? 'Activating…'
|
|
: 'Activate'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => deleteTaskMutation.mutate()}
|
|
disabled={
|
|
deleteTaskMutation.isPending ||
|
|
inactivateTaskMutation.isPending ||
|
|
activateTaskMutation.isPending
|
|
}
|
|
className="rounded-md border border-red-800 px-4 py-2 text-sm text-red-200 hover:border-red-600 disabled:opacity-50"
|
|
>
|
|
{deleteTaskMutation.isPending ? 'Deleting…' : 'Delete'}
|
|
</button>
|
|
<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 || updateTaskMutation.isPending}
|
|
>
|
|
{updateTaskMutation.isPending ? 'Saving…' : 'Save Changes'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
} |