diff --git a/ui/src/components/routine-sections/context.tsx b/ui/src/components/routine-sections/context.tsx
index 7f969f9d..332b8437 100644
--- a/ui/src/components/routine-sections/context.tsx
+++ b/ui/src/components/routine-sections/context.tsx
@@ -67,6 +67,15 @@ export type NewTriggerDraft = {
replayWindowSec: string;
};
+export function createDefaultNewTrigger(): NewTriggerDraft {
+ return {
+ kind: "schedule",
+ cronExpression: "0 10 * * *",
+ signingMode: "bearer",
+ replayWindowSec: "300",
+ };
+}
+
export type SecretMessage = {
title: string;
entries: Array<{ webhookUrl: string; webhookSecret: string }>;
diff --git a/ui/src/components/routine-sections/editable-sections.test.tsx b/ui/src/components/routine-sections/editable-sections.test.tsx
new file mode 100644
index 00000000..978e2d32
--- /dev/null
+++ b/ui/src/components/routine-sections/editable-sections.test.tsx
@@ -0,0 +1,145 @@
+// @vitest-environment jsdom
+
+import { useState } from "react";
+import { flushSync } from "react-dom";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { TriggersSection } from "./editable-sections";
+import {
+ RoutineDetailContext,
+ createDefaultNewTrigger,
+ type NewTriggerDraft,
+ type RoutineDetailContextValue,
+} from "./context";
+
+(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+vi.mock("../MarkdownEditor", () => ({
+ MarkdownEditor: () => null,
+}));
+
+function act(callback: () => void) {
+ flushSync(callback);
+}
+
+function buttonByText(container: HTMLElement, label: string): HTMLButtonElement {
+ const button = [...container.querySelectorAll("button")].find(
+ (candidate) => candidate.textContent?.trim() === label,
+ );
+ if (!button) throw new Error(`Button not found: ${label}`);
+ return button as HTMLButtonElement;
+}
+
+function typeCron(input: HTMLInputElement, value: string) {
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
+ setter?.call(input, value);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+}
+
+function Harness({ createMutate }: { createMutate: ReturnType
}) {
+ const [newTrigger, setNewTrigger] = useState({
+ ...createDefaultNewTrigger(),
+ cronExpression: "0 8-18/2 * * 1-5",
+ });
+
+ const value = {
+ routine: {
+ id: "routine-1",
+ triggers: [],
+ },
+ newTrigger,
+ setNewTrigger,
+ createTrigger: {
+ isPending: false,
+ mutate: createMutate,
+ },
+ updateTrigger: { mutate: vi.fn() },
+ deleteTrigger: { mutate: vi.fn() },
+ rotateTrigger: { mutate: vi.fn() },
+ } as unknown as RoutineDetailContextValue;
+
+ return (
+
+
+
+ );
+}
+
+describe("TriggersSection", () => {
+ let container: HTMLDivElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ container.remove();
+ document.body.innerHTML = "";
+ });
+
+ it("closes the add-trigger composer and resets the draft after a successful create", () => {
+ const createMutate = vi.fn((_variables, options?: { onSuccess?: () => void }) => {
+ options?.onSuccess?.();
+ });
+ const root = createRoot(container);
+
+ act(() => {
+ root.render();
+ });
+
+ act(() => {
+ buttonByText(container, "New trigger").dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ expect(container.querySelector('input[aria-label="Cron expression"]')).not.toBeNull();
+
+ act(() => {
+ buttonByText(container, "Add trigger").dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(createMutate).toHaveBeenCalledTimes(1);
+ expect(container.querySelector('input[aria-label="Cron expression"]')).toBeNull();
+ expect(buttonByText(container, "New trigger")).not.toBeNull();
+
+ act(() => {
+ buttonByText(container, "New trigger").dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ expect(container.querySelector('input[aria-label="Cron expression"]')).toBeNull();
+ expect(container.textContent).toContain("Every day");
+
+ act(() => root.unmount());
+ });
+
+ it("disables add trigger while the custom cron draft is invalid locally", () => {
+ const createMutate = vi.fn();
+ const root = createRoot(container);
+
+ act(() => {
+ root.render();
+ });
+
+ act(() => {
+ buttonByText(container, "New trigger").dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ const input = container.querySelector('input[aria-label="Cron expression"]');
+ expect(input).not.toBeNull();
+ expect(buttonByText(container, "Add trigger").disabled).toBe(false);
+
+ act(() => {
+ typeCron(input!, "0 8-18/2 *");
+ });
+
+ expect(input?.value).toBe("0 8-18/2 *");
+ expect(container.textContent).toContain("Use exactly 5 fields");
+ expect(buttonByText(container, "Add trigger").disabled).toBe(true);
+
+ act(() => {
+ buttonByText(container, "Add trigger").dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(createMutate).not.toHaveBeenCalled();
+
+ act(() => root.unmount());
+ });
+});
diff --git a/ui/src/components/routine-sections/editable-sections.tsx b/ui/src/components/routine-sections/editable-sections.tsx
index 0ab24040..8fb50579 100644
--- a/ui/src/components/routine-sections/editable-sections.tsx
+++ b/ui/src/components/routine-sections/editable-sections.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import {
ArrowRight,
Braces,
@@ -29,11 +29,11 @@ import { EmptyState } from "../EmptyState";
import { InlineEntitySelector } from "../InlineEntitySelector";
import { AgentIcon } from "../AgentIconPicker";
import { MarkdownEditor } from "../MarkdownEditor";
-import { ScheduleEditor } from "../ScheduleEditor";
+import { ScheduleEditor, getScheduleCronValidation } from "../ScheduleEditor";
import { RoutineVariablesEditor, RoutineVariablesHint } from "../RoutineVariablesEditor";
import { RoutineTriggerCard } from "../RoutineTriggerCard";
import { EnvVarEditor } from "../EnvVarEditor";
-import { useRoutineDetail } from "./context";
+import { createDefaultNewTrigger, useRoutineDetail } from "./context";
import type { EnvBinding, RoutineDetail as RoutineDetailType } from "@paperclipai/shared";
const concurrencyPolicyOptions = [
@@ -341,6 +341,18 @@ export function TriggersSection() {
const ctx = useRoutineDetail();
const { routine, newTrigger, setNewTrigger, createTrigger, updateTrigger, deleteTrigger, rotateTrigger } = ctx;
const [addOpen, setAddOpen] = useState(false);
+ const [newScheduleEditorValid, setNewScheduleEditorValid] = useState(true);
+ const newScheduleValidation = useMemo(
+ () => newTrigger.kind === "schedule" ? getScheduleCronValidation(newTrigger.cronExpression) : null,
+ [newTrigger.cronExpression, newTrigger.kind],
+ );
+ const addDisabled =
+ createTrigger.isPending ||
+ (newScheduleValidation ? !newScheduleValidation.valid || !newScheduleEditorValid : false);
+
+ useEffect(() => {
+ if (newTrigger.kind !== "schedule") setNewScheduleEditorValid(true);
+ }, [newTrigger.kind]);
return (
@@ -403,6 +415,7 @@ export function TriggersSection() {
onChange={(cronExpression) =>
setNewTrigger((current) => ({ ...current, cronExpression }))
}
+ onValidityChange={setNewScheduleEditorValid}
/>
)}
@@ -451,8 +464,15 @@ export function TriggersSection() {
diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx
index ca7d5433..f8428677 100644
--- a/ui/src/pages/RoutineDetail.tsx
+++ b/ui/src/pages/RoutineDetail.tsx
@@ -43,6 +43,7 @@ import {
ROUTINE_SECTION_KEYS,
SECTION_FIELD_KEYS,
RoutineDetailContext,
+ createDefaultNewTrigger,
type RoutineDetailContextValue,
type RoutineEditDraft,
type RoutineSectionKey,
@@ -154,12 +155,7 @@ export function RoutineDetail() {
const [secretMessage, setSecretMessage] = useState(null);
const [saveConflict, setSaveConflict] = useState(false);
const [runVariablesOpen, setRunVariablesOpen] = useState(false);
- const [newTrigger, setNewTrigger] = useState({
- kind: "schedule",
- cronExpression: "0 10 * * *",
- signingMode: "bearer",
- replayWindowSec: "300",
- });
+ const [newTrigger, setNewTrigger] = useState(createDefaultNewTrigger);
const [editDraft, setEditDraft] = useState({
title: "",
description: "",