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>
  );
}