feat(routines): add date variable controls (#8655)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-06-26 12:00:16 -05:00 committed by GitHub
parent 1a3e398107
commit b3c0fadd63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 481 additions and 37 deletions

View File

@ -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 = [

View File

@ -1439,6 +1439,8 @@ export {
getBuiltinRoutineVariableValues,
interpolateRoutineTemplate,
isBuiltinRoutineVariable,
isRoutineDateVariableName,
isValidRoutineDateString,
isValidRoutineVariableName,
stringifyRoutineVariableValue,
syncRoutineVariablesWithTemplate,

View File

@ -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}}", {

View File

@ -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: [],

View File

@ -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/);
});
});

View File

@ -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({

View File

@ -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) => {

View File

@ -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") {

View File

@ -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(
<QueryClientProvider client={queryClient}>
<RoutineRunVariablesDialog
open
onOpenChange={() => {}}
companyId="company-1"
projects={[]}
agents={[createAgent()]}
defaultAssigneeAgentId="agent-1"
variables={props.variables}
isPending={false}
onSubmit={onSubmit}
/>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<RoutineRunVariablesDialog
@ -210,9 +271,6 @@ describe("RoutineRunVariablesDialog", () => {
/>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<RoutineRunVariablesDialog
@ -261,9 +319,6 @@ describe("RoutineRunVariablesDialog", () => {
/>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<RoutineRunVariablesDialog
@ -336,15 +391,10 @@ describe("RoutineRunVariablesDialog", () => {
/>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<RoutineRunVariablesDialog
@ -412,15 +462,10 @@ describe("RoutineRunVariablesDialog", () => {
/>
</QueryClientProvider>,
);
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<HTMLInputElement>('input[type="date"]'));
expect(dateInputs).toHaveLength(1);
expect(dateInputs[0]?.value).toBe("2026-07-01");
const textInput = Array.from(document.querySelectorAll<HTMLInputElement>('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<HTMLInputElement>('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();
});
});

View File

@ -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({
))}
</SelectContent>
</Select>
) : shouldUseDateInput(variable) ? (
<Input
type="date"
value={values[variable.name] == null ? "" : String(values[variable.name])}
onChange={(event) => setValues((current) => ({ ...current, [variable.name]: event.target.value }))}
/>
) : (
<Input
type={variable.type === "number" ? "number" : "text"}

View File

@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import type { RoutineVariable } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RoutineVariablesEditor, RoutineVariablesHint } from "./RoutineVariablesEditor";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function flushUi(callback: () => 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(
<RoutineVariablesEditor
title="Review {{startDate}}"
description=""
value={variables}
onChange={vi.fn()}
/>,
);
});
const dateInput = container.querySelector<HTMLInputElement>('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(<RoutineVariablesHint />);
});
const helpButton = document.querySelector<HTMLButtonElement>('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());
});
});

View File

@ -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({
</Select>
</div>
</div>
) : variable.type === "date" ? (
<Input
type="date"
value={typeof variable.defaultValue === "string" ? variable.defaultValue : ""}
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
...current,
defaultValue: event.target.value || null,
})))}
/>
) : (
<Input
type={variable.type === "number" ? "number" : "text"}
@ -295,7 +312,8 @@ export function RoutineVariablesHint() {
</p>
<ul className="list-disc space-y-1 pl-5 text-muted-foreground">
<li>Names must start with a letter and may use letters, numbers, and underscores.</li>
<li>Pick a type (text, textarea, number, boolean, select), default value, and whether it is required.</li>
<li>Pick a type (text, textarea, number, boolean, select, date), default value, and whether it is required.</li>
<li>Variable names ending in capital Date, such as startDate, are created as date variables by default.</li>
<li>The same name reused across the title and instructions is treated as one variable.</li>
</ul>
</section>