diff --git a/skyvern-frontend/src/components/FileUpload.tsx b/skyvern-frontend/src/components/FileUpload.tsx index 069ad5541..93d9fbf58 100644 --- a/skyvern-frontend/src/components/FileUpload.tsx +++ b/skyvern-frontend/src/components/FileUpload.tsx @@ -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(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) { {typeof value === "string" && ( onChange(event.target.value)} /> diff --git a/skyvern-frontend/src/routes/schedules/CreateOrgScheduleDialog.tsx b/skyvern-frontend/src/routes/schedules/CreateOrgScheduleDialog.tsx index 9c88bff5b..5cd2ef1f4 100644 --- a/skyvern-frontend/src/routes/schedules/CreateOrgScheduleDialog.tsx +++ b/skyvern-frontend/src/routes/schedules/CreateOrgScheduleDialog.tsx @@ -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) { const [scheduleName, setScheduleName] = useState(""); const [scheduleDescription, setScheduleDescription] = useState(""); + const allTimezones = useMemo(() => getTimezones(), []); + const { data: workflows = [] } = useQuery>({ queryKey: ["workflows", "scheduleDialogPicker", workflowSearch], queryFn: async () => { @@ -64,13 +73,65 @@ function CreateOrgScheduleDialog({ open, onOpenChange }: Readonly) { 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>(() => { + 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) { 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) { timezone, ...(scheduleName && { name: scheduleName }), ...(scheduleDescription && { description: scheduleDescription }), + ...(payload && { parameters: payload }), }, }, { @@ -195,6 +264,14 @@ function CreateOrgScheduleDialog({ open, onOpenChange }: Readonly) { /> + + {/* Cron Presets */}
@@ -305,7 +382,9 @@ function CreateOrgScheduleDialog({ open, onOpenChange }: Readonly) { Cancel
+ + {/* Cron Presets */}
@@ -454,6 +494,50 @@ function ScheduleDetailPage() {
+ {hasUserFacingParameters(workflowParameters) && ( +
+

+ Workflow Parameters +

+
+ {workflowParameters + .filter(isScheduleParameter) + .map((parameter) => { + const storedValue = schedule.parameters?.[parameter.key]; + const hasValue = + storedValue !== undefined && storedValue !== null; + return ( +
+ + {parameter.key} + + + {hasValue ? ( + formatScheduleParameterValue(storedValue) + ) : parameter.default_value !== null && + parameter.default_value !== undefined ? ( + + default:{" "} + {formatScheduleParameterValue( + parameter.default_value, + )} + + ) : ( + + (not set) + + )} + +
+ ); + })} +
+
+ )} +

Upcoming Runs

diff --git a/skyvern-frontend/src/routes/workflows/WorkflowParameterInput.tsx b/skyvern-frontend/src/routes/workflows/WorkflowParameterInput.tsx index c254cc580..b3aa090c1 100644 --- a/skyvern-frontend/src/routes/workflows/WorkflowParameterInput.tsx +++ b/skyvern-frontend/src/routes/workflows/WorkflowParameterInput.tsx @@ -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 ( 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 ( onChange(e.target.value)} /> ); @@ -46,6 +58,8 @@ function WorkflowParameterInput({ type, value, onChange }: Props) { if (type === "integer") { return ( { const val = e.target.value; @@ -60,6 +74,8 @@ function WorkflowParameterInput({ type, value, onChange }: Props) { if (type === "float") { return ( { const val = e.target.value; @@ -75,10 +91,11 @@ function WorkflowParameterInput({ type, value, onChange }: Props) { if (type === "boolean") { return (

- {/* Cron Presets */} + +
@@ -126,7 +175,6 @@ function CreateScheduleDialog({ onSubmit, isPending }: Props) {
- {/* Custom Cron Input */}
- {/* Timezone Selector */}
Current: {timezone}

- {/* Next Runs Preview */} {nextRuns.length > 0 && (
diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/WorkflowSchedulePanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/WorkflowSchedulePanel.tsx index 6edc1354f..f3678137c 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/WorkflowSchedulePanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/WorkflowSchedulePanel.tsx @@ -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 | 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})` : ""} diff --git a/skyvern-frontend/src/routes/workflows/hooks/useScheduleParameterState.test.ts b/skyvern-frontend/src/routes/workflows/hooks/useScheduleParameterState.test.ts new file mode 100644 index 000000000..7c29f4bbe --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useScheduleParameterState.test.ts @@ -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 & { + 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 = [ + 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 = [ + 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 = [ + 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 = [ + makeWorkflowParam({ + key: "threshold", + workflow_parameter_type: "float", + }), + ]; + + expect( + getScheduleParameterValidationResult(workflowParameters, { + threshold: 3.14, + }), + ).toEqual({ + errors: {}, + isValid: true, + }); + }); +}); diff --git a/skyvern-frontend/src/routes/workflows/hooks/useScheduleParameterState.ts b/skyvern-frontend/src/routes/workflows/hooks/useScheduleParameterState.ts new file mode 100644 index 000000000..cc607a572 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useScheduleParameterState.ts @@ -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; +type ScheduleParameterErrors = Record; +type ScheduleParameterState = { + values: ScheduleParameterValues; + errors: ScheduleParameterErrors; +}; + +function createScheduleParameterState( + workflowParameters: ReadonlyArray, + storedValues: Record | 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, + values: ScheduleParameterValues, +): { errors: ScheduleParameterErrors; isValid: boolean } { + const errors = validateScheduleParameters(workflowParameters, values); + return { + errors, + isValid: Object.keys(errors).length === 0, + }; +} + +function useScheduleParameterState( + workflowParameters: ReadonlyArray, + storedValues: Record | null = null, +) { + const [values, setValues] = useState(() => + buildInitialParameterValues(workflowParameters, storedValues), + ); + const [errors, setErrors] = useState({}); + 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 | 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, +};