feat(FN-1979): add routine cron presets

This commit is contained in:
gsxdsm
2026-04-16 22:58:00 -07:00
parent 066d4e5ed2
commit 64ae788e92
2 changed files with 129 additions and 5 deletions

View File

@@ -14,6 +14,33 @@ import type {
RoutineExecutionPolicy,
} from "@fusion/core";
type CronPresetType = "hourly" | "daily" | "weekly" | "monthly" | "custom";
const CRON_PRESETS: Record<Exclude<CronPresetType, "custom">, string> = {
hourly: "0 * * * *",
daily: "0 0 * * *",
weekly: "0 0 * * 1",
monthly: "0 0 1 * *",
};
const CRON_PRESET_LABELS: Record<CronPresetType, string> = {
hourly: "Every hour",
daily: "Every day (midnight)",
weekly: "Every week (Monday)",
monthly: "Every month (1st)",
custom: "Custom cron expression",
};
function resolveCronPreset(cronExpression: string): CronPresetType {
const normalizedCron = cronExpression.trim();
for (const [preset, value] of Object.entries(CRON_PRESETS)) {
if (value === normalizedCron) {
return preset as Exclude<CronPresetType, "custom">;
}
}
return "custom";
}
/**
* Simple cron expression validator (5-field format).
* Checks basic structure — authoritative validation happens server-side.
@@ -143,6 +170,10 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
const [description, setDescription] = useState(routine?.description ?? "");
const [triggerType, setTriggerType] = useState<RoutineTriggerType>(initialTriggerFields.triggerType);
const [cronExpression, setCronExpression] = useState(initialTriggerFields.cronExpression);
const [cronPreset, setCronPreset] = useState<CronPresetType>(() => {
if (initialTriggerFields.triggerType !== "cron") return "custom";
return resolveCronPreset(initialTriggerFields.cronExpression);
});
const [webhookPath, setWebhookPath] = useState(initialTriggerFields.webhookPath);
const [webhookSecret, setWebhookSecret] = useState(initialTriggerFields.webhookSecret);
const [endpoint, setEndpoint] = useState(initialTriggerFields.endpoint);
@@ -166,7 +197,7 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
e.scope = "Project-specific entries require an active project.";
}
if (triggerType === "cron") {
if (triggerType === "cron" && cronPreset === "custom") {
if (!cronExpression.trim()) {
e.cronExpression = "Cron expression is required";
} else if (!isLikelyCron(cronExpression)) {
@@ -181,7 +212,7 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
}
setErrors(e);
return Object.keys(e).length === 0;
}, [name, triggerType, cronExpression, webhookPath, endpoint, formScope, projectId]);
}, [name, triggerType, cronExpression, cronPreset, webhookPath, endpoint, formScope, projectId]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
@@ -220,6 +251,13 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
const webhookErrorId = "routine-webhook-error";
const endpointErrorId = "routine-endpoint-error";
const handleCronPresetChange = useCallback((preset: CronPresetType) => {
setCronPreset(preset);
if (preset !== "custom") {
setCronExpression(CRON_PRESETS[preset]);
}
}, []);
return (
<form className="routine-form" onSubmit={handleSubmit} noValidate>
<h4 className="settings-section-heading">
@@ -343,6 +381,17 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
{/* Cron Expression */}
{triggerType === "cron" && (
<div className="form-group">
<label htmlFor="routine-frequency">Frequency</label>
<select
id="routine-frequency"
value={cronPreset}
onChange={(e) => handleCronPresetChange(e.target.value as CronPresetType)}
>
{Object.entries(CRON_PRESET_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<label htmlFor="routine-cron">Cron Expression</label>
<input
id="routine-cron"
@@ -350,13 +399,20 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
placeholder="* * * * *"
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
disabled={cronPreset !== "custom"}
aria-invalid={!!errors.cronExpression}
aria-describedby={errors.cronExpression ? cronErrorId : undefined}
/>
{errors.cronExpression ? (
<small id={cronErrorId} className="field-error">{errors.cronExpression}</small>
) : (
<small>min hour day month weekday <a href="https://crontab.guru" target="_blank" rel="noopener noreferrer">crontab.guru</a></small>
<small>
{cronPreset === "custom" ? (
<>min hour day month weekday <a href="https://crontab.guru" target="_blank" rel="noopener noreferrer">crontab.guru</a></>
) : (
`Auto-filled from preset: ${cronExpression}`
)}
</small>
)}
</div>
)}

View File

@@ -67,6 +67,38 @@ describe("RoutineEditor", () => {
expect(screen.getByLabelText("Cron Expression")).toHaveValue("0 * * * *");
});
it("shows frequency dropdown with preset options when triggerType is 'cron'", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
const frequency = screen.getByLabelText("Frequency");
expect(frequency).toBeDefined();
expect(screen.getByRole("option", { name: "Every hour" })).toBeDefined();
expect(screen.getByRole("option", { name: "Every day (midnight)" })).toBeDefined();
expect(screen.getByRole("option", { name: "Every week (Monday)" })).toBeDefined();
expect(screen.getByRole("option", { name: "Every month (1st)" })).toBeDefined();
expect(screen.getByRole("option", { name: "Custom cron expression" })).toBeDefined();
});
it("selecting a preset auto-fills the cron expression and disables the input", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Frequency"), { target: { value: "daily" } });
const cronInput = screen.getByLabelText("Cron Expression");
expect(cronInput).toHaveValue("0 0 * * *");
expect(cronInput).toBeDisabled();
expect(screen.getByText("Auto-filled from preset: 0 0 * * *")).toBeDefined();
});
it("selecting 'Custom' enables the cron expression input", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Frequency"), { target: { value: "custom" } });
expect(screen.getByLabelText("Cron Expression")).not.toBeDisabled();
expect(screen.getByText(/crontab\.guru/)).toBeDefined();
});
it("defaults executionPolicy to 'queue'", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByLabelText("Execution Policy")).toHaveValue("queue");
@@ -152,6 +184,24 @@ describe("RoutineEditor", () => {
expect(screen.getByText("Save Changes")).toBeDefined();
});
it("editing an existing routine with a preset cron expression selects the matching preset", () => {
const routine = makeRoutine({
trigger: { type: "cron", cronExpression: "0 0 * * *" },
});
render(<RoutineEditor routine={routine} onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByLabelText("Frequency")).toHaveValue("daily");
expect(screen.getByLabelText("Cron Expression")).toBeDisabled();
});
it("editing an existing routine with a non-preset cron expression selects 'Custom'", () => {
const routine = makeRoutine({
trigger: { type: "cron", cronExpression: "0 9 * * 1-5" },
});
render(<RoutineEditor routine={routine} onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByLabelText("Frequency")).toHaveValue("custom");
expect(screen.getByLabelText("Cron Expression")).not.toBeDisabled();
});
it("pre-fills webhook trigger fields", () => {
const routine = makeRoutine({
trigger: { type: "webhook", webhookPath: "/trigger/test", secret: "secret123" },
@@ -185,8 +235,9 @@ describe("RoutineEditor", () => {
expect(onSubmit).not.toHaveBeenCalled();
});
it("shows error when triggerType is 'cron' and cronExpression is empty", async () => {
it("shows error when triggerType is 'cron' and custom cronExpression is empty", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Frequency"), { target: { value: "custom" } });
fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "" } });
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
@@ -195,8 +246,9 @@ describe("RoutineEditor", () => {
expect(onSubmit).not.toHaveBeenCalled();
});
it("shows error when triggerType is 'cron' and cronExpression is invalid", async () => {
it("shows error when triggerType is 'cron' and custom cronExpression is invalid", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Frequency"), { target: { value: "custom" } });
fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "invalid" } });
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
@@ -248,6 +300,22 @@ describe("RoutineEditor", () => {
});
});
it("submitting with a preset sends the correct cron expression", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Weekly Routine" } });
fireEvent.change(screen.getByLabelText("Frequency"), { target: { value: "weekly" } });
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "Weekly Routine",
trigger: { type: "cron", cronExpression: "0 0 * * 1" },
})
);
});
});
it("calls onSubmit with correct shape on valid edit", async () => {
const routine = makeRoutine({ name: "Old Name" });
render(<RoutineEditor routine={routine} onSubmit={onSubmit} onCancel={onCancel} />);