From b3c0fadd635c51a4ad147cc5f0170957c5167e55 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:00:16 -0500 Subject: [PATCH] feat(routines): add date variable controls (#8655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Scheduled routines can prompt agents with variables that are filled in at dispatch time. > - Existing routine variable handling supported plain text-like values, but date inputs need a structured contract so routines can pass consistent date values. > - Operators also need date variables to be easy to configure and override from the routine UI. > - This pull request adds a date variable type across shared validation, server dispatch, and UI editing/run dialogs. > - The benefit is that routine authors can define date inputs once and agents receive validated ISO-style date values when routines run. ## Linked Issues or Issue Description Refs #219 Feature request: - Problem/motivation: Scheduled routines need first-class, typed date variables so operators can configure dates without relying on free-form text conventions. - Proposed solution: Add an `x-date` routine variable type with shared parsing/validation, server dispatch support, and UI date-picker controls in routine variable editors and run dialogs. - Alternatives considered: Continue treating dates as plain text, but that leaves validation and formatting to individual operators and agents. - Roadmap alignment: This is a focused improvement to the completed Scheduled Routines milestone and does not duplicate an active roadmap item. Related PR search: - Searched existing PRs/issues for `routine date picker`, `date variables`, and `scheduled routine date variable`; no direct duplicate PR was found. ## What Changed - Added the shared `x-date` routine variable contract, parsing, defaults, and validation coverage. - Extended routine dispatch to validate and pass date variable values. - Added date input controls to the routine variable editor and routine run variables dialog. - Added focused tests for shared validation, server dispatch, and the UI date controls. ## Verification - `git diff --check public/master...HEAD` - `pnpm run preflight:workspace-links && pnpm exec vitest run packages/shared/src/routine-variables.test.ts packages/shared/src/validators/routine.test.ts server/src/__tests__/routines-service.test.ts ui/src/components/RoutineRunVariablesDialog.test.tsx ui/src/components/RoutineVariablesEditor.test.tsx` - 5 test files passed - 68 tests passed ## Risks Low to medium risk. This adds a new routine variable type across shared/server/UI paths, so the main risk is compatibility with existing routine variable payloads. The change keeps existing variable types intact and adds targeted validation tests for the new date behavior. > 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 coding agent based on GPT-5, with terminal, git, GitHub CLI, and local test execution capabilities. ## 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] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] 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 --- packages/shared/src/constants.ts | 2 +- packages/shared/src/index.ts | 2 + packages/shared/src/routine-variables.test.ts | 34 ++- packages/shared/src/routine-variables.ts | 33 ++- .../shared/src/validators/routine.test.ts | 27 +++ packages/shared/src/validators/routine.ts | 10 + server/src/__tests__/routines-service.test.ts | 83 ++++++++ server/src/services/routines.ts | 13 ++ .../RoutineRunVariablesDialog.test.tsx | 200 +++++++++++++++--- .../components/RoutineRunVariablesDialog.tsx | 10 + .../RoutineVariablesEditor.test.tsx | 78 +++++++ ui/src/components/RoutineVariablesEditor.tsx | 26 ++- 12 files changed, 481 insertions(+), 37 deletions(-) create mode 100644 ui/src/components/RoutineVariablesEditor.test.tsx diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 085fb13245..36e66880b7 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -497,7 +497,7 @@ export type RoutineTriggerKind = (typeof ROUTINE_TRIGGER_KINDS)[number]; export const ROUTINE_TRIGGER_SIGNING_MODES = ["bearer", "hmac_sha256", "github_hmac", "none"] as const; export type RoutineTriggerSigningMode = (typeof ROUTINE_TRIGGER_SIGNING_MODES)[number]; -export const ROUTINE_VARIABLE_TYPES = ["text", "textarea", "number", "boolean", "select"] as const; +export const ROUTINE_VARIABLE_TYPES = ["text", "textarea", "number", "boolean", "select", "date"] as const; export type RoutineVariableType = (typeof ROUTINE_VARIABLE_TYPES)[number]; export const ROUTINE_RUN_STATUSES = [ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 493d552b4b..c1ac1f1eb8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1439,6 +1439,8 @@ export { getBuiltinRoutineVariableValues, interpolateRoutineTemplate, isBuiltinRoutineVariable, + isRoutineDateVariableName, + isValidRoutineDateString, isValidRoutineVariableName, stringifyRoutineVariableValue, syncRoutineVariablesWithTemplate, diff --git a/packages/shared/src/routine-variables.test.ts b/packages/shared/src/routine-variables.test.ts index 15d63a652e..3cd4ea5e1a 100644 --- a/packages/shared/src/routine-variables.test.ts +++ b/packages/shared/src/routine-variables.test.ts @@ -5,6 +5,8 @@ import { getBuiltinRoutineVariableValues, interpolateRoutineTemplate, isBuiltinRoutineVariable, + isRoutineDateVariableName, + isValidRoutineDateString, syncRoutineVariablesWithTemplate, } from "./routine-variables.js"; @@ -26,15 +28,43 @@ describe("routine variable helpers", () => { it("preserves existing metadata when syncing variables from a template", () => { expect( - syncRoutineVariablesWithTemplate(["Triage {{repo}}", "Review {{repo}} and {{priority}}"], [ + syncRoutineVariablesWithTemplate(["Triage {{repo}}", "Review {{repo}} and {{startDate}}"], [ { name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] }, + { name: "startDate", label: "Start", type: "text", defaultValue: "soon", required: false, options: [] }, ]), ).toEqual([ { name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] }, - { name: "priority", label: null, type: "text", defaultValue: null, required: true, options: [] }, + { name: "startDate", label: "Start", type: "text", defaultValue: "soon", required: false, options: [] }, ]); }); + it("identifies routine date variable names by strict capital-Date suffix", () => { + expect(isRoutineDateVariableName("startDate")).toBe(true); + expect(isRoutineDateVariableName("endDate")).toBe(true); + expect(isRoutineDateVariableName("fooDate")).toBe(true); + expect(isRoutineDateVariableName("date")).toBe(false); + expect(isRoutineDateVariableName("startdate")).toBe(false); + expect(isRoutineDateVariableName("candidate")).toBe(false); + expect(isRoutineDateVariableName("Date")).toBe(false); + }); + + it("defaults newly synced capital-Date variables to date type", () => { + expect( + syncRoutineVariablesWithTemplate("Compare {{startDate}} to {{endDate}} with {{date}}", []), + ).toEqual([ + { name: "startDate", label: null, type: "date", defaultValue: null, required: true, options: [] }, + { name: "endDate", label: null, type: "date", defaultValue: null, required: true, options: [] }, + ]); + }); + + it("validates YYYY-MM-DD routine date strings as real calendar dates", () => { + expect(isValidRoutineDateString("2024-02-29")).toBe(true); + expect(isValidRoutineDateString("2024-02-30")).toBe(false); + expect(isValidRoutineDateString("2023-02-29")).toBe(false); + expect(isValidRoutineDateString("2024-13-01")).toBe(false); + expect(isValidRoutineDateString("2024-1-01")).toBe(false); + }); + it("interpolates provided variable values into the routine template", () => { expect( interpolateRoutineTemplate("Review {{repo}} for {{priority}}", { diff --git a/packages/shared/src/routine-variables.ts b/packages/shared/src/routine-variables.ts index 84257188ab..43d57551aa 100644 --- a/packages/shared/src/routine-variables.ts +++ b/packages/shared/src/routine-variables.ts @@ -49,6 +49,37 @@ export function isValidRoutineVariableName(name: string): boolean { return /^[A-Za-z][A-Za-z0-9_]*$/.test(name); } +export function isRoutineDateVariableName(name: string): boolean { + return isValidRoutineVariableName(name) && name.length > "Date".length && name.endsWith("Date"); +} + +export function isValidRoutineDateString(value: string): boolean { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return false; + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (month < 1 || month > 12) return false; + + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [ + 31, + leapYear ? 29 : 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ][month - 1]!; + return day >= 1 && day <= daysInMonth; +} + function normalizeRoutineTemplateInput(input: RoutineTemplateInput): string[] { const templates = Array.isArray(input) ? input : [input]; return templates.filter((template): template is string => typeof template === "string" && template.length > 0); @@ -71,7 +102,7 @@ function defaultRoutineVariable(name: string): RoutineVariable { return { name, label: null, - type: "text", + type: isRoutineDateVariableName(name) ? "date" : "text", defaultValue: null, required: true, options: [], diff --git a/packages/shared/src/validators/routine.test.ts b/packages/shared/src/validators/routine.test.ts index 97580179ac..82c921b9cc 100644 --- a/packages/shared/src/validators/routine.test.ts +++ b/packages/shared/src/validators/routine.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { routineRevisionSnapshotV1Schema, + routineVariableSchema, updateRoutineSchema, } from "./routine.js"; @@ -83,4 +84,30 @@ describe("routine validators", () => { baseRevisionId, }).baseRevisionId).toBe(baseRevisionId); }); + + it("accepts date variables with valid YYYY-MM-DD defaults", () => { + expect(routineVariableSchema.parse({ + name: "startDate", + type: "date", + defaultValue: "2024-02-29", + })).toMatchObject({ + name: "startDate", + type: "date", + defaultValue: "2024-02-29", + }); + }); + + it("rejects date variables with non-calendar or non-string defaults", () => { + expect(() => routineVariableSchema.parse({ + name: "startDate", + type: "date", + defaultValue: "2024-02-30", + })).toThrow(/YYYY-MM-DD/); + + expect(() => routineVariableSchema.parse({ + name: "startDate", + type: "date", + defaultValue: 20240229, + })).toThrow(/YYYY-MM-DD/); + }); }); diff --git a/packages/shared/src/validators/routine.ts b/packages/shared/src/validators/routine.ts index 2db48fbbf5..e9cea498a2 100644 --- a/packages/shared/src/validators/routine.ts +++ b/packages/shared/src/validators/routine.ts @@ -13,6 +13,7 @@ import { issueExecutionWorkspaceSettingsSchema, } from "./issue.js"; import { envConfigSchema } from "./secret.js"; +import { isValidRoutineDateString } from "../routine-variables.js"; const routineVariableValueSchema = z.union([z.string(), z.number().finite(), z.boolean()]); @@ -47,6 +48,15 @@ export const routineVariableSchema = z.object({ }); } } + if (value.type === "date" && value.defaultValue != null) { + if (typeof value.defaultValue !== "string" || !isValidRoutineDateString(value.defaultValue)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["defaultValue"], + message: "Date variable defaults must be valid YYYY-MM-DD calendar dates", + }); + } + } }); export const createRoutineSchema = z.object({ diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index c5f6a1a470..183913f227 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -1073,6 +1073,63 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { }); }); + it("infers capital-Date variables, preserves builtin date, and validates submitted date values", async () => { + const { companyId, agentId, projectId, svc } = await seedFixture(); + const dateRoutine = await svc.create( + companyId, + { + projectId, + goalId: null, + parentIssueId: null, + title: "date check {{startDate}} on {{date}}", + description: "Range {{startDate}} to {{endDate}}", + assigneeAgentId: agentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + }, + {}, + ); + + expect(dateRoutine.variables).toEqual([ + { name: "startDate", label: null, type: "date", defaultValue: null, required: true, options: [] }, + { name: "endDate", label: null, type: "date", defaultValue: null, required: true, options: [] }, + ]); + + await expect( + svc.runRoutine(dateRoutine.id, { + source: "manual", + variables: { startDate: "2024-02-30", endDate: "2024-03-01" }, + }), + ).rejects.toThrow(/valid YYYY-MM-DD date/i); + + const run = await svc.runRoutine(dateRoutine.id, { + source: "manual", + variables: { startDate: "2024-02-29", endDate: "2024-03-01" }, + }); + + const storedIssue = await db + .select({ title: issues.title, description: issues.description }) + .from(issues) + .where(eq(issues.id, run.linkedIssueId!)) + .then((rows) => rows[0] ?? null); + const storedRun = await db + .select({ triggerPayload: routineRuns.triggerPayload }) + .from(routineRuns) + .where(eq(routineRuns.id, run.id)) + .then((rows) => rows[0] ?? null); + + expect(storedIssue?.title).toMatch(/^date check 2024-02-29 on \d{4}-\d{2}-\d{2}$/); + expect(storedIssue?.description).toBe("Range 2024-02-29 to 2024-03-01"); + expect(storedRun?.triggerPayload).toEqual({ + variables: { + startDate: "2024-02-29", + endDate: "2024-03-01", + }, + }); + }); + it("attaches the selected execution workspace to manually triggered routine issues", async () => { const { companyId, projectId, routine, svc } = await seedFixture(); const projectWorkspaceId = randomUUID(); @@ -1366,6 +1423,32 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { ).rejects.toThrow(/require defaults for required variables/i); }); + it("rejects invalid date defaults before persisting routine variables", async () => { + const { companyId, agentId, projectId, svc } = await seedFixture(); + + await expect( + svc.create( + companyId, + { + projectId, + goalId: null, + parentIssueId: null, + title: "date check {{startDate}}", + description: null, + assigneeAgentId: agentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [ + { name: "startDate", label: null, type: "date", defaultValue: "2024-02-30", required: true, options: [] }, + ], + }, + {}, + ), + ).rejects.toThrow(/valid YYYY-MM-DD date/i); + }); + it("serializes concurrent dispatches until the first execution issue is linked to a queued run", async () => { const { routine, svc } = await seedFixture({ wakeup: async (wakeupAgentId, wakeupOpts) => { diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 9ad737f3c5..d1eb9f795e 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -46,6 +46,7 @@ import { getBuiltinRoutineVariableValues, extractRoutineVariableNames, interpolateRoutineTemplate, + isValidRoutineDateString, pluginOperationIssueOriginKind, stringifyRoutineVariableValue, syncRoutineVariablesWithTemplate, @@ -223,10 +224,22 @@ function parseNumberVariableValue(name: string, raw: unknown) { throw unprocessable(`Variable "${name}" must be a number`); } +function parseDateVariableValue(name: string, raw: unknown) { + if (typeof raw !== "string") { + throw unprocessable(`Variable "${name}" must be a YYYY-MM-DD date`); + } + const normalized = raw.trim(); + if (!isValidRoutineDateString(normalized)) { + throw unprocessable(`Variable "${name}" must be a valid YYYY-MM-DD date`); + } + return normalized; +} + function normalizeRoutineVariableValue(variable: RoutineVariable, raw: unknown): string | number | boolean | null { if (raw == null) return null; if (variable.type === "boolean") return parseBooleanVariableValue(variable.name, raw); if (variable.type === "number") return parseNumberVariableValue(variable.name, raw); + if (variable.type === "date") return parseDateVariableValue(variable.name, raw); const normalized = stringifyRoutineVariableValue(raw); if (variable.type === "select") { diff --git a/ui/src/components/RoutineRunVariablesDialog.test.tsx b/ui/src/components/RoutineRunVariablesDialog.test.tsx index f4d5e1c2dc..d2704b1a57 100644 --- a/ui/src/components/RoutineRunVariablesDialog.test.tsx +++ b/ui/src/components/RoutineRunVariablesDialog.test.tsx @@ -1,9 +1,9 @@ // @vitest-environment jsdom -import { act } from "react"; +import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { Agent, ExecutionWorkspace, Project } from "@paperclipai/shared"; +import type { Agent, ExecutionWorkspace, Project, RoutineVariable } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog"; @@ -56,6 +56,17 @@ vi.mock("./IssueWorkspaceCard", async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +async function settleEffects() { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function flushUi(callback: () => void) { + flushSync(callback); + await settleEffects(); +} + function createProject(): Project { return { id: "project-1", @@ -162,6 +173,56 @@ function createExecutionWorkspace(): ExecutionWorkspace { }; } +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); +} + +async function renderRoutineRunDialog(container: HTMLDivElement, props: { + variables: RoutineVariable[]; + onSubmit?: (data: unknown) => void; +}) { + const root = createRoot(container); + const queryClient = createQueryClient(); + const onSubmit = props.onSubmit ?? vi.fn(); + + await flushUi(() => { + root.render( + + {}} + companyId="company-1" + projects={[]} + agents={[createAgent()]} + defaultAssigneeAgentId="agent-1" + variables={props.variables} + isPending={false} + onSubmit={onSubmit} + /> + , + ); + }); + + return { root, onSubmit }; +} + +function setInputValue(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 findRunButton() { + return Array.from(document.querySelectorAll("button")) + .find((button) => button.textContent === "Run routine") as HTMLButtonElement | undefined; +} + describe("RoutineRunVariablesDialog", () => { let container: HTMLDivElement; @@ -193,7 +254,7 @@ describe("RoutineRunVariablesDialog", () => { }, }); - await act(async () => { + await flushUi(() => { root.render( { /> , ); - await Promise.resolve(); - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); }); expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2); @@ -220,7 +278,7 @@ describe("RoutineRunVariablesDialog", () => { expect(document.body.textContent).not.toContain("Search agents..."); expect(document.body.textContent).not.toContain("Search projects..."); - await act(async () => { + await flushUi(() => { root.unmount(); }); }); @@ -235,7 +293,7 @@ describe("RoutineRunVariablesDialog", () => { }, }); - await act(async () => { + await flushUi(() => { root.render( { /> , ); - await Promise.resolve(); - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); }); const dialogContent = Array.from(document.body.querySelectorAll("div")).find((element) => @@ -288,7 +343,7 @@ describe("RoutineRunVariablesDialog", () => { expect(footer?.contains(formScrollRegion ?? null)).toBe(false); expect(footer?.textContent).toContain("Run routine"); - await act(async () => { + await flushUi(() => { root.unmount(); }); }); @@ -310,7 +365,7 @@ describe("RoutineRunVariablesDialog", () => { }, }); - await act(async () => { + await flushUi(() => { root.render( { /> , ); - await Promise.resolve(); - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); }); for (let i = 0; i < 10 && !document.querySelector('[data-testid="workspace-card"]'); i += 1) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); + await settleEffects(); } const branchInput = Array.from(document.querySelectorAll("input")) @@ -356,7 +406,7 @@ describe("RoutineRunVariablesDialog", () => { .find((button) => button.textContent === "Run routine"); expect(runButton).toBeTruthy(); - await act(async () => { + await flushUi(() => { runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); @@ -371,7 +421,7 @@ describe("RoutineRunVariablesDialog", () => { executionWorkspaceSettings: { mode: "isolated_workspace" }, }); - await act(async () => { + await flushUi(() => { root.unmount(); }); }); @@ -394,7 +444,7 @@ describe("RoutineRunVariablesDialog", () => { }, }); - await act(async () => { + await flushUi(() => { root.render( { /> , ); - await Promise.resolve(); - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); }); for (let i = 0; i < 10 && latestWorkspaceIssue === null; i += 1) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); + await settleEffects(); } expect(latestWorkspaceIssue).toMatchObject({ @@ -430,7 +475,104 @@ describe("RoutineRunVariablesDialog", () => { projectWorkspaceId: workspace.projectWorkspaceId, }); - await act(async () => { + await flushUi(() => { + root.unmount(); + }); + }); + + it("respects explicit date and text variable types for Date-suffixed names", async () => { + const { root } = await renderRoutineRunDialog(container, { + variables: [ + { + name: "startDate", + label: null, + type: "text", + defaultValue: "2026-06-26", + required: true, + options: [], + }, + { + name: "releaseOn", + label: "Release on", + type: "date", + defaultValue: "2026-07-01", + required: false, + options: [], + }, + ], + }); + + const dateInputs = Array.from(document.querySelectorAll('input[type="date"]')); + expect(dateInputs).toHaveLength(1); + expect(dateInputs[0]?.value).toBe("2026-07-01"); + + const textInput = Array.from(document.querySelectorAll('input[type="text"]')) + .find((input) => input.value === "2026-06-26"); + expect(textInput).toBeTruthy(); + + await flushUi(() => { + root.unmount(); + }); + }); + + it("blocks empty required dates, submits date strings, and omits optional empty dates", async () => { + const onSubmit = vi.fn(); + const { root } = await renderRoutineRunDialog(container, { + variables: [ + { + name: "startDate", + label: null, + type: "date", + defaultValue: null, + required: true, + options: [], + }, + { + name: "releaseOn", + label: "Release on", + type: "date", + defaultValue: null, + required: true, + options: [], + }, + { + name: "endDate", + label: null, + type: "date", + defaultValue: null, + required: false, + options: [], + }, + ], + onSubmit, + }); + + const runButton = findRunButton(); + expect(runButton?.disabled).toBe(true); + expect(document.body.textContent).toContain("Missing: startDate, Release on"); + + const dateInputs = Array.from(document.querySelectorAll('input[type="date"]')); + await flushUi(() => { + setInputValue(dateInputs[0]!, "2026-07-04"); + setInputValue(dateInputs[1]!, "2026-08-01"); + }); + + expect(runButton?.disabled).toBe(false); + + await flushUi(() => { + runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onSubmit).toHaveBeenCalledWith({ + variables: { + startDate: "2026-07-04", + releaseOn: "2026-08-01", + }, + assigneeAgentId: "agent-1", + projectId: null, + }); + + await flushUi(() => { root.unmount(); }); }); diff --git a/ui/src/components/RoutineRunVariablesDialog.tsx b/ui/src/components/RoutineRunVariablesDialog.tsx index 1dfb94919d..25449ec732 100644 --- a/ui/src/components/RoutineRunVariablesDialog.tsx +++ b/ui/src/components/RoutineRunVariablesDialog.tsx @@ -153,6 +153,10 @@ function isMissingRequiredValue(value: unknown) { return value == null || (typeof value === "string" && value.trim().length === 0); } +function shouldUseDateInput(variable: RoutineVariable) { + return variable.type === "date"; +} + function supportsRoutineRunWorkspaceSelection( project: Project | null | undefined, isolatedWorkspacesEnabled: boolean, @@ -497,6 +501,12 @@ export function RoutineRunVariablesDialog({ ))} + ) : shouldUseDateInput(variable) ? ( + setValues((current) => ({ ...current, [variable.name]: event.target.value }))} + /> ) : ( void) { + flushSync(callback); +} + +describe("RoutineVariablesEditor", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + }); + + it("renders date variable defaults with a date input", () => { + const root = createRoot(container); + const variables: RoutineVariable[] = [ + { + name: "startDate", + label: null, + type: "date", + defaultValue: "2026-06-26", + required: true, + options: [], + }, + ]; + + flushUi(() => { + root.render( + , + ); + }); + + const dateInput = container.querySelector('input[type="date"]'); + expect(dateInput?.value).toBe("2026-06-26"); + + flushUi(() => root.unmount()); + }); + + it("documents capital-Date default type behavior", () => { + const root = createRoot(container); + + flushUi(() => { + root.render(); + }); + + const helpButton = document.querySelector('button[aria-label="Show variable help"]'); + expect(helpButton).toBeTruthy(); + + flushUi(() => { + helpButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(document.body.textContent).toContain("Variable names ending in capital Date"); + expect(document.body.textContent).toContain("startDate"); + + flushUi(() => root.unmount()); + }); +}); diff --git a/ui/src/components/RoutineVariablesEditor.tsx b/ui/src/components/RoutineVariablesEditor.tsx index cae1b0bc75..b93091c4b7 100644 --- a/ui/src/components/RoutineVariablesEditor.tsx +++ b/ui/src/components/RoutineVariablesEditor.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { ChevronDown, ChevronRight, HelpCircle } from "lucide-react"; -import { syncRoutineVariablesWithTemplate, type RoutineVariable } from "@paperclipai/shared"; +import { isValidRoutineDateString, syncRoutineVariablesWithTemplate, type RoutineVariable } from "@paperclipai/shared"; import { Badge } from "@/components/ui/badge"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { @@ -21,7 +21,7 @@ import { } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -const variableTypes: RoutineVariable["type"][] = ["text", "textarea", "number", "boolean", "select"]; +const variableTypes: RoutineVariable["type"][] = ["text", "textarea", "number", "boolean", "select", "date"]; function serializeVariables(value: RoutineVariable[]) { return JSON.stringify(value); @@ -42,6 +42,14 @@ function updateVariableList( return variables.map((variable) => (variable.name === name ? mutate(variable) : variable)); } +function defaultValueForType(type: RoutineVariable["type"], current: RoutineVariable["defaultValue"]) { + if (type === "boolean") return null; + if (type === "date") { + return typeof current === "string" && isValidRoutineDateString(current) ? current : null; + } + return current; +} + export function RoutineVariablesEditor({ title, description, @@ -114,7 +122,7 @@ export function RoutineVariablesEditor({ onValueChange={(type) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({ ...current, type: type as RoutineVariable["type"], - defaultValue: type === "boolean" ? null : current.defaultValue, + defaultValue: defaultValueForType(type as RoutineVariable["type"], current.defaultValue), options: type === "select" ? current.options : [], })))} > @@ -212,6 +220,15 @@ export function RoutineVariablesEditor({ + ) : variable.type === "date" ? ( + onChange(updateVariableList(syncedVariables, variable.name, (current) => ({ + ...current, + defaultValue: event.target.value || null, + })))} + /> ) : (
  • Names must start with a letter and may use letters, numbers, and underscores.
  • -
  • Pick a type (text, textarea, number, boolean, select), default value, and whether it is required.
  • +
  • Pick a type (text, textarea, number, boolean, select, date), default value, and whether it is required.
  • +
  • Variable names ending in capital Date, such as startDate, are created as date variables by default.
  • The same name reused across the title and instructions is treated as one variable.