Prompt user for parameter values before running blocks in debugger (#SKY-6097) (#4668)
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user