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 (
|
return (
|
||||||
<Input
|
<Input
|
||||||
value={value === null ? "" : Number(value)}
|
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"
|
type="number"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -57,7 +61,11 @@ function WorkflowParameterInput({ type, value, onChange }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
value={value === null ? "" : Number(value)}
|
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"
|
type="number"
|
||||||
step="any"
|
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,
|
scriptableWorkflowBlockTypes,
|
||||||
type WorkflowBlockType,
|
type WorkflowBlockType,
|
||||||
type WorkflowApiResponse,
|
type WorkflowApiResponse,
|
||||||
|
type WorkflowParameter,
|
||||||
|
type Parameter,
|
||||||
} from "@/routes/workflows/types/workflowTypes";
|
} from "@/routes/workflows/types/workflowTypes";
|
||||||
import { getInitialValues } from "@/routes/workflows/utils";
|
import { getInitialValues } from "@/routes/workflows/utils";
|
||||||
import { useBlockOutputStore } from "@/store/BlockOutputStore";
|
import { useBlockOutputStore } from "@/store/BlockOutputStore";
|
||||||
@@ -49,6 +51,16 @@ import { NodeActionMenu } from "../NodeActionMenu";
|
|||||||
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
|
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
|
||||||
import { workflowBlockTitle } from "../types";
|
import { workflowBlockTitle } from "../types";
|
||||||
import { MicroDropdown } from "./MicroDropdown";
|
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 {
|
interface Transmutations {
|
||||||
blockTitle: string;
|
blockTitle: string;
|
||||||
@@ -213,6 +225,13 @@ function NodeHeader({
|
|||||||
const [workflowRunStatus, setWorkflowRunStatus] = useState(
|
const [workflowRunStatus, setWorkflowRunStatus] = useState(
|
||||||
workflowRun?.status,
|
workflowRun?.status,
|
||||||
);
|
);
|
||||||
|
const [showParamsDialog, setShowParamsDialog] = useState(false);
|
||||||
|
const [parametersToPrompt, setParametersToPrompt] = useState<
|
||||||
|
WorkflowParameter[]
|
||||||
|
>([]);
|
||||||
|
const [currentParamValues, setCurrentParamValues] = useState<
|
||||||
|
Record<string, unknown>
|
||||||
|
>({});
|
||||||
const { getAutoplay, setAutoplay } = useAutoplayStore();
|
const { getAutoplay, setAutoplay } = useAutoplayStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -286,7 +305,10 @@ function NodeHeader({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const runBlock = useMutation({
|
const runBlock = useMutation({
|
||||||
mutationFn: async (opts?: { codeGen: boolean }) => {
|
mutationFn: async (opts?: {
|
||||||
|
codeGen: boolean;
|
||||||
|
parameterOverrides?: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
closeWorkflowPanel();
|
closeWorkflowPanel();
|
||||||
|
|
||||||
await saveWorkflow.mutateAsync();
|
await saveWorkflow.mutateAsync();
|
||||||
@@ -332,6 +354,11 @@ function NodeHeader({
|
|||||||
|
|
||||||
const parameters = getInitialValues(location, workflowParameters ?? []);
|
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 client = await getClient(credentialGetter, "sans-api-v1");
|
||||||
|
|
||||||
const body = getPayload({
|
const body = getPayload({
|
||||||
@@ -341,7 +368,7 @@ function NodeHeader({
|
|||||||
browserSessionId: debugSession.browser_session_id,
|
browserSessionId: debugSession.browser_session_id,
|
||||||
debugSessionId: debugSession.debug_session_id,
|
debugSessionId: debugSession.debug_session_id,
|
||||||
codeGen: opts?.codeGen ?? false,
|
codeGen: opts?.codeGen ?? false,
|
||||||
parameters,
|
parameters: mergedParameters,
|
||||||
totpIdentifier,
|
totpIdentifier,
|
||||||
totpUrl,
|
totpUrl,
|
||||||
workflowPermanentId,
|
workflowPermanentId,
|
||||||
@@ -480,9 +507,25 @@ function NodeHeader({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleOnPlay = () => {
|
const handleOnPlay = () => {
|
||||||
const numBlocksInWorkflow = (workflow?.workflow_definition.blocks ?? [])
|
const blocks = workflow?.workflow_definition?.blocks ?? [];
|
||||||
.length;
|
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 });
|
runBlock.mutate({ codeGen: numBlocksInWorkflow === 1 });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -636,6 +679,33 @@ function NodeHeader({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 !== undefined
|
||||||
? enabled && !!workflowPermanentId
|
? enabled && !!workflowPermanentId
|
||||||
: !!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_api_key: Annotated[str | None, Header(include_in_schema=False)] = None,
|
||||||
x_user_agent: Annotated[str | None, Header(include_in_schema=False)] = None,
|
x_user_agent: Annotated[str | None, Header(include_in_schema=False)] = None,
|
||||||
) -> str:
|
) -> 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)
|
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":
|
if x_api_key and x_user_agent == "skyvern-ui":
|
||||||
organization = await _get_current_org_cached(x_api_key, app.DATABASE)
|
organization = await _get_current_org_cached(x_api_key, app.DATABASE)
|
||||||
if organization:
|
if organization:
|
||||||
|
|||||||
Reference in New Issue
Block a user