Prompt user for parameter values before running blocks in debugger (#SKY-6097) (#4668)

This commit is contained in:
Celal Zamanoglu
2026-02-09 20:42:57 +03:00
committed by GitHub
parent 94bf5385dc
commit 7e6dfcc6d1
5 changed files with 345 additions and 7 deletions

View File

@@ -47,7 +47,11 @@ function WorkflowParameterInput({ type, value, onChange }: Props) {
return (
<Input
value={value === null ? "" : Number(value)}
onChange={(e) => onChange(parseInt(e.target.value))}
onChange={(e) => {
const val = e.target.value;
// Return null for empty input, otherwise parse as integer
onChange(val === "" ? null : parseInt(val, 10));
}}
type="number"
/>
);
@@ -57,7 +61,11 @@ function WorkflowParameterInput({ type, value, onChange }: Props) {
return (
<Input
value={value === null ? "" : Number(value)}
onChange={(e) => onChange(parseFloat(e.target.value))}
onChange={(e) => {
const val = e.target.value;
// Return null for empty input, otherwise parse as float
onChange(val === "" ? null : parseFloat(val));
}}
type="number"
step="any"
/>

View File

@@ -0,0 +1,245 @@
import { useState, useEffect, useMemo } from "react";
import { ReloadIcon } from "@radix-ui/react-icons";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area";
import { WorkflowParameterInput } from "@/routes/workflows/WorkflowParameterInput";
import { getLabelForWorkflowParameterType } from "@/routes/workflows/editor/workflowEditorUtils";
import type { WorkflowParameter } from "@/routes/workflows/types/workflowTypes";
/**
* Validates a parameter value based on its type.
* Matches the validation logic in RunWorkflowForm.
*/
function validateParameterValue(value: unknown, type: string): string | null {
switch (type) {
case "json":
if (value === null || value === undefined) {
return "This field is required";
}
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed === "") {
return "This field is required";
}
try {
JSON.parse(trimmed);
return null;
} catch (e) {
const message = e instanceof SyntaxError ? e.message : "Parse error";
return `Invalid JSON: ${message}`;
}
}
return null;
case "boolean":
if (value === null || value === undefined) {
return "This field is required";
}
return null;
case "integer":
case "float":
if (value === null || value === undefined || Number.isNaN(value)) {
return "This field is required";
}
return null;
case "file_url":
if (
value === null ||
value === undefined ||
(typeof value === "string" && value.trim() === "") ||
(typeof value === "object" &&
value !== null &&
"s3uri" in value &&
!(value as { s3uri: unknown }).s3uri)
) {
return "This field is required";
}
return null;
default:
return null;
}
}
interface BlockParametersDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
blockLabel: string;
parameters: WorkflowParameter[];
initialValues: Record<string, unknown>;
onSubmit: (values: Record<string, unknown>) => void;
isLoading?: boolean;
}
function getDefaultValues(
parameters: WorkflowParameter[],
initialValues: Record<string, unknown>,
): Record<string, unknown> {
const values: Record<string, unknown> = { ...initialValues };
for (const param of parameters) {
if (values[param.key] === undefined || values[param.key] === null) {
// Set defaults - use null for required types to force user selection
// This matches RunWorkflowForm behavior
switch (param.workflow_parameter_type) {
case "string":
values[param.key] = "";
break;
case "integer":
case "float":
case "boolean":
case "file_url":
values[param.key] = null;
break;
case "json":
values[param.key] = "";
break;
default:
values[param.key] = null;
}
}
}
return values;
}
function BlockParametersDialog({
open,
onOpenChange,
blockLabel,
parameters,
initialValues,
onSubmit,
isLoading = false,
}: BlockParametersDialogProps) {
const [values, setValues] = useState<Record<string, unknown>>({});
// Reset values when dialog opens
useEffect(() => {
if (open) {
setValues(getDefaultValues(parameters, initialValues));
}
// Only reset when dialog opens - don't react to parameter/initialValues changes while open
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Validate all parameters matching RunWorkflowForm validation
const validationErrors = useMemo(() => {
const errors: Record<string, string> = {};
for (const param of parameters) {
const error = validateParameterValue(
values[param.key],
param.workflow_parameter_type,
);
if (error) {
errors[param.key] = error;
}
}
return errors;
}, [parameters, values]);
const hasValidationErrors = Object.keys(validationErrors).length > 0;
const handleValueChange = (key: string, value: unknown) => {
setValues((prev) => ({ ...prev, [key]: value }));
};
const handleSubmit = () => {
if (hasValidationErrors) {
return;
}
// Merge with initial values to include all parameters
const mergedValues = { ...initialValues, ...values };
onSubmit(mergedValues);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Enter Parameter Values</DialogTitle>
<DialogDescription>
The block "{blockLabel}" requires the following parameters to run.
</DialogDescription>
</DialogHeader>
<ScrollArea>
{/* Height set to ~3.5 parameters to create visual cutoff hint */}
<ScrollAreaViewport className="max-h-[340px]">
{/* px-1 prevents focus ring from being clipped on left/right edges */}
<div className="space-y-6 px-1 py-2 pr-4">
{parameters.map((param) => (
<div key={param.key} className="space-y-2">
<div className="flex items-baseline gap-2">
<Label
htmlFor={param.key}
className="text-base font-medium"
>
{param.key}
</Label>
<span className="text-sm text-slate-400">
{getLabelForWorkflowParameterType(
param.workflow_parameter_type,
)}
</span>
</div>
{param.description && (
<p className="text-sm text-slate-400">
{param.description}
</p>
)}
<div className="pt-1">
<WorkflowParameterInput
type={param.workflow_parameter_type}
value={values[param.key]}
onChange={(value) => handleValueChange(param.key, value)}
/>
{validationErrors[param.key] && (
<p className="mt-1 text-sm text-destructive">
{validationErrors[param.key]}
</p>
)}
</div>
</div>
))}
</div>
</ScrollAreaViewport>
</ScrollArea>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={isLoading || hasValidationErrors}
>
{isLoading ? (
<>
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
Running...
</>
) : (
"Run Block"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { BlockParametersDialog };

View File

@@ -26,6 +26,8 @@ import {
scriptableWorkflowBlockTypes,
type WorkflowBlockType,
type WorkflowApiResponse,
type WorkflowParameter,
type Parameter,
} from "@/routes/workflows/types/workflowTypes";
import { getInitialValues } from "@/routes/workflows/utils";
import { useBlockOutputStore } from "@/store/BlockOutputStore";
@@ -49,6 +51,16 @@ import { NodeActionMenu } from "../NodeActionMenu";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import { workflowBlockTitle } from "../types";
import { MicroDropdown } from "./MicroDropdown";
import { BlockParametersDialog } from "./BlockParametersDialog";
function isWorkflowParameter(param: Parameter): param is WorkflowParameter {
return (
param?.parameter_type === "workflow" &&
"workflow_parameter_type" in param &&
typeof param.workflow_parameter_type === "string" &&
param.workflow_parameter_type.length > 0
);
}
interface Transmutations {
blockTitle: string;
@@ -213,6 +225,13 @@ function NodeHeader({
const [workflowRunStatus, setWorkflowRunStatus] = useState(
workflowRun?.status,
);
const [showParamsDialog, setShowParamsDialog] = useState(false);
const [parametersToPrompt, setParametersToPrompt] = useState<
WorkflowParameter[]
>([]);
const [currentParamValues, setCurrentParamValues] = useState<
Record<string, unknown>
>({});
const { getAutoplay, setAutoplay } = useAutoplayStore();
useEffect(() => {
@@ -286,7 +305,10 @@ function NodeHeader({
]);
const runBlock = useMutation({
mutationFn: async (opts?: { codeGen: boolean }) => {
mutationFn: async (opts?: {
codeGen: boolean;
parameterOverrides?: Record<string, unknown>;
}) => {
closeWorkflowPanel();
await saveWorkflow.mutateAsync();
@@ -332,6 +354,11 @@ function NodeHeader({
const parameters = getInitialValues(location, workflowParameters ?? []);
// Merge with parameter overrides if provided
const mergedParameters = opts?.parameterOverrides
? { ...parameters, ...opts.parameterOverrides }
: parameters;
const client = await getClient(credentialGetter, "sans-api-v1");
const body = getPayload({
@@ -341,7 +368,7 @@ function NodeHeader({
browserSessionId: debugSession.browser_session_id,
debugSessionId: debugSession.debug_session_id,
codeGen: opts?.codeGen ?? false,
parameters,
parameters: mergedParameters,
totpIdentifier,
totpUrl,
workflowPermanentId,
@@ -480,9 +507,25 @@ function NodeHeader({
});
const handleOnPlay = () => {
const numBlocksInWorkflow = (workflow?.workflow_definition.blocks ?? [])
.length;
const blocks = workflow?.workflow_definition?.blocks ?? [];
const numBlocksInWorkflow = blocks.length;
// Get workflow parameters using type guard for proper type narrowing
const workflowParameters = (
workflow?.workflow_definition?.parameters ?? []
).filter(isWorkflowParameter);
// If there are any workflow parameters, always prompt the user
// The backend requires all params to be specified for each run
if (workflowParameters.length > 0) {
const currentValues = getInitialValues(location, workflowParameters);
setCurrentParamValues(currentValues);
setParametersToPrompt(workflowParameters);
setShowParamsDialog(true);
return;
}
// No parameters, run directly
runBlock.mutate({ codeGen: numBlocksInWorkflow === 1 });
};
@@ -636,6 +679,33 @@ function NodeHeader({
)}
</div>
</header>
<BlockParametersDialog
open={showParamsDialog}
onOpenChange={setShowParamsDialog}
blockLabel={blockLabel}
parameters={parametersToPrompt}
initialValues={currentParamValues}
onSubmit={(values) => {
const numBlocksInWorkflow = (
workflow?.workflow_definition.blocks ?? []
).length;
runBlock.mutate(
{
codeGen: numBlocksInWorkflow === 1,
parameterOverrides: values,
},
{
onSuccess: () => {
// Close dialog on success - navigation also happens in mutation's onSuccess
setShowParamsDialog(false);
},
// On error, dialog stays open so user can retry. Toast is shown by mutation's onError.
},
);
}}
isLoading={runBlock.isPending}
/>
</>
);
}

View File

@@ -23,6 +23,19 @@ function useDebugSessionQuery({ workflowPermanentId, enabled }: Opts) {
enabled !== undefined
? enabled && !!workflowPermanentId
: !!workflowPermanentId,
// Reduce polling frequency on errors
retry: 3,
retryDelay: 10000,
refetchOnWindowFocus: false,
// Don't keep retrying if in error state
refetchInterval: (query) => {
// If query is in error state, poll much less frequently (30s)
// Otherwise don't auto-refetch
if (query.state.status === "error") {
return 30000;
}
return false;
},
});
}

View File

@@ -130,9 +130,11 @@ async def get_current_user_id(
x_api_key: Annotated[str | None, Header(include_in_schema=False)] = None,
x_user_agent: Annotated[str | None, Header(include_in_schema=False)] = None,
) -> str:
if authorization:
# Try authorization header first, but only if the authentication function is configured
if authorization and app.authenticate_user_function:
return await _authenticate_user_helper(authorization)
# Fall back to API key + skyvern-ui user agent
if x_api_key and x_user_agent == "skyvern-ui":
organization = await _get_current_org_cached(x_api_key, app.DATABASE)
if organization: