[SKY-8784][SKY-8553] Address Schedule Missing Params (#5409)

This commit is contained in:
Aaron Perez 2026-04-07 15:35:35 -05:00 committed by GitHub
parent 2d4ed49b7e
commit f8cf5ee49b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1616 additions and 50 deletions

View file

@ -21,6 +21,7 @@ export type FileInputValue =
type Props = {
value: FileInputValue;
onChange: (value: FileInputValue) => void;
required?: boolean;
};
const FILE_SIZE_LIMIT_IN_BYTES = 10 * 1024 * 1024; // 10 MB
@ -34,7 +35,7 @@ function showFileSizeError() {
});
}
function FileUpload({ value, onChange }: Props) {
function FileUpload({ value, onChange, required }: Props) {
const credentialGetter = useCredentialGetter();
const [file, setFile] = useState<File | null>(null);
const inputId = useId();
@ -155,6 +156,7 @@ function FileUpload({ value, onChange }: Props) {
id={inputId}
type="file"
onChange={handleFileChange}
aria-required={required || undefined}
accept=".csv,.pdf"
className="hidden"
/>
@ -176,6 +178,7 @@ function FileUpload({ value, onChange }: Props) {
<Label>File URL</Label>
{typeof value === "string" && (
<Input
aria-required={required || undefined}
value={value}
onChange={(event) => onChange(event.target.value)}
/>

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
@ -14,7 +14,10 @@ import { Label } from "@/components/ui/label";
import { useQuery } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import type { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
import type {
WorkflowApiResponse,
Parameter,
} from "@/routes/workflows/types/workflowTypes";
import {
CRON_PRESETS,
cronToHumanReadable,
@ -25,6 +28,10 @@ import {
isValidCron,
} from "@/routes/workflows/editor/panels/schedulePanel/cronUtils";
import { cn } from "@/util/utils";
import { useWorkflowQuery } from "@/routes/workflows/hooks/useWorkflowQuery";
import { ScheduleParametersSection } from "@/routes/workflows/components/ScheduleParametersSection";
import { buildScheduleParametersPayload } from "@/routes/workflows/components/scheduleParameters";
import { useScheduleParameterState } from "@/routes/workflows/hooks/useScheduleParameterState";
import { useCreateOrgScheduleMutation } from "./useCreateOrgScheduleMutation";
type Props = {
@ -48,6 +55,8 @@ function CreateOrgScheduleDialog({ open, onOpenChange }: Readonly<Props>) {
const [scheduleName, setScheduleName] = useState("");
const [scheduleDescription, setScheduleDescription] = useState("");
const allTimezones = useMemo(() => getTimezones(), []);
const { data: workflows = [] } = useQuery<Array<WorkflowApiResponse>>({
queryKey: ["workflows", "scheduleDialogPicker", workflowSearch],
queryFn: async () => {
@ -64,13 +73,65 @@ function CreateOrgScheduleDialog({ open, onOpenChange }: Readonly<Props>) {
enabled: open,
});
const allTimezones = useMemo(() => getTimezones(), []);
const { data: selectedWorkflowDetail } = useWorkflowQuery({
workflowPermanentId: selectedWorkflow?.workflow_permanent_id,
});
// The workflow detail (and therefore the parameter definitions) is loaded
// iff the resolved workflow id matches the selection. While the request is
// still in flight or stale, the form must not allow submission — otherwise
// we'd POST without `parameters` for a workflow that has required inputs
// and the backend would 400 with "Missing schedule parameters".
const workflowDetailLoaded =
selectedWorkflow !== null &&
selectedWorkflowDetail?.workflow_permanent_id ===
selectedWorkflow.workflow_permanent_id;
const workflowParameters = useMemo<ReadonlyArray<Parameter>>(() => {
if (
!selectedWorkflowDetail ||
selectedWorkflowDetail.workflow_permanent_id !==
selectedWorkflow?.workflow_permanent_id
) {
return [];
}
return selectedWorkflowDetail.workflow_definition.parameters;
}, [selectedWorkflow, selectedWorkflowDetail]);
const {
values: parameters,
errors: parameterErrors,
handleChange: handleParameterChange,
validate: validateParameters,
reset: resetParameters,
clear: clearParameters,
} = useScheduleParameterState(workflowParameters);
// Re-seed parameter state when (a) the user switches workflows or
// (b) the parameter definitions for the currently-selected workflow
// arrive from a still-in-flight react-query fetch. We track the
// (workflowId, paramsLength) tuple in a ref so refetches that produce
// an identical parameter set don't clobber user-typed values.
const selectedWorkflowId = selectedWorkflow?.workflow_permanent_id ?? null;
const lastSeededRef = useRef<{ id: string | null; count: number }>({
id: null,
count: 0,
});
useEffect(() => {
const last = lastSeededRef.current;
const next = { id: selectedWorkflowId, count: workflowParameters.length };
if (last.id !== next.id || last.count !== next.count) {
lastSeededRef.current = next;
resetParameters();
}
}, [selectedWorkflowId, workflowParameters, resetParameters]);
const filteredTimezones = useMemo(() => {
if (timezoneFilter === null) return allTimezones;
if (!timezoneFilter) return allTimezones;
const lower = timezoneFilter.toLowerCase();
return allTimezones.filter((tz) => tz.toLowerCase().includes(lower));
}, [allTimezones, timezoneFilter]);
}, [timezoneFilter, allTimezones]);
const valid = isValidCron(cronExpression);
const humanReadable = valid ? cronToHumanReadable(cronExpression) : null;
@ -85,10 +146,17 @@ function CreateOrgScheduleDialog({ open, onOpenChange }: Readonly<Props>) {
setTimezoneFilter(null);
setScheduleName("");
setScheduleDescription("");
clearParameters();
}
function handleSubmit() {
if (!valid || !selectedWorkflow) return;
if (!selectedWorkflow || !workflowDetailLoaded) return;
const parametersValid = validateParameters();
if (!valid || !parametersValid) return;
const payload = buildScheduleParametersPayload(
parameters,
workflowParameters,
);
createMutation.mutate(
{
workflowPermanentId: selectedWorkflow.workflow_permanent_id,
@ -97,6 +165,7 @@ function CreateOrgScheduleDialog({ open, onOpenChange }: Readonly<Props>) {
timezone,
...(scheduleName && { name: scheduleName }),
...(scheduleDescription && { description: scheduleDescription }),
...(payload && { parameters: payload }),
},
},
{
@ -195,6 +264,14 @@ function CreateOrgScheduleDialog({ open, onOpenChange }: Readonly<Props>) {
/>
</div>
<ScheduleParametersSection
parameters={workflowParameters}
values={parameters}
onChange={handleParameterChange}
errors={parameterErrors}
disabled={createMutation.isPending}
/>
{/* Cron Presets */}
<div className="space-y-2">
<Label>Quick Presets</Label>
@ -305,7 +382,9 @@ function CreateOrgScheduleDialog({ open, onOpenChange }: Readonly<Props>) {
Cancel
</Button>
<Button
disabled={!valid || !selectedWorkflow || createMutation.isPending}
disabled={
!valid || !workflowDetailLoaded || createMutation.isPending
}
onClick={handleSubmit}
>
{createMutation.isPending ? "Creating..." : "Create Schedule"}

View file

@ -18,10 +18,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useQuery } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import type { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
import { useWorkflowQuery } from "@/routes/workflows/hooks/useWorkflowQuery";
import { useScheduleDetailQuery } from "./useScheduleDetailQuery";
import {
useDeleteOrgScheduleMutation,
@ -39,12 +36,22 @@ import {
} from "@/routes/workflows/editor/panels/schedulePanel/cronUtils";
import { cn } from "@/util/utils";
import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat";
import { ScheduleParametersSection } from "@/routes/workflows/components/ScheduleParametersSection";
import {
buildScheduleParametersPayload,
formatScheduleParameterValue,
hasUserFacingParameters,
isScheduleParameter,
} from "@/routes/workflows/components/scheduleParameters";
import { useScheduleParameterState } from "@/routes/workflows/hooks/useScheduleParameterState";
import type { Parameter } from "@/routes/workflows/types/workflowTypes";
const allTimezones = getTimezones();
function ScheduleDetailPage() {
const navigate = useNavigate();
const location = useLocation();
const { workflowPermanentId, scheduleId } = useParams();
const credentialGetter = useCredentialGetter();
const { data, isLoading, isError } = useScheduleDetailQuery(
workflowPermanentId,
scheduleId,
@ -52,15 +59,8 @@ function ScheduleDetailPage() {
const titleFromState = (location.state as { workflowTitle?: string })
?.workflowTitle;
const { data: workflow } = useQuery<WorkflowApiResponse>({
queryKey: ["workflow", workflowPermanentId],
queryFn: async () => {
const client = await getClient(credentialGetter);
return client
.get(`/workflows/${workflowPermanentId}`)
.then((r) => r.data);
},
enabled: !!workflowPermanentId && !titleFromState,
const { data: workflow, isSuccess: workflowLoaded } = useWorkflowQuery({
workflowPermanentId,
});
const workflowTitle =
titleFromState || workflow?.title || workflowPermanentId || "Schedule";
@ -82,13 +82,27 @@ function ScheduleDetailPage() {
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const allTimezones = useMemo(() => getTimezones(), []);
const workflowParameters: ReadonlyArray<Parameter> = useMemo(
() => workflow?.workflow_definition.parameters ?? [],
[workflow],
);
const {
values: editParameters,
errors: parameterErrors,
handleChange: handleParameterChange,
validate: validateParameters,
reset: resetParameters,
} = useScheduleParameterState(
workflowParameters,
data?.schedule.parameters ?? null,
);
const filteredTimezones = useMemo(() => {
if (timezoneFilter === null) return allTimezones;
if (!timezoneFilter) return allTimezones;
const lower = timezoneFilter.toLowerCase();
return allTimezones.filter((tz) => tz.toLowerCase().includes(lower));
}, [allTimezones, timezoneFilter]);
}, [timezoneFilter]);
// ! end TODO
const editValid = isValidCron(editCron);
@ -120,16 +134,30 @@ function ScheduleDetailPage() {
setTimezoneFilter(null);
setEditName(schedule.name ?? "");
setEditDescription(schedule.description ?? "");
resetParameters();
setEditing(true);
}
function cancelEditing() {
setEditing(false);
setTimezoneFilter(null);
// Drop any in-progress parameter edits so they don't reappear on
// re-entry; re-seed from the stored schedule values.
resetParameters();
}
function handleSave() {
if (!editValid || !workflowPermanentId || !scheduleId) return;
if (!workflowPermanentId || !scheduleId) return;
const parametersValid = validateParameters();
if (!editValid || !parametersValid) return;
// Only persist explicitly-set overrides. The form is seeded from
// workflow defaults, so blindly sending the whole values dict would
// pin the current default into the schedule and change semantics from
// "use workflow default at execution time" to "freeze current default".
const payload = buildScheduleParametersPayload(
editParameters,
workflowParameters,
);
updateMutation.mutate(
{
workflowPermanentId,
@ -138,7 +166,7 @@ function ScheduleDetailPage() {
cron_expression: editCron,
timezone: editTimezone,
enabled: schedule.enabled,
parameters: schedule.parameters,
parameters: payload,
...(editName && { name: editName }),
description: editDescription || undefined,
},
@ -246,6 +274,10 @@ function ScheduleDetailPage() {
size="sm"
className="h-7 gap-1.5 px-2 text-xs"
onClick={startEditing}
disabled={!workflowLoaded}
title={
workflowLoaded ? undefined : "Loading workflow definition..."
}
>
<Pencil1Icon className="size-3" />
Edit
@ -277,6 +309,14 @@ function ScheduleDetailPage() {
/>
</div>
<ScheduleParametersSection
parameters={workflowParameters}
values={editParameters}
onChange={handleParameterChange}
errors={parameterErrors}
disabled={updateMutation.isPending}
/>
{/* Cron Presets */}
<div className="space-y-2">
<Label className="text-xs">Quick Presets</Label>
@ -454,6 +494,50 @@ function ScheduleDetailPage() {
</div>
</div>
{hasUserFacingParameters(workflowParameters) && (
<div className="rounded-lg border border-slate-700 p-4">
<h3 className="mb-4 text-sm text-slate-400">
Workflow Parameters
</h3>
<div className="space-y-2">
{workflowParameters
.filter(isScheduleParameter)
.map((parameter) => {
const storedValue = schedule.parameters?.[parameter.key];
const hasValue =
storedValue !== undefined && storedValue !== null;
return (
<div
key={parameter.key}
className="flex items-start justify-between gap-4"
>
<span className="font-mono text-xs text-slate-400">
{parameter.key}
</span>
<span className="max-w-[60%] truncate text-right text-xs text-slate-50">
{hasValue ? (
formatScheduleParameterValue(storedValue)
) : parameter.default_value !== null &&
parameter.default_value !== undefined ? (
<span className="italic text-slate-500">
default:{" "}
{formatScheduleParameterValue(
parameter.default_value,
)}
</span>
) : (
<span className="italic text-slate-500">
(not set)
</span>
)}
</span>
</div>
);
})}
</div>
</div>
)}
<div className="rounded-lg border border-slate-700 p-4">
<h3 className="mb-4 text-sm text-slate-400">Upcoming Runs</h3>
<p className="mb-2 text-xs text-slate-400">

View file

@ -16,14 +16,24 @@ type Props = {
type: WorkflowParameterValueType;
value: unknown;
onChange: (value: unknown) => void;
required?: boolean;
disabled?: boolean;
};
function WorkflowParameterInput({ type, value, onChange }: Props) {
function WorkflowParameterInput({
type,
value,
onChange,
required,
disabled,
}: Props) {
if (type === "json") {
return (
<CodeEditor
className="w-full"
language="json"
aria-required={required || undefined}
readOnly={disabled}
onChange={(value) => onChange(value)}
value={
typeof value === "string" ? value : JSON.stringify(value, null, 2)
@ -37,7 +47,9 @@ function WorkflowParameterInput({ type, value, onChange }: Props) {
if (type === "string") {
return (
<AutoResizingTextarea
value={value as string}
aria-required={required || undefined}
disabled={disabled}
value={(value as string | null | undefined) ?? ""}
onChange={(e) => onChange(e.target.value)}
/>
);
@ -46,6 +58,8 @@ function WorkflowParameterInput({ type, value, onChange }: Props) {
if (type === "integer") {
return (
<Input
aria-required={required || undefined}
disabled={disabled}
value={value === null ? "" : Number(value)}
onChange={(e) => {
const val = e.target.value;
@ -60,6 +74,8 @@ function WorkflowParameterInput({ type, value, onChange }: Props) {
if (type === "float") {
return (
<Input
aria-required={required || undefined}
disabled={disabled}
value={value === null ? "" : Number(value)}
onChange={(e) => {
const val = e.target.value;
@ -75,10 +91,11 @@ function WorkflowParameterInput({ type, value, onChange }: Props) {
if (type === "boolean") {
return (
<Select
disabled={disabled}
value={value === null ? "" : String(value)}
onValueChange={(v) => onChange(v === "true")}
>
<SelectTrigger className="w-48">
<SelectTrigger aria-required={required || undefined} className="w-48">
<SelectValue placeholder="Select value..." />
</SelectTrigger>
<SelectContent>
@ -92,6 +109,7 @@ function WorkflowParameterInput({ type, value, onChange }: Props) {
if (type === "file_url") {
return (
<FileUpload
required={required}
value={value as FileInputValue}
onChange={(value) => onChange(value)}
/>
@ -102,11 +120,14 @@ function WorkflowParameterInput({ type, value, onChange }: Props) {
const credentialId = value as string | null;
return (
<CredentialSelector
required={required}
value={credentialId ?? ""}
onChange={(value) => onChange(value)}
/>
);
}
return null;
}
export { WorkflowParameterInput };

View file

@ -31,7 +31,7 @@ type Props = {
className?: string;
fontSize?: number;
fullHeight?: boolean;
};
} & Pick<React.HTMLAttributes<HTMLDivElement>, "aria-required">;
const fullHeightExtension = EditorView.theme({
"&": { height: "100%" },
@ -49,6 +49,7 @@ function CodeEditor({
readOnly = false,
fontSize = 12,
fullHeight = false,
...restProps
}: Props) {
const viewRef = useRef<EditorView | null>(null);
const [internalValue, setInternalValue] = useState(value);
@ -127,6 +128,7 @@ function CodeEditor({
readOnly={readOnly}
className={cn("cursor-auto", className)}
style={style}
{...restProps}
onCreateEditor={(view) => {
viewRef.current = view;
}}

View file

@ -22,9 +22,10 @@ type Props = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
};
function CredentialSelector({ value, onChange, placeholder }: Props) {
function CredentialSelector({ value, onChange, placeholder, required }: Props) {
const { setIsOpen, setType } = useCredentialModalState();
const { data: credentials, isLoading } = useCredentialsQuery({
page_size: 100, // Reasonable limit for dropdown selector
@ -51,7 +52,7 @@ function CredentialSelector({ value, onChange, placeholder }: Props) {
}
}}
>
<SelectTrigger>
<SelectTrigger aria-required={required || undefined}>
<SelectValue placeholder={placeholder ?? "Select a credential"} />
</SelectTrigger>
<SelectContent>

View file

@ -0,0 +1,91 @@
import { Label } from "@/components/ui/label";
import { WorkflowParameterInput } from "@/routes/workflows/WorkflowParameterInput";
import type { Parameter } from "@/routes/workflows/types/workflowTypes";
import { getLabelForWorkflowParameterType } from "@/routes/workflows/editor/workflowEditorUtils";
import {
hasUserFacingParameters,
isRequired,
isScheduleParameter,
} from "./scheduleParameters";
type Props = {
parameters: ReadonlyArray<Parameter>;
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
errors?: Record<string, string | undefined>;
disabled?: boolean;
};
function ScheduleParametersSection({
parameters,
values,
onChange,
errors,
disabled,
}: Readonly<Props>) {
if (!hasUserFacingParameters(parameters)) {
return null;
}
const workflowParameters = parameters.filter(isScheduleParameter);
return (
<div className="space-y-2">
<div className="space-y-1">
<Label>Workflow Parameters</Label>
<p className="text-xs text-slate-500">
Values supplied here are used every time this schedule runs.
</p>
</div>
<div className="space-y-4 rounded-md border border-slate-700 bg-slate-elevation3 p-3">
{workflowParameters.map((parameter) => {
const error = errors?.[parameter.key];
const required = isRequired(parameter);
return (
<div key={parameter.key} className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">
{parameter.key}
{required && (
<span
aria-label="required"
className="ml-1 text-destructive"
>
*
</span>
)}
</Label>
<span className="text-[10px] uppercase tracking-wide text-slate-500">
{getLabelForWorkflowParameterType(
parameter.workflow_parameter_type,
)}
</span>
</div>
{parameter.description && (
<p className="text-xs text-slate-500">
{parameter.description}
</p>
)}
<fieldset disabled={disabled} className="contents">
<WorkflowParameterInput
type={parameter.workflow_parameter_type}
value={values[parameter.key] ?? null}
required={required}
disabled={disabled}
onChange={(next) => onChange(parameter.key, next)}
/>
</fieldset>
{error && (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
)}
</div>
);
})}
</div>
</div>
);
}
export { ScheduleParametersSection };

View file

@ -0,0 +1,626 @@
import { describe, test, expect } from "vitest";
import {
isScheduleParameter,
isRequired,
isMissingRequiredValue,
hasUserFacingParameters,
buildInitialParameterValues,
buildScheduleParametersPayload,
formatScheduleParameterValue,
validateScheduleParameters,
} from "./scheduleParameters";
import type {
Parameter,
WorkflowParameter,
} from "@/routes/workflows/types/workflowTypes";
function makeWorkflowParam(
partial: Partial<WorkflowParameter> & {
key: string;
workflow_parameter_type: WorkflowParameter["workflow_parameter_type"];
},
): WorkflowParameter {
return {
parameter_type: "workflow",
key: partial.key,
description: null,
workflow_parameter_type: partial.workflow_parameter_type,
default_value: partial.default_value ?? null,
// Cast-only fields the helpers never read:
workflow_parameter_id: "wp_test",
workflow_id: "w_test",
created_at: "2026-01-01T00:00:00Z",
modified_at: "2026-01-01T00:00:00Z",
deleted_at: null,
} as WorkflowParameter;
}
describe("isScheduleParameter", () => {
test("returns true for workflow parameters", () => {
const param = makeWorkflowParam({
key: "x",
workflow_parameter_type: "string",
});
expect(isScheduleParameter(param)).toBe(true);
});
test("returns false for context parameters", () => {
const param = { parameter_type: "context", key: "ctx" } as Parameter;
expect(isScheduleParameter(param)).toBe(false);
});
test("returns false for output parameters", () => {
const param = { parameter_type: "output", key: "out" } as Parameter;
expect(isScheduleParameter(param)).toBe(false);
});
test("returns false for aws_secret parameters", () => {
const param = { parameter_type: "aws_secret", key: "s" } as Parameter;
expect(isScheduleParameter(param)).toBe(false);
});
});
describe("isRequired", () => {
test("returns true for null default_value", () => {
const p = makeWorkflowParam({
key: "k",
workflow_parameter_type: "string",
default_value: null,
});
expect(isRequired(p)).toBe(true);
});
test("returns true for undefined default_value", () => {
const p = makeWorkflowParam({
key: "k",
workflow_parameter_type: "string",
});
// makeWorkflowParam coalesces undefined → null via ??, so explicitly set:
(p as { default_value: unknown }).default_value = undefined;
expect(isRequired(p)).toBe(true);
});
test("returns false for empty string default", () => {
const p = makeWorkflowParam({
key: "k",
workflow_parameter_type: "string",
default_value: "",
});
expect(isRequired(p)).toBe(false);
});
test("returns false for false boolean default", () => {
const p = makeWorkflowParam({
key: "k",
workflow_parameter_type: "boolean",
default_value: false,
});
expect(isRequired(p)).toBe(false);
});
test("returns false for 0 numeric default", () => {
const p = makeWorkflowParam({
key: "k",
workflow_parameter_type: "integer",
default_value: 0,
});
expect(isRequired(p)).toBe(false);
});
test("returns false for non-empty string default", () => {
const p = makeWorkflowParam({
key: "k",
workflow_parameter_type: "string",
default_value: "hello",
});
expect(isRequired(p)).toBe(false);
});
});
describe("isMissingRequiredValue", () => {
test("null is missing for every type", () => {
const types = [
"string",
"integer",
"float",
"boolean",
"json",
"file_url",
"credential_id",
] as const;
for (const t of types) {
const p = makeWorkflowParam({ key: "k", workflow_parameter_type: t });
expect(isMissingRequiredValue(p, null)).toBe(true);
}
});
test("undefined is missing for every type", () => {
const p = makeWorkflowParam({
key: "k",
workflow_parameter_type: "string",
});
expect(isMissingRequiredValue(p, undefined)).toBe(true);
});
test("empty string is NOT missing for string type", () => {
const p = makeWorkflowParam({
key: "k",
workflow_parameter_type: "string",
});
expect(isMissingRequiredValue(p, "")).toBe(false);
});
test("empty string IS missing for integer/float/boolean/json/file_url/credential_id", () => {
const types = [
"integer",
"float",
"boolean",
"json",
"file_url",
"credential_id",
] as const;
for (const t of types) {
const p = makeWorkflowParam({ key: "k", workflow_parameter_type: t });
expect(isMissingRequiredValue(p, "")).toBe(true);
}
});
test("whitespace-only string is missing for json", () => {
const p = makeWorkflowParam({ key: "k", workflow_parameter_type: "json" });
expect(isMissingRequiredValue(p, " ")).toBe(true);
});
test("whitespace-only string is missing for credential_id", () => {
const p = makeWorkflowParam({
key: "k",
workflow_parameter_type: "credential_id",
});
expect(isMissingRequiredValue(p, " ")).toBe(true);
});
test("whitespace-only string is missing for integer/float/boolean", () => {
const types = ["integer", "float", "boolean"] as const;
for (const t of types) {
const p = makeWorkflowParam({ key: "k", workflow_parameter_type: t });
expect(isMissingRequiredValue(p, " ")).toBe(true);
}
});
test("whitespace-only string is missing for file_url", () => {
const p = makeWorkflowParam({
key: "k",
workflow_parameter_type: "file_url",
});
expect(isMissingRequiredValue(p, " ")).toBe(true);
});
test("empty dict is missing for file_url", () => {
const p = makeWorkflowParam({
key: "k",
workflow_parameter_type: "file_url",
});
expect(isMissingRequiredValue(p, {})).toBe(true);
});
test("file_url dict with empty s3uri is missing", () => {
const p = makeWorkflowParam({
key: "k",
workflow_parameter_type: "file_url",
});
expect(isMissingRequiredValue(p, { s3uri: "" })).toBe(true);
});
test("file_url dict with non-empty s3uri is not missing", () => {
const p = makeWorkflowParam({
key: "k",
workflow_parameter_type: "file_url",
});
expect(isMissingRequiredValue(p, { s3uri: "s3://bucket/k" })).toBe(false);
});
test("valid values for each type are not missing", () => {
const cases: Array<
[WorkflowParameter["workflow_parameter_type"], unknown]
> = [
["string", "hello"],
["integer", 42],
["float", 3.14],
["boolean", true],
["boolean", false],
["json", '{"k": 1}'],
["credential_id", "cred_abc"],
];
for (const [t, v] of cases) {
const p = makeWorkflowParam({ key: "k", workflow_parameter_type: t });
expect(isMissingRequiredValue(p, v)).toBe(false);
}
});
});
describe("hasUserFacingParameters", () => {
test("returns false for empty array", () => {
expect(hasUserFacingParameters([])).toBe(false);
});
test("returns false when only context/output parameters exist", () => {
const params = [
{ parameter_type: "context", key: "c" } as Parameter,
{ parameter_type: "output", key: "o" } as Parameter,
];
expect(hasUserFacingParameters(params)).toBe(false);
});
test("returns true when at least one workflow parameter exists", () => {
const params = [
{ parameter_type: "context", key: "c" } as Parameter,
makeWorkflowParam({ key: "w", workflow_parameter_type: "string" }),
];
expect(hasUserFacingParameters(params)).toBe(true);
});
});
describe("buildInitialParameterValues", () => {
test("returns empty object when no workflow parameters", () => {
expect(buildInitialParameterValues([], null)).toEqual({});
expect(buildInitialParameterValues([], {})).toEqual({});
});
test("seeds missing keys with parameter default_value", () => {
const params = [
makeWorkflowParam({
key: "a",
workflow_parameter_type: "string",
default_value: "hello",
}),
makeWorkflowParam({
key: "b",
workflow_parameter_type: "integer",
default_value: 7,
}),
];
expect(buildInitialParameterValues(params, null)).toEqual({
a: "hello",
b: 7,
});
});
test("existing values in stored override defaults", () => {
const params = [
makeWorkflowParam({
key: "a",
workflow_parameter_type: "string",
default_value: "hello",
}),
];
expect(buildInitialParameterValues(params, { a: "custom" })).toEqual({
a: "custom",
});
});
test("missing stored values with no default yield empty string for string type", () => {
const params = [
makeWorkflowParam({
key: "a",
workflow_parameter_type: "string",
default_value: null,
}),
];
expect(buildInitialParameterValues(params, null)).toEqual({ a: "" });
});
test("missing stored values with no default yield null for non-string types", () => {
const params = [
makeWorkflowParam({
key: "n",
workflow_parameter_type: "integer",
default_value: null,
}),
makeWorkflowParam({
key: "b",
workflow_parameter_type: "boolean",
default_value: null,
}),
];
expect(buildInitialParameterValues(params, null)).toEqual({
n: null,
b: null,
});
});
test("ignores unknown keys in stored (does not pass them through)", () => {
const params = [
makeWorkflowParam({
key: "a",
workflow_parameter_type: "string",
default_value: null,
}),
];
expect(buildInitialParameterValues(params, { a: "x", bogus: "y" })).toEqual(
{ a: "x" },
);
});
test("json parameter with object default is stringified", () => {
const params = [
makeWorkflowParam({
key: "j",
workflow_parameter_type: "json",
default_value: { foo: 1, bar: [2, 3] },
}),
];
const result = buildInitialParameterValues(params, null);
expect(typeof result.j).toBe("string");
expect(JSON.parse(result.j as string)).toEqual({ foo: 1, bar: [2, 3] });
});
test("json parameter with string default is kept as-is", () => {
const params = [
makeWorkflowParam({
key: "j",
workflow_parameter_type: "json",
default_value: '{"foo": 1}',
}),
];
expect(buildInitialParameterValues(params, null)).toEqual({
j: '{"foo": 1}',
});
});
test('boolean parameter with string "true" default is coerced to true', () => {
const params = [
makeWorkflowParam({
key: "b",
workflow_parameter_type: "boolean",
default_value: "true",
}),
];
expect(buildInitialParameterValues(params, null)).toEqual({ b: true });
});
test('boolean parameter with string "false" default is coerced to false', () => {
const params = [
makeWorkflowParam({
key: "b",
workflow_parameter_type: "boolean",
default_value: "false",
}),
];
expect(buildInitialParameterValues(params, null)).toEqual({ b: false });
});
test("boolean parameter with actual true default is passed through", () => {
const params = [
makeWorkflowParam({
key: "b",
workflow_parameter_type: "boolean",
default_value: true,
}),
];
expect(buildInitialParameterValues(params, null)).toEqual({ b: true });
});
test("stored value overrides default without coercion", () => {
// Even if default_value would be stringified, an existing stored value
// (from an already-saved schedule) must be passed through verbatim so
// opening+saving the edit form is a no-op.
const params = [
makeWorkflowParam({
key: "j",
workflow_parameter_type: "json",
default_value: { foo: 1 },
}),
];
// Stored value is already a string — passed through.
expect(buildInitialParameterValues(params, { j: '{"bar": 2}' })).toEqual({
j: '{"bar": 2}',
});
});
});
describe("formatScheduleParameterValue", () => {
test("null and undefined render as empty string", () => {
expect(formatScheduleParameterValue(null)).toBe("");
expect(formatScheduleParameterValue(undefined)).toBe("");
});
test("primitive strings render as-is", () => {
expect(formatScheduleParameterValue("hello")).toBe("hello");
expect(formatScheduleParameterValue("")).toBe("");
});
test("primitive numbers and booleans use String()", () => {
expect(formatScheduleParameterValue(42)).toBe("42");
expect(formatScheduleParameterValue(3.14)).toBe("3.14");
expect(formatScheduleParameterValue(true)).toBe("true");
expect(formatScheduleParameterValue(false)).toBe("false");
});
test("file_url dict with string s3uri unwraps to the URI", () => {
expect(formatScheduleParameterValue({ s3uri: "s3://bucket/key" })).toBe(
"s3://bucket/key",
);
});
test("file_url dict with non-string s3uri falls back to JSON", () => {
const value = { s3uri: null };
expect(formatScheduleParameterValue(value)).toBe(JSON.stringify(value));
});
test("plain objects render as JSON (not [object Object])", () => {
expect(formatScheduleParameterValue({ foo: 1, bar: [2, 3] })).toBe(
'{"foo":1,"bar":[2,3]}',
);
});
test("arrays render as JSON", () => {
expect(formatScheduleParameterValue([1, 2, 3])).toBe("[1,2,3]");
});
});
describe("validateScheduleParameters", () => {
test("returns empty object when all required values are present", () => {
const params = [
makeWorkflowParam({ key: "a", workflow_parameter_type: "string" }),
makeWorkflowParam({ key: "b", workflow_parameter_type: "integer" }),
];
expect(validateScheduleParameters(params, { a: "hello", b: 7 })).toEqual(
{},
);
});
test("flags a single missing required parameter", () => {
const params = [
makeWorkflowParam({ key: "a", workflow_parameter_type: "integer" }),
];
expect(validateScheduleParameters(params, {})).toEqual({ a: "Required" });
});
test("flags multiple missing required parameters", () => {
const params = [
makeWorkflowParam({ key: "a", workflow_parameter_type: "integer" }),
makeWorkflowParam({ key: "b", workflow_parameter_type: "boolean" }),
];
expect(validateScheduleParameters(params, {})).toEqual({
a: "Required",
b: "Required",
});
});
test("ignores parameters that have a default value", () => {
const params = [
makeWorkflowParam({
key: "a",
workflow_parameter_type: "integer",
default_value: 42,
}),
];
expect(validateScheduleParameters(params, {})).toEqual({});
});
test("ignores non-workflow parameters (context, output, aws_secret)", () => {
const params: Parameter[] = [
{ parameter_type: "context", key: "ctx" } as Parameter,
{ parameter_type: "output", key: "out" } as Parameter,
{ parameter_type: "aws_secret", key: "sec" } as Parameter,
];
expect(validateScheduleParameters(params, {})).toEqual({});
});
test("flags whitespace-only string for json type (matches backend)", () => {
const params = [
makeWorkflowParam({ key: "j", workflow_parameter_type: "json" }),
];
expect(validateScheduleParameters(params, { j: " " })).toEqual({
j: "Required",
});
});
test("does NOT flag empty string for plain string type", () => {
const params = [
makeWorkflowParam({ key: "s", workflow_parameter_type: "string" }),
];
expect(validateScheduleParameters(params, { s: "" })).toEqual({});
});
});
describe("buildScheduleParametersPayload", () => {
test("returns null when there are no parameters", () => {
expect(buildScheduleParametersPayload({}, [])).toBeNull();
});
test("always includes required parameters even when empty string", () => {
const params = [
makeWorkflowParam({ key: "name", workflow_parameter_type: "string" }),
];
expect(buildScheduleParametersPayload({ name: "" }, params)).toEqual({
name: "",
});
});
test("required parameters with values are included as-is", () => {
const params = [
makeWorkflowParam({ key: "name", workflow_parameter_type: "string" }),
makeWorkflowParam({ key: "n", workflow_parameter_type: "integer" }),
];
expect(
buildScheduleParametersPayload({ name: "alice", n: 42 }, params),
).toEqual({ name: "alice", n: 42 });
});
test("omits optional parameters whose value still matches the seeded default", () => {
const params = [
makeWorkflowParam({
key: "greeting",
workflow_parameter_type: "string",
default_value: "hello",
}),
makeWorkflowParam({
key: "n",
workflow_parameter_type: "integer",
default_value: 5,
}),
];
expect(
buildScheduleParametersPayload({ greeting: "hello", n: 5 }, params),
).toBeNull();
});
test("includes optional parameter when user changed it from the default", () => {
const params = [
makeWorkflowParam({
key: "greeting",
workflow_parameter_type: "string",
default_value: "hello",
}),
];
expect(buildScheduleParametersPayload({ greeting: "hi" }, params)).toEqual({
greeting: "hi",
});
});
test("optional string cleared to empty is dropped (will use default at exec)", () => {
const params = [
makeWorkflowParam({
key: "greeting",
workflow_parameter_type: "string",
default_value: "hello",
}),
];
expect(buildScheduleParametersPayload({ greeting: "" }, params)).toBeNull();
});
test("drops empty file_url shaped objects for optional file_url params", () => {
const params = [
makeWorkflowParam({
key: "doc",
workflow_parameter_type: "file_url",
default_value: "s3://default/key",
}),
];
expect(
buildScheduleParametersPayload({ doc: { s3uri: "" } }, params),
).toBeNull();
});
test("preserves boolean false set by user when default is true", () => {
const params = [
makeWorkflowParam({
key: "flag",
workflow_parameter_type: "boolean",
default_value: true,
}),
];
expect(buildScheduleParametersPayload({ flag: false }, params)).toEqual({
flag: false,
});
});
test("ignores values for keys not in the parameter list", () => {
const params = [
makeWorkflowParam({ key: "a", workflow_parameter_type: "string" }),
];
expect(
buildScheduleParametersPayload({ a: "hi", stale: "leftover" }, params),
).toEqual({ a: "hi" });
});
});

View file

@ -0,0 +1,329 @@
import {
WorkflowParameterTypes,
type Parameter,
type WorkflowParameter,
} from "@/routes/workflows/types/workflowTypes";
/**
* Mirror of `_is_schedule_input_parameter` in
* skyvern/forge/sdk/workflow/service.py. Only `WorkflowParameter` instances
* (parameter_type === "workflow") are user-supplied for schedules.
*/
export function isScheduleParameter(
parameter: Parameter,
): parameter is WorkflowParameter {
return parameter.parameter_type === WorkflowParameterTypes.Workflow;
}
/**
* A workflow parameter is "required" iff it has no default value. The form
* validators block submission until every required parameter has a value;
* parameters with defaults can be left blank and the backend will fall back
* to the default at execution time (see service.py:582-583).
*/
export function isRequired(parameter: WorkflowParameter): boolean {
return (
parameter.default_value === null || parameter.default_value === undefined
);
}
export function hasUserFacingParameters(
parameters: ReadonlyArray<Parameter>,
): boolean {
return parameters.some(isScheduleParameter);
}
/**
* Compute the validation errors for a schedule parameter form. Returns
* a `{ key: "Required" }` map for every required parameter that is
* missing a value, mirroring the backend's loop in
* skyvern/forge/sdk/workflow/service.py:552-607. The caller decides
* what to do with the result typically:
* const errors = validateScheduleParameters(workflowParameters, values);
* setParameterErrors(errors);
* if (Object.keys(errors).length > 0) return; // block submit
*/
export function validateScheduleParameters(
parameters: ReadonlyArray<Parameter>,
values: Record<string, unknown>,
): Record<string, string> {
const errors: Record<string, string> = {};
for (const parameter of parameters) {
if (!isScheduleParameter(parameter)) continue;
if (!isRequired(parameter)) continue;
const value = values[parameter.key];
if (isMissingRequiredValue(parameter, value)) {
errors[parameter.key] = "Required";
}
}
return errors;
}
/**
* Format a stored schedule parameter value for read-only display in the
* schedule detail page. Plain `String(value)` produces "[object Object]"
* for `json` parameters with object values and for `file_url` parameters
* stored as `{ s3uri: "..." }` dicts. This helper unwraps a `s3uri` dict
* to its underlying URI string and falls back to `JSON.stringify` for
* other objects.
*/
export function formatScheduleParameterValue(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value;
if (typeof value === "object") {
const obj = value as Record<string, unknown>;
if ("s3uri" in obj) {
const uri = obj.s3uri;
return typeof uri === "string" ? uri : JSON.stringify(value);
}
return JSON.stringify(value);
}
return String(value);
}
const BLANK_STRING_REQUIRED_TYPES = new Set<string>([
"json",
"credential_id",
"integer",
"float",
"boolean",
]);
function isBlankString(value: unknown): value is string {
return typeof value === "string" && value.trim() === "";
}
function isMissingFileUrlValue(value: unknown): boolean {
if (isBlankString(value)) return true;
if (typeof value !== "object" || value === null) return false;
const fileUrlValue = value as Record<string, unknown>;
return (
Object.keys(fileUrlValue).length === 0 ||
("s3uri" in fileUrlValue &&
(fileUrlValue.s3uri === "" || fileUrlValue.s3uri == null))
);
}
/**
* Mirror of `_is_missing_required_value` in
* skyvern/forge/sdk/workflow/service.py:839. Keep these two in sync if the
* backend rule changes, update both.
*
* Rules:
* - null/undefined is always missing
* - string parameters allow empty strings (per UI behavior comment in backend)
* - json / boolean / integer / float / credential_id treat empty/whitespace as missing
* - file_url treats empty string, empty dict, or dict with empty s3uri as missing
*/
export function isMissingRequiredValue(
parameter: WorkflowParameter,
value: unknown,
): boolean {
if (value == null) return true;
const parameterType = parameter.workflow_parameter_type;
if (parameterType === "string") {
return false; // backend allows empty strings
}
if (parameterType === "file_url") {
return isMissingFileUrlValue(value);
}
if (BLANK_STRING_REQUIRED_TYPES.has(parameterType)) {
return isBlankString(value);
}
return false;
}
function hasStoredValue(
storedValues: Record<string, unknown> | null | undefined,
key: string,
): storedValues is Record<string, unknown> {
return (
storedValues != null &&
Object.prototype.hasOwnProperty.call(storedValues, key)
);
}
function normalizeDefaultValue(parameter: WorkflowParameter): unknown {
const {
default_value: defaultValue,
workflow_parameter_type: parameterType,
} = parameter;
if (parameterType === "json") {
return typeof defaultValue === "string"
? defaultValue
: JSON.stringify(defaultValue, null, 2);
}
if (parameterType === "boolean") {
// Backend stores booleans as strings; coerce to real boolean for the form.
return defaultValue === true || defaultValue === "true";
}
return defaultValue;
}
function resolveInitialParameterValue(
parameter: WorkflowParameter,
storedValues: Record<string, unknown> | null | undefined,
): unknown {
const {
key,
default_value: defaultValue,
workflow_parameter_type: parameterType,
} = parameter;
if (hasStoredValue(storedValues, key)) {
return storedValues[key];
}
if (defaultValue != null) {
return normalizeDefaultValue(parameter);
}
return parameterType === "string" ? "" : null;
}
/**
* Build the parameters payload to send to the backend on schedule
* create/update. The schedule parameter form is seeded with a value for
* every workflow parameter (default-or-blank), and we want to:
*
* 1. Always include REQUIRED parameters (no default_value), even when
* the value is an empty string. The backend's
* `_is_missing_required_value` allows `""` for the `string` type, so
* stripping empty strings here would let an empty required string
* pass client validation but get dropped from the payload at which
* point the backend treats the key as missing and 400s.
* 2. For OPTIONAL parameters (have a default_value), omit any value
* that still matches the seeded initial. Persisting an untouched
* default would pin it into the schedule and change semantics from
* "use workflow default at execution time" to "freeze current
* default", causing silent drift after the workflow default changes.
* 3. Strip placeholder empties (`""`, `{}`, `{ s3uri: "" }`) for
* non-required parameters so the backend doesn't reject the key.
*/
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Object.prototype.toString.call(value) === "[object Object]";
}
function sanitizeOptionalValue(value: unknown): unknown {
if (value == null) return undefined;
if (typeof value === "string") {
return value.trim() === "" ? undefined : value;
}
if (Array.isArray(value)) {
const sanitized = value
.map((item) => sanitizeOptionalValue(item))
.filter((item) => item !== undefined);
return sanitized.length > 0 ? sanitized : undefined;
}
if (isPlainObject(value)) {
if (isMissingFileUrlValue(value)) return undefined;
const entries = Object.entries(value).flatMap(([key, nested]) => {
const sanitized = sanitizeOptionalValue(nested);
return sanitized === undefined ? [] : ([[key, sanitized]] as const);
});
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
return value;
}
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a == null || b == null) return false;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((item, idx) => deepEqual(item, b[idx]));
}
if (isPlainObject(a) && isPlainObject(b)) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every(
(key) =>
Object.prototype.hasOwnProperty.call(b, key) &&
deepEqual(a[key], b[key]),
);
}
return false;
}
export function buildScheduleParametersPayload(
values: Record<string, unknown>,
parameters: ReadonlyArray<Parameter>,
): Record<string, unknown> | null {
const result: Record<string, unknown> = {};
for (const parameter of parameters) {
if (!isScheduleParameter(parameter)) continue;
const key = parameter.key;
if (!Object.prototype.hasOwnProperty.call(values, key)) continue;
const value = values[key];
if (isRequired(parameter)) {
// Required parameters must always round-trip — even an empty
// string for required `string` (which the backend allows).
result[key] =
value == null && parameter.workflow_parameter_type === "string"
? ""
: value;
continue;
}
// Optional parameter: omit if it still matches the seeded default.
const seeded = resolveInitialParameterValue(parameter, null);
if (deepEqual(value, seeded)) continue;
const sanitized = sanitizeOptionalValue(value);
if (sanitized === undefined) continue;
result[key] = sanitized;
}
return Object.keys(result).length > 0 ? result : null;
}
/**
* Schedule-flavored variant of `getInitialValues` in
* `skyvern-frontend/src/routes/workflows/utils.ts`.
*
* Differs from the run-form equivalent in three ways:
* - Reads stored values from a `storedValues` record instead of router state
* - Narrows `Parameter[]` to schedule-editable workflow parameters via `isScheduleParameter`
* - Drops unknown keys from `storedValues` so removed workflow parameters
* do not leak into the submission payload
*
* Keep the JSON / boolean default coercion logic aligned with
* `getInitialValues` so both helpers produce consistent form state.
*
* Builds the initial values record for a schedule form. Each user-facing
* workflow parameter is seeded with:
* 1. the value already stored on the schedule, else
* 2. the parameter's `default_value`, else
* 3. an empty string for `"string"` parameters, `null` otherwise.
*/
export function buildInitialParameterValues(
parameters: ReadonlyArray<Parameter>,
storedValues: Record<string, unknown> | null | undefined,
): Record<string, unknown> {
const initialValues: Record<string, unknown> = {};
for (const parameter of parameters) {
if (!isScheduleParameter(parameter)) continue;
initialValues[parameter.key] = resolveInitialParameterValue(
parameter,
storedValues,
);
}
return initialValues;
}

View file

@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -12,6 +12,10 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PlusIcon } from "@radix-ui/react-icons";
import type { Parameter } from "@/routes/workflows/types/workflowTypes";
import { ScheduleParametersSection } from "@/routes/workflows/components/ScheduleParametersSection";
import { buildScheduleParametersPayload } from "@/routes/workflows/components/scheduleParameters";
import { useScheduleParameterState } from "@/routes/workflows/hooks/useScheduleParameterState";
import {
CRON_PRESETS,
cronToHumanReadable,
@ -24,24 +28,48 @@ import {
import { cn } from "@/util/utils";
type Props = {
workflowParameters: ReadonlyArray<Parameter>;
onSubmit: (
cronExpression: string,
timezone: string,
name: string,
description: string,
parameters: Record<string, unknown> | null,
callbacks: { onSuccess: () => void },
) => void;
isPending?: boolean;
};
function CreateScheduleDialog({ onSubmit, isPending }: Props) {
function CreateScheduleDialog({
workflowParameters,
onSubmit,
isPending,
}: Readonly<Props>) {
const [open, setOpen] = useState(false);
const [cronExpression, setCronExpression] = useState("0 9 * * *");
const [timezone, setTimezone] = useState(getLocalTimezone);
// TODO - Create shared util
const [timezoneFilter, setTimezoneFilter] = useState<string | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const {
values: parameters,
errors: parameterErrors,
handleChange: handleParameterChange,
validate: validateParameters,
reset: resetParameters,
} = useScheduleParameterState(workflowParameters);
// Re-seed parameter state if the workflow definition resolves after mount
// (e.g., the query was still loading when this dialog mounted) or if the
// workflow's parameters change while the dialog is closed. Also re-fires
// when the workflowParameters reference changes so newly-arrived
// definitions get seeded with their defaults. Skipped while the dialog is
// open so we don't clobber user input mid-edit.
useEffect(() => {
if (!open) {
resetParameters();
}
}, [open, workflowParameters, resetParameters]);
const allTimezones = useMemo(() => getTimezones(), []);
const filteredTimezones = useMemo(() => {
@ -50,28 +78,43 @@ function CreateScheduleDialog({ onSubmit, isPending }: Props) {
const lower = timezoneFilter.toLowerCase();
return allTimezones.filter((tz) => tz.toLowerCase().includes(lower));
}, [allTimezones, timezoneFilter]);
// ! end TODO
const valid = isValidCron(cronExpression);
const humanReadable = valid ? cronToHumanReadable(cronExpression) : null;
const nextRuns = valid ? getNextRuns(cronExpression, timezone, 5) : [];
const handleSubmit = () => {
if (!valid) return;
onSubmit(cronExpression, timezone, name, description, {
function resetFormState() {
setCronExpression("0 9 * * *");
setTimezone(getLocalTimezone());
setTimezoneFilter(null);
setName("");
setDescription("");
resetParameters();
}
function handleSubmit() {
const parametersValid = validateParameters();
if (!valid || !parametersValid) return;
const payload = buildScheduleParametersPayload(
parameters,
workflowParameters,
);
onSubmit(cronExpression, timezone, name, description, payload, {
onSuccess: () => {
setOpen(false);
setCronExpression("0 9 * * *");
setTimezone(getLocalTimezone());
setTimezoneFilter(null);
setName("");
setDescription("");
resetFormState();
},
});
};
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) resetFormState();
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-1.5">
<PlusIcon className="size-3" />
@ -87,7 +130,6 @@ function CreateScheduleDialog({ onSubmit, isPending }: Props) {
</DialogHeader>
<div className="space-y-6 py-4">
{/* Schedule Name & Description */}
<div className="space-y-2">
<Label>Name (optional)</Label>
<Input
@ -105,7 +147,14 @@ function CreateScheduleDialog({ onSubmit, isPending }: Props) {
/>
</div>
{/* Cron Presets */}
<ScheduleParametersSection
parameters={workflowParameters}
values={parameters}
onChange={handleParameterChange}
errors={parameterErrors}
disabled={isPending}
/>
<div className="space-y-2">
<Label>Quick Presets</Label>
<div className="flex flex-wrap gap-2">
@ -126,7 +175,6 @@ function CreateScheduleDialog({ onSubmit, isPending }: Props) {
</div>
</div>
{/* Custom Cron Input */}
<div className="space-y-2">
<Label>Cron Expression</Label>
<Input
@ -145,7 +193,6 @@ function CreateScheduleDialog({ onSubmit, isPending }: Props) {
)}
</div>
{/* Timezone Selector */}
<div className="space-y-2">
<Label>Timezone</Label>
<Input
@ -192,7 +239,6 @@ function CreateScheduleDialog({ onSubmit, isPending }: Props) {
<p className="text-xs text-slate-500">Current: {timezone}</p>
</div>
{/* Next Runs Preview */}
{nextRuns.length > 0 && (
<div className="space-y-2">
<Label>Next Scheduled Runs</Label>

View file

@ -14,10 +14,12 @@ import {
useToggleScheduleMutation,
useDeleteScheduleMutation,
} from "@/routes/workflows/hooks/useScheduleMutations";
import { useWorkflowQuery } from "@/routes/workflows/hooks/useWorkflowQuery";
import { ScheduleCard } from "./ScheduleCard";
import { CreateScheduleDialog } from "./CreateScheduleDialog";
import { ReloadIcon } from "@radix-ui/react-icons";
import { useState } from "react";
import { useParams } from "react-router-dom";
function WorkflowSchedulePanel() {
const {
@ -34,11 +36,16 @@ function WorkflowSchedulePanel() {
scheduleId: string | null;
}>({ open: false, scheduleId: null });
const { workflowPermanentId } = useParams();
const { data: workflow } = useWorkflowQuery({ workflowPermanentId });
const workflowParameters = workflow?.workflow_definition.parameters ?? [];
const handleCreate = (
cronExpression: string,
timezone: string,
name: string,
description: string,
parameters: Record<string, unknown> | null,
callbacks: { onSuccess: () => void },
) => {
createSchedule.mutate(
@ -48,6 +55,7 @@ function WorkflowSchedulePanel() {
enabled: true,
...(name && { name }),
...(description && { description }),
...(parameters && { parameters }),
},
{ onSuccess: callbacks.onSuccess },
);
@ -75,6 +83,7 @@ function WorkflowSchedulePanel() {
{schedules && schedules.length > 0 ? ` (${schedules.length})` : ""}
</h3>
<CreateScheduleDialog
workflowParameters={workflowParameters}
onSubmit={handleCreate}
isPending={createSchedule.isPending}
/>

View file

@ -0,0 +1,151 @@
import { describe, expect, test } from "vitest";
import type {
Parameter,
WorkflowParameter,
} from "@/routes/workflows/types/workflowTypes";
import {
applyScheduleParameterChange,
createScheduleParameterState,
getScheduleParameterValidationResult,
} from "./useScheduleParameterState";
function makeWorkflowParam(
partial: Partial<WorkflowParameter> & {
key: string;
workflow_parameter_type: WorkflowParameter["workflow_parameter_type"];
},
): WorkflowParameter {
return {
parameter_type: "workflow",
key: partial.key,
description: null,
workflow_parameter_type: partial.workflow_parameter_type,
default_value: partial.default_value ?? null,
workflow_parameter_id: "wp_test",
workflow_id: "w_test",
created_at: "2026-01-01T00:00:00Z",
modified_at: "2026-01-01T00:00:00Z",
deleted_at: null,
} as WorkflowParameter;
}
describe("createScheduleParameterState", () => {
test("builds values from workflow defaults when no stored values exist", () => {
const workflowParameters: ReadonlyArray<Parameter> = [
makeWorkflowParam({
key: "name",
workflow_parameter_type: "string",
}),
makeWorkflowParam({
key: "enabled",
workflow_parameter_type: "boolean",
default_value: "true",
}),
];
expect(createScheduleParameterState(workflowParameters, null)).toEqual({
values: {
name: "",
enabled: true,
},
errors: {},
});
});
test("prefers stored values over workflow defaults", () => {
const workflowParameters: ReadonlyArray<Parameter> = [
makeWorkflowParam({
key: "payload",
workflow_parameter_type: "json",
default_value: { foo: "default" },
}),
makeWorkflowParam({
key: "count",
workflow_parameter_type: "integer",
default_value: 1,
}),
];
expect(
createScheduleParameterState(workflowParameters, {
payload: '{"foo":"custom"}',
count: 5,
}),
).toEqual({
values: {
payload: '{"foo":"custom"}',
count: 5,
},
errors: {},
});
});
});
describe("applyScheduleParameterChange", () => {
test("updates the parameter value and clears only that parameter's error", () => {
const nextState = applyScheduleParameterChange(
{
values: {
source: "",
untouched: 1,
},
errors: {
source: "Required",
untouched: "Required",
},
},
"source",
"https://example.com",
);
expect(nextState).toEqual({
values: {
source: "https://example.com",
untouched: 1,
},
errors: {
untouched: "Required",
},
});
});
});
describe("getScheduleParameterValidationResult", () => {
test("returns the validation errors and invalid status", () => {
const workflowParameters: ReadonlyArray<Parameter> = [
makeWorkflowParam({
key: "credential",
workflow_parameter_type: "credential_id",
}),
];
expect(
getScheduleParameterValidationResult(workflowParameters, {
credential: "",
}),
).toEqual({
errors: {
credential: "Required",
},
isValid: false,
});
});
test("returns an empty error map when all required parameters are present", () => {
const workflowParameters: ReadonlyArray<Parameter> = [
makeWorkflowParam({
key: "threshold",
workflow_parameter_type: "float",
}),
];
expect(
getScheduleParameterValidationResult(workflowParameters, {
threshold: 3.14,
}),
).toEqual({
errors: {},
isValid: true,
});
});
});

View file

@ -0,0 +1,124 @@
import { useCallback, useRef, useState } from "react";
import type { Parameter } from "@/routes/workflows/types/workflowTypes";
import {
buildInitialParameterValues,
validateScheduleParameters,
} from "@/routes/workflows/components/scheduleParameters";
type ScheduleParameterValues = Record<string, unknown>;
type ScheduleParameterErrors = Record<string, string | undefined>;
type ScheduleParameterState = {
values: ScheduleParameterValues;
errors: ScheduleParameterErrors;
};
function createScheduleParameterState(
workflowParameters: ReadonlyArray<Parameter>,
storedValues: Record<string, unknown> | null = null,
): ScheduleParameterState {
return {
values: buildInitialParameterValues(workflowParameters, storedValues),
errors: {},
};
}
function clearScheduleParameterError(
errors: ScheduleParameterErrors,
key: string,
): ScheduleParameterErrors {
if (!errors[key]) {
return errors;
}
const nextErrors = { ...errors };
delete nextErrors[key];
return nextErrors;
}
function applyScheduleParameterChange(
state: ScheduleParameterState,
key: string,
value: unknown,
): ScheduleParameterState {
return {
values: { ...state.values, [key]: value },
errors: clearScheduleParameterError(state.errors, key),
};
}
function getScheduleParameterValidationResult(
workflowParameters: ReadonlyArray<Parameter>,
values: ScheduleParameterValues,
): { errors: ScheduleParameterErrors; isValid: boolean } {
const errors = validateScheduleParameters(workflowParameters, values);
return {
errors,
isValid: Object.keys(errors).length === 0,
};
}
function useScheduleParameterState(
workflowParameters: ReadonlyArray<Parameter>,
storedValues: Record<string, unknown> | null = null,
) {
const [values, setValues] = useState<ScheduleParameterValues>(() =>
buildInitialParameterValues(workflowParameters, storedValues),
);
const [errors, setErrors] = useState<ScheduleParameterErrors>({});
const valuesRef = useRef(values);
valuesRef.current = values;
const handleChange = useCallback((key: string, value: unknown) => {
setValues((prevValues) => {
const next = { ...prevValues, [key]: value };
valuesRef.current = next;
return next;
});
setErrors((prevErrors) => clearScheduleParameterError(prevErrors, key));
}, []);
const validate = useCallback(() => {
const result = getScheduleParameterValidationResult(
workflowParameters,
valuesRef.current,
);
setErrors(result.errors);
return result.isValid;
}, [workflowParameters]);
const reset = useCallback(
(nextStoredValues: Record<string, unknown> | null = storedValues) => {
const nextState = createScheduleParameterState(
workflowParameters,
nextStoredValues,
);
valuesRef.current = nextState.values;
setValues(nextState.values);
setErrors(nextState.errors);
},
[storedValues, workflowParameters],
);
const clear = useCallback(() => {
valuesRef.current = {};
setValues({});
setErrors({});
}, []);
return {
values,
errors,
handleChange,
validate,
reset,
clear,
};
}
export {
applyScheduleParameterChange,
clearScheduleParameterError,
createScheduleParameterState,
getScheduleParameterValidationResult,
useScheduleParameterState,
};