feat(desktop): improve workflow schedule picker

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-13 14:11:54 -07:00
parent a6e7e05b6e
commit 604b9b7390
8 changed files with 1011 additions and 34 deletions
@@ -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<CronFields>(() =>
cronFieldsFromExpression(value),
);
const [pasteError, setPasteError] = React.useState<string | null>(null);
const inputRefs = React.useRef<Array<HTMLInputElement | null>>([]);
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 (
<fieldset className="space-y-1.5">
<legend className="text-xs font-medium text-muted-foreground">
Cron expression
</legend>
<div aria-describedby={messageId} className="space-y-1">
<div className="grid grid-cols-5 gap-px px-px">
{CRON_FIELD_DEFINITIONS.map((definition, index) => (
<label
className={cn(
"min-w-0 truncate px-1 text-center text-2xs text-muted-foreground",
validationErrors[index] && "text-destructive",
)}
htmlFor={`wf-trigger-cron-${definition.label.toLowerCase()}`}
key={definition.label}
>
{definition.label}
</label>
))}
</div>
<div
className={cn(
"grid grid-cols-5 overflow-hidden rounded-lg border border-input/50 bg-background",
"focus-within:border-ring focus-within:ring-1 focus-within:ring-ring",
disabled && "opacity-50",
)}
>
{CRON_FIELD_DEFINITIONS.map((definition, index) => (
<input
aria-describedby={messageId}
aria-invalid={Boolean(validationErrors[index])}
aria-label={definition.label}
autoCapitalize="characters"
autoCorrect="off"
className={cn(
"h-11 min-w-0 border-l border-input/50 bg-transparent px-1 text-center font-mono text-sm text-foreground outline-hidden first:border-l-0",
"placeholder:text-muted-foreground/60 focus:z-10 focus:bg-muted/35",
"disabled:cursor-not-allowed",
validationErrors[index] &&
"bg-destructive/5 text-destructive focus:bg-destructive/10",
)}
disabled={disabled}
id={`wf-trigger-cron-${definition.label.toLowerCase()}`}
key={definition.label}
onChange={(event) => {
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]}
/>
))}
</div>
</div>
<p
className={cn(
"text-xs text-muted-foreground",
firstError && "text-destructive",
)}
id={messageId}
role={firstError ? "alert" : undefined}
>
{firstError ??
"UTC · Paste all 5 fields, or use wildcards, lists, ranges, and steps."}
</p>
</fieldset>
);
}
@@ -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 (
<div className="space-y-3">
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-cron">
Cron expression (optional)
</FieldLabel>
<Input
autoCapitalize="off"
id="wf-trigger-cron"
onChange={(event) =>
onUpdate({ ...trigger, cron: event.target.value })
}
placeholder="e.g. 0 9 * * 1-5 (weekdays at 9am UTC)"
value={trigger.cron ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-interval">
Interval (optional)
</FieldLabel>
<Input
autoCapitalize="off"
id="wf-trigger-interval"
onChange={(event) =>
onUpdate({ ...trigger, interval: event.target.value })
}
placeholder="e.g. 1h, 30m"
value={trigger.interval ?? ""}
/>
</div>
<p className="text-xs text-muted-foreground">
Use either cron or interval.
</p>
</div>
<WorkflowScheduleFields
disabled={disabled}
onUpdate={onUpdate}
trigger={trigger}
/>
);
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" ? (
<div>
<TriggerConfigFields
disabled={disabled}
onUpdate={(trigger) =>
updateFormState({ ...formState, trigger })
}
@@ -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<ScheduleFormState>) => {
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 (
<div className="space-y-3">
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<p
className="text-xs font-medium text-muted-foreground"
id="wf-trigger-frequency-label"
>
Repeats
</p>
</div>
<div
aria-labelledby="wf-trigger-frequency-label"
className="grid grid-cols-2 gap-2.5"
role="radiogroup"
>
{SCHEDULE_FREQUENCIES.map((frequency) => {
const id = `wf-trigger-frequency-${frequency}`;
return (
<div
className={cn(
"relative",
frequency === "custom_cron" && "col-span-2",
)}
key={frequency}
>
<input
checked={schedule.frequency === frequency}
className="peer sr-only"
disabled={disabled}
id={id}
name="wf-trigger-frequency"
onChange={() => {
const isCustom = frequency === "custom_cron";
setForceCustomCron(isCustom);
updateSchedule({
customCron: isCustom
? customCronSeed(schedule)
: schedule.customCron,
frequency,
});
}}
type="radio"
value={frequency}
/>
<label
className={cn(
"flex min-h-12 cursor-pointer items-center justify-center rounded-lg border px-3 py-2 text-center text-sm font-medium",
"outline-2 outline-offset-2 outline-transparent transition-[background-color,border-color,color,outline-color]",
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring",
"peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
schedule.frequency === frequency
? "border-border/0 bg-transparent text-foreground outline-foreground/45"
: "border-border/70 bg-background/35 text-muted-foreground hover:border-border hover:bg-muted/55 hover:text-foreground hover:outline-muted-foreground/20",
)}
htmlFor={id}
>
{SCHEDULE_FREQUENCY_LABELS[frequency]}
</label>
</div>
);
})}
</div>
</div>
{schedule.frequency === "weekly" ? (
<fieldset className="space-y-1.5">
<legend className="text-xs font-medium text-muted-foreground">
Repeat on
</legend>
<div className="grid grid-cols-7 gap-2">
{WEEKDAYS.map(([value, label, shortLabel]) => {
const id = `wf-trigger-weekday-${value}`;
return (
<div className="relative" key={value}>
<input
aria-label={label}
checked={selectedWeekdays.has(value)}
className="peer sr-only"
disabled={disabled}
id={id}
name="wf-trigger-weekday"
onChange={() => {
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}
/>
<label
className={cn(
"flex aspect-square cursor-pointer items-center justify-center rounded-full text-xs font-medium transition-colors",
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2",
"peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
selectedWeekdays.has(value)
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/70 hover:text-foreground",
)}
htmlFor={id}
>
{shortLabel}
</label>
</div>
);
})}
</div>
</fieldset>
) : null}
{schedule.frequency === "monthly" ? (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-month-day">Day of month</FieldLabel>
<FormSelect
disabled={disabled}
id="wf-trigger-month-day"
onChange={(monthDay) => updateSchedule({ monthDay })}
value={schedule.monthDay}
>
{MONTH_DAYS.map((day) => (
<option key={day} value={day}>
{day}
</option>
))}
</FormSelect>
{monthDayWarning ? (
<div
className="flex gap-2 rounded-lg border border-warning/30 bg-warning-bg px-3 py-2"
role="status"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p className="text-xs leading-5 text-warning">
{monthDayWarning}
</p>
</div>
) : null}
</div>
) : null}
{usesTime ? (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-time">Run time (UTC)</FieldLabel>
<Input
disabled={disabled}
id="wf-trigger-time"
onChange={(event) => updateSchedule({ time: event.target.value })}
type="time"
value={schedule.time}
/>
</div>
) : null}
{schedule.frequency === "custom_cron" ? (
<CronExpressionInput
disabled={disabled}
onChange={(customCron) => updateSchedule({ customCron })}
value={schedule.customCron}
/>
) : null}
{schedule.frequency === "custom_interval" ? (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-interval">
Existing interval
</FieldLabel>
<Input
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id="wf-trigger-interval"
onChange={(event) =>
updateSchedule({ customInterval: event.target.value })
}
placeholder="e.g. 2h"
value={schedule.customInterval}
/>
<p className="text-xs text-muted-foreground">
Keep this legacy interval or choose a repeat option above.
</p>
</div>
) : null}
</div>
);
}
@@ -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,
});
});
@@ -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<string | null> {
return fields.map((field, index) =>
validateCronField(field, CRON_FIELD_DEFINITIONS[index]),
);
}
@@ -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);
});
@@ -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<ScheduleFrequency, string> = {
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<Record<ScheduleFrequency, string>> = {
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<number>();
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" }));
}
+83
View File
@@ -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,
}) => {