From 82147887e1609165a4d0e7d0803ba5d5e6bf7faa Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Mon, 13 Apr 2026 11:17:44 +0200 Subject: [PATCH] feat(ui): redesign routine triggers tab with list-view cards and modal editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/src/components/ConfirmDialog.tsx | 57 ++ ui/src/components/ScheduleEditor.test.ts | 42 +- ui/src/components/ScheduleEditor.tsx | 1114 ++++++++++++++++------ ui/src/components/TriggerDialog.tsx | 254 +++++ ui/src/components/TriggerListCard.tsx | 139 +++ ui/src/pages/RoutineDetail.tsx | 337 +++---- 6 files changed, 1434 insertions(+), 509 deletions(-) create mode 100644 ui/src/components/ConfirmDialog.tsx create mode 100644 ui/src/components/TriggerDialog.tsx create mode 100644 ui/src/components/TriggerListCard.tsx diff --git a/ui/src/components/ConfirmDialog.tsx b/ui/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000000..3a8ed9a515 --- /dev/null +++ b/ui/src/components/ConfirmDialog.tsx @@ -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 ( + + + + {title} + {description && {description}} + + + + + + + + ); +} diff --git a/ui/src/components/ScheduleEditor.test.ts b/ui/src/components/ScheduleEditor.test.ts index 1d81d593fc..83b14a5312 100644 --- a/ui/src/components/ScheduleEditor.test.ts +++ b/ui/src/components/ScheduleEditor.test.ts @@ -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"); }); }); diff --git a/ui/src/components/ScheduleEditor.tsx b/ui/src/components/ScheduleEditor.tsx index 75c58a14d6..7c0e6c04d6 100644 --- a/ui/src/components/ScheduleEditor.tsx +++ b/ui/src/components/ScheduleEditor.tsx @@ -2,62 +2,72 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Input } from "@/components/ui/input"; -import { ChevronDown, ChevronRight } from "lucide-react"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { X } from "lucide-react"; -type SchedulePreset = "every_minute" | "every_hour" | "every_day" | "weekdays" | "weekly" | "monthly" | "custom"; +// --------------------------------------------------------------------------- +// Public (stable) types & helpers +// --------------------------------------------------------------------------- -const PRESETS: { value: SchedulePreset; label: string }[] = [ - { value: "every_minute", label: "Every minute" }, - { value: "every_hour", label: "Every hour" }, - { value: "every_day", label: "Every day" }, - { value: "weekdays", label: "Weekdays" }, - { value: "weekly", label: "Weekly" }, - { value: "monthly", label: "Monthly" }, - { value: "custom", label: "Custom (cron)" }, +/** + * Limited preset set kept for backwards compatibility. `parseCronToPreset` and + * the `describeSchedule` fallback rely on this set. Any cron that can't be + * expressed with a single hour / minute / day-of-week / day-of-month routes + * to "custom" from this parser so old callers never see a lossy preset. + */ +export type SchedulePreset = + | "every_minute" + | "every_hour" + | "every_day" + | "weekdays" + | "weekly" + | "monthly" + | "custom"; + +const DAY_NAMES_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const DAY_NAMES_LONG = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", ]; -const HOURS = Array.from({ length: 24 }, (_, i) => ({ - value: String(i), - label: i === 0 ? "12 AM" : i < 12 ? `${i} AM` : i === 12 ? "12 PM" : `${i - 12} PM`, -})); +function pad(n: number): string { + return String(n).padStart(2, "0"); +} -const MINUTES = Array.from({ length: 12 }, (_, i) => ({ - value: String(i * 5), - label: String(i * 5).padStart(2, "0"), -})); +function ordinalSuffix(n: number): string { + const s = ["th", "st", "nd", "rd"]; + const v = n % 100; + return s[(v - 20) % 10] || s[v] || s[0]; +} -const DAYS_OF_WEEK = [ - { value: "1", label: "Mon" }, - { value: "2", label: "Tue" }, - { value: "3", label: "Wed" }, - { value: "4", label: "Thu" }, - { value: "5", label: "Fri" }, - { value: "6", label: "Sat" }, - { value: "0", label: "Sun" }, -]; +function ordinal(n: number): string { + return `${n}${ordinalSuffix(n)}`; +} -const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => ({ - value: String(i + 1), - label: String(i + 1), -})); - -// A cron field is "simple" if it is `*` or a single non-negative integer -// (e.g. `0`, `9`, `13`). Anything with a comma list, a range, a step -// expression, or a named alias is considered complex and should round-trip -// through the custom cron input rather than through one of the preset -// branches below. -// -// The UI's preset form only knows how to serialize a single hour / minute / -// day-of-week / day-of-month. If a complex field is coerced into a preset, -// saving the form silently overwrites the original cron with a degraded -// single-value version — e.g. `0 9,13,17 * * *` becomes `0 10 * * *`, -// collapsing three daily fires into one. Detecting complex fields up front -// and routing to `custom` prevents that data loss. function isSimpleCronField(field: string): boolean { return field === "*" || /^\d+$/.test(field); } -function parseCronToPreset(cron: string): { +// --------------------------------------------------------------------------- +// Back-compat parser (kept so tests and any external callers continue to work) +// --------------------------------------------------------------------------- + +/** + * Parse a cron into one of the *limited* presets (original behaviour). + * Complex expressions (comma lists, ranges, steps, named tokens) all map to + * "custom" so the caller can safely round-trip the raw string without losing + * multi-value information. Don't change these semantics without updating the + * ScheduleEditor tests — they intentionally guard against silent collapse of + * multi-value crons like `0 9,13,17 * * *`. + */ +export function parseCronToPreset(cron: string): { preset: SchedulePreset; hour: string; minute: string; @@ -76,11 +86,6 @@ function parseCronToPreset(cron: string): { } const [min, hr, dom, month, dow] = parts; - - // If any field contains a list / range / step / unknown token, bail out to - // `custom` so the raw cron string is shown and edited directly. The only - // non-simple field we accept is `dow === "1-5"`, which is recognised below - // as the "weekdays" preset. const dowIsWeekdayRange = dow === "1-5"; const allFieldsSimple = isSimpleCronField(min) && @@ -91,97 +96,481 @@ function parseCronToPreset(cron: string): { if (!allFieldsSimple) { return { preset: "custom", ...defaults }; } - - // None of the presets use the month field, so a non-wildcard month can - // only be expressed as a custom cron. if (month !== "*") { return { preset: "custom", ...defaults }; } - // Every minute: "* * * * *" if (min === "*" && hr === "*" && dom === "*" && dow === "*") { return { preset: "every_minute", ...defaults }; } - - // Every hour: "0 * * * *" if (hr === "*" && dom === "*" && dow === "*") { return { preset: "every_hour", ...defaults, minute: min === "*" ? "0" : min }; } - - // Every day: "M H * * *" if (dom === "*" && dow === "*" && hr !== "*") { return { preset: "every_day", ...defaults, hour: hr, minute: min === "*" ? "0" : min }; } - - // Weekdays: "M H * * 1-5" if (dom === "*" && dow === "1-5" && hr !== "*") { return { preset: "weekdays", ...defaults, hour: hr, minute: min === "*" ? "0" : min }; } - - // Weekly: "M H * * D" (single day) if (dom === "*" && /^\d$/.test(dow) && hr !== "*") { - return { preset: "weekly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfWeek: dow }; + return { + preset: "weekly", + ...defaults, + hour: hr, + minute: min === "*" ? "0" : min, + dayOfWeek: dow, + }; } - - // Monthly: "M H D * *" if (/^\d{1,2}$/.test(dom) && dow === "*" && hr !== "*") { - return { preset: "monthly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfMonth: dom }; + return { + preset: "monthly", + ...defaults, + hour: hr, + minute: min === "*" ? "0" : min, + dayOfMonth: dom, + }; } - return { preset: "custom", ...defaults }; } -function buildCron(preset: SchedulePreset, hour: string, minute: string, dayOfWeek: string, dayOfMonth: string): string { - switch (preset) { +// --------------------------------------------------------------------------- +// Richer internal parser that can handle multi-value fields +// --------------------------------------------------------------------------- + +type EditorPreset = + | "every_minute" + | "every_n_minutes" + | "hourly" + | "every_n_hours" + | "daily" + | "weekdays" + | "monthly" + | "custom"; + +interface EditorState { + preset: EditorPreset; + n: number; // every_n_minutes, every_n_hours + windowEnabled: boolean; + windowStart: number; + windowEnd: number; + weekdaysOnly: boolean; + minutePast: number; // hourly, every_n_hours + times: string[]; // "HH:mm" strings, for daily / weekdays / monthly + days: number[]; // 0-6, for weekdays preset + domDays: number[]; // 1-31, for monthly + custom: string; +} + +const DEFAULT_STATE: EditorState = { + preset: "daily", + n: 15, + windowEnabled: false, + windowStart: 9, + windowEnd: 17, + weekdaysOnly: false, + minutePast: 0, + times: ["09:00"], + days: [1, 2, 3, 4, 5], + domDays: [1], + custom: "0 10 * * *", +}; + +function parseCronField(field: string, min: number, max: number): number[] { + if (field === "*" || field === "?") { + return Array.from({ length: max - min + 1 }, (_, i) => i + min); + } + const parts = field.split(","); + const out = new Set(); + for (const p of parts) { + const stepMatch = p.match(/^(.+)\/(\d+)$/); + let base = p; + let step = 1; + if (stepMatch) { + base = stepMatch[1]; + step = parseInt(stepMatch[2], 10); + } + if (base === "*") { + for (let i = min; i <= max; i += step) out.add(i); + } else if (base.includes("-")) { + const [a, b] = base.split("-").map(Number); + for (let i = a; i <= b; i += step) out.add(i); + } else { + const n = parseInt(base, 10); + if (!Number.isNaN(n) && n >= min && n <= max) out.add(n); + } + } + return [...out].sort((a, b) => a - b); +} + +function timesFromFields(minuteField: string, hourField: string): string[] { + const minutes = parseCronField(minuteField, 0, 59); + const hours = parseCronField(hourField, 0, 23); + const out: string[] = []; + for (const h of hours) for (const mi of minutes) out.push(`${pad(h)}:${pad(mi)}`); + return out.length ? out.slice(0, 24) : ["09:00"]; +} + +function parseCronToEditorState(cron: string): EditorState { + if (!cron || !cron.trim()) return { ...DEFAULT_STATE }; + + const fields = cron.trim().split(/\s+/); + if (fields.length !== 5) return { ...DEFAULT_STATE, preset: "custom", custom: cron }; + const [m, h, dom, mon, dow] = fields; + + // validate each field is parseable + try { + parseCronField(m, 0, 59); + parseCronField(h, 0, 23); + parseCronField(dom, 1, 31); + parseCronField(mon, 1, 12); + parseCronField(dow.replace(/7/g, "0"), 0, 6); + } catch { + return { ...DEFAULT_STATE, preset: "custom", custom: cron }; + } + + // non-wildcard month → custom + if (mon !== "*") return { ...DEFAULT_STATE, preset: "custom", custom: cron }; + + // every minute + if (cron.trim() === "* * * * *") return { ...DEFAULT_STATE, preset: "every_minute" }; + + // every N minutes (optionally windowed / weekdays-only) + const minuteStep = m.match(/^\*\/(\d+)$/); + const hourRange = h.match(/^(\d+)-(\d+)$/); + if (minuteStep && dom === "*") { + const n = parseInt(minuteStep[1], 10); + const state = { ...DEFAULT_STATE, preset: "every_n_minutes" as EditorPreset, n }; + if (h !== "*") { + if (hourRange) { + state.windowEnabled = true; + state.windowStart = +hourRange[1]; + state.windowEnd = +hourRange[2]; + } else { + // unsupported hour field for this preset → custom + return { ...DEFAULT_STATE, preset: "custom", custom: cron }; + } + } + if (dow === "1-5") state.weekdaysOnly = true; + else if (dow !== "*") return { ...DEFAULT_STATE, preset: "custom", custom: cron }; + return state; + } + + // hourly: single minute, hour=* + if (/^\d+$/.test(m) && h === "*" && dom === "*" && dow === "*") { + return { ...DEFAULT_STATE, preset: "hourly", minutePast: parseInt(m, 10) }; + } + + // every N hours + const hourStep = h.match(/^\*\/(\d+)$/); + if (/^\d+$/.test(m) && hourStep && dom === "*") { + const state = { + ...DEFAULT_STATE, + preset: "every_n_hours" as EditorPreset, + n: parseInt(hourStep[1], 10), + minutePast: parseInt(m, 10), + }; + if (dow === "1-5") state.weekdaysOnly = true; + else if (dow !== "*") return { ...DEFAULT_STATE, preset: "custom", custom: cron }; + return state; + } + + // monthly: specific dom (may be multi), dow = * + if (dom !== "*" && dow === "*") { + const domDays = parseCronField(dom, 1, 31); + if (domDays.length === 0) return { ...DEFAULT_STATE, preset: "custom", custom: cron }; + return { + ...DEFAULT_STATE, + preset: "monthly", + domDays, + times: timesFromFields(m, h), + }; + } + + // weekdays (any subset of days) + if (dom === "*" && dow !== "*") { + const days = parseCronField(dow.replace(/7/g, "0"), 0, 6); + if (days.length === 0) return { ...DEFAULT_STATE, preset: "custom", custom: cron }; + return { + ...DEFAULT_STATE, + preset: "weekdays", + days, + times: timesFromFields(m, h), + }; + } + + // daily (any time(s)) + if (dom === "*" && dow === "*") { + return { + ...DEFAULT_STATE, + preset: "daily", + times: timesFromFields(m, h), + }; + } + + return { ...DEFAULT_STATE, preset: "custom", custom: cron }; +} + +function buildCronFromState(s: EditorState): string { + const fmt = (arr: number[] | string): string => { + if (typeof arr === "string") return arr; + if (arr.length === 0) return "*"; + return arr.join(","); + }; + switch (s.preset) { case "every_minute": return "* * * * *"; - case "every_hour": - return `${minute} * * * *`; - case "every_day": - return `${minute} ${hour} * * *`; - case "weekdays": - return `${minute} ${hour} * * 1-5`; - case "weekly": - return `${minute} ${hour} * * ${dayOfWeek}`; - case "monthly": - return `${minute} ${hour} ${dayOfMonth} * *`; - case "custom": - return ""; - } -} - -function describeSchedule(cron: string): string { - const { preset, hour, minute, dayOfWeek, dayOfMonth } = parseCronToPreset(cron); - const hourLabel = HOURS.find((h) => h.value === hour)?.label ?? `${hour}`; - const timeStr = `${hourLabel.replace(/ (AM|PM)$/, "")}:${minute.padStart(2, "0")} ${hourLabel.match(/(AM|PM)$/)?.[0] ?? ""}`; - - switch (preset) { - case "every_minute": - return "Every minute"; - case "every_hour": - return `Every hour at :${minute.padStart(2, "0")}`; - case "every_day": - return `Every day at ${timeStr}`; - case "weekdays": - return `Weekdays at ${timeStr}`; - case "weekly": { - const day = DAYS_OF_WEEK.find((d) => d.value === dayOfWeek)?.label ?? dayOfWeek; - return `Every ${day} at ${timeStr}`; + case "every_n_minutes": { + const hourField = s.windowEnabled ? `${s.windowStart}-${s.windowEnd}` : "*"; + const dowField = s.weekdaysOnly ? "1-5" : "*"; + return `*/${s.n} ${hourField} * * ${dowField}`; + } + case "hourly": + return `${s.minutePast} * * * *`; + case "every_n_hours": { + const dowField = s.weekdaysOnly ? "1-5" : "*"; + return `${s.minutePast} */${s.n} * * ${dowField}`; + } + case "daily": { + const minutes = [...new Set(s.times.map((t) => +t.split(":")[1]))].sort((a, b) => a - b); + const hours = [...new Set(s.times.map((t) => +t.split(":")[0]))].sort((a, b) => a - b); + return `${fmt(minutes)} ${fmt(hours)} * * *`; + } + case "weekdays": { + const minutes = [...new Set(s.times.map((t) => +t.split(":")[1]))].sort((a, b) => a - b); + const hours = [...new Set(s.times.map((t) => +t.split(":")[0]))].sort((a, b) => a - b); + const days = s.days.length === 0 ? "*" : s.days.slice().sort((a, b) => a - b).join(","); + return `${fmt(minutes)} ${fmt(hours)} * * ${days}`; + } + case "monthly": { + const minutes = [...new Set(s.times.map((t) => +t.split(":")[1]))].sort((a, b) => a - b); + const hours = [...new Set(s.times.map((t) => +t.split(":")[0]))].sort((a, b) => a - b); + const doms = s.domDays.length === 0 ? [1] : s.domDays.slice().sort((a, b) => a - b); + return `${fmt(minutes)} ${fmt(hours)} ${fmt(doms)} * *`; } - case "monthly": - return `Monthly on the ${dayOfMonth}${ordinalSuffix(Number(dayOfMonth))} at ${timeStr}`; case "custom": - return cron || "No schedule set"; + return s.custom; } } -function ordinalSuffix(n: number): string { - const s = ["th", "st", "nd", "rd"]; - const v = n % 100; - return s[(v - 20) % 10] || s[v] || s[0]; +// --------------------------------------------------------------------------- +// Rich describer that handles multi-value fields +// --------------------------------------------------------------------------- + +/** + * Produce a human-readable description of a cron expression. Handles + * multi-value time and day fields (e.g. `0 9,13,17 * * 1-5` becomes + * "Every weekday at 09:00, 13:00 and 17:00"). Falls back to the raw cron + * expression when it can't confidently describe the schedule. + */ +export function describeSchedule(cron: string): string { + if (!cron || !cron.trim()) return "Every day at 10:00 AM"; + const fields = cron.trim().split(/\s+/); + if (fields.length !== 5) return cron; + const [m, h, dom, mon, dow] = fields; + + let minutes: number[], hours: number[], daysOfWeek: number[], daysOfMonth: number[]; + try { + minutes = parseCronField(m, 0, 59); + hours = parseCronField(h, 0, 23); + daysOfMonth = parseCronField(dom, 1, 31); + parseCronField(mon, 1, 12); + daysOfWeek = parseCronField(dow.replace(/7/g, "0"), 0, 6); + } catch { + return cron; + } + // `parseCronField` silently drops tokens it can't parse (e.g. "MON"), so an + // otherwise-valid shape with a named token produces an empty set. Treat any + // empty field as unrepresentable and hand the raw cron back to the caller. + if ( + minutes.length === 0 || + hours.length === 0 || + daysOfMonth.length === 0 || + daysOfWeek.length === 0 + ) { + return cron; + } + if (mon !== "*") return cron; + + if (minutes.length === 60 && hours.length === 24) return "Every minute"; + + const minStep = m.match(/^\*\/(\d+)$/); + if (minStep && h === "*" && dom === "*" && dow === "*") { + return `Every ${minStep[1]} minutes`; + } + if (minStep && h === "*" && dom === "*" && dow === "1-5") { + return `Every ${minStep[1]} minutes, weekdays`; + } + const hourRange = h.match(/^(\d+)-(\d+)$/); + if (minStep && hourRange && dom === "*") { + const dayPart = dow === "1-5" ? "weekdays" : dow === "*" ? "every day" : "selected days"; + return `Every ${minStep[1]} minutes between ${pad(+hourRange[1])}:00 and ${pad(+hourRange[2])}:00, ${dayPart}`; + } + if (minutes.length === 1 && h === "*" && dom === "*" && dow === "*") { + return `Every hour at :${pad(minutes[0])}`; + } + const hourStep = h.match(/^\*\/(\d+)$/); + if (minutes.length === 1 && hourStep && dom === "*") { + const dayPart = dow === "1-5" ? ", weekdays" : ""; + return `Every ${hourStep[1]} hours at :${pad(minutes[0])}${dayPart}`; + } + + // day phrase + let dayPart = ""; + if (dom === "*" && dow === "*") dayPart = "every day"; + else if (dom === "*" && dow === "1-5") dayPart = "every weekday"; + else if (dom === "*" && (dow === "0,6" || dow === "6,0")) dayPart = "every weekend"; + else if (dom === "*") { + if (daysOfWeek.length === 1) dayPart = `every ${DAY_NAMES_LONG[daysOfWeek[0]]}`; + else dayPart = `every ${daysOfWeek.map((d) => DAY_NAMES_SHORT[d]).join(", ")}`; + } else if (dow === "*") { + if (daysOfMonth.length === 1) dayPart = `on the ${ordinal(daysOfMonth[0])} of the month`; + else dayPart = `on the ${daysOfMonth.map(ordinal).join(", ")} of the month`; + } else { + return cron; + } + + // time phrase + const timeStrs: string[] = []; + for (const hh of hours) for (const mi of minutes) timeStrs.push(`${pad(hh)}:${pad(mi)}`); + let timePart = ""; + if (timeStrs.length === 1) timePart = `at ${timeStrs[0]}`; + else if (timeStrs.length <= 4) { + timePart = `at ${timeStrs.slice(0, -1).join(", ")} and ${timeStrs[timeStrs.length - 1]}`; + } else timePart = `${timeStrs.length} times per day`; + + const sentence = `${dayPart} ${timePart}`.replace(/\s+/g, " ").trim(); + return sentence.charAt(0).toUpperCase() + sentence.slice(1); } -export { describeSchedule, parseCronToPreset }; +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +const PRESET_OPTIONS: { value: EditorPreset; label: string }[] = [ + { value: "every_minute", label: "Every minute" }, + { value: "every_n_minutes", label: "Every N minutes" }, + { value: "hourly", label: "Hourly" }, + { value: "every_n_hours", label: "Every N hours" }, + { value: "daily", label: "Daily — at one or more times" }, + { value: "weekdays", label: "On selected days of the week" }, + { value: "monthly", label: "Monthly — on selected dates" }, + { value: "custom", label: "Custom (cron expression)" }, +]; + +function TimeList({ + times, + onChange, +}: { + times: string[]; + onChange: (next: string[]) => void; +}) { + return ( +
+ {times.map((t, i) => ( +
+ { + const next = times.slice(); + next[i] = e.target.value || "00:00"; + onChange(next); + }} + /> + +
+ ))} + +
+ ); +} + +function DayOfWeekPicker({ + days, + onChange, +}: { + days: number[]; + onChange: (next: number[]) => void; +}) { + const letters = ["S", "M", "T", "W", "T", "F", "S"]; + return ( +
+ {letters.map((l, i) => { + const active = days.includes(i); + return ( + + ); + })} +
+ ); +} + +function DayOfMonthPicker({ + domDays, + onChange, +}: { + domDays: number[]; + onChange: (next: number[]) => void; +}) { + return ( +
+ {Array.from({ length: 31 }, (_, i) => i + 1).map((d) => { + const active = domDays.includes(d); + return ( + + ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// ScheduleEditor component (rich) +// --------------------------------------------------------------------------- export function ScheduleEditor({ value, @@ -190,68 +579,232 @@ export function ScheduleEditor({ value: string; onChange: (cron: string) => void; }) { - const parsed = useMemo(() => parseCronToPreset(value), [value]); - const [preset, setPreset] = useState(parsed.preset); - const [hour, setHour] = useState(parsed.hour); - const [minute, setMinute] = useState(parsed.minute); - const [dayOfWeek, setDayOfWeek] = useState(parsed.dayOfWeek); - const [dayOfMonth, setDayOfMonth] = useState(parsed.dayOfMonth); - const [customCron, setCustomCron] = useState(preset === "custom" ? value : ""); + const [state, setState] = useState(() => parseCronToEditorState(value)); - // Sync from external value changes + // Sync when external value changes and isn't the same cron we just emitted. useEffect(() => { - const p = parseCronToPreset(value); - setPreset(p.preset); - setHour(p.hour); - setMinute(p.minute); - setDayOfWeek(p.dayOfWeek); - setDayOfMonth(p.dayOfMonth); - if (p.preset === "custom") setCustomCron(value); + const currentCron = buildCronFromState(state); + if (currentCron !== value) { + setState(parseCronToEditorState(value)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); - const emitChange = useCallback( - (p: SchedulePreset, h: string, m: string, dow: string, dom: string, custom: string) => { - if (p === "custom") { - onChange(custom); - } else { - onChange(buildCron(p, h, m, dow, dom)); - } + const emitState = useCallback( + (next: EditorState) => { + setState(next); + onChange(buildCronFromState(next)); }, [onChange], ); - const handlePresetChange = (newPreset: SchedulePreset) => { - setPreset(newPreset); - if (newPreset === "custom") { - setCustomCron(value); - } else { - emitChange(newPreset, hour, minute, dayOfWeek, dayOfMonth, customCron); - } - }; + const update = useCallback( + (patch: Pick | Partial) => { + emitState({ ...state, ...patch }); + }, + [emitState, state], + ); + + const { preset } = state; return ( -
- +
+ {/* Preset */} +
+ + +
- {preset === "custom" ? ( + {preset === "every_minute" && ( +

+ No options — runs every minute, around the clock. +

+ )} + + {preset === "every_n_minutes" && ( +
+
+ +
+ update({ n: clamp(+e.target.value || 1, 1, 59) })} + /> + minutes +
+
+ {[1, 5, 10, 15, 20, 30].map((v) => ( + update({ n: v })} + > + {v} + + ))} +
+
+ +
+ )} + + {preset === "hourly" && (
+ { - setCustomCron(e.target.value); - emitChange("custom", hour, minute, dayOfWeek, dayOfMonth, e.target.value); - }} + type="number" + min={0} + max={59} + className="w-24 font-mono" + value={state.minutePast} + onChange={(e) => update({ minutePast: clamp(+e.target.value || 0, 0, 59) })} + /> +

+ Runs once an hour at :{pad(state.minutePast)} +

+
+ )} + + {preset === "every_n_hours" && ( +
+
+ +
+ update({ n: clamp(+e.target.value || 1, 1, 23) })} + /> + hours +
+
+ {[1, 2, 3, 4, 6, 8, 12].map((v) => ( + update({ n: v })} + > + {v} + + ))} +
+
+
+ + update({ minutePast: clamp(+e.target.value || 0, 0, 59) })} + /> +
+ +
+ )} + + {preset === "daily" && ( +
+ + update({ times })} /> +
+ )} + + {preset === "weekdays" && ( +
+
+ + update({ days })} /> +
+ {( + [ + ["Weekdays", [1, 2, 3, 4, 5]], + ["Weekends", [0, 6]], + ["All days", [0, 1, 2, 3, 4, 5, 6]], + ["Mon · Wed · Fri", [1, 3, 5]], + ["Tue · Thu", [2, 4]], + ] as const + ).map(([label, days]) => ( + update({ days: [...days] })} + > + {label} + + ))} +
+
+
+ + update({ times })} /> +
+
+ )} + + {preset === "monthly" && ( +
+
+ + update({ domDays })} /> +
+ {( + [ + ["1st only", [1]], + ["15th only", [15]], + ["1st & 15th", [1, 15]], + ["Last day (28th)", [28]], + ] as const + ).map(([label, days]) => ( + update({ domDays: [...days] })} + > + {label} + + ))} +
+

+ Days 29–31 are skipped in months that don't have them. +

+
+
+ + update({ times })} /> +
+
+ )} + + {preset === "custom" && ( +
+ + update({ custom: e.target.value })} placeholder="0 10 * * *" className="font-mono text-sm" /> @@ -259,123 +812,136 @@ export function ScheduleEditor({ Five fields: minute hour day-of-month month day-of-week

- ) : ( -
- {preset !== "every_minute" && preset !== "every_hour" && ( - <> - at - - : - - - )} - - {preset === "every_hour" && ( - <> - at minute - - - )} - - {preset === "weekly" && ( - <> - on -
- {DAYS_OF_WEEK.map((d) => ( - - ))} -
- - )} - - {preset === "monthly" && ( - <> - on day - - - )} -
)} + + + +
+
+ Summary — + {describeSchedule(buildCronFromState(state))} +
+ + {buildCronFromState(state)} + +
); } + +function WindowAndWeekdaysToggles({ + state, + update, +}: { + state: EditorState; + update: (patch: Partial) => void; +}) { + return ( + <> +
+ + {state.windowEnabled && ( +
+ + to + +
+ )} +
+ + + ); +} + +function WeekdaysOnlyToggle({ + state, + update, +}: { + state: EditorState; + update: (patch: Partial) => void; +}) { + return ( + + ); +} + +function changePreset(state: EditorState, next: EditorPreset): EditorState { + // Reset ambiguous sub-state when switching presets so we don't carry + // over a stale weekdaysOnly / windowEnabled from a sibling preset. + switch (next) { + case "every_minute": + return { ...state, preset: next }; + case "every_n_minutes": + return { ...state, preset: next, n: 15, windowEnabled: false, weekdaysOnly: false }; + case "hourly": + return { ...state, preset: next }; + case "every_n_hours": + return { ...state, preset: next, n: 2, weekdaysOnly: false }; + case "daily": + return { ...state, preset: next, times: state.times.length ? state.times : ["09:00"] }; + case "weekdays": + return { + ...state, + preset: next, + days: state.days.length ? state.days : [1, 2, 3, 4, 5], + times: state.times.length ? state.times : ["09:00"], + }; + case "monthly": + return { + ...state, + preset: next, + domDays: state.domDays.length ? state.domDays : [1], + times: state.times.length ? state.times : ["09:00"], + }; + case "custom": + return { ...state, preset: next, custom: state.custom || buildCronFromState(state) }; + } +} + +function clamp(v: number, lo: number, hi: number): number { + return Math.min(Math.max(v, lo), hi); +} diff --git a/ui/src/components/TriggerDialog.tsx b/ui/src/components/TriggerDialog.tsx new file mode 100644 index 0000000000..5bceadd33c --- /dev/null +++ b/ui/src/components/TriggerDialog.tsx @@ -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(["github_hmac", "none"]); +const signingModeDescriptions: Record = { + 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; + }) => 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(() => 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 = { + 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 = { + 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 ( + + + + {isEdit ? "Edit trigger" : "Add trigger"} + + Configure when and how this routine fires. + + + +
+
+ + setDraft((d) => ({ ...d, label: e.target.value }))} + /> +

+ Optional — shown in the trigger list. +

+
+ +
+ + + {isEdit && ( +

+ Kind can't be changed after creation. +

+ )} +
+ + {showScheduleFields && ( + setDraft((d) => ({ ...d, cronExpression }))} + /> + )} + + {showWebhookFields && ( +
+
+ + +

+ {signingModeDescriptions[draft.signingMode]} +

+
+ {!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && ( +
+ + + setDraft((d) => ({ ...d, replayWindowSec: e.target.value })) + } + /> +
+ )} +
+ )} +
+ + + {isEdit && ( + + )} + + + +
+
+ ); +} diff --git a/ui/src/components/TriggerListCard.tsx b/ui/src/components/TriggerListCard.tsx new file mode 100644 index 0000000000..606faebdef --- /dev/null +++ b/ui/src/components/TriggerListCard.tsx @@ -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 ( +
+
+
+ +
+ +
+
+ + {trigger.label || (isSchedule ? "Schedule" : isWebhook ? "Webhook" : "Trigger")} + + + {trigger.kind} + + {!trigger.enabled && ( + + paused + + )} +
+
{summary}
+ {isSchedule && trigger.cronExpression && ( +
+ {trigger.cronExpression} + {trigger.timezone ? ` · ${trigger.timezone}` : ""} +
+ )} + +
+
+
Next run
+
{nextRun}
+
+
+
Last fired
+
{lastFired}
+
+
+
Last result
+
+ {trigger.lastResult ? ( + + {trigger.lastResult} + + ) : ( + — + )} +
+
+
+
+ +
+ +
+ {isWebhook && onRotateSecret && ( + + )} + + +
+
+
+
+ ); +} diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index 6356b4b998..f9501b8c01 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -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 = { coalesce_if_active: "Keep one follow-up run queued while an active run is still working.", @@ -70,13 +66,6 @@ const catchUpPolicyDescriptions: Record = { 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 = { - 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) => 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 ( -
-
-
- {trigger.kind === "schedule" ? : trigger.kind === "webhook" ? : } - {trigger.label ?? trigger.kind} -
- - {trigger.kind === "schedule" && trigger.nextRunAt - ? `Next: ${new Date(trigger.nextRunAt).toLocaleString()}` - : trigger.kind === "webhook" - ? "Webhook" - : "API"} - -
- -
-
- - setDraft((current) => ({ ...current, label: event.target.value }))} - /> -
- {trigger.kind === "schedule" && ( -
- - setDraft((current) => ({ ...current, cronExpression }))} - /> -
- )} - {trigger.kind === "webhook" && ( - <> -
- - -
- {!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && ( -
- - setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))} - /> -
- )} - - )} -
- -
- {trigger.lastResult && Last: {trigger.lastResult}} -
- {trigger.kind === "webhook" && ( - - )} - - -
-
-
- ); -} - export function RoutineDetail() { const { routineId } = useParams<{ routineId: string }>(); const { selectedCompanyId } = useCompany(); @@ -277,12 +144,10 @@ export function RoutineDetail() { const [secretMessage, setSecretMessage] = useState(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(null); + const [triggerPendingDelete, setTriggerPendingDelete] = useState(null); + const [togglingTriggerId, setTogglingTriggerId] = useState(null); const [editDraft, setEditDraft] = useState<{ title: string; description: string; @@ -504,24 +369,23 @@ export function RoutineDetail() { }); const createTrigger = useMutation({ - mutationFn: async (): Promise => { - 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): Promise => { + // 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() { - {/* Add trigger form */} -
-

Add trigger

-
-
- - -
- {newTrigger.kind === "schedule" && ( -
- - setNewTrigger((current) => ({ ...current, cronExpression }))} - /> -
- )} - {newTrigger.kind === "webhook" && ( - <> -
- - -

{signingModeDescriptions[newTrigger.signingMode]}

-
- {!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(newTrigger.signingMode) && ( -
- - setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} /> -
- )} - - )} -
-
- +
+
+

Triggers

+

+ Schedules and webhooks that fire this routine. +

+
- {/* Existing triggers */} {routine.triggers.length === 0 ? ( -

No triggers configured yet.

+
+

No triggers yet

+

+ Triggers fire this routine on a schedule or via webhook. +

+ +
) : (
{routine.triggers.map((trigger) => ( - 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} /> ))}
@@ -1122,6 +976,43 @@ export function RoutineDetail() { isPending={runRoutine.isPending} onSubmit={(data) => runRoutine.mutate(data)} /> + + { + 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); + } + }} + /> + + { + 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); + }} + />
); }