fix(triggers-ui): hydrate flows from backend + replace cron text input

Two usability fixes after first-pass review:

1. Flow selector was empty when /triggers was opened directly (without
   visiting the home page first). The page now calls
   useGetRefreshFlowsQuery({ get_all: true, remove_example_flows: true })
   which populates flowsManagerStore — the same hook the home page
   uses — so the dropdown lists real flows and the table can map
   flow_id → flow name in the same paint. The query is gated on
   flows === undefined to skip the round-trip when the store is
   already hydrated.

   The modal also fires the same hook (with enabled = open) as a
   defensive second source: if a future caller renders the modal
   outside of the triggers page, the selector still works.

2. The raw cron-string field was unusable for non-technical users.
   New CronScheduleField component exposes five modes:
     - Every N minutes (1, 2, 5, 10, 15, 20, 30)
     - Every N hours (1, 2, 3, 4, 6, 8, 12)
     - Daily at HH:MM (HTML time input)
     - Weekly on day-of-week at HH:MM
     - Custom (raw cron, for advanced users)
   It parses an incoming cron back into the closest mode (so editing
   an existing trigger preserves intent) and re-composes the cron
   on every change. A read-only preview shows the resulting cron
   string so the user always sees what is going to the backend.

No new dependency. Zero tsc errors on the new files; biome lint
clean on all 13 trigger UI files.
This commit is contained in:
Tarcio
2026-05-20 21:21:03 -03:00
parent c474bc112b
commit 266fe74116
4 changed files with 378 additions and 18 deletions

View File

@ -459,8 +459,26 @@
"triggers.field.flowPlaceholder": "Select a flow",
"triggers.field.name": "Name",
"triggers.field.namePlaceholder": "e.g. daily-summary",
"triggers.field.cron": "Cron expression",
"triggers.field.cron": "Schedule",
"triggers.field.cronHint": "Five-field POSIX cron, e.g. */5 * * * * (every 5 minutes).",
"triggers.cron.minutes": "Every N minutes",
"triggers.cron.hours": "Every N hours",
"triggers.cron.daily": "Daily at…",
"triggers.cron.weekly": "Weekly on…",
"triggers.cron.custom": "Custom (cron expression)",
"triggers.cron.everyLabel": "every",
"triggers.cron.atLabel": "at",
"triggers.cron.minutesUnit": "minutes",
"triggers.cron.hoursUnit": "hours",
"triggers.cron.customHint": "Advanced: 5-field POSIX cron. Validated by the backend.",
"triggers.cron.previewLabel": "Cron:",
"triggers.cron.day.sun": "Sunday",
"triggers.cron.day.mon": "Monday",
"triggers.cron.day.tue": "Tuesday",
"triggers.cron.day.wed": "Wednesday",
"triggers.cron.day.thu": "Thursday",
"triggers.cron.day.fri": "Friday",
"triggers.cron.day.sat": "Saturday",
"triggers.field.timezone": "Timezone",
"triggers.field.timezoneOther": "Other (IANA name)…",
"triggers.field.maxAttempts": "Max attempts",

View File

@ -0,0 +1,326 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// Friendly schedule modes. Each one composes into a 5-field POSIX cron
// expression that the backend's croniter accepts as-is. Users who need
// expressions outside these patterns switch to ``custom`` mode and
// type the cron string themselves.
type Mode = "minutes" | "hours" | "daily" | "weekly" | "custom";
const MINUTE_INTERVALS = [1, 2, 5, 10, 15, 20, 30] as const;
const HOUR_INTERVALS = [1, 2, 3, 4, 6, 8, 12] as const;
const DAYS = [
{ value: "0", labelKey: "triggers.cron.day.sun" },
{ value: "1", labelKey: "triggers.cron.day.mon" },
{ value: "2", labelKey: "triggers.cron.day.tue" },
{ value: "3", labelKey: "triggers.cron.day.wed" },
{ value: "4", labelKey: "triggers.cron.day.thu" },
{ value: "5", labelKey: "triggers.cron.day.fri" },
{ value: "6", labelKey: "triggers.cron.day.sat" },
] as const;
interface ParsedCron {
mode: Mode;
minutes: number;
hours: number;
daily: { hour: number; minute: number };
weekly: { day: string; hour: number; minute: number };
custom: string;
}
const DEFAULTS: ParsedCron = {
mode: "minutes",
minutes: 5,
hours: 1,
daily: { hour: 9, minute: 0 },
weekly: { day: "1", hour: 9, minute: 0 },
custom: "*/5 * * * *",
};
// Parse a cron string back into one of the friendly modes when
// possible. Anything that does not fit one of the recognized patterns
// falls back to ``custom`` so the original expression is preserved.
function parseCron(value: string): ParsedCron {
const next: ParsedCron = { ...DEFAULTS, custom: value || DEFAULTS.custom };
const parts = (value ?? "").trim().split(/\s+/);
if (parts.length !== 5) return next;
const [m, h, dom, mon, dow] = parts;
const everyN = (field: string) => {
const match = field.match(/^\*\/(\d+)$/);
return match ? Number.parseInt(match[1], 10) : null;
};
if (
h === "*" &&
dom === "*" &&
mon === "*" &&
dow === "*" &&
everyN(m) !== null
) {
return { ...next, mode: "minutes", minutes: everyN(m)! };
}
if (
m === "0" &&
dom === "*" &&
mon === "*" &&
dow === "*" &&
everyN(h) !== null
) {
return { ...next, mode: "hours", hours: everyN(h)! };
}
const numericMinute = m.match(/^\d{1,2}$/);
const numericHour = h.match(/^\d{1,2}$/);
if (numericMinute && numericHour && dom === "*" && mon === "*") {
if (dow === "*") {
return {
...next,
mode: "daily",
daily: {
hour: Number.parseInt(h, 10),
minute: Number.parseInt(m, 10),
},
};
}
if (dow.match(/^[0-6]$/)) {
return {
...next,
mode: "weekly",
weekly: {
day: dow,
hour: Number.parseInt(h, 10),
minute: Number.parseInt(m, 10),
},
};
}
}
return { ...next, mode: "custom" };
}
function composeCron(state: ParsedCron): string {
switch (state.mode) {
case "minutes":
return `*/${state.minutes} * * * *`;
case "hours":
return `0 */${state.hours} * * *`;
case "daily":
return `${state.daily.minute} ${state.daily.hour} * * *`;
case "weekly":
return `${state.weekly.minute} ${state.weekly.hour} * * ${state.weekly.day}`;
case "custom":
return state.custom;
}
}
interface CronScheduleFieldProps {
value: string;
onChange: (cron: string) => void;
error?: string;
}
export default function CronScheduleField({
value,
onChange,
error,
}: CronScheduleFieldProps) {
const { t } = useTranslation();
// ``value`` is the canonical cron string in the parent form. We
// mirror it into local state on mount and whenever the parent
// resets (e.g. switching between create and edit). Internal edits
// recompose the cron and bubble it up via ``onChange``.
const [state, setState] = useState<ParsedCron>(() => parseCron(value));
useEffect(() => {
setState(parseCron(value));
// We only want to re-sync when ``value`` changes from the parent's
// perspective, not on every local re-render that pushed a new
// string up — eslint will warn about onChange, ignore intentionally.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const composed = useMemo(() => composeCron(state), [state]);
// Push composed cron up whenever state changes.
useEffect(() => {
if (composed !== value) onChange(composed);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [composed]);
const update = (patch: Partial<ParsedCron>) =>
setState((prev) => ({ ...prev, ...patch }));
return (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-2">
<Select
value={state.mode}
onValueChange={(m) => update({ mode: m as Mode })}
>
<SelectTrigger data-testid="cron-mode-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">{t("triggers.cron.minutes")}</SelectItem>
<SelectItem value="hours">{t("triggers.cron.hours")}</SelectItem>
<SelectItem value="daily">{t("triggers.cron.daily")}</SelectItem>
<SelectItem value="weekly">{t("triggers.cron.weekly")}</SelectItem>
<SelectItem value="custom">{t("triggers.cron.custom")}</SelectItem>
</SelectContent>
</Select>
{state.mode === "minutes" && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{t("triggers.cron.everyLabel")}
</span>
<Select
value={String(state.minutes)}
onValueChange={(v) => update({ minutes: Number.parseInt(v, 10) })}
>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MINUTE_INTERVALS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">
{t("triggers.cron.minutesUnit")}
</span>
</div>
)}
{state.mode === "hours" && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{t("triggers.cron.everyLabel")}
</span>
<Select
value={String(state.hours)}
onValueChange={(v) => update({ hours: Number.parseInt(v, 10) })}
>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HOUR_INTERVALS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">
{t("triggers.cron.hoursUnit")}
</span>
</div>
)}
{state.mode === "daily" && (
<div className="flex items-center gap-2">
<Label className="text-sm text-muted-foreground" htmlFor="daily-time">
{t("triggers.cron.atLabel")}
</Label>
<Input
id="daily-time"
type="time"
className="w-[140px]"
value={`${String(state.daily.hour).padStart(2, "0")}:${String(
state.daily.minute,
).padStart(2, "0")}`}
onChange={(e) => {
const [h, m] = e.target.value.split(":");
update({
daily: {
hour: Number.parseInt(h, 10),
minute: Number.parseInt(m, 10),
},
});
}}
/>
</div>
)}
{state.mode === "weekly" && (
<div className="flex items-center gap-2">
<Select
value={state.weekly.day}
onValueChange={(day) =>
update({ weekly: { ...state.weekly, day } })
}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DAYS.map((d) => (
<SelectItem key={d.value} value={d.value}>
{t(d.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-sm text-muted-foreground" htmlFor="weekly-time">
{t("triggers.cron.atLabel")}
</Label>
<Input
id="weekly-time"
type="time"
className="w-[140px]"
value={`${String(state.weekly.hour).padStart(2, "0")}:${String(
state.weekly.minute,
).padStart(2, "0")}`}
onChange={(e) => {
const [h, m] = e.target.value.split(":");
update({
weekly: {
...state.weekly,
hour: Number.parseInt(h, 10),
minute: Number.parseInt(m, 10),
},
});
}}
/>
</div>
)}
{state.mode === "custom" && (
<div className="flex flex-col gap-1">
<Input
data-testid="cron-custom-input"
className="font-mono"
value={state.custom}
onChange={(e) => update({ custom: e.target.value })}
placeholder="*/5 * * * *"
/>
<span className="text-xs text-muted-foreground">
{t("triggers.cron.customHint")}
</span>
</div>
)}
</div>
<div className="flex items-center gap-2 rounded bg-muted/40 px-2 py-1.5">
<span className="text-xs text-muted-foreground">
{t("triggers.cron.previewLabel")}
</span>
<code className="text-xs">{composed}</code>
</div>
{error && <span className="text-xs text-destructive">{error}</span>}
</div>
);
}

View File

@ -22,6 +22,7 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useGetRefreshFlowsQuery } from "@/controllers/API/queries/flows/use-get-refresh-flows-query";
import {
usePatchTrigger,
usePostTrigger,
@ -29,6 +30,7 @@ import {
import useAlertStore from "@/stores/alertStore";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
import type { Trigger } from "../types";
import CronScheduleField from "./CronScheduleField";
// Curated list of common IANA timezones. The user can still type any
// IANA name via the "Other" option which switches to a free-text
@ -110,6 +112,15 @@ export default function TriggerFormModal({
const isEditing = existingTrigger !== null;
// Make sure the flow selector has data even if the user landed on
// /triggers without visiting the home page first. The query also
// hydrates the flowsManagerStore that the table uses to render
// flow names.
useGetRefreshFlowsQuery(
{ get_all: true, remove_example_flows: true },
{ enabled: open && (flows === undefined || flows.length === 0) },
);
const flowOptions = useMemo(
() =>
(flows ?? [])
@ -268,24 +279,18 @@ export default function TriggerFormModal({
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cron_expression">
{t("triggers.field.cron")}
</Label>
<Input
id="cron_expression"
data-testid="trigger-cron-input"
className="font-mono"
{...register("cron_expression")}
placeholder="*/5 * * * *"
<Label>{t("triggers.field.cron")}</Label>
<Controller
name="cron_expression"
control={control}
render={({ field }) => (
<CronScheduleField
value={field.value}
onChange={field.onChange}
error={errors.cron_expression?.message}
/>
)}
/>
<span className="text-xs text-muted-foreground">
{t("triggers.field.cronHint")}
</span>
{errors.cron_expression && (
<span className="text-xs text-destructive">
{errors.cron_expression.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">

View File

@ -3,7 +3,9 @@ import { useTranslation } from "react-i18next";
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { Button } from "@/components/ui/button";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { useGetRefreshFlowsQuery } from "@/controllers/API/queries/flows/use-get-refresh-flows-query";
import { useGetTriggers } from "@/controllers/API/queries/triggers";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
import TriggerFormModal from "./components/TriggerFormModal";
import TriggerJobsDrawer from "./components/TriggerJobsDrawer";
import TriggersTable from "./components/TriggersTable";
@ -15,6 +17,15 @@ export default function TriggersPage() {
const [editing, setEditing] = useState<Trigger | null>(null);
const [jobsTrigger, setJobsTrigger] = useState<Trigger | null>(null);
// Hydrate the flowsManagerStore when the user lands here directly,
// before visiting the home page. The hook is a no-op for repeat
// mounts because TanStack Query dedupes by key.
const flows = useFlowsManagerStore((s) => s.flows);
useGetRefreshFlowsQuery(
{ get_all: true, remove_example_flows: true },
{ enabled: flows === undefined },
);
const { data: triggers, isLoading } = useGetTriggers({});
const handleCreate = () => {