mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-28 03:30:10 +00:00
[SKY-8784][SKY-8553] Address Schedule Missing Params (#5409)
This commit is contained in:
parent
2d4ed49b7e
commit
f8cf5ee49b
13 changed files with 1616 additions and 50 deletions
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue