diff --git a/desktop/src/features/workflows/ui/CronExpressionInput.tsx b/desktop/src/features/workflows/ui/CronExpressionInput.tsx new file mode 100644 index 000000000..5d5f6bfdf --- /dev/null +++ b/desktop/src/features/workflows/ui/CronExpressionInput.tsx @@ -0,0 +1,169 @@ +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { + CRON_FIELD_DEFINITIONS, + cronExpressionFromFields, + cronFieldsFromExpression, + cronFieldsFromPaste, + normalizeCronExpression, + validateCronFields, +} from "./cronExpression"; +import type { CronFields } from "./cronExpression"; + +export function CronExpressionInput({ + disabled, + onChange, + value, +}: { + disabled?: boolean; + onChange: (value: string) => void; + value: string; +}) { + const [fields, setFields] = React.useState(() => + cronFieldsFromExpression(value), + ); + const [pasteError, setPasteError] = React.useState(null); + const inputRefs = React.useRef>([]); + const localValue = React.useRef(normalizeCronExpression(value)); + const validationErrors = validateCronFields(fields); + const firstError = pasteError ?? validationErrors.find(Boolean) ?? null; + const messageId = "wf-trigger-cron-message"; + + React.useEffect(() => { + const nextValue = normalizeCronExpression(value); + if (nextValue !== localValue.current) { + setFields(cronFieldsFromExpression(value)); + localValue.current = nextValue; + setPasteError(null); + } + }, [value]); + + const commitFields = (nextFields: CronFields) => { + const expression = cronExpressionFromFields(nextFields); + setFields(nextFields); + setPasteError(null); + localValue.current = normalizeCronExpression(expression); + onChange(expression); + }; + + const focusField = (index: number) => { + inputRefs.current[index]?.focus(); + inputRefs.current[index]?.select(); + }; + + return ( +
+ + Cron expression + +
+
+ {CRON_FIELD_DEFINITIONS.map((definition, index) => ( + + ))} +
+
+ {CRON_FIELD_DEFINITIONS.map((definition, index) => ( + { + const nextFields = [...fields] as CronFields; + nextFields[index] = event.target.value.replace(/\s/g, ""); + commitFields(nextFields); + }} + onKeyDown={(event) => { + const input = event.currentTarget; + if (event.key === " " && index < fields.length - 1) { + event.preventDefault(); + focusField(index + 1); + } else if ( + event.key === "Backspace" && + !input.value && + index > 0 + ) { + event.preventDefault(); + focusField(index - 1); + } else if ( + event.key === "ArrowLeft" && + input.selectionStart === 0 && + index > 0 + ) { + event.preventDefault(); + focusField(index - 1); + } else if ( + event.key === "ArrowRight" && + input.selectionStart === input.value.length && + index < fields.length - 1 + ) { + event.preventDefault(); + focusField(index + 1); + } + }} + onPaste={(event) => { + const pastedValue = + event.clipboardData.getData("text/plain") || + event.clipboardData.getData("text"); + if (!/\s/.test(pastedValue.trim())) return; + + event.preventDefault(); + const result = cronFieldsFromPaste(pastedValue); + if (!result.ok) { + setPasteError(result.error); + return; + } + commitFields(result.fields); + }} + placeholder="*" + ref={(element) => { + inputRefs.current[index] = element; + }} + spellCheck={false} + value={fields[index]} + /> + ))} +
+
+

+ {firstError ?? + "UTC · Paste all 5 fields, or use wildcards, lists, ranges, and steps."} +

+
+ ); +} diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index 2ca2f4f59..f8738e763 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -14,6 +14,7 @@ import { import { Input } from "@/shared/ui/input"; import { Switch } from "@/shared/ui/switch"; import { Textarea } from "@/shared/ui/textarea"; +import { WorkflowScheduleFields } from "./WorkflowScheduleFields"; import { WorkflowStepCard } from "./WorkflowStepCard"; import { FieldLabel } from "./workflowFormPrimitives"; import { @@ -32,11 +33,14 @@ import type { TriggerConfig, WorkflowFormState, } from "./workflowFormTypes"; +import { defaultScheduleTrigger } from "./workflowSchedule"; function TriggerConfigFields({ + disabled, trigger, onUpdate, }: { + disabled?: boolean; trigger: TriggerConfig; onUpdate: (trigger: TriggerConfig) => void; }) { @@ -87,39 +91,11 @@ function TriggerConfigFields({ ); case "schedule": return ( -
-
- - Cron expression (optional) - - - onUpdate({ ...trigger, cron: event.target.value }) - } - placeholder="e.g. 0 9 * * 1-5 (weekdays at 9am UTC)" - value={trigger.cron ?? ""} - /> -
-
- - Interval (optional) - - - onUpdate({ ...trigger, interval: event.target.value }) - } - placeholder="e.g. 1h, 30m" - value={trigger.interval ?? ""} - /> -
-

- Use either cron or interval. -

-
+ ); default: return null; @@ -587,7 +563,10 @@ export function WorkflowFormBuilder({ onChange={(triggerType) => updateFormState({ ...formState, - trigger: { on: triggerType }, + trigger: + triggerType === "schedule" + ? defaultScheduleTrigger() + : { on: triggerType }, }) } options={TRIGGER_TYPES} @@ -663,6 +642,7 @@ export function WorkflowFormBuilder({ {selectedNode.type === "trigger" ? (
updateFormState({ ...formState, trigger }) } diff --git a/desktop/src/features/workflows/ui/WorkflowScheduleFields.tsx b/desktop/src/features/workflows/ui/WorkflowScheduleFields.tsx new file mode 100644 index 000000000..bd25daaa9 --- /dev/null +++ b/desktop/src/features/workflows/ui/WorkflowScheduleFields.tsx @@ -0,0 +1,278 @@ +import * as React from "react"; +import { AlertTriangle } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { Input } from "@/shared/ui/input"; +import { CronExpressionInput } from "./CronExpressionInput"; +import { FieldLabel, FormSelect } from "./workflowFormPrimitives"; +import type { TriggerConfig } from "./workflowFormTypes"; +import { + SCHEDULE_FREQUENCIES, + SCHEDULE_FREQUENCY_LABELS, + scheduleFormFromTrigger, + scheduleTriggerFromForm, + scheduleWeekdaysFromCronField, +} from "./workflowSchedule"; +import type { ScheduleFormState } from "./workflowSchedule"; + +const WEEKDAYS = [ + ["0", "Sunday", "S"], + ["1", "Monday", "M"], + ["2", "Tuesday", "T"], + ["3", "Wednesday", "W"], + ["4", "Thursday", "T"], + ["5", "Friday", "F"], + ["6", "Saturday", "S"], +] as const; + +const MONTH_DAYS = Array.from({ length: 31 }, (_, index) => String(index + 1)); + +function monthlyDayWarning(monthDay: string): string | null { + switch (monthDay) { + case "29": + return "Day 29 does not occur in February every year, so this schedule will occasionally skip a month."; + case "30": + return "Day 30 does not occur in February, so this schedule will skip February."; + case "31": + return "Day 31 does not occur in every month, so this schedule will skip shorter months."; + default: + return null; + } +} + +function customCronSeed(schedule: ScheduleFormState): string { + const currentTrigger = scheduleTriggerFromForm(schedule); + if (currentTrigger.cron) return currentTrigger.cron; + + switch (currentTrigger.interval) { + case "15m": + return "*/15 * * * *"; + case "30m": + return "*/30 * * * *"; + case "1h": + return "0 * * * *"; + default: + return ""; + } +} + +export function WorkflowScheduleFields({ + disabled, + onUpdate, + trigger, +}: { + disabled?: boolean; + onUpdate: (trigger: TriggerConfig) => void; + trigger: TriggerConfig; +}) { + const [forceCustomCron, setForceCustomCron] = React.useState(false); + const parsedSchedule = scheduleFormFromTrigger(trigger); + const schedule: ScheduleFormState = forceCustomCron + ? { + ...parsedSchedule, + customCron: trigger.cron ?? parsedSchedule.customCron, + frequency: "custom_cron", + } + : parsedSchedule; + const updateSchedule = (updates: Partial) => { + onUpdate(scheduleTriggerFromForm({ ...schedule, ...updates })); + }; + const usesTime = ["daily", "weekly", "monthly"].includes(schedule.frequency); + const selectedWeekdays = new Set( + scheduleWeekdaysFromCronField(schedule.weekday), + ); + const monthDayWarning = monthlyDayWarning(schedule.monthDay); + + return ( +
+
+
+

+ Repeats +

+
+
+ {SCHEDULE_FREQUENCIES.map((frequency) => { + const id = `wf-trigger-frequency-${frequency}`; + return ( +
+ { + const isCustom = frequency === "custom_cron"; + setForceCustomCron(isCustom); + updateSchedule({ + customCron: isCustom + ? customCronSeed(schedule) + : schedule.customCron, + frequency, + }); + }} + type="radio" + value={frequency} + /> + +
+ ); + })} +
+
+ + {schedule.frequency === "weekly" ? ( +
+ + Repeat on + +
+ {WEEKDAYS.map(([value, label, shortLabel]) => { + const id = `wf-trigger-weekday-${value}`; + return ( +
+ { + const nextWeekdays = new Set(selectedWeekdays); + if (nextWeekdays.has(value)) { + if (nextWeekdays.size === 1) return; + nextWeekdays.delete(value); + } else { + nextWeekdays.add(value); + } + updateSchedule({ + weekday: WEEKDAYS.map(([day]) => day) + .filter((day) => nextWeekdays.has(day)) + .join(","), + }); + }} + type="checkbox" + value={value} + /> + +
+ ); + })} +
+
+ ) : null} + + {schedule.frequency === "monthly" ? ( +
+ Day of month + updateSchedule({ monthDay })} + value={schedule.monthDay} + > + {MONTH_DAYS.map((day) => ( + + ))} + + {monthDayWarning ? ( +
+ +

+ {monthDayWarning} +

+
+ ) : null} +
+ ) : null} + + {usesTime ? ( +
+ Run time (UTC) + updateSchedule({ time: event.target.value })} + type="time" + value={schedule.time} + /> +
+ ) : null} + + {schedule.frequency === "custom_cron" ? ( + updateSchedule({ customCron })} + value={schedule.customCron} + /> + ) : null} + + {schedule.frequency === "custom_interval" ? ( +
+ + Existing interval + + + updateSchedule({ customInterval: event.target.value }) + } + placeholder="e.g. 2h" + value={schedule.customInterval} + /> +

+ Keep this legacy interval or choose a repeat option above. +

+
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/workflows/ui/cronExpression.test.mjs b/desktop/src/features/workflows/ui/cronExpression.test.mjs new file mode 100644 index 000000000..933b90ba6 --- /dev/null +++ b/desktop/src/features/workflows/ui/cronExpression.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CRON_FIELD_DEFINITIONS, + cronExpressionFromFields, + cronFieldsFromPaste, + validateCronField, + validateCronFields, +} from "./cronExpression.ts"; + +test("accepts common five-field cron syntax", () => { + for (const expression of [ + "0 9 * * 1-5", + "*/15 * * * *", + "0 */2 1,15 JAN,MAR MON-FRI", + ]) { + const result = cronFieldsFromPaste(expression); + assert.equal(result.ok, true); + assert.deepEqual(validateCronFields(result.fields), [ + null, + null, + null, + null, + null, + ]); + assert.equal(cronExpressionFromFields(result.fields), expression); + } +}); + +test("validates each cron field against its range", () => { + assert.equal( + validateCronField("60", CRON_FIELD_DEFINITIONS[0]), + "Minute must be between 0 and 59.", + ); + assert.equal( + validateCronField("5-2", CRON_FIELD_DEFINITIONS[2]), + "Day range must go from lower to higher.", + ); + assert.equal( + validateCronField("*/0", CRON_FIELD_DEFINITIONS[1]), + "Hour step must be a positive whole number.", + ); +}); + +test("whole-expression paste requires exactly five fields", () => { + assert.deepEqual(cronFieldsFromPaste("0 9 * *"), { + error: "Paste a 5-field cron expression. Found 4 fields.", + ok: false, + }); +}); diff --git a/desktop/src/features/workflows/ui/cronExpression.ts b/desktop/src/features/workflows/ui/cronExpression.ts new file mode 100644 index 000000000..5ac6c95b2 --- /dev/null +++ b/desktop/src/features/workflows/ui/cronExpression.ts @@ -0,0 +1,150 @@ +export const CRON_FIELD_DEFINITIONS = [ + { label: "Minute", max: 59, min: 0 }, + { label: "Hour", max: 23, min: 0 }, + { label: "Day", max: 31, min: 1 }, + { + aliases: [ + "JAN", + "FEB", + "MAR", + "APR", + "MAY", + "JUN", + "JUL", + "AUG", + "SEP", + "OCT", + "NOV", + "DEC", + ], + label: "Month", + max: 12, + min: 1, + }, + { + aliases: ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"], + label: "Weekday", + max: 7, + min: 0, + }, +] as const; + +export type CronFields = [string, string, string, string, string]; + +export function cronFieldsFromExpression(expression: string): CronFields { + const values = expression.trim() ? expression.trim().split(/\s+/) : []; + return [ + values[0] ?? "", + values[1] ?? "", + values[2] ?? "", + values[3] ?? "", + values[4] ?? "", + ]; +} + +export function cronExpressionFromFields(fields: CronFields): string { + return fields.join(" "); +} + +export function normalizeCronExpression(expression: string): string { + return expression.trim().replace(/\s+/g, " "); +} + +export function cronFieldsFromPaste( + pastedValue: string, +): { fields: CronFields; ok: true } | { error: string; ok: false } { + const values = pastedValue.trim().split(/\s+/); + if (values.length !== CRON_FIELD_DEFINITIONS.length) { + return { + error: `Paste a 5-field cron expression. Found ${values.length} field${values.length === 1 ? "" : "s"}.`, + ok: false, + }; + } + return { fields: values as CronFields, ok: true }; +} + +type CronFieldDefinition = (typeof CRON_FIELD_DEFINITIONS)[number]; + +function atomError( + atom: string, + definition: CronFieldDefinition, +): string | null { + const upperAtom = atom.toUpperCase(); + if ( + "aliases" in definition && + definition.aliases.includes(upperAtom as never) + ) { + return null; + } + if (!/^\d+$/.test(atom)) { + return `${definition.label} contains “${atom}”, which is not a supported value.`; + } + + const value = Number(atom); + if (value < definition.min || value > definition.max) { + return `${definition.label} must be between ${definition.min} and ${definition.max}.`; + } + return null; +} + +function segmentError( + segment: string, + definition: CronFieldDefinition, +): string | null { + const stepParts = segment.split("/"); + if (stepParts.length > 2 || stepParts.some((part) => !part)) { + return `${definition.label} has an invalid step.`; + } + + const [base, step] = stepParts; + if (step !== undefined) { + if (!/^\d+$/.test(step) || Number(step) < 1) { + return `${definition.label} step must be a positive whole number.`; + } + } + + if (base === "*") return null; + + const rangeParts = base.split("-"); + if (rangeParts.length > 2 || rangeParts.some((part) => !part)) { + return `${definition.label} has an invalid range.`; + } + + const startError = atomError(rangeParts[0], definition); + if (startError) return startError; + if (rangeParts.length === 1) return null; + + const endError = atomError(rangeParts[1], definition); + if (endError) return endError; + + const start = Number(rangeParts[0]); + const end = Number(rangeParts[1]); + if (Number.isFinite(start) && Number.isFinite(end) && start > end) { + return `${definition.label} range must go from lower to higher.`; + } + return null; +} + +export function validateCronField( + value: string, + definition: CronFieldDefinition, +): string | null { + if (!value) return `${definition.label} is required.`; + + const segments = value.split(","); + if (segments.some((segment) => !segment)) { + return `${definition.label} has an empty list item.`; + } + + for (const segment of segments) { + const error = segmentError(segment, definition); + if (error) return error; + } + return null; +} + +export function validateCronFields(fields: CronFields): Array { + return fields.map((field, index) => + validateCronField(field, CRON_FIELD_DEFINITIONS[index]), + ); +} diff --git a/desktop/src/features/workflows/ui/workflowSchedule.test.mjs b/desktop/src/features/workflows/ui/workflowSchedule.test.mjs new file mode 100644 index 000000000..5e71f7488 --- /dev/null +++ b/desktop/src/features/workflows/ui/workflowSchedule.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + defaultScheduleTrigger, + scheduleFormFromTrigger, + scheduleTriggerFromForm, + scheduleWeekdaysFromCronField, +} from "./workflowSchedule.ts"; + +test("new schedules default to daily at 09:00 UTC", () => { + assert.deepEqual(defaultScheduleTrigger(), { + on: "schedule", + cron: "0 9 * * *", + }); +}); + +test("recognizes common interval schedules", () => { + assert.equal( + scheduleFormFromTrigger({ on: "schedule", interval: "15m" }).frequency, + "every_15_minutes", + ); + assert.equal( + scheduleFormFromTrigger({ on: "schedule", interval: "30m" }).frequency, + "every_30_minutes", + ); + assert.equal( + scheduleFormFromTrigger({ on: "schedule", interval: "1h" }).frequency, + "hourly", + ); +}); + +test("round-trips the structured cron schedules", () => { + for (const cron of [ + "30 14 * * *", + "30 14 * * 1-5", + "30 14 * * 1,3,5", + "30 14 * * 4", + "30 14 23 * *", + ]) { + const form = scheduleFormFromTrigger({ on: "schedule", cron }); + assert.deepEqual(scheduleTriggerFromForm(form), { on: "schedule", cron }); + } +}); + +test("expands numeric weekday lists and ranges for the weekly picker", () => { + assert.deepEqual(scheduleWeekdaysFromCronField("1-5"), [ + "1", + "2", + "3", + "4", + "5", + ]); + assert.deepEqual(scheduleWeekdaysFromCronField("1,3,5"), ["1", "3", "5"]); + assert.deepEqual(scheduleWeekdaysFromCronField("MON-FRI"), []); +}); + +test("preserves advanced cron expressions in the custom field", () => { + const cron = "0 */2 * * 1,3,5"; + const form = scheduleFormFromTrigger({ on: "schedule", cron }); + + assert.equal(form.frequency, "custom_cron"); + assert.equal(form.customCron, cron); + assert.deepEqual(scheduleTriggerFromForm(form), { on: "schedule", cron }); +}); + +test("preserves legacy non-preset intervals", () => { + const form = scheduleFormFromTrigger({ on: "schedule", interval: "2h" }); + + assert.equal(form.frequency, "custom_interval"); + assert.equal(form.customInterval, "2h"); + assert.deepEqual(scheduleTriggerFromForm(form), { + on: "schedule", + interval: "2h", + }); +}); + +test("switching schedule modes never emits cron and interval together", () => { + const form = scheduleFormFromTrigger({ on: "schedule", interval: "30m" }); + const custom = scheduleTriggerFromForm({ + ...form, + customCron: "0 8 * * 6", + frequency: "custom_cron", + }); + + assert.deepEqual(custom, { on: "schedule", cron: "0 8 * * 6" }); + assert.equal("interval" in custom, false); +}); diff --git a/desktop/src/features/workflows/ui/workflowSchedule.ts b/desktop/src/features/workflows/ui/workflowSchedule.ts new file mode 100644 index 000000000..a2fd23998 --- /dev/null +++ b/desktop/src/features/workflows/ui/workflowSchedule.ts @@ -0,0 +1,178 @@ +import type { TriggerConfig } from "./workflowFormTypes"; + +export const SCHEDULE_FREQUENCIES = [ + "every_15_minutes", + "every_30_minutes", + "hourly", + "daily", + "weekly", + "monthly", + "custom_cron", +] as const; + +export type ScheduleFrequency = (typeof SCHEDULE_FREQUENCIES)[number]; +type ScheduleFormFrequency = ScheduleFrequency | "custom_interval"; + +export type ScheduleFormState = { + customCron: string; + customInterval: string; + frequency: ScheduleFormFrequency; + monthDay: string; + time: string; + weekday: string; +}; + +export const SCHEDULE_FREQUENCY_LABELS: Record = { + every_15_minutes: "Every 15 minutes", + every_30_minutes: "Every 30 minutes", + hourly: "Every hour", + daily: "Daily", + weekly: "Weekly", + monthly: "Monthly", + custom_cron: "Custom", +}; + +const INTERVAL_FREQUENCIES = { + "15m": "every_15_minutes", + "30m": "every_30_minutes", + "1h": "hourly", +} as const; + +const FREQUENCY_INTERVALS: Partial> = { + every_15_minutes: "15m", + every_30_minutes: "30m", + hourly: "1h", +}; + +const DEFAULT_TIME = "09:00"; +const DEFAULT_WEEKDAY = "1"; +const DEFAULT_MONTH_DAY = "1"; + +type ParsedCommonCron = { + frequency: "daily" | "weekly" | "monthly"; + monthDay?: string; + time: string; + weekday?: string; +}; + +export function scheduleWeekdaysFromCronField(field: string): string[] { + const weekdays = new Set(); + for (const segment of field.split(",")) { + if (/^[0-6]$/.test(segment)) { + weekdays.add(Number(segment)); + continue; + } + + const range = /^([0-6])-([0-6])$/.exec(segment); + if (!range || Number(range[1]) > Number(range[2])) return []; + for (let day = Number(range[1]); day <= Number(range[2]); day += 1) { + weekdays.add(day); + } + } + return [...weekdays].sort((left, right) => left - right).map(String); +} + +function parseCommonCron(cron: string): ParsedCommonCron | null { + const fields = cron.trim().split(/\s+/); + if (fields.length !== 5) return null; + + const [minute, hour, monthDay, month, weekday] = fields; + const parsedMinute = Number(minute); + const parsedHour = Number(hour); + if ( + !/^\d+$/.test(minute) || + !/^\d+$/.test(hour) || + parsedMinute < 0 || + parsedMinute > 59 || + parsedHour < 0 || + parsedHour > 23 || + month !== "*" + ) { + return null; + } + + const time = `${String(parsedHour).padStart(2, "0")}:${String(parsedMinute).padStart(2, "0")}`; + if (monthDay === "*" && weekday === "*") { + return { frequency: "daily", time }; + } + if (monthDay === "*" && scheduleWeekdaysFromCronField(weekday).length > 0) { + return { frequency: "weekly", time, weekday }; + } + if (weekday === "*" && /^(?:[1-9]|[12]\d|3[01])$/.test(monthDay)) { + return { frequency: "monthly", monthDay, time }; + } + return null; +} + +export function scheduleFormFromTrigger( + trigger: TriggerConfig, +): ScheduleFormState { + const interval = trigger.interval?.trim(); + if (interval) { + const frequency = + INTERVAL_FREQUENCIES[interval as keyof typeof INTERVAL_FREQUENCIES]; + return { + customCron: "", + customInterval: frequency ? "" : interval, + frequency: frequency ?? "custom_interval", + monthDay: DEFAULT_MONTH_DAY, + time: DEFAULT_TIME, + weekday: DEFAULT_WEEKDAY, + }; + } + + const cron = trigger.cron?.trim() ?? ""; + const commonCron = cron ? parseCommonCron(cron) : null; + return { + customCron: commonCron ? "" : cron, + customInterval: "", + frequency: commonCron?.frequency ?? (cron ? "custom_cron" : "daily"), + monthDay: commonCron?.monthDay ?? DEFAULT_MONTH_DAY, + time: commonCron?.time ?? DEFAULT_TIME, + weekday: commonCron?.weekday ?? DEFAULT_WEEKDAY, + }; +} + +function cronTrigger(cron: string): TriggerConfig { + return { on: "schedule", cron }; +} + +function intervalTrigger(interval: string): TriggerConfig { + return { on: "schedule", interval }; +} + +function cronTime(time: string): { hour: string; minute: string } { + const match = /^(\d{2}):(\d{2})$/.exec(time); + if (!match) return { hour: "9", minute: "0" }; + return { + hour: String(Number(match[1])), + minute: String(Number(match[2])), + }; +} + +export function scheduleTriggerFromForm( + form: ScheduleFormState, +): TriggerConfig { + const interval = FREQUENCY_INTERVALS[form.frequency as ScheduleFrequency]; + if (interval) return intervalTrigger(interval); + if (form.frequency === "custom_interval") { + return intervalTrigger(form.customInterval); + } + if (form.frequency === "custom_cron") { + return cronTrigger(form.customCron); + } + + const { hour, minute } = cronTime(form.time); + switch (form.frequency) { + case "weekly": + return cronTrigger(`${minute} ${hour} * * ${form.weekday}`); + case "monthly": + return cronTrigger(`${minute} ${hour} ${form.monthDay} * *`); + default: + return cronTrigger(`${minute} ${hour} * * *`); + } +} + +export function defaultScheduleTrigger(): TriggerConfig { + return scheduleTriggerFromForm(scheduleFormFromTrigger({ on: "schedule" })); +} diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 01d768e4c..630859e51 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -123,6 +123,89 @@ test("disables autocapitalization in the workflow form", async ({ page }) => { ); }); +test("configures common schedules and exposes custom cron", async ({ + page, +}) => { + await navigateToWorkflows(page); + + await page.getByRole("button", { name: "Create Workflow" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: /^Trigger:/ }).click(); + const inspector = dialog.getByTestId("workflow-node-inspector"); + await inspector.getByLabel("Trigger event").click(); + await page.getByRole("menuitem", { name: "Schedule" }).click(); + + const repeatOptions = inspector.getByRole("radio"); + await expect(repeatOptions).toHaveCount(7); + await expect(repeatOptions.first()).toHaveAccessibleName("Every 15 minutes"); + await expect(repeatOptions.last()).toHaveAccessibleName("Custom"); + await expect(inspector.getByRole("radio", { name: "Daily" })).toBeChecked(); + await expect(inspector.getByLabel("Run time (UTC)")).toHaveValue("09:00"); + + await inspector.getByText("Monthly", { exact: true }).click(); + await inspector.getByLabel("Day of month").selectOption("31"); + await expect( + inspector.getByText( + "Day 31 does not occur in every month, so this schedule will skip shorter months.", + ), + ).toBeVisible(); + await dialog.getByRole("tab", { name: "YAML" }).click(); + await expect(dialog.getByLabel("Workflow YAML")).toHaveValue( + /cron: 0 9 31 \* \*/, + ); + await dialog.getByRole("tab", { name: "Form" }).click(); + await dialog.getByRole("button", { name: /^Trigger:/ }).click(); + + await inspector.getByText("Weekly", { exact: true }).click(); + const weekdayPicker = inspector.getByRole("group", { name: "Repeat on" }); + await weekdayPicker.getByText("T", { exact: true }).nth(1).click(); + await expect( + weekdayPicker.getByRole("checkbox", { name: "Monday" }), + ).toBeChecked(); + await expect( + weekdayPicker.getByRole("checkbox", { name: "Thursday" }), + ).toBeChecked(); + await inspector.getByLabel("Run time (UTC)").fill("14:30"); + await dialog.getByRole("tab", { name: "YAML" }).click(); + await expect(dialog.getByLabel("Workflow YAML")).toHaveValue( + /cron: 30 14 \* \* 1,4/, + ); + await dialog.getByRole("tab", { name: "Form" }).click(); + await dialog.getByRole("button", { name: /^Trigger:/ }).click(); + await dialog.getByText("Custom", { exact: true }).click(); + const cronInput = inspector.getByRole("group", { name: "Cron expression" }); + const minuteField = cronInput.getByRole("textbox", { name: "Minute" }); + await expect(cronInput).toBeVisible(); + await expect(minuteField).toHaveValue("30"); + await expect(cronInput.getByRole("textbox", { name: "Hour" })).toHaveValue( + "14", + ); + await expect(cronInput.getByRole("textbox", { name: "Weekday" })).toHaveValue( + "1,4", + ); + + await minuteField.fill("60"); + await expect( + inspector.getByText("Minute must be between 0 and 59."), + ).toBeVisible(); + + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await page.evaluate(() => navigator.clipboard.writeText("0 */2 * * 1,3,5")); + await minuteField.press("ControlOrMeta+V"); + await expect(minuteField).toHaveValue("0"); + await expect(cronInput.getByRole("textbox", { name: "Hour" })).toHaveValue( + "*/2", + ); + await expect(cronInput.getByRole("textbox", { name: "Weekday" })).toHaveValue( + "1,3,5", + ); + + await dialog.getByRole("tab", { name: "YAML" }).click(); + await expect(dialog.getByLabel("Workflow YAML")).toHaveValue( + /cron: 0 \*\/2 \* \* 1,3,5/, + ); +}); + test("switches an empty workflow between form and YAML modes", async ({ page, }) => {