mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-25 15:17:26 +08:00
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:
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user