feat(FN-1979): add routine cron presets
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
Reference in New Issue
Block a user