From aeea5f9195d43a7ba66ddd811b26ec2575c7dcd1 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:01:17 -0500 Subject: [PATCH] fix(ui): stabilize routine schedule editor and interrupted run labels (#8333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source control plane people use to manage AI agents for work. > - This change touches the board UI surfaces for issue run timelines and routine schedule editing. > - Operators need cancelled runs to distinguish ordinary cancellation from human interruption, otherwise the run history reads as more severe than it is. > - Routine schedule editing also needs to preserve user-entered cron values while rendering common schedules in a stable, understandable editor. > - This pull request keeps the editor state tied to explicit schedule values, adds coverage for routine editable sections, and makes interrupted run copy more precise. > - The benefit is less surprising routine editing and clearer issue run history for operators. ## Linked Issues or Issue Description No public GitHub issue is filed for this exact branch. Related public PRs: - Refs #3581, which addresses a narrower schedule reset case. - Refs #1803, which is another open schedule editor UI improvement. Problem description: Routine trigger schedules can be edited through the board UI, but the previous schedule editor path could normalize or reset cron state in ways that made unsaved edits fragile. Issue run history also labeled operator-interrupted cancelled runs like ordinary cancellations. Reviewers should treat this PR as a combined UI stabilization pass for those two visible operator workflows. ## What Changed - Added a more stable routine schedule editor flow that preserves explicit cron values and handles custom/common schedule transitions. - Wired routine editable-section state so schedule drafts do not get overwritten by unrelated section refreshes. - Added tests for schedule editor behavior, routine editable sections, and routine service schedule preservation. - Updated issue run timeline copy so operator-interrupted cancelled runs display as interrupted, while ordinary cancelled runs remain cancelled. - Kept the classic issue thread run label behavior aligned with the current issue thread surface. ## Verification - `NODE_ENV=development pnpm run preflight:workspace-links && NODE_ENV=development pnpm exec vitest run server/src/__tests__/routines-service.test.ts ui/src/components/IssueChatThread.test.tsx ui/src/components/ScheduleEditor.test.tsx ui/src/components/routine-sections/editable-sections.test.tsx` — 103 tests passed. Note: direct `pnpm exec vitest ...` without `NODE_ENV=development` loaded a React build where `React.act` is undefined in this workspace. The same targeted tests pass under the development React build. ## Risks Low to medium risk. The changes are UI-focused but touch routine schedule editing, which is a high-frequency operator workflow. The main risk is that an uncommon cron expression could render as custom when a user expected a preset; the added tests cover preservation and explicit custom handling. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5-class coding agent. Exact hosted runtime model ID and context window were not exposed in this session. Tool use and local command execution were used for inspection, verification, GitHub PR creation, and Paperclip issue updates. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- server/src/__tests__/routines-service.test.ts | 20 +++ ui/src/components/IssueChatThread.test.tsx | 51 ++++++ ui/src/components/IssueChatThreadClassic.tsx | 38 +---- ui/src/components/ScheduleEditor.test.tsx | 133 ++++++++++++++++ ui/src/components/ScheduleEditor.tsx | 104 ++++++++++--- .../components/routine-sections/context.tsx | 9 ++ .../editable-sections.test.tsx | 145 ++++++++++++++++++ .../routine-sections/editable-sections.tsx | 30 +++- ui/src/pages/RoutineDetail.tsx | 8 +- 9 files changed, 475 insertions(+), 63 deletions(-) create mode 100644 ui/src/components/ScheduleEditor.test.tsx create mode 100644 ui/src/components/routine-sections/editable-sections.test.tsx diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 22756ff9..f1797c79 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -495,6 +495,26 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(restoredTrigger?.publicId).not.toBe(created.trigger.publicId); }); + it("persists custom schedule cron expressions exactly", async () => { + const { companyId, routine, svc } = await seedFixture(); + const cronExpression = "0 8-18/2 * * 1-5"; + + const created = await svc.createTrigger(routine.id, { + kind: "schedule", + label: "Business hours", + cronExpression, + timezone: "UTC", + }, {}); + + expect(created.trigger.cronExpression).toBe(cronExpression); + + const storedTrigger = await svc.getTrigger(created.trigger.id); + expect(storedTrigger?.cronExpression).toBe(cronExpression); + + const [listed] = await svc.list(companyId); + expect(listed?.triggers[0]?.cronExpression).toBe(cronExpression); + }); + it("blocks agents from restoring routine revisions assigned to another agent", async () => { const { companyId, routine, svc } = await seedFixture(); const otherAgentId = randomUUID(); diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 5d1b7b4a..799c000c 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -353,6 +353,57 @@ describe("IssueChatThread", () => { }); }); + it("labels operator-interrupted cancelled runs as interrupted while preserving plain cancelled runs", () => { + const root = createRoot(container); + const linkedRuns: IssueChatLinkedRun[] = [ + { + runId: "run-interrupted", + status: "cancelled", + agentId: "agent-1", + agentName: "CodexCoder", + createdAt: new Date("2026-04-06T12:00:00.000Z"), + startedAt: new Date("2026-04-06T12:00:00.000Z"), + finishedAt: new Date("2026-04-06T12:01:00.000Z"), + errorCode: "operator_interrupted", + resultJson: { operatorInterrupted: true, interruptionSource: "issue_comment_interrupt" }, + }, + { + runId: "run-cancelled", + status: "cancelled", + agentId: "agent-1", + agentName: "CodexCoder", + createdAt: new Date("2026-04-06T12:02:00.000Z"), + startedAt: new Date("2026-04-06T12:02:00.000Z"), + finishedAt: new Date("2026-04-06T12:03:00.000Z"), + resultJson: { stopReason: "cancelled" }, + }, + ]; + + act(() => { + root.render( + + {}} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(container.textContent).toContain("interrupted by board after 1 minute"); + expect(container.textContent).toContain("cancelled after 1 minute"); + expect(container.textContent).not.toContain("run interrupted"); + + act(() => { + root.unmount(); + }); + }); + it("falls back to execCommand for comment copy actions in insecure contexts", async () => { const clipboardWrite = vi.fn(async () => { throw new Error("Clipboard API blocked"); diff --git a/ui/src/components/IssueChatThreadClassic.tsx b/ui/src/components/IssueChatThreadClassic.tsx index fc68fb92..eb4736cb 100644 --- a/ui/src/components/IssueChatThreadClassic.tsx +++ b/ui/src/components/IssueChatThreadClassic.tsx @@ -107,6 +107,7 @@ import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { IssueThreadInteractionCardClassic } from "./IssueThreadInteractionCardClassic"; import { AgentIcon } from "./AgentIconPicker"; +import { RunStatusBadge } from "./interrupt-handoff/InterruptHandoffViews"; import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; import { captureComposerViewportSnapshot, @@ -793,36 +794,6 @@ export function resolveIssueChatHumanAuthor(args: { }; } -function formatRunStatusLabel(status: string) { - switch (status) { - case "timed_out": - return "timed out"; - default: - return status.replace(/_/g, " "); - } -} - -function runStatusClass(status: string) { - switch (status) { - case "succeeded": - return "text-green-700 dark:text-green-300"; - case "failed": - case "error": - return "text-red-700 dark:text-red-300"; - case "timed_out": - return "text-orange-700 dark:text-orange-300"; - case "running": - return "text-cyan-700 dark:text-cyan-300"; - case "queued": - case "pending": - return "text-amber-700 dark:text-amber-300"; - case "cancelled": - return "text-muted-foreground"; - default: - return "text-foreground"; - } -} - function toolCountSummary(toolParts: ToolCallMessagePart[]): string | null { if (toolParts.length === 0) return null; let commands = 0; @@ -2717,9 +2688,10 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { > {runId.slice(0, 8)} - - {formatRunStatusLabel(runStatus)} - + void; + onValidityChange?: (valid: boolean) => void; +}) { + const [value, setValue] = useState(initial); + return ( + { + setValue(cron); + onChange?.(cron); + }} + /> + ); +} + +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 act(callback: () => void) { + flushSync(callback); +} + +describe("ScheduleEditor cron helpers", () => { + it("classifies unknown valid 5-field cron expressions as custom", () => { + expect(parseCronToPreset("0 8-18/2 * * 1-5").preset).toBe("custom"); + expect(getScheduleCronValidation("0 8-18/2 * * 1-5").valid).toBe(true); + }); + + it("still recognizes supported presets and emits their expected cron", () => { + expect(parseCronToPreset("0 10 * * 1-5")).toMatchObject({ + preset: "weekdays", + hour: "10", + minute: "0", + }); + expect(buildCron("weekdays", "8", "15", "1", "1")).toBe("15 8 * * 1-5"); + }); + + it("reports partial cron edits as invalid without treating them as presets", () => { + const validation = getScheduleCronValidation("0 8-18/2 *"); + expect(validation.valid).toBe(false); + expect(validation.message).toContain("5 fields"); + }); +}); + +describe("ScheduleEditor", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + }); + + function cronInput() { + return container.querySelector('input[aria-label="Cron expression"]'); + } + + it("renders unknown valid cron expressions in Custom with the original text", () => { + const root = createRoot(container); + act(() => { + root.render(); + }); + + expect(container.textContent).toContain("Custom (cron)"); + expect(cronInput()?.value).toBe("0 8-18/2 * * 1-5"); + expect(container.textContent).toContain("Valid cron."); + + act(() => root.unmount()); + }); + + it("keeps Custom open while partial edits and pasted valid cron values round-trip through parent state", () => { + const onChange = vi.fn(); + const onValidityChange = vi.fn(); + const root = createRoot(container); + act(() => { + root.render( + , + ); + }); + + act(() => { + typeCron(cronInput()!, "0 8-18/2 *"); + }); + expect(cronInput()?.value).toBe("0 8-18/2 *"); + expect(cronInput()?.getAttribute("aria-invalid")).toBe("true"); + expect(container.textContent).toContain("Use exactly 5 fields"); + expect(onChange).not.toHaveBeenCalledWith("0 8-18/2 *"); + expect(onValidityChange).toHaveBeenLastCalledWith(false); + + act(() => { + typeCron(cronInput()!, "0 8-18/2 * * 1-5"); + }); + expect(cronInput()?.value).toBe("0 8-18/2 * * 1-5"); + expect(cronInput()?.getAttribute("aria-invalid")).toBe("false"); + expect(onChange).toHaveBeenLastCalledWith("0 8-18/2 * * 1-5"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/ScheduleEditor.tsx b/ui/src/components/ScheduleEditor.tsx index e5a1bc63..2d991d5b 100644 --- a/ui/src/components/ScheduleEditor.tsx +++ b/ui/src/components/ScheduleEditor.tsx @@ -2,9 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Input } from "@/components/ui/input"; -import { ChevronDown, ChevronRight } from "lucide-react"; +import { nextCronFires, parseCronExpression } from "../lib/cron-fires"; -type SchedulePreset = "every_minute" | "every_hour" | "every_day" | "weekdays" | "weekly" | "monthly" | "custom"; +export type SchedulePreset = "every_minute" | "every_hour" | "every_day" | "weekdays" | "weekly" | "monthly" | "custom"; const PRESETS: { value: SchedulePreset; label: string }[] = [ { value: "every_minute", label: "Every minute" }, @@ -41,7 +41,11 @@ const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => ({ label: String(i + 1), })); -function parseCronToPreset(cron: string): { +function hasOption(options: Array<{ value: string }>, value: string): boolean { + return options.some((option) => option.value === value); +} + +export function parseCronToPreset(cron: string): { preset: SchedulePreset; hour: string; minute: string; @@ -59,42 +63,44 @@ function parseCronToPreset(cron: string): { return { preset: "custom", ...defaults }; } - const [min, hr, dom, , dow] = parts; + const [min, hr, dom, month, dow] = parts; + const selectableMinute = hasOption(MINUTES, min); + const selectableHour = hasOption(HOURS, hr); // Every minute: "* * * * *" - if (min === "*" && hr === "*" && dom === "*" && dow === "*") { + if (min === "*" && hr === "*" && dom === "*" && month === "*" && dow === "*") { return { preset: "every_minute", ...defaults }; } // Every hour: "0 * * * *" - if (hr === "*" && dom === "*" && dow === "*") { - return { preset: "every_hour", ...defaults, minute: min === "*" ? "0" : min }; + if (hr === "*" && dom === "*" && month === "*" && dow === "*" && selectableMinute) { + return { preset: "every_hour", ...defaults, minute: min }; } // Every day: "M H * * *" - if (dom === "*" && dow === "*" && hr !== "*") { - return { preset: "every_day", ...defaults, hour: hr, minute: min === "*" ? "0" : min }; + if (dom === "*" && month === "*" && dow === "*" && selectableHour && selectableMinute) { + return { preset: "every_day", ...defaults, hour: hr, minute: min }; } // Weekdays: "M H * * 1-5" - if (dom === "*" && dow === "1-5" && hr !== "*") { - return { preset: "weekdays", ...defaults, hour: hr, minute: min === "*" ? "0" : min }; + if (dom === "*" && month === "*" && dow === "1-5" && selectableHour && selectableMinute) { + return { preset: "weekdays", ...defaults, hour: hr, minute: min }; } // Weekly: "M H * * D" (single day) - if (dom === "*" && /^\d$/.test(dow) && hr !== "*") { - return { preset: "weekly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfWeek: dow }; + if (dom === "*" && month === "*" && hasOption(DAYS_OF_WEEK, dow) && selectableHour && selectableMinute) { + return { preset: "weekly", ...defaults, hour: hr, minute: min, dayOfWeek: dow }; } // Monthly: "M H D * *" - if (/^\d{1,2}$/.test(dom) && dow === "*" && hr !== "*") { - return { preset: "monthly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfMonth: dom }; + if (month === "*" && hasOption(DAYS_OF_MONTH, dom) && dow === "*" && selectableHour && selectableMinute) { + return { preset: "monthly", ...defaults, hour: hr, minute: min, dayOfMonth: dom }; } return { preset: "custom", ...defaults }; } -function buildCron(preset: SchedulePreset, hour: string, minute: string, dayOfWeek: string, dayOfMonth: string): string { +export function buildCron(preset: SchedulePreset, hour: string, minute: string, dayOfWeek: string, dayOfMonth: string): string { switch (preset) { case "every_minute": return "* * * * *"; @@ -146,12 +152,53 @@ function ordinalSuffix(n: number): string { export { describeSchedule }; +export function getScheduleCronValidation(cron: string): { + valid: boolean; + message: string; + nextFires: Date[]; +} { + const trimmed = cron.trim(); + if (!trimmed) { + return { + valid: false, + message: "Enter a 5-field cron expression.", + nextFires: [], + }; + } + + const fields = trimmed.split(/\s+/); + if (fields.length !== 5) { + return { + valid: false, + message: `Use exactly 5 fields; this has ${fields.length}.`, + nextFires: [], + }; + } + + if (!parseCronExpression(trimmed)) { + return { + valid: false, + message: "Cron fields must use valid numbers, ranges, lists, wildcards, or steps.", + nextFires: [], + }; + } + + const nextFires = nextCronFires(trimmed, 3, { timeZone: "UTC" }); + return { + valid: true, + message: nextFires.length > 0 ? "Valid cron." : "Valid cron, but no upcoming fires were found.", + nextFires, + }; +} + export function ScheduleEditor({ value, onChange, + onValidityChange, }: { value: string; onChange: (cron: string) => void; + onValidityChange?: (valid: boolean) => void; }) { const parsed = useMemo(() => parseCronToPreset(value), [value]); const [preset, setPreset] = useState(parsed.preset); @@ -160,6 +207,11 @@ export function ScheduleEditor({ const [dayOfWeek, setDayOfWeek] = useState(parsed.dayOfWeek); const [dayOfMonth, setDayOfMonth] = useState(parsed.dayOfMonth); const [customCron, setCustomCron] = useState(preset === "custom" ? value : ""); + const customValidation = useMemo(() => getScheduleCronValidation(customCron), [customCron]); + + useEffect(() => { + onValidityChange?.(preset !== "custom" || customValidation.valid); + }, [customValidation.valid, onValidityChange, preset]); // Sync from external value changes useEffect(() => { @@ -195,7 +247,7 @@ export function ScheduleEditor({ return (
{ - setCustomCron(e.target.value); - emitChange("custom", hour, minute, dayOfWeek, dayOfMonth, e.target.value); + const nextCron = e.target.value; + setCustomCron(nextCron); + if (getScheduleCronValidation(nextCron).valid) { + emitChange("custom", hour, minute, dayOfWeek, dayOfMonth, nextCron); + } }} placeholder="0 10 * * *" + aria-label="Cron expression" + aria-invalid={!customValidation.valid} className="font-mono text-sm" />

Five fields: minute hour day-of-month month day-of-week

+

+ {customValidation.message} + {customValidation.valid && customValidation.nextFires.length > 0 + ? ` Next: ${customValidation.nextFires.map((fire) => fire.toLocaleString()).join(", ")}.` + : null} +

) : (
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: "",