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:
parent
1a3e398107
commit
b3c0fadd63
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -1439,6 +1439,8 @@ export {
|
|||
getBuiltinRoutineVariableValues,
|
||||
interpolateRoutineTemplate,
|
||||
isBuiltinRoutineVariable,
|
||||
isRoutineDateVariableName,
|
||||
isValidRoutineDateString,
|
||||
isValidRoutineVariableName,
|
||||
stringifyRoutineVariableValue,
|
||||
syncRoutineVariablesWithTemplate,
|
||||
|
|
|
|||
|
|
@ -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}}", {
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue