feat(ui): redesign routine triggers tab with list-view cards and modal editor
Replace the always-visible "Add trigger" inline form + inline per-trigger editor with a clearer pattern: a header + "Add trigger" button, a list of compact TriggerListCard entries, and an add/edit dialog. Enable/disable becomes a switch on each card (single-click toggle, no save step). ScheduleEditor gains first-class support for the shapes users actually want: every-N-minutes (with optional hour window and weekdays-only), every-N-hours, hourly at a minute offset, daily with one or more times per day, selected-days-of-week with one or more times, and monthly with one or more dates. The legacy parseCronToPreset/describeSchedule exports remain backwards-compatible; describeSchedule now unfolds multi-value hour and day lists into readable sentences instead of returning the raw cron. Falls back to the raw cron for named-token forms it can't describe. The CRUD wiring reuses the existing create/update/delete/rotate-secret mutations, query invalidations, and the secret-material banner. Delete now goes through a ConfirmDialog instead of firing on trash click. Files: - ui/src/components/ScheduleEditor.tsx — richer internal preset model - ui/src/components/ScheduleEditor.test.ts — cover the new describers - ui/src/components/TriggerListCard.tsx — new card view per trigger - ui/src/components/TriggerDialog.tsx — add/edit modal wrapping the editor - ui/src/components/ConfirmDialog.tsx — small confirm-dialog primitive - ui/src/pages/RoutineDetail.tsx — swap the triggers tab to the new flow
This commit is contained in:
parent
9018e461a6
commit
82147887e1
|
|
@ -0,0 +1,57 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
destructive?: boolean;
|
||||
onConfirm: () => void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
destructive,
|
||||
onConfirm,
|
||||
busy,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={destructive ? "destructive" : "default"}
|
||||
size="sm"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "Working…" : confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -88,23 +88,41 @@ describe("parseCronToPreset", () => {
|
|||
describe("describeSchedule", () => {
|
||||
it("describes simple presets in plain English", () => {
|
||||
expect(describeSchedule("0 9 * * *")).toContain("Every day");
|
||||
expect(describeSchedule("0 9 * * 1-5")).toContain("Weekdays");
|
||||
expect(describeSchedule("0 9 * * 1")).toContain("Mon");
|
||||
expect(describeSchedule("0 9 * * 1-5")).toContain("weekday");
|
||||
expect(describeSchedule("0 9 * * 1")).toContain("Monday");
|
||||
});
|
||||
|
||||
it("returns the raw cron string for complex crons (so the user sees what's actually scheduled)", () => {
|
||||
// These are the three patterns in the Traffic Exchange Script company's
|
||||
// routines that exposed the round-trip bug. Before the fix they all
|
||||
// rendered as some variant of "Every day at …" with a silently wrong
|
||||
// hour. After the fix they render as the cron string itself.
|
||||
expect(describeSchedule("0 9,13,17 * * *")).toBe("0 9,13,17 * * *");
|
||||
expect(describeSchedule("0 10,16 * * *")).toBe("0 10,16 * * *");
|
||||
expect(describeSchedule("0 */4 * * *")).toBe("0 */4 * * *");
|
||||
it("describes multi-value hour lists (these previously collapsed silently)", () => {
|
||||
// Regression guard. Pre-fix, these crons round-tripped to "Every day at …"
|
||||
// with a silently-wrong single hour. Post-fix they rendered as the raw
|
||||
// cron string. Now that the editor can represent multi-value hour lists
|
||||
// first-class, describeSchedule unfolds them into a readable sentence.
|
||||
expect(describeSchedule("0 9,13,17 * * *")).toBe("Every day at 09:00, 13:00 and 17:00");
|
||||
expect(describeSchedule("0 10,16 * * *")).toBe("Every day at 10:00 and 16:00");
|
||||
});
|
||||
|
||||
it("describes step expressions in plain English", () => {
|
||||
expect(describeSchedule("0 */4 * * *")).toBe("Every 4 hours at :00");
|
||||
expect(describeSchedule("*/15 * * * *")).toBe("Every 15 minutes");
|
||||
expect(describeSchedule("*/15 9-17 * * 1-5")).toContain("between 09:00 and 17:00");
|
||||
});
|
||||
|
||||
it("describes multi-day weekday selections", () => {
|
||||
expect(describeSchedule("0 9 * * 1,3,5")).toBe("Every Mon, Wed, Fri at 09:00");
|
||||
});
|
||||
|
||||
it("describes multi-date monthly selections with ordinals", () => {
|
||||
expect(describeSchedule("0 9 1,15 * *")).toBe("On the 1st, 15th of the month at 09:00");
|
||||
});
|
||||
|
||||
it("falls back to the raw cron string for expressions it can't confidently describe", () => {
|
||||
// Named tokens and exotic forms still round-trip as the raw cron.
|
||||
expect(describeSchedule("0 MON * * *")).toBe("0 MON * * *");
|
||||
expect(describeSchedule("@daily")).toBe("@daily");
|
||||
expect(describeSchedule("not a cron")).toBe("not a cron");
|
||||
});
|
||||
|
||||
it("falls back to the default 10:00 AM preset for an empty cron", () => {
|
||||
// `parseCronToPreset("")` returns `every_day` with the default hour (10)
|
||||
// and minute (0), so `describeSchedule` renders the default preset label.
|
||||
expect(describeSchedule("")).toBe("Every day at 10:00 AM");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,254 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import type { RoutineTrigger } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { ScheduleEditor } from "./ScheduleEditor";
|
||||
|
||||
const triggerKinds = ["schedule", "webhook"] as const;
|
||||
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"] as const;
|
||||
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set<string>(["github_hmac", "none"]);
|
||||
const signingModeDescriptions: Record<string, string> = {
|
||||
bearer: "Expect a shared bearer token in the Authorization header.",
|
||||
hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.",
|
||||
github_hmac: "Accept GitHub-style X-Hub-Signature-256 header (HMAC over raw body, no timestamp).",
|
||||
none: "No authentication — the webhook URL itself acts as a shared secret.",
|
||||
};
|
||||
|
||||
type TriggerKind = (typeof triggerKinds)[number];
|
||||
|
||||
export interface TriggerDialogState {
|
||||
label: string;
|
||||
kind: TriggerKind;
|
||||
cronExpression: string;
|
||||
signingMode: string;
|
||||
replayWindowSec: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface TriggerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** When editing an existing trigger, pass it here. Null for create. */
|
||||
trigger: RoutineTrigger | null;
|
||||
/** Timezone to use when creating a new schedule trigger (the detail page uses the browser's zone). */
|
||||
fallbackTimezone: string;
|
||||
/** Called when the user submits. For updates `id` is non-null. */
|
||||
onSubmit: (payload: {
|
||||
id: string | null;
|
||||
kind: TriggerKind;
|
||||
// For create: full body. For update: partial patch ready to send.
|
||||
body: Record<string, unknown>;
|
||||
}) => void;
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
||||
const BLANK: TriggerDialogState = {
|
||||
label: "",
|
||||
kind: "schedule",
|
||||
cronExpression: "0 9 * * 1-5",
|
||||
signingMode: "bearer",
|
||||
replayWindowSec: "300",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
function draftFromTrigger(trigger: RoutineTrigger | null): TriggerDialogState {
|
||||
if (!trigger) return { ...BLANK };
|
||||
return {
|
||||
label: trigger.label ?? "",
|
||||
kind: (trigger.kind as TriggerKind) ?? "schedule",
|
||||
cronExpression: trigger.cronExpression ?? "0 9 * * 1-5",
|
||||
signingMode: trigger.signingMode ?? "bearer",
|
||||
replayWindowSec: String(trigger.replayWindowSec ?? 300),
|
||||
enabled: trigger.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function TriggerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
trigger,
|
||||
fallbackTimezone,
|
||||
onSubmit,
|
||||
submitting,
|
||||
}: TriggerDialogProps) {
|
||||
const isEdit = !!trigger;
|
||||
const [draft, setDraft] = useState<TriggerDialogState>(() => draftFromTrigger(trigger));
|
||||
|
||||
// Reset the draft whenever the dialog opens with a different trigger.
|
||||
useEffect(() => {
|
||||
if (open) setDraft(draftFromTrigger(trigger));
|
||||
}, [open, trigger]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const labelTrimmed = draft.label.trim();
|
||||
|
||||
if (isEdit && trigger) {
|
||||
// Build a PATCH body. Match the fields the backend accepts on
|
||||
// PATCH /routine-triggers/:id (see updateRoutineTriggerSchema).
|
||||
const patch: Record<string, unknown> = {
|
||||
label: labelTrimmed || null,
|
||||
enabled: draft.enabled,
|
||||
};
|
||||
if (trigger.kind === "schedule") {
|
||||
patch.cronExpression = draft.cronExpression.trim();
|
||||
patch.timezone = trigger.timezone ?? fallbackTimezone;
|
||||
}
|
||||
if (trigger.kind === "webhook") {
|
||||
patch.signingMode = draft.signingMode;
|
||||
patch.replayWindowSec = Number(draft.replayWindowSec || "300");
|
||||
}
|
||||
onSubmit({ id: trigger.id, kind: trigger.kind as TriggerKind, body: patch });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create body: match POST /routines/:id/triggers (createRoutineTriggerSchema).
|
||||
const body: Record<string, unknown> = {
|
||||
kind: draft.kind,
|
||||
label: labelTrimmed || draft.kind,
|
||||
};
|
||||
if (draft.kind === "schedule") {
|
||||
body.cronExpression = draft.cronExpression.trim();
|
||||
body.timezone = fallbackTimezone;
|
||||
}
|
||||
if (draft.kind === "webhook") {
|
||||
body.signingMode = draft.signingMode;
|
||||
body.replayWindowSec = Number(draft.replayWindowSec || "300");
|
||||
}
|
||||
onSubmit({ id: null, kind: draft.kind, body });
|
||||
};
|
||||
|
||||
const showWebhookFields = draft.kind === "webhook";
|
||||
const showScheduleFields = draft.kind === "schedule";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit trigger" : "Add trigger"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure when and how this routine fires.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 pt-1">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="trigger-label" className="text-xs">Label</Label>
|
||||
<Input
|
||||
id="trigger-label"
|
||||
placeholder="e.g. Morning digest"
|
||||
value={draft.label}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, label: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional — shown in the trigger list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Kind</Label>
|
||||
<Select
|
||||
value={draft.kind}
|
||||
onValueChange={(kind) => setDraft((d) => ({ ...d, kind: kind as TriggerKind }))}
|
||||
disabled={isEdit}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{triggerKinds.map((kind) => (
|
||||
<SelectItem key={kind} value={kind}>
|
||||
{kind}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isEdit && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kind can't be changed after creation.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showScheduleFields && (
|
||||
<ScheduleEditor
|
||||
value={draft.cronExpression}
|
||||
onChange={(cronExpression) => setDraft((d) => ({ ...d, cronExpression }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showWebhookFields && (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Signing mode</Label>
|
||||
<Select
|
||||
value={draft.signingMode}
|
||||
onValueChange={(signingMode) => setDraft((d) => ({ ...d, signingMode }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{signingModes.map((mode) => (
|
||||
<SelectItem key={mode} value={mode}>
|
||||
{mode}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{signingModeDescriptions[draft.signingMode]}
|
||||
</p>
|
||||
</div>
|
||||
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Replay window (seconds)</Label>
|
||||
<Input
|
||||
value={draft.replayWindowSec}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, replayWindowSec: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
{isEdit && (
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm mr-auto">
|
||||
<ToggleSwitch
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(enabled) => setDraft((d) => ({ ...d, enabled }))}
|
||||
/>
|
||||
<span>{draft.enabled ? "Enabled" : "Paused"}</span>
|
||||
</label>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Saving…" : isEdit ? "Save changes" : "Add trigger"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { Clock3, Pencil, RefreshCw, Trash2, Webhook, Zap } from "lucide-react";
|
||||
import type { RoutineTrigger } from "@paperclipai/shared";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { describeSchedule } from "./ScheduleEditor";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
|
||||
interface TriggerListCardProps {
|
||||
trigger: RoutineTrigger;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
onRotateSecret?: () => void;
|
||||
togglePending?: boolean;
|
||||
}
|
||||
|
||||
export function TriggerListCard({
|
||||
trigger,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleEnabled,
|
||||
onRotateSecret,
|
||||
togglePending,
|
||||
}: TriggerListCardProps) {
|
||||
const isSchedule = trigger.kind === "schedule";
|
||||
const isWebhook = trigger.kind === "webhook";
|
||||
const Icon = isSchedule ? Clock3 : isWebhook ? Webhook : Zap;
|
||||
|
||||
const summary = isSchedule && trigger.cronExpression
|
||||
? describeSchedule(trigger.cronExpression)
|
||||
: isWebhook
|
||||
? `Webhook${trigger.publicId ? ` · ${trigger.publicId}` : ""}`
|
||||
: "API trigger";
|
||||
|
||||
const nextRun = isSchedule && trigger.enabled && trigger.nextRunAt
|
||||
? new Date(trigger.nextRunAt).toLocaleString(undefined, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: trigger.enabled ? "—" : "Disabled";
|
||||
|
||||
const lastFired = trigger.lastFiredAt ? timeAgo(trigger.lastFiredAt) : "Never";
|
||||
|
||||
const resultIsError = typeof trigger.lastResult === "string" && /error|fail/i.test(trigger.lastResult);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border border-border p-4 transition-colors ${trigger.enabled ? "bg-card" : "bg-muted/40"}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-sm font-medium ${trigger.enabled ? "" : "text-muted-foreground"}`}>
|
||||
{trigger.label || (isSchedule ? "Schedule" : isWebhook ? "Webhook" : "Trigger")}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{trigger.kind}
|
||||
</Badge>
|
||||
{!trigger.enabled && (
|
||||
<Badge variant="secondary" className="text-[11px] text-muted-foreground">
|
||||
paused
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm mt-1.5">{summary}</div>
|
||||
{isSchedule && trigger.cronExpression && (
|
||||
<div className="text-xs text-muted-foreground mt-1 font-mono">
|
||||
{trigger.cronExpression}
|
||||
{trigger.timezone ? ` · ${trigger.timezone}` : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 mt-4 text-xs">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-0.5">Next run</div>
|
||||
<div>{nextRun}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-0.5">Last fired</div>
|
||||
<div>{lastFired}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-0.5">Last result</div>
|
||||
<div>
|
||||
{trigger.lastResult ? (
|
||||
<Badge
|
||||
variant={resultIsError ? "destructive" : "secondary"}
|
||||
className="text-[11px] max-w-full truncate"
|
||||
title={trigger.lastResult}
|
||||
>
|
||||
{trigger.lastResult}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-3 shrink-0">
|
||||
<ToggleSwitch
|
||||
checked={trigger.enabled}
|
||||
onCheckedChange={onToggleEnabled}
|
||||
disabled={togglePending}
|
||||
aria-label={trigger.enabled ? "Disable trigger" : "Enable trigger"}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
{isWebhook && onRotateSecret && (
|
||||
<Button variant="ghost" size="xs" onClick={onRotateSecret} title="Rotate secret">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="xs" onClick={onEdit} title="Edit">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={onDelete}
|
||||
title="Delete"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,14 +8,14 @@ import {
|
|||
Clock3,
|
||||
Copy,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Repeat,
|
||||
Save,
|
||||
Trash2,
|
||||
Webhook,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines";
|
||||
import { TriggerListCard } from "../components/TriggerListCard";
|
||||
import { TriggerDialog } from "../components/TriggerDialog";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { LiveRunWidget } from "../components/LiveRunWidget";
|
||||
import { agentsApi } from "../api/agents";
|
||||
|
|
@ -24,7 +24,6 @@ import { useCompany } from "../context/CompanyContext";
|
|||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
|
|
@ -37,7 +36,6 @@ import {
|
|||
type RoutineRunDialogSubmitData,
|
||||
} from "../components/RoutineRunVariablesDialog";
|
||||
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
|
||||
import { ScheduleEditor, describeSchedule } from "../components/ScheduleEditor";
|
||||
import { RunButton } from "../components/AgentActionButtons";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -58,8 +56,6 @@ import type { RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
|
|||
|
||||
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
||||
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
|
||||
const triggerKinds = ["schedule", "webhook"];
|
||||
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"];
|
||||
const routineTabs = ["triggers", "runs", "activity"] as const;
|
||||
const concurrencyPolicyDescriptions: Record<string, string> = {
|
||||
coalesce_if_active: "Keep one follow-up run queued while an active run is still working.",
|
||||
|
|
@ -70,13 +66,6 @@ const catchUpPolicyDescriptions: Record<string, string> = {
|
|||
skip_missed: "Ignore schedule windows that were missed while the routine or scheduler was paused.",
|
||||
enqueue_missed_with_cap: "Catch up missed schedule windows in capped batches after recovery.",
|
||||
};
|
||||
const signingModeDescriptions: Record<string, string> = {
|
||||
bearer: "Expect a shared bearer token in the Authorization header.",
|
||||
hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.",
|
||||
github_hmac: "Accept GitHub-style X-Hub-Signature-256 header (HMAC over raw body, no timestamp).",
|
||||
none: "No authentication — the webhook URL itself acts as a shared secret.",
|
||||
};
|
||||
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]);
|
||||
|
||||
type RoutineTab = (typeof routineTabs)[number];
|
||||
|
||||
|
|
@ -139,128 +128,6 @@ function buildRoutineMutationPayload(input: {
|
|||
};
|
||||
}
|
||||
|
||||
function TriggerEditor({
|
||||
trigger,
|
||||
onSave,
|
||||
onRotate,
|
||||
onDelete,
|
||||
}: {
|
||||
trigger: RoutineTrigger;
|
||||
onSave: (id: string, patch: Record<string, unknown>) => void;
|
||||
onRotate: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState({
|
||||
label: trigger.label ?? "",
|
||||
cronExpression: trigger.cronExpression ?? "",
|
||||
signingMode: trigger.signingMode ?? "bearer",
|
||||
replayWindowSec: String(trigger.replayWindowSec ?? 300),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setDraft({
|
||||
label: trigger.label ?? "",
|
||||
cronExpression: trigger.cronExpression ?? "",
|
||||
signingMode: trigger.signingMode ?? "bearer",
|
||||
replayWindowSec: String(trigger.replayWindowSec ?? 300),
|
||||
});
|
||||
}, [trigger]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{trigger.kind === "schedule" ? <Clock3 className="h-3.5 w-3.5" /> : trigger.kind === "webhook" ? <Webhook className="h-3.5 w-3.5" /> : <Zap className="h-3.5 w-3.5" />}
|
||||
{trigger.label ?? trigger.kind}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{trigger.kind === "schedule" && trigger.nextRunAt
|
||||
? `Next: ${new Date(trigger.nextRunAt).toLocaleString()}`
|
||||
: trigger.kind === "webhook"
|
||||
? "Webhook"
|
||||
: "API"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Label</Label>
|
||||
<Input
|
||||
value={draft.label}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, label: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
{trigger.kind === "schedule" && (
|
||||
<div className="md:col-span-2 space-y-1.5">
|
||||
<Label className="text-xs">Schedule</Label>
|
||||
<ScheduleEditor
|
||||
value={draft.cronExpression}
|
||||
onChange={(cronExpression) => setDraft((current) => ({ ...current, cronExpression }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{trigger.kind === "webhook" && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Signing mode</Label>
|
||||
<Select
|
||||
value={draft.signingMode}
|
||||
onValueChange={(signingMode) => setDraft((current) => ({ ...current, signingMode }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{signingModes.map((mode) => (
|
||||
<SelectItem key={mode} value={mode}>{mode}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Replay window (seconds)</Label>
|
||||
<Input
|
||||
value={draft.replayWindowSec}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{trigger.lastResult && <span className="text-xs text-muted-foreground">Last: {trigger.lastResult}</span>}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{trigger.kind === "webhook" && (
|
||||
<Button variant="outline" size="sm" onClick={() => onRotate(trigger.id)}>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Rotate secret
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSave(trigger.id, buildRoutineTriggerPatch(trigger, draft, getLocalTimezone()))}
|
||||
>
|
||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||
Save trigger
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(trigger.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoutineDetail() {
|
||||
const { routineId } = useParams<{ routineId: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
|
|
@ -277,12 +144,10 @@ export function RoutineDetail() {
|
|||
const [secretMessage, setSecretMessage] = useState<SecretMessage | null>(null);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [runVariablesOpen, setRunVariablesOpen] = useState(false);
|
||||
const [newTrigger, setNewTrigger] = useState({
|
||||
kind: "schedule",
|
||||
cronExpression: "0 10 * * *",
|
||||
signingMode: "bearer",
|
||||
replayWindowSec: "300",
|
||||
});
|
||||
const [triggerDialogOpen, setTriggerDialogOpen] = useState(false);
|
||||
const [editingTrigger, setEditingTrigger] = useState<RoutineTrigger | null>(null);
|
||||
const [triggerPendingDelete, setTriggerPendingDelete] = useState<RoutineTrigger | null>(null);
|
||||
const [togglingTriggerId, setTogglingTriggerId] = useState<string | null>(null);
|
||||
const [editDraft, setEditDraft] = useState<{
|
||||
title: string;
|
||||
description: string;
|
||||
|
|
@ -504,24 +369,23 @@ export function RoutineDetail() {
|
|||
});
|
||||
|
||||
const createTrigger = useMutation({
|
||||
mutationFn: async (): Promise<RoutineTriggerResponse> => {
|
||||
const existingOfKind = (routine?.triggers ?? []).filter((t) => t.kind === newTrigger.kind).length;
|
||||
const autoLabel = existingOfKind > 0 ? `${newTrigger.kind}-${existingOfKind + 1}` : newTrigger.kind;
|
||||
return routinesApi.createTrigger(routineId!, {
|
||||
kind: newTrigger.kind,
|
||||
label: autoLabel,
|
||||
...(newTrigger.kind === "schedule"
|
||||
? { cronExpression: newTrigger.cronExpression.trim(), timezone: getLocalTimezone() }
|
||||
: {}),
|
||||
...(newTrigger.kind === "webhook"
|
||||
? {
|
||||
signingMode: newTrigger.signingMode,
|
||||
replayWindowSec: Number(newTrigger.replayWindowSec || "300"),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
mutationFn: async (body: Record<string, unknown>): Promise<RoutineTriggerResponse> => {
|
||||
// Auto-label when the caller didn't provide one (e.g. dialog left the
|
||||
// Label field blank). Keeps the existing "schedule-2"-style numbering
|
||||
// behaviour so existing routines keep unique-ish labels.
|
||||
const kind = String(body.kind ?? "schedule");
|
||||
const trimmedLabel = typeof body.label === "string" ? body.label.trim() : "";
|
||||
let finalLabel: string;
|
||||
if (trimmedLabel.length > 0 && trimmedLabel !== kind) {
|
||||
finalLabel = trimmedLabel;
|
||||
} else {
|
||||
const existingOfKind = (routine?.triggers ?? []).filter((t) => t.kind === kind).length;
|
||||
finalLabel = existingOfKind > 0 ? `${kind}-${existingOfKind + 1}` : kind;
|
||||
}
|
||||
return routinesApi.createTrigger(routineId!, { ...body, label: finalLabel });
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
setTriggerDialogOpen(false);
|
||||
if (result.secretMaterial) {
|
||||
setSecretMessage({
|
||||
title: "Webhook trigger created",
|
||||
|
|
@ -555,9 +419,10 @@ export function RoutineDetail() {
|
|||
onSuccess: async () => {
|
||||
pushToast({
|
||||
title: "Trigger saved",
|
||||
body: "The routine cadence update was saved.",
|
||||
tone: "success",
|
||||
});
|
||||
setTriggerDialogOpen(false);
|
||||
setEditingTrigger(null);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||
|
|
@ -571,6 +436,9 @@ export function RoutineDetail() {
|
|||
tone: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
setTogglingTriggerId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteTrigger = useMutation({
|
||||
|
|
@ -580,6 +448,7 @@ export function RoutineDetail() {
|
|||
title: "Trigger deleted",
|
||||
tone: "success",
|
||||
});
|
||||
setTriggerPendingDelete(null);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||
|
|
@ -972,78 +841,63 @@ export function RoutineDetail() {
|
|||
</TabsList>
|
||||
|
||||
<TabsContent value="triggers" className="space-y-4">
|
||||
{/* Add trigger form */}
|
||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||
<p className="text-sm font-medium">Add trigger</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Kind</Label>
|
||||
<Select value={newTrigger.kind} onValueChange={(kind) => setNewTrigger((current) => ({ ...current, kind }))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{triggerKinds.map((kind) => (
|
||||
<SelectItem key={kind} value={kind} disabled={kind === "webhook"}>
|
||||
{kind}{kind === "webhook" ? " — COMING SOON" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{newTrigger.kind === "schedule" && (
|
||||
<div className="md:col-span-2 space-y-1.5">
|
||||
<Label className="text-xs">Schedule</Label>
|
||||
<ScheduleEditor
|
||||
value={newTrigger.cronExpression}
|
||||
onChange={(cronExpression) => setNewTrigger((current) => ({ ...current, cronExpression }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{newTrigger.kind === "webhook" && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Signing mode</Label>
|
||||
<Select value={newTrigger.signingMode} onValueChange={(signingMode) => setNewTrigger((current) => ({ ...current, signingMode }))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{signingModes.map((mode) => (
|
||||
<SelectItem key={mode} value={mode}>{mode}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{signingModeDescriptions[newTrigger.signingMode]}</p>
|
||||
</div>
|
||||
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(newTrigger.signingMode) && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Replay window (seconds)</Label>
|
||||
<Input value={newTrigger.replayWindowSec} onChange={(event) => setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button size="sm" onClick={() => createTrigger.mutate()} disabled={createTrigger.isPending}>
|
||||
{createTrigger.isPending ? "Adding..." : "Add trigger"}
|
||||
</Button>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">Triggers</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Schedules and webhooks that fire this routine.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingTrigger(null);
|
||||
setTriggerDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Add trigger
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Existing triggers */}
|
||||
{routine.triggers.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No triggers configured yet.</p>
|
||||
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-8 text-center">
|
||||
<p className="text-sm font-medium">No triggers yet</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">
|
||||
Triggers fire this routine on a schedule or via webhook.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingTrigger(null);
|
||||
setTriggerDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Add your first trigger
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{routine.triggers.map((trigger) => (
|
||||
<TriggerEditor
|
||||
<TriggerListCard
|
||||
key={trigger.id}
|
||||
trigger={trigger}
|
||||
onSave={(id, patch) => updateTrigger.mutate({ id, patch })}
|
||||
onRotate={(id) => rotateTrigger.mutate(id)}
|
||||
onDelete={(id) => deleteTrigger.mutate(id)}
|
||||
onEdit={() => {
|
||||
setEditingTrigger(trigger);
|
||||
setTriggerDialogOpen(true);
|
||||
}}
|
||||
onDelete={() => setTriggerPendingDelete(trigger)}
|
||||
onToggleEnabled={(enabled) => {
|
||||
setTogglingTriggerId(trigger.id);
|
||||
updateTrigger.mutate({ id: trigger.id, patch: { enabled } });
|
||||
}}
|
||||
onRotateSecret={
|
||||
trigger.kind === "webhook"
|
||||
? () => rotateTrigger.mutate(trigger.id)
|
||||
: undefined
|
||||
}
|
||||
togglePending={togglingTriggerId === trigger.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1122,6 +976,43 @@ export function RoutineDetail() {
|
|||
isPending={runRoutine.isPending}
|
||||
onSubmit={(data) => runRoutine.mutate(data)}
|
||||
/>
|
||||
|
||||
<TriggerDialog
|
||||
open={triggerDialogOpen}
|
||||
onOpenChange={(next) => {
|
||||
setTriggerDialogOpen(next);
|
||||
if (!next) setEditingTrigger(null);
|
||||
}}
|
||||
trigger={editingTrigger}
|
||||
fallbackTimezone={getLocalTimezone()}
|
||||
submitting={createTrigger.isPending || updateTrigger.isPending}
|
||||
onSubmit={({ id, body }) => {
|
||||
if (id) {
|
||||
updateTrigger.mutate({ id, patch: body });
|
||||
} else {
|
||||
createTrigger.mutate(body);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!triggerPendingDelete}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setTriggerPendingDelete(null);
|
||||
}}
|
||||
title="Delete trigger?"
|
||||
description={
|
||||
triggerPendingDelete
|
||||
? `"${triggerPendingDelete.label ?? triggerPendingDelete.kind}" will be removed. This can't be undone.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
busy={deleteTrigger.isPending}
|
||||
onConfirm={() => {
|
||||
if (triggerPendingDelete) deleteTrigger.mutate(triggerPendingDelete.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue