AddTriggerForm.tsx10.5 KBView on GitHub 'use client';
import {
Field,
FieldRows,
FormActions,
FormError,
SelectField,
TextField,
} from '@/components/ui/field';
import type { NewPlaybookTrigger, NewSourceRequest } from './AgentSourcesSection';
import { CronScheduleFields } from '@/modules/aop/components/CronScheduleFields';
import { TRIGGER_EVENT_TYPES } from '@/modules/agents/utils/trigger-events';
import { COMMON_TIMEZONES, getTimezoneAbbreviation } from '@/lib/timezones';
import { AGENT_SECTION_SURFACE } from './AgentConfigPage';
import { Button } from '@/components/ui/button';
import { useMemo, useState } from 'react';
import { cn } from '@/lib/utils';
/** The picker's own value space: the typed triggers, plus the minted webhook. */
type SourceFormType = NewPlaybookTrigger['type'] | 'webhook';
const TRIGGER_TYPE_LABELS: Array<{ value: SourceFormType; label: string }> = [
{ value: 'cron', label: 'On a schedule' },
{ value: 'event', label: 'When something happens' },
{ value: 'any', label: 'On every event' },
{ value: 'before_meeting', label: 'Before a meeting' },
{ value: 'field_change', label: 'When a field changes' },
// Selectable because ONE call now writes both halves — the `playbook_webhooks`
// row that holds the token and the playbook block that points at it.
{ value: 'webhook', label: 'Inbound webhook (POST a URL)' },
];
/** Minutes-before options, so "before a meeting" is a choice and not arithmetic. */
const MEETING_LEAD_TIMES = [
{ value: '15', label: '15 minutes before' },
{ value: '30', label: '30 minutes before' },
{ value: '60', label: '1 hour before' },
{ value: '120', label: '2 hours before' },
{ value: '1440', label: 'A day before' },
];
function localTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch {
return 'UTC';
}
}
/** Everything the form holds, before it is a trigger. */
interface TriggerDraft {
type: NewPlaybookTrigger['type'];
schedule: string;
timezone: string;
eventType: string;
minutes: string;
field: string;
toValue: string;
stage: string;
}
/**
* One draft → one trigger, or a sentence saying why not.
*
* Pure and outside the component so the shape submitted to
* `agent.upsertPlaybookSource` is a thing that can be read in one place and
* asserted on directly — the wire format is the part that has to be right.
*/
export function draftToTrigger(draft: TriggerDraft): NewPlaybookTrigger | string {
const stage = draft.stage.trim();
// Blank stage ⇒ `<global>`; a named one ⇒ that `<stage>`. The playbook already
// makes exactly this distinction, so the form does not add a second control.
const where = stage ? ({ scope: 'stage', stage } as const) : ({ scope: 'global' } as const);
switch (draft.type) {
case 'cron': {
const schedule = draft.schedule.trim();
const timezone = draft.timezone.trim();
if (!schedule) return 'A schedule is required.';
if (!timezone) return 'A timezone is required.';
return { type: 'cron', ...where, schedule, timezone };
}
case 'event': {
const eventType = draft.eventType.trim();
if (!eventType) return 'An event type is required.';
return { type: 'event', ...where, eventType };
}
case 'any':
return { type: 'any', ...where };
case 'before_meeting': {
const minutes = Number(draft.minutes);
if (!Number.isInteger(minutes) || minutes <= 0)
return 'Minutes must be a whole number greater than zero.';
return { type: 'before_meeting', ...where, minutes };
}
case 'field_change': {
const field = draft.field.trim();
if (!field) return 'A field name is required.';
// Empty ⇒ null, which is the playbook's "any value" and NOT the empty
// string: `to-value=""` would be a block that can never match.
return { type: 'field_change', ...where, field, toValue: draft.toValue.trim() || null };
}
}
}
/**
* The "no stage" value.
*
* A sentinel and not `''`, because radix reserves the empty string for "nothing is
* selected" and throws on an item that uses it — and "everywhere" IS a selection.
* It maps back to `''` on the way into the draft, where blank means `<global>`.
*/
export const STAGE_EVERYWHERE = '__everywhere__';
/** A stage the playbook actually has. */
export interface TriggerStageOption {
value: string;
label: string;
}
/**
* Trigger type first, then only the fields that type needs.
*
* EVERY choice here is a choice, not a string to be typed. That is the whole change:
* a cron expression, an event name and a stage name are each things the system knows
* the valid values of, and each one was a text box where a typo saved cleanly and
* fired never. The stage box was the worst of the three — `upsertPlaybookSource`
* refuses a stage that is not in the playbook, so the only thing free text bought was
* a round trip to be told no.
*/
export function AddTriggerForm({
onSubmit,
onCancel,
isMutating,
stages,
}: {
onSubmit: (request: NewSourceRequest) => void;
onCancel: () => void;
isMutating?: boolean;
/** The playbook's stages. Empty (or still loading) hides the stage field. */
stages?: TriggerStageOption[];
}) {
const [type, setType] = useState<SourceFormType>('cron');
const [webhookLabel, setWebhookLabel] = useState('');
const [schedule, setSchedule] = useState('0 9 * * 1-5');
const [timezone, setTimezone] = useState(localTimezone);
const [eventType, setEventType] = useState('email');
const [minutes, setMinutes] = useState('60');
const [field, setField] = useState('status');
const [toValue, setToValue] = useState('');
const [stage, setStage] = useState(STAGE_EVERYWHERE);
const [invalid, setInvalid] = useState<string | null>(null);
/**
* The browser's own zone first, then the curated list the calendar picker uses —
* deduped, because "America/New_York" appearing twice in a dropdown is a bug you
* only notice after picking the wrong one.
*/
const timezones = useMemo(() => {
const here = localTimezone();
const seen = new Set<string>();
return [{ id: here, label: `${here.split('/').pop()?.replace(/_/g, ' ')} (yours)` }]
.concat(COMMON_TIMEZONES)
.filter((tz) => !seen.has(tz.id) && seen.add(tz.id))
.map((tz) => ({ value: tz.id, label: `${tz.label} · ${getTimezoneAbbreviation(tz.id)}` }));
}, []);
/** The picker wants `{ value, label }`; the shared list also carries an icon. */
const eventOptions = useMemo(
() => TRIGGER_EVENT_TYPES.map(({ value, label }) => ({ value, label })),
[],
);
const submit = () => {
if (type === 'webhook') {
setInvalid(null);
onSubmit({ kind: 'webhook', label: webhookLabel.trim() });
return;
}
const built = draftToTrigger({
type,
schedule,
timezone,
eventType,
minutes,
field,
toValue,
stage: stage === STAGE_EVERYWHERE ? '' : stage,
});
if (typeof built === 'string') {
setInvalid(built);
return;
}
setInvalid(null);
onSubmit({ kind: 'trigger', trigger: built });
};
return (
<form
className={cn(AGENT_SECTION_SURFACE, 'flex flex-col')}
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
{/* One field per row — name left, control right — and `bare` because the section
surface above is already the frame. Without this the fields fall back to
stacked, which is what made this form look unlike every other one. */}
<FieldRows bare>
<SelectField
label="Trigger"
aria-label="Trigger type"
value={type}
onValueChange={setType}
options={TRIGGER_TYPE_LABELS}
/>
{type === 'webhook' && (
<TextField
label="Name"
optional
aria-label="Webhook name"
value={webhookLabel}
onChange={(e) => setWebhookLabel(e.target.value)}
placeholder="e.g. Zapier — new signup"
hint="The URL appears once saved. Treat it as a secret."
/>
)}
{type === 'cron' && (
<>
<Field label="Runs">
<CronScheduleFields schedule={schedule} onChange={(next) => setSchedule(next)} />
</Field>
<SelectField
label="Timezone"
aria-label="Timezone"
value={timezone}
onValueChange={setTimezone}
options={timezones}
/>
</>
)}
{type === 'event' && (
<SelectField
label="Event"
aria-label="Event type"
value={eventType}
onValueChange={setEventType}
options={eventOptions}
/>
)}
{type === 'before_meeting' && (
<SelectField
label="How long before"
aria-label="Minutes before"
value={minutes}
onValueChange={setMinutes}
options={MEETING_LEAD_TIMES}
/>
)}
{type === 'field_change' && (
<>
<TextField
label="Field"
aria-label="Field"
value={field}
onChange={(e) => setField(e.target.value)}
/>
<TextField
label="Changes to"
optional
aria-label="Changes to"
value={toValue}
onChange={(e) => setToValue(e.target.value)}
placeholder="any value"
/>
</>
)}
{/* Only the stages the playbook HAS. `upsertPlaybookSource` writes into an
existing `<stage id="…">` and never creates one, so a name that is not in
this list is a guaranteed error — which is exactly what the text box used
to let you submit. */}
{stages && stages.length > 0 && (
<SelectField
label="Where"
aria-label="Stage"
value={stage}
onValueChange={setStage}
options={[{ value: STAGE_EVERYWHERE, label: 'Everywhere' }, ...stages]}
/>
)}
</FieldRows>
{invalid && (
<div className="px-4">
<FormError>{invalid}</FormError>
</div>
)}
<FormActions className="p-4 pt-2">
<Button type="submit" size="sm" className="cursor-pointer" disabled={isMutating}>
Add trigger
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="cursor-pointer"
onClick={onCancel}
>
Cancel
</Button>
</FormActions>
</form>
);
}