mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): improve workflow schedule picker
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -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" }));
|
||||
}
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user