From e3ecc4b6579c7dc6e5cb129be6baac4673877fa0 Mon Sep 17 00:00:00 2001 From: Jonathan Dobson Date: Fri, 17 Oct 2025 12:02:03 -0400 Subject: [PATCH] Rework state management arch for blocks (fix rando max recursion errors, maybe other bugs) (#3755) --- .../src/components/ModelSelector.tsx | 1 - .../components/WorkflowBlockInputTextarea.tsx | 10 +- .../workflows/components/CodeEditor.tsx | 24 ++- .../routes/workflows/editor/FlowRenderer.tsx | 35 ++-- .../editor/nodes/ActionNode/ActionNode.tsx | 101 ++++------- .../nodes/CodeBlockNode/CodeBlockNode.tsx | 49 ++---- .../nodes/ExtractionNode/ExtractionNode.tsx | 70 +++----- .../FileDownloadNode/FileDownloadNode.tsx | 104 ++++------- .../nodes/FileParserNode/FileParserNode.tsx | 32 +--- .../nodes/FileUploadNode/FileUploadNode.tsx | 72 +++----- .../nodes/HttpRequestNode/HttpRequestNode.tsx | 154 +++++++--------- .../editor/nodes/LoginNode/LoginNode.tsx | 111 +++++------- .../editor/nodes/LoopNode/LoopNode.tsx | 39 ++--- .../workflows/editor/nodes/LoopNode/types.ts | 1 + .../nodes/NavigationNode/NavigationNode.tsx | 122 +++++-------- .../nodes/PDFParserNode/PDFParserNode.tsx | 32 +--- .../nodes/SendEmailNode/SendEmailNode.tsx | 37 ++-- .../editor/nodes/StartNode/StartNode.tsx | 165 +++++++++--------- .../workflows/editor/nodes/StartNode/types.ts | 2 +- .../editor/nodes/TaskNode/TaskNode.tsx | 138 ++++++--------- .../editor/nodes/Taskv2Node/Taskv2Node.tsx | 57 +++--- .../editor/nodes/Taskv2Node/types.ts | 2 + .../nodes/TextPromptNode/TextPromptNode.tsx | 32 +--- .../editor/nodes/URLNode/URLNode.tsx | 21 +-- .../nodes/ValidationNode/ValidationNode.tsx | 66 +++---- .../editor/nodes/ValidationNode/types.ts | 2 + .../editor/nodes/WaitNode/WaitNode.tsx | 23 +-- .../editor/nodes/components/NodeHeader.tsx | 4 +- .../src/routes/workflows/editor/useUpdate.ts | 38 ++++ .../workflows/editor/workflowEditorUtils.ts | 6 +- .../routes/workflows/types/workflowTypes.ts | 1 + .../src/store/WorkflowSettingsStore.ts | 9 +- 32 files changed, 622 insertions(+), 938 deletions(-) create mode 100644 skyvern-frontend/src/routes/workflows/editor/useUpdate.ts diff --git a/skyvern-frontend/src/components/ModelSelector.tsx b/skyvern-frontend/src/components/ModelSelector.tsx index 69185586..e56d30c8 100644 --- a/skyvern-frontend/src/components/ModelSelector.tsx +++ b/skyvern-frontend/src/components/ModelSelector.tsx @@ -66,7 +66,6 @@ function ModelSelector({ const newValue = v === constants.SkyvernOptimized ? null : v; const modelName = newValue ? reverseMap[newValue] : null; const value = modelName ? { model_name: modelName } : null; - console.log({ v, newValue, modelName, value }); onChange(value); }} > diff --git a/skyvern-frontend/src/components/WorkflowBlockInputTextarea.tsx b/skyvern-frontend/src/components/WorkflowBlockInputTextarea.tsx index 879e71ea..bd3b2432 100644 --- a/skyvern-frontend/src/components/WorkflowBlockInputTextarea.tsx +++ b/skyvern-frontend/src/components/WorkflowBlockInputTextarea.tsx @@ -5,6 +5,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { WorkflowBlockParameterSelect } from "@/routes/workflows/editor/nodes/WorkflowBlockParameterSelect"; import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore"; import { useEffect, useRef, useState } from "react"; +import { useDebouncedCallback } from "use-debounce"; type Props = Omit< React.ComponentProps, @@ -29,14 +30,14 @@ function WorkflowBlockInputTextarea(props: Props) { setInternalValue(props.value ?? ""); }, [props.value]); - const doOnChange = (value: string) => { + const doOnChange = useDebouncedCallback((value: string) => { onChange(value); if (canWriteTitle) { maybeWriteTitle(value); maybeAcceptTitle(); } - }; + }, 300); const handleTextareaSelect = () => { if (textareaRef.current) { @@ -76,12 +77,13 @@ function WorkflowBlockInputTextarea(props: Props) { {...textAreaProps} value={internalValue} ref={textareaRef} - onBlur={(event) => { - doOnChange(event.target.value); + onBlur={() => { + doOnChange.flush(); }} onChange={(event) => { setInternalValue(event.target.value); handleTextareaSelect(); + doOnChange(event.target.value); }} onClick={handleTextareaSelect} onKeyUp={handleTextareaSelect} diff --git a/skyvern-frontend/src/routes/workflows/components/CodeEditor.tsx b/skyvern-frontend/src/routes/workflows/components/CodeEditor.tsx index 17e0692c..3c7a6a26 100644 --- a/skyvern-frontend/src/routes/workflows/components/CodeEditor.tsx +++ b/skyvern-frontend/src/routes/workflows/components/CodeEditor.tsx @@ -3,8 +3,9 @@ import { json } from "@codemirror/lang-json"; import { python } from "@codemirror/lang-python"; import { html } from "@codemirror/lang-html"; import { tokyoNightStorm } from "@uiw/codemirror-theme-tokyo-night-storm"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { cn } from "@/util/utils"; +import { useDebouncedCallback } from "use-debounce"; import "./code-mirror-overrides.css"; @@ -50,6 +51,20 @@ function CodeEditor({ fullHeight = false, }: Props) { const viewRef = useRef(null); + const [internalValue, setInternalValue] = useState(value); + + useEffect(() => { + setInternalValue(value); + }, [value]); + + const debouncedOnChange = useDebouncedCallback((newValue: string) => { + onChange?.(newValue); + }, 300); + + const handleChange = (newValue: string) => { + setInternalValue(newValue); + debouncedOnChange(newValue); + }; const extensions = language ? [getLanguageExtension(language), lineWrap ? EditorView.lineWrapping : []] @@ -103,8 +118,8 @@ function CodeEditor({ return ( { if (!viewRef.current) viewRef.current = viewUpdate.view; }} + onBlur={() => { + debouncedOnChange.flush(); + }} /> ); } diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index 22700d87..5250ceda 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -83,8 +83,6 @@ import { getWorkflowErrors } from "./workflowEditorUtils"; import { toast } from "@/components/ui/use-toast"; import { useAutoPan } from "./useAutoPan"; -const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0)); - function convertToParametersYAML( parameters: ParametersState, ): Array< @@ -278,7 +276,6 @@ function FlowRenderer({ const parameters = useWorkflowParametersStore((state) => state.parameters); const nodesInitialized = useNodesInitialized(); const [shouldConstrainPan, setShouldConstrainPan] = useState(false); - const onNodesChangeTimeoutRef = useRef(null); const flowIsConstrained = debugStore.isDebugMode; useEffect(() => { @@ -672,6 +669,7 @@ function FlowRenderer({ } } }); + if (dimensionChanges.length > 0) { doLayout(tempNodes, edges); } @@ -687,20 +685,23 @@ function FlowRenderer({ workflowChangesStore.setHasChanges(true); } - // only allow one update in _this_ render cycle - if (onNodesChangeTimeoutRef.current === null) { - onNodesChange(changes); - onNodesChangeTimeoutRef.current = setTimeout(() => { - onNodesChangeTimeoutRef.current = null; - }, 0); - } else { - // if we have an update in this render cycle already, then to - // prevent max recursion errors, defer the update to next render - // cycle - nextTick().then(() => { - onNodesChange(changes); - }); - } + onNodesChange(changes); + + // NOTE: should no longer be needed (woot!) - delete if true (want real-world testing first) + // // only allow one update in _this_ render cycle + // if (onNodesChangeTimeoutRef.current === null) { + // onNodesChange(changes); + // onNodesChangeTimeoutRef.current = setTimeout(() => { + // onNodesChangeTimeoutRef.current = null; + // }, 0); + // } else { + // // if we have an update in this render cycle already, then to + // // prevent max recursion errors, defer the update to next render + // // cycle + // nextTick().then(() => { + // onNodesChange(changes); + // }); + // } }} onEdgesChange={onEdgesChange} nodeTypes={nodeTypes} diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx index 519b64ac..c02c57f4 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx @@ -8,14 +8,7 @@ import { } from "@/components/ui/accordion"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; -import { - Handle, - NodeProps, - Position, - useEdges, - useNodes, - useReactFlow, -} from "@xyflow/react"; +import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react"; import { useState } from "react"; import type { ActionNode } from "./types"; import { HelpTooltip } from "@/components/HelpTooltip"; @@ -40,6 +33,7 @@ import { useParams } from "react-router-dom"; import { NodeHeader } from "../components/NodeHeader"; import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; +import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { DisableCache } from "../DisableCache"; @@ -51,25 +45,10 @@ const navigationGoalTooltip = const navigationGoalPlaceholder = 'Input {{ name }} into "Name" field.'; function ActionNode({ id, data, type }: NodeProps) { - const { updateNodeData } = useReactFlow(); const [facing, setFacing] = useState<"front" | "back">("front"); const blockScriptStore = useBlockScriptStore(); const { editable, label } = data; const script = blockScriptStore.scripts[label]; - const [inputs, setInputs] = useState({ - url: data.url, - navigationGoal: data.navigationGoal, - errorCodeMapping: data.errorCodeMapping, - allowDownloads: data.allowDownloads, - continueOnFailure: data.continueOnFailure, - cacheActions: data.cacheActions, - disableCache: data.disableCache, - downloadSuffix: data.downloadSuffix, - totpVerificationUrl: data.totpVerificationUrl, - model: data.model, - totpIdentifier: data.totpIdentifier, - engine: data.engine, - }); const { blockLabel: urlBlockLabel } = useParams(); const { data: workflowRun } = useWorkflowRunQuery(); const workflowRunIsRunningOrQueued = @@ -79,19 +58,10 @@ function ActionNode({ id, data, type }: NodeProps) { const thisBlockIsPlaying = workflowRunIsRunningOrQueued && thisBlockIsTargetted; const rerender = useRerender({ prefix: "accordian" }); - const nodes = useNodes(); const edges = useEdges(); const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); - - function handleChange(key: string, value: unknown) { - if (!editable) { - return; - } - setInputs({ ...inputs, [key]: value }); - updateNodeData(id, { [key]: value }); - } - + const update = useUpdate({ id, editable }); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); useEffect(() => { @@ -128,8 +98,8 @@ function ActionNode({ id, data, type }: NodeProps) { blockLabel={label} editable={editable} nodeId={id} - totpIdentifier={inputs.totpIdentifier} - totpUrl={inputs.totpVerificationUrl} + totpIdentifier={data.totpIdentifier} + totpUrl={data.totpVerificationUrl} type={type} />
) { canWriteTitle={true} nodeId={id} onChange={(value) => { - handleChange("url", value); + update({ url: value }); }} - value={inputs.url} + value={data.url} placeholder={placeholders["action"]["url"]} className="nopan text-xs" /> @@ -171,9 +141,9 @@ function ActionNode({ id, data, type }: NodeProps) { { - handleChange("navigationGoal", value); + update({ navigationGoal: value }); }} - value={inputs.navigationGoal} + value={data.navigationGoal} placeholder={navigationGoalPlaceholder} className="nopan text-xs" /> @@ -203,16 +173,16 @@ function ActionNode({ id, data, type }: NodeProps) {
{ - handleChange("model", value); + update({ model: value }); }} /> { - updateNodeData(id, { parameterKeys }); + update({ parameterKeys }); }} />
@@ -223,9 +193,9 @@ function ActionNode({ id, data, type }: NodeProps) {
{ - handleChange("engine", value); + update({ engine: value }); }} className="nopan w-52 text-xs" /> @@ -241,35 +211,34 @@ function ActionNode({ id, data, type }: NodeProps) { /> { if (!editable) { return; } - handleChange( - "errorCodeMapping", - checked + update({ + errorCodeMapping: checked ? JSON.stringify( errorMappingExampleValue, null, 2, ) : "null", - ); + }); }} /> - {inputs.errorCodeMapping !== "null" && ( + {data.errorCodeMapping !== "null" && (
{ if (!editable) { return; } - handleChange("errorCodeMapping", value); + update({ errorCodeMapping: value }); }} className="nopan" fontSize={8} @@ -289,25 +258,25 @@ function ActionNode({ id, data, type }: NodeProps) {
{ if (!editable) { return; } - handleChange("continueOnFailure", checked); + update({ continueOnFailure: checked }); }} />
{ - handleChange("cacheActions", cacheActions); + update({ cacheActions }); }} onDisableCacheChange={(disableCache) => { - handleChange("disableCache", disableCache); + update({ disableCache }); }} /> @@ -322,12 +291,12 @@ function ActionNode({ id, data, type }: NodeProps) {
{ if (!editable) { return; } - handleChange("allowDownloads", checked); + update({ allowDownloads: checked }); }} />
@@ -346,9 +315,9 @@ function ActionNode({ id, data, type }: NodeProps) { type="text" placeholder={placeholders["action"]["downloadSuffix"]} className="nopan w-52 text-xs" - value={inputs.downloadSuffix ?? ""} + value={data.downloadSuffix ?? ""} onChange={(value) => { - handleChange("downloadSuffix", value); + update({ downloadSuffix: value }); }} /> @@ -365,9 +334,9 @@ function ActionNode({ id, data, type }: NodeProps) { { - handleChange("totpIdentifier", value); + update({ totpIdentifier: value }); }} - value={inputs.totpIdentifier ?? ""} + value={data.totpIdentifier ?? ""} placeholder={placeholders["action"]["totpIdentifier"]} className="nopan text-xs" /> @@ -384,9 +353,9 @@ function ActionNode({ id, data, type }: NodeProps) { { - handleChange("totpVerificationUrl", value); + update({ totpVerificationUrl: value }); }} - value={inputs.totpVerificationUrl ?? ""} + value={data.totpVerificationUrl ?? ""} placeholder={placeholders["task"]["totpVerificationUrl"]} className="nopan text-xs" /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/CodeBlockNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/CodeBlockNode.tsx index 44c7553b..ea64be6d 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/CodeBlockNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/CodeBlockNode.tsx @@ -1,18 +1,19 @@ +import { useParams } from "react-router-dom"; +import { Handle, NodeProps, Position } from "@xyflow/react"; + import { Label } from "@/components/ui/label"; import { WorkflowBlockInputSet } from "@/components/WorkflowBlockInputSet"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; -import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; -import { useState } from "react"; -import type { CodeBlockNode } from "./types"; -import { cn } from "@/util/utils"; -import { NodeHeader } from "../components/NodeHeader"; -import { useParams } from "react-router-dom"; import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; +import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { deepEqualStringArrays } from "@/util/equality"; +import { cn } from "@/util/utils"; + +import type { CodeBlockNode } from "./types"; +import { NodeHeader } from "../components/NodeHeader"; function CodeBlockNode({ id, data }: NodeProps) { - const { updateNodeData } = useReactFlow(); const { editable, label } = data; const { blockLabel: urlBlockLabel } = useParams(); const { data: workflowRun } = useWorkflowRunQuery(); @@ -22,10 +23,7 @@ function CodeBlockNode({ id, data }: NodeProps) { urlBlockLabel !== undefined && urlBlockLabel === label; const thisBlockIsPlaying = workflowRunIsRunningOrQueued && thisBlockIsTargetted; - const [inputs, setInputs] = useState({ - code: data.code, - parameterKeys: data.parameterKeys, - }); + const update = useUpdate({ id, editable }); return (
@@ -65,36 +63,23 @@ function CodeBlockNode({ id, data }: NodeProps) { { - const differs = !deepEqualStringArrays( - inputs.parameterKeys, - Array.from(parameterKeys), - ); - - if (!differs) { - return; + const newParameterKeys = Array.from(parameterKeys); + if ( + !deepEqualStringArrays(data.parameterKeys, newParameterKeys) + ) { + update({ parameterKeys: newParameterKeys }); } - - setInputs({ - ...inputs, - parameterKeys: Array.from(parameterKeys), - }); - - updateNodeData(id, { parameterKeys: Array.from(parameterKeys) }); }} - values={new Set(inputs.parameterKeys ?? [])} + values={new Set(data.parameterKeys ?? [])} />
{ - if (!data.editable) { - return; - } - setInputs({ ...inputs, code: value }); - updateNodeData(id, { code: value }); + update({ code: value }); }} className="nopan" fontSize={8} diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ExtractionNode/ExtractionNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/ExtractionNode/ExtractionNode.tsx index f7184d00..3b74e576 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/ExtractionNode/ExtractionNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ExtractionNode/ExtractionNode.tsx @@ -11,14 +11,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; -import { - Handle, - NodeProps, - Position, - useEdges, - useNodes, - useReactFlow, -} from "@xyflow/react"; +import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react"; import { useState } from "react"; import { dataSchemaExampleValue } from "../types"; import type { ExtractionNode } from "./types"; @@ -40,12 +33,12 @@ import { NodeTabs } from "../components/NodeTabs"; import { useParams } from "react-router-dom"; import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; +import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { useRerender } from "@/hooks/useRerender"; import { DisableCache } from "../DisableCache"; function ExtractionNode({ id, data, type }: NodeProps) { - const { updateNodeData } = useReactFlow(); const [facing, setFacing] = useState<"front" | "back">("front"); const blockScriptStore = useBlockScriptStore(); const { editable, label } = data; @@ -58,31 +51,12 @@ function ExtractionNode({ id, data, type }: NodeProps) { urlBlockLabel !== undefined && urlBlockLabel === label; const thisBlockIsPlaying = workflowRunIsRunningOrQueued && thisBlockIsTargetted; - const [inputs, setInputs] = useState({ - url: data.url, - dataExtractionGoal: data.dataExtractionGoal, - dataSchema: data.dataSchema, - maxStepsOverride: data.maxStepsOverride, - continueOnFailure: data.continueOnFailure, - cacheActions: data.cacheActions, - disableCache: data.disableCache, - engine: data.engine, - model: data.model, - }); const nodes = useNodes(); const edges = useEdges(); const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); const rerender = useRerender({ prefix: "accordian" }); - const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); - - function handleChange(key: string, value: unknown) { - if (!editable) { - return; - } - setInputs({ ...inputs, [key]: value }); - updateNodeData(id, { [key]: value }); - } + const update = useUpdate({ id, editable }); useEffect(() => { setFacing(data.showCode ? "back" : "front"); @@ -144,22 +118,22 @@ function ExtractionNode({ id, data, type }: NodeProps) { if (!editable) { return; } - handleChange("dataExtractionGoal", value); + update({ dataExtractionGoal: value }); }} - value={inputs.dataExtractionGoal} + value={data.dataExtractionGoal} placeholder={placeholders["extraction"]["dataExtractionGoal"]} className="nopan text-xs" />
{ - handleChange("dataSchema", value); + update({ dataSchema: value }); }} exampleValue={dataSchemaExampleValue} suggestionContext={{ - data_extraction_goal: inputs.dataExtractionGoal, - current_schema: inputs.dataSchema, + data_extraction_goal: data.dataExtractionGoal, + current_schema: data.dataSchema, }} /> @@ -177,16 +151,16 @@ function ExtractionNode({ id, data, type }: NodeProps) {
{ - handleChange("model", value); + update({ model: value }); }} /> { - updateNodeData(id, { parameterKeys }); + update({ parameterKeys }); }} />
@@ -197,9 +171,9 @@ function ExtractionNode({ id, data, type }: NodeProps) { { - handleChange("engine", value); + update({ engine: value }); }} className="nopan w-52 text-xs" /> @@ -220,7 +194,7 @@ function ExtractionNode({ id, data, type }: NodeProps) { } className="nopan w-52 text-xs" min="0" - value={inputs.maxStepsOverride ?? ""} + value={data.maxStepsOverride ?? ""} onChange={(event) => { if (!editable) { return; @@ -229,7 +203,7 @@ function ExtractionNode({ id, data, type }: NodeProps) { event.target.value === "" ? null : Number(event.target.value); - handleChange("maxStepsOverride", value); + update({ maxStepsOverride: value }); }} /> @@ -247,25 +221,25 @@ function ExtractionNode({ id, data, type }: NodeProps) {
{ if (!editable) { return; } - handleChange("continueOnFailure", checked); + update({ continueOnFailure: checked }); }} />
{ - handleChange("cacheActions", cacheActions); + update({ cacheActions }); }} onDisableCacheChange={(disableCache) => { - handleChange("disableCache", disableCache); + update({ disableCache }); }} /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx index 243e973c..fb90ddc2 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx @@ -16,14 +16,7 @@ import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTexta import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; import { useBlockScriptStore } from "@/store/BlockScriptStore"; -import { - Handle, - NodeProps, - Position, - useEdges, - useNodes, - useReactFlow, -} from "@xyflow/react"; +import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react"; import { useState } from "react"; import { helpTooltips, placeholders } from "../../helpContent"; import { errorMappingExampleValue } from "../types"; @@ -39,6 +32,7 @@ import { NodeHeader } from "../components/NodeHeader"; import { useParams } from "react-router-dom"; import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; +import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { useRerender } from "@/hooks/useRerender"; import { BROWSER_DOWNLOAD_TIMEOUT_SECONDS } from "@/api/types"; @@ -52,7 +46,6 @@ const navigationGoalTooltip = const navigationGoalPlaceholder = "Tell Skyvern which file to download."; function FileDownloadNode({ id, data }: NodeProps) { - const { updateNodeData } = useReactFlow(); const [facing, setFacing] = useState<"front" | "back">("front"); const blockScriptStore = useBlockScriptStore(); const { editable, label } = data; @@ -65,34 +58,12 @@ function FileDownloadNode({ id, data }: NodeProps) { urlBlockLabel !== undefined && urlBlockLabel === label; const thisBlockIsPlaying = workflowRunIsRunningOrQueued && thisBlockIsTargetted; - const [inputs, setInputs] = useState({ - url: data.url, - navigationGoal: data.navigationGoal, - errorCodeMapping: data.errorCodeMapping, - maxStepsOverride: data.maxStepsOverride, - continueOnFailure: data.continueOnFailure, - cacheActions: data.cacheActions, - disableCache: data.disableCache, - downloadSuffix: data.downloadSuffix, - totpVerificationUrl: data.totpVerificationUrl, - totpIdentifier: data.totpIdentifier, - engine: data.engine, - model: data.model, - downloadTimeout: data.downloadTimeout, - }); const rerender = useRerender({ prefix: "accordian" }); const nodes = useNodes(); const edges = useEdges(); const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); - function handleChange(key: string, value: unknown) { - if (!editable) { - return; - } - - setInputs({ ...inputs, [key]: value }); - updateNodeData(id, { [key]: value }); - } + const update = useUpdate({ id, editable }); useEffect(() => { setFacing(data.showCode ? "back" : "front"); @@ -127,8 +98,8 @@ function FileDownloadNode({ id, data }: NodeProps) { blockLabel={label} editable={editable} nodeId={id} - totpIdentifier={inputs.totpIdentifier} - totpUrl={inputs.totpVerificationUrl} + totpIdentifier={data.totpIdentifier} + totpUrl={data.totpVerificationUrl} type="file_download" // sic: the naming for this block is not consistent />
@@ -148,9 +119,9 @@ function FileDownloadNode({ id, data }: NodeProps) { canWriteTitle={true} nodeId={id} onChange={(value) => { - handleChange("url", value); + update({ url: value }); }} - value={inputs.url} + value={data.url} placeholder={urlPlaceholder} className="nopan text-xs" /> @@ -163,9 +134,9 @@ function FileDownloadNode({ id, data }: NodeProps) { { - handleChange("navigationGoal", value); + update({ navigationGoal: value }); }} - value={inputs.navigationGoal} + value={data.navigationGoal} placeholder={navigationGoalPlaceholder} className="nopan text-xs" /> @@ -181,7 +152,7 @@ function FileDownloadNode({ id, data }: NodeProps) { { const value = @@ -190,7 +161,7 @@ function FileDownloadNode({ id, data }: NodeProps) { : Number(event.target.value); if (value) { - handleChange("downloadTimeout", value); + update({ downloadTimeout: value }); } }} /> @@ -215,16 +186,16 @@ function FileDownloadNode({ id, data }: NodeProps) {
{ - handleChange("model", value); + update({ model: value }); }} /> { - updateNodeData(id, { parameterKeys }); + update({ parameterKeys }); }} />
@@ -235,9 +206,9 @@ function FileDownloadNode({ id, data }: NodeProps) {
{ - handleChange("engine", value); + update({ engine: value }); }} className="nopan w-52 text-xs" /> @@ -256,13 +227,13 @@ function FileDownloadNode({ id, data }: NodeProps) { placeholder={placeholders["download"]["maxStepsOverride"]} className="nopan w-52 text-xs" min="0" - value={inputs.maxStepsOverride ?? ""} + value={data.maxStepsOverride ?? ""} onChange={(event) => { const value = event.target.value === "" ? null : Number(event.target.value); - handleChange("maxStepsOverride", value); + update({ maxStepsOverride: value }); }} /> @@ -277,29 +248,28 @@ function FileDownloadNode({ id, data }: NodeProps) { /> { - handleChange( - "errorCodeMapping", - checked + update({ + errorCodeMapping: checked ? JSON.stringify( errorMappingExampleValue, null, 2, ) : "null", - ); + }); }} /> - {inputs.errorCodeMapping !== "null" && ( + {data.errorCodeMapping !== "null" && (
{ - handleChange("errorCodeMapping", value); + update({ errorCodeMapping: value }); }} className="nopan" fontSize={8} @@ -319,22 +289,22 @@ function FileDownloadNode({ id, data }: NodeProps) {
{ - handleChange("continueOnFailure", checked); + update({ continueOnFailure: checked }); }} />
{ - handleChange("cacheActions", cacheActions); + update({ cacheActions }); }} onDisableCacheChange={(disableCache) => { - handleChange("disableCache", disableCache); + update({ disableCache }); }} /> @@ -350,9 +320,9 @@ function FileDownloadNode({ id, data }: NodeProps) { { - handleChange("downloadSuffix", value); + update({ downloadSuffix: value }); }} - value={inputs.downloadSuffix ?? ""} + value={data.downloadSuffix ?? ""} placeholder={placeholders["download"]["downloadSuffix"]} className="nopan text-xs" /> @@ -370,9 +340,9 @@ function FileDownloadNode({ id, data }: NodeProps) { { - handleChange("totpIdentifier", value); + update({ totpIdentifier: value }); }} - value={inputs.totpIdentifier ?? ""} + value={data.totpIdentifier ?? ""} placeholder={placeholders["download"]["totpIdentifier"]} className="nopan text-xs" /> @@ -389,9 +359,9 @@ function FileDownloadNode({ id, data }: NodeProps) { { - handleChange("totpVerificationUrl", value); + update({ totpVerificationUrl: value }); }} - value={inputs.totpVerificationUrl ?? ""} + value={data.totpVerificationUrl ?? ""} placeholder={placeholders["task"]["totpVerificationUrl"]} className="nopan text-xs" /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/FileParserNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/FileParserNode.tsx index bf29eeed..5106496a 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/FileParserNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/FileParserNode.tsx @@ -1,7 +1,6 @@ import { HelpTooltip } from "@/components/HelpTooltip"; import { Label } from "@/components/ui/label"; -import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; -import { useState } from "react"; +import { Handle, NodeProps, Position } from "@xyflow/react"; import { helpTooltips } from "../../helpContent"; import { type FileParserNode } from "./types"; import { WorkflowBlockInput } from "@/components/WorkflowBlockInput"; @@ -13,10 +12,10 @@ import { WorkflowDataSchemaInputGroup } from "@/components/DataSchemaInputGroup/ import { dataSchemaExampleForFileExtraction } from "../types"; import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; +import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { ModelSelector } from "@/components/ModelSelector"; function FileParserNode({ id, data }: NodeProps) { - const { updateNodeData } = useReactFlow(); const { editable, label } = data; const { blockLabel: urlBlockLabel } = useParams(); const { data: workflowRun } = useWorkflowRunQuery(); @@ -26,21 +25,8 @@ function FileParserNode({ id, data }: NodeProps) { urlBlockLabel !== undefined && urlBlockLabel === label; const thisBlockIsPlaying = workflowRunIsRunningOrQueued && thisBlockIsTargetted; - const [inputs, setInputs] = useState({ - fileUrl: data.fileUrl, - jsonSchema: data.jsonSchema, - model: data.model, - }); - - function handleChange(key: string, value: unknown) { - if (!data.editable) { - return; - } - setInputs({ ...inputs, [key]: value }); - updateNodeData(id, { [key]: value }); - } - const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); + const update = useUpdate({ id, editable }); return (
@@ -90,26 +76,26 @@ function FileParserNode({ id, data }: NodeProps) { { - handleChange("fileUrl", value); + update({ fileUrl: value }); }} className="nopan text-xs" />
{ - handleChange("jsonSchema", value); + update({ jsonSchema: value }); }} suggestionContext={{}} /> { - handleChange("model", value); + update({ model: value }); }} /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/FileUploadNode/FileUploadNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/FileUploadNode/FileUploadNode.tsx index 342f7d94..5b02a65e 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/FileUploadNode/FileUploadNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/FileUploadNode/FileUploadNode.tsx @@ -1,11 +1,10 @@ import { HelpTooltip } from "@/components/HelpTooltip"; import { Label } from "@/components/ui/label"; -import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; +import { Handle, NodeProps, Position } from "@xyflow/react"; import { helpTooltips } from "../../helpContent"; import { type FileUploadNode } from "./types"; import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea"; import { WorkflowBlockInput } from "@/components/WorkflowBlockInput"; -import { useState } from "react"; import { cn } from "@/util/utils"; import { NodeHeader } from "../components/NodeHeader"; import { useParams } from "react-router-dom"; @@ -18,9 +17,9 @@ import { } from "@/components/ui/select"; import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; +import { useUpdate } from "@/routes/workflows/editor/useUpdate"; function FileUploadNode({ id, data }: NodeProps) { - const { updateNodeData } = useReactFlow(); const { editable, label } = data; const { blockLabel: urlBlockLabel } = useParams(); const { data: workflowRun } = useWorkflowRunQuery(); @@ -30,26 +29,7 @@ function FileUploadNode({ id, data }: NodeProps) { urlBlockLabel !== undefined && urlBlockLabel === label; const thisBlockIsPlaying = workflowRunIsRunningOrQueued && thisBlockIsTargetted; - - const [inputs, setInputs] = useState({ - storageType: data.storageType, - awsAccessKeyId: data.awsAccessKeyId ?? "", - awsSecretAccessKey: data.awsSecretAccessKey ?? "", - s3Bucket: data.s3Bucket ?? "", - regionName: data.regionName ?? "", - path: data.path ?? "", - azureStorageAccountName: data.azureStorageAccountName ?? "", - azureStorageAccountKey: data.azureStorageAccountKey ?? "", - azureBlobContainerName: data.azureBlobContainerName ?? "", - }); - - function handleChange(key: string, value: unknown) { - if (!data.editable) { - return; - } - setInputs({ ...inputs, [key]: value }); - updateNodeData(id, { [key]: value }); - } + const update = useUpdate({ id, editable }); return (
@@ -92,8 +72,10 @@ function FileUploadNode({ id, data }: NodeProps) { />
- {inputs.storageType === "s3" && ( + {data.storageType === "s3" && ( <>
@@ -120,9 +102,9 @@ function FileUploadNode({ id, data }: NodeProps) { { - handleChange("awsAccessKeyId", value); + update({ awsAccessKeyId: value }); }} - value={inputs.awsAccessKeyId as string} + value={data.awsAccessKeyId as string} className="nopan text-xs" />
@@ -140,10 +122,10 @@ function FileUploadNode({ id, data }: NodeProps) { { - handleChange("awsSecretAccessKey", value); + update({ awsSecretAccessKey: value }); }} />
@@ -157,9 +139,9 @@ function FileUploadNode({ id, data }: NodeProps) { { - handleChange("s3Bucket", value); + update({ s3Bucket: value }); }} - value={inputs.s3Bucket as string} + value={data.s3Bucket as string} className="nopan text-xs" /> @@ -173,9 +155,9 @@ function FileUploadNode({ id, data }: NodeProps) { { - handleChange("regionName", value); + update({ regionName: value }); }} - value={inputs.regionName as string} + value={data.regionName as string} className="nopan text-xs" /> @@ -189,16 +171,16 @@ function FileUploadNode({ id, data }: NodeProps) { { - handleChange("path", value); + update({ path: value }); }} - value={inputs.path as string} + value={data.path as string} className="nopan text-xs" /> )} - {inputs.storageType === "azure" && ( + {data.storageType === "azure" && ( <>
@@ -214,9 +196,9 @@ function FileUploadNode({ id, data }: NodeProps) { { - handleChange("azureStorageAccountName", value); + update({ azureStorageAccountName: value }); }} - value={inputs.azureStorageAccountName as string} + value={data.azureStorageAccountName as string} className="nopan text-xs" />
@@ -234,10 +216,10 @@ function FileUploadNode({ id, data }: NodeProps) { { - handleChange("azureStorageAccountKey", value); + update({ azureStorageAccountKey: value }); }} />
@@ -255,9 +237,9 @@ function FileUploadNode({ id, data }: NodeProps) { { - handleChange("azureBlobContainerName", value); + update({ azureBlobContainerName: value }); }} - value={inputs.azureBlobContainerName as string} + value={data.azureBlobContainerName as string} className="nopan text-xs" /> @@ -271,9 +253,9 @@ function FileUploadNode({ id, data }: NodeProps) { { - handleChange("path", value); + update({ path: value }); }} - value={inputs.path as string} + value={data.path as string} className="nopan text-xs" /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/HttpRequestNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/HttpRequestNode.tsx index 07dabc82..b0c6b8dd 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/HttpRequestNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/HttpRequestNode.tsx @@ -8,15 +8,8 @@ import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler"; -import { - Handle, - NodeProps, - Position, - useEdges, - useNodes, - useReactFlow, -} from "@xyflow/react"; -import { useState } from "react"; +import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react"; +import { useCallback } from "react"; import { EditableNodeTitle } from "../components/EditableNodeTitle"; import { NodeActionMenu } from "../NodeActionMenu"; import type { HttpRequestNode as HttpRequestNodeType } from "./types"; @@ -31,6 +24,7 @@ import { getAvailableOutputParameterKeys } from "../../workflowEditorUtils"; import { ParametersMultiSelect } from "../TaskNode/ParametersMultiSelect"; import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; +import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { Select, SelectContent, @@ -67,83 +61,59 @@ const followRedirectsTooltip = "Whether to automatically follow HTTP redirects."; function HttpRequestNode({ id, data }: NodeProps) { - const { updateNodeData } = useReactFlow(); const { editable } = data; const [label, setLabel] = useNodeLabelChangeHandler({ id, initialValue: data.label, }); - const [inputs, setInputs] = useState({ - method: data.method, - url: data.url, - headers: data.headers, - body: data.body, - timeout: data.timeout, - followRedirects: data.followRedirects, - continueOnFailure: data.continueOnFailure, - }); const deleteNodeCallback = useDeleteNodeCallback(); - const rerender = useRerender({ prefix: "accordian" }); const nodes = useNodes(); const edges = useEdges(); const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); + const update = useUpdate({ id, editable }); - function handleChange(key: string, value: unknown) { - if (!editable) { - return; - } - setInputs({ ...inputs, [key]: value }); - updateNodeData(id, { [key]: value }); - } + const handleCurlImport = useCallback( + (importedData: { + method: string; + url: string; + headers: string; + body: string; + timeout: number; + followRedirects: boolean; + }) => { + update({ + method: importedData.method, + url: importedData.url, + headers: importedData.headers, + body: importedData.body, + timeout: importedData.timeout, + followRedirects: importedData.followRedirects, + }); + }, + [update], + ); - const handleCurlImport = (importedData: { - method: string; - url: string; - headers: string; - body: string; - timeout: number; - followRedirects: boolean; - }) => { - const newInputs = { - ...inputs, - method: importedData.method, - url: importedData.url, - headers: importedData.headers, - body: importedData.body, - timeout: importedData.timeout, - followRedirects: importedData.followRedirects, - }; - setInputs(newInputs); - updateNodeData(id, { - method: importedData.method, - url: importedData.url, - headers: importedData.headers, - body: importedData.body, - timeout: importedData.timeout, - followRedirects: importedData.followRedirects, - }); - }; - - const handleQuickHeaders = (headers: Record) => { - try { - const existingHeaders = JSON.parse(inputs.headers || "{}"); - const mergedHeaders = { ...existingHeaders, ...headers }; - const newHeadersString = JSON.stringify(mergedHeaders, null, 2); - handleChange("headers", newHeadersString); - } catch (error) { - // If existing headers are invalid, just use the new ones - const newHeadersString = JSON.stringify(headers, null, 2); - handleChange("headers", newHeadersString); - } - }; + const handleQuickHeaders = useCallback( + (headers: Record) => { + try { + const existingHeaders = JSON.parse(data.headers || "{}"); + const mergedHeaders = { ...existingHeaders, ...headers }; + const newHeadersString = JSON.stringify(mergedHeaders, null, 2); + update({ headers: newHeadersString }); + } catch (error) { + // If existing headers are invalid, just use the new ones + const newHeadersString = JSON.stringify(headers, null, 2); + update({ headers: newHeadersString }); + } + }, + [data.headers, update], + ); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const showBodyEditor = - inputs.method !== "GET" && - inputs.method !== "HEAD" && - inputs.method !== "DELETE"; + data.method !== "GET" && data.method !== "HEAD" && data.method !== "DELETE"; return (
@@ -210,13 +180,13 @@ function HttpRequestNode({ id, data }: NodeProps) {
{ - handleChange( - "webhookCallbackUrl", - event.target.value, - ); + update({ + webhookCallbackUrl: event.target.value, + }); }} /> @@ -211,9 +186,9 @@ function StartNode({ id, data }: NodeProps) { { - handleChange("proxyLocation", value); + update({ proxyLocation: value }); }} /> @@ -225,9 +200,9 @@ function StartNode({ id, data }: NodeProps) { { const value = @@ -350,7 +343,7 @@ function StartNode({ id, data }: NodeProps) { ? null : Number(event.target.value); - handleChange("maxScreenshotScrolls", value); + update({ maxScreenshotScrolls: value }); }} /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts index 0e2458e4..5ad698cd 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts @@ -10,7 +10,7 @@ export type WorkflowStartNodeData = { persistBrowserSession: boolean; model: WorkflowModel | null; maxScreenshotScrolls: number | null; - extraHttpHeaders: string | null; + extraHttpHeaders: string | Record | null; editable: boolean; runWith: string | null; scriptCacheKey: string | null; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx index 2512155c..3581e045 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx @@ -17,14 +17,7 @@ import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTexta import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; import { useBlockScriptStore } from "@/store/BlockScriptStore"; -import { - Handle, - NodeProps, - Position, - useEdges, - useNodes, - useReactFlow, -} from "@xyflow/react"; +import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react"; import { useState } from "react"; import { AppNode } from ".."; import { helpTooltips, placeholders } from "../../helpContent"; @@ -41,12 +34,12 @@ import { NodeHeader } from "../components/NodeHeader"; import { useParams } from "react-router-dom"; import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; +import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { useRerender } from "@/hooks/useRerender"; import { DisableCache } from "../DisableCache"; function TaskNode({ id, data, type }: NodeProps) { - const { updateNodeData } = useReactFlow(); const [facing, setFacing] = useState<"front" | "back">("front"); const blockScriptStore = useBlockScriptStore(); const { editable, label } = data; @@ -59,41 +52,12 @@ function TaskNode({ id, data, type }: NodeProps) { urlBlockLabel !== undefined && urlBlockLabel === label; const thisBlockIsPlaying = workflowRunIsRunningOrQueued && thisBlockIsTargetted; - const rerender = useRerender({ prefix: "accordian" }); const nodes = useNodes(); const edges = useEdges(); const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); - - const [inputs, setInputs] = useState({ - url: data.url, - navigationGoal: data.navigationGoal, - dataExtractionGoal: data.dataExtractionGoal, - completeCriterion: data.completeCriterion, - terminateCriterion: data.terminateCriterion, - dataSchema: data.dataSchema, - maxStepsOverride: data.maxStepsOverride, - allowDownloads: data.allowDownloads, - continueOnFailure: data.continueOnFailure, - cacheActions: data.cacheActions, - disableCache: data.disableCache, - downloadSuffix: data.downloadSuffix, - errorCodeMapping: data.errorCodeMapping, - totpVerificationUrl: data.totpVerificationUrl, - totpIdentifier: data.totpIdentifier, - includeActionHistoryInVerification: data.includeActionHistoryInVerification, - engine: data.engine, - model: data.model, - }); - - function handleChange(key: string, value: unknown) { - if (!editable) { - return; - } - setInputs({ ...inputs, [key]: value }); - updateNodeData(id, { [key]: value }); - } + const update = useUpdate({ id, editable }); useEffect(() => { setFacing(data.showCode ? "back" : "front"); @@ -129,8 +93,8 @@ function TaskNode({ id, data, type }: NodeProps) { blockLabel={label} editable={editable} nodeId={id} - totpIdentifier={inputs.totpIdentifier} - totpUrl={inputs.totpVerificationUrl} + totpIdentifier={data.totpIdentifier} + totpUrl={data.totpVerificationUrl} type={type} /> ) { canWriteTitle={true} nodeId={id} onChange={(value) => { - handleChange("url", value); + update({ url: value }); }} - value={inputs.url} + value={data.url} placeholder={placeholders["task"]["url"]} className="nopan text-xs" /> @@ -175,9 +139,9 @@ function TaskNode({ id, data, type }: NodeProps) { { - handleChange("navigationGoal", value); + update({ navigationGoal: value }); }} - value={inputs.navigationGoal} + value={data.navigationGoal} placeholder={placeholders["task"]["navigationGoal"]} className="nopan text-xs" /> @@ -187,7 +151,7 @@ function TaskNode({ id, data, type }: NodeProps) { availableOutputParameters={outputParameterKeys} parameters={data.parameterKeys} onParametersChange={(parameterKeys) => { - updateNodeData(id, { parameterKeys }); + update({ parameterKeys }); }} /> @@ -210,9 +174,9 @@ function TaskNode({ id, data, type }: NodeProps) { { - handleChange("dataExtractionGoal", value); + update({ dataExtractionGoal: value }); }} - value={inputs.dataExtractionGoal} + value={data.dataExtractionGoal} placeholder={placeholders["task"]["dataExtractionGoal"]} className="nopan text-xs" /> @@ -220,13 +184,13 @@ function TaskNode({ id, data, type }: NodeProps) { { - handleChange("dataSchema", value); + update({ dataSchema: value }); }} - value={inputs.dataSchema} + value={data.dataSchema} suggestionContext={{ - data_extraction_goal: inputs.dataExtractionGoal, - current_schema: inputs.dataSchema, - navigation_goal: inputs.navigationGoal, + data_extraction_goal: data.dataExtractionGoal, + current_schema: data.dataSchema, + navigation_goal: data.navigationGoal, }} /> @@ -243,18 +207,18 @@ function TaskNode({ id, data, type }: NodeProps) { { - handleChange("completeCriterion", value); + update({ completeCriterion: value }); }} - value={inputs.completeCriterion} + value={data.completeCriterion} className="nopan text-xs" /> { - handleChange("model", value); + update({ model: value }); }} />
@@ -264,9 +228,9 @@ function TaskNode({ id, data, type }: NodeProps) {
{ - handleChange("engine", value); + update({ engine: value }); }} className="nopan w-52 text-xs" /> @@ -285,13 +249,13 @@ function TaskNode({ id, data, type }: NodeProps) { placeholder={placeholders["task"]["maxStepsOverride"]} className="nopan w-52 text-xs" min="0" - value={inputs.maxStepsOverride ?? ""} + value={data.maxStepsOverride ?? ""} onChange={(event) => { const value = event.target.value === "" ? null : Number(event.target.value); - handleChange("maxStepsOverride", value); + update({ maxStepsOverride: value }); }} /> @@ -306,29 +270,28 @@ function TaskNode({ id, data, type }: NodeProps) { /> { - handleChange( - "errorCodeMapping", - checked + update({ + errorCodeMapping: checked ? JSON.stringify( errorMappingExampleValue, null, 2, ) : "null", - ); + }); }} /> - {inputs.errorCodeMapping !== "null" && ( + {data.errorCodeMapping !== "null" && (
{ - handleChange("errorCodeMapping", value); + update({ errorCodeMapping: value }); }} className="nopan" fontSize={8} @@ -352,12 +315,11 @@ function TaskNode({ id, data, type }: NodeProps) {
{ - handleChange( - "includeActionHistoryInVerification", - checked, - ); + update({ + includeActionHistoryInVerification: checked, + }); }} />
@@ -373,22 +335,22 @@ function TaskNode({ id, data, type }: NodeProps) {
{ - handleChange("continueOnFailure", checked); + update({ continueOnFailure: checked }); }} />
{ - handleChange("cacheActions", cacheActions); + update({ cacheActions }); }} onDisableCacheChange={(disableCache) => { - handleChange("disableCache", disableCache); + update({ disableCache }); }} /> @@ -403,9 +365,9 @@ function TaskNode({ id, data, type }: NodeProps) {
{ - handleChange("allowDownloads", checked); + update({ allowDownloads: checked }); }} />
@@ -424,9 +386,9 @@ function TaskNode({ id, data, type }: NodeProps) { type="text" placeholder={placeholders["task"]["downloadSuffix"]} className="nopan w-52 text-xs" - value={inputs.downloadSuffix ?? ""} + value={data.downloadSuffix ?? ""} onChange={(value) => { - handleChange("downloadSuffix", value); + update({ downloadSuffix: value }); }} /> @@ -443,9 +405,9 @@ function TaskNode({ id, data, type }: NodeProps) { { - handleChange("totpIdentifier", value); + update({ totpIdentifier: value }); }} - value={inputs.totpIdentifier ?? ""} + value={data.totpIdentifier ?? ""} placeholder={placeholders["task"]["totpIdentifier"]} className="nopan text-xs" /> @@ -462,9 +424,9 @@ function TaskNode({ id, data, type }: NodeProps) { { - handleChange("totpVerificationUrl", value); + update({ totpVerificationUrl: value }); }} - value={inputs.totpVerificationUrl ?? ""} + value={data.totpVerificationUrl ?? ""} placeholder={placeholders["task"]["totpVerificationUrl"]} className="nopan text-xs" /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/Taskv2Node.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/Taskv2Node.tsx index 0238ad64..99679577 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/Taskv2Node.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/Taskv2Node.tsx @@ -11,7 +11,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; -import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; +import { Handle, NodeProps, Position } from "@xyflow/react"; import { helpTooltips, placeholders } from "../../helpContent"; import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow"; import { MAX_STEPS_DEFAULT, type Taskv2Node } from "./types"; @@ -24,6 +24,7 @@ import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useRerender } from "@/hooks/useRerender"; import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor"; +import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { useBlockScriptStore } from "@/store/BlockScriptStore"; import { DisableCache } from "../DisableCache"; @@ -38,30 +39,12 @@ function Taskv2Node({ id, data, type }: NodeProps) { urlBlockLabel !== undefined && urlBlockLabel === label; const thisBlockIsPlaying = workflowRunIsRunningOrQueued && thisBlockIsTargetted; - const { updateNodeData } = useReactFlow(); const [facing, setFacing] = useState<"front" | "back">("front"); const blockScriptStore = useBlockScriptStore(); const script = blockScriptStore.scripts[label]; const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const rerender = useRerender({ prefix: "accordian" }); - - const [inputs, setInputs] = useState({ - prompt: data.prompt, - url: data.url, - totpVerificationUrl: data.totpVerificationUrl, - totpIdentifier: data.totpIdentifier, - maxSteps: data.maxSteps, - disableCache: data.disableCache, - model: data.model, - }); - - function handleChange(key: string, value: unknown) { - if (!editable) { - return; - } - setInputs({ ...inputs, [key]: value }); - updateNodeData(id, { [key]: value }); - } + const update = useUpdate({ id, editable }); useEffect(() => { setFacing(data.showCode ? "back" : "front"); @@ -96,8 +79,8 @@ function Taskv2Node({ id, data, type }: NodeProps) { blockLabel={label} editable={editable} nodeId={id} - totpIdentifier={inputs.totpIdentifier} - totpUrl={inputs.totpVerificationUrl} + totpIdentifier={data.totpIdentifier} + totpUrl={data.totpVerificationUrl} type="task_v2" // sic: the naming is not consistent />
@@ -113,9 +96,9 @@ function Taskv2Node({ id, data, type }: NodeProps) { { - handleChange("prompt", value); + update({ prompt: value }); }} - value={inputs.prompt} + value={data.prompt} placeholder={placeholders[type]["prompt"]} className="nopan text-xs" /> @@ -126,9 +109,9 @@ function Taskv2Node({ id, data, type }: NodeProps) { canWriteTitle={true} nodeId={id} onChange={(value) => { - handleChange("url", value); + update({ url: value }); }} - value={inputs.url} + value={data.url} placeholder={placeholders[type]["url"]} className="nopan text-xs" /> @@ -148,9 +131,9 @@ function Taskv2Node({ id, data, type }: NodeProps) {
{ - handleChange("model", value); + update({ model: value }); }} />
@@ -166,19 +149,21 @@ function Taskv2Node({ id, data, type }: NodeProps) { className="nopan text-xs" value={data.maxSteps ?? MAX_STEPS_DEFAULT} onChange={(event) => { - handleChange("maxSteps", Number(event.target.value)); + update({ + maxSteps: Number(event.target.value), + }); }} />
{ - handleChange("cacheActions", cacheActions); + update({ cacheActions }); }} onDisableCacheChange={(disableCache) => { - handleChange("disableCache", disableCache); + update({ disableCache }); }} /> @@ -194,9 +179,9 @@ function Taskv2Node({ id, data, type }: NodeProps) { { - handleChange("totpIdentifier", value); + update({ totpIdentifier: value }); }} - value={inputs.totpIdentifier ?? ""} + value={data.totpIdentifier ?? ""} placeholder={placeholders["navigation"]["totpIdentifier"]} className="nopan text-xs" /> @@ -213,9 +198,9 @@ function Taskv2Node({ id, data, type }: NodeProps) { { - handleChange("totpVerificationUrl", value); + update({ totpVerificationUrl: value }); }} - value={inputs.totpVerificationUrl ?? ""} + value={data.totpVerificationUrl ?? ""} placeholder={placeholders["task"]["totpVerificationUrl"]} className="nopan text-xs" /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/types.ts index b3f477c5..e3a5c349 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/types.ts @@ -11,6 +11,7 @@ export type Taskv2NodeData = NodeBaseData & { totpVerificationUrl: string | null; totpIdentifier: string | null; maxSteps: number | null; + cacheActions: boolean; disableCache: boolean; maxScreenshotScrolls: number | null; }; @@ -27,6 +28,7 @@ export const taskv2NodeDefaultData: Taskv2NodeData = { totpIdentifier: null, totpVerificationUrl: null, maxSteps: MAX_STEPS_DEFAULT, + cacheActions: false, disableCache: false, model: null, maxScreenshotScrolls: null, diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/TextPromptNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/TextPromptNode.tsx index b07835cf..9e15ba09 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/TextPromptNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/TextPromptNode.tsx @@ -1,8 +1,7 @@ import { HelpTooltip } from "@/components/HelpTooltip"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; -import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; -import { useState } from "react"; +import { Handle, NodeProps, Position } from "@xyflow/react"; import { helpTooltips } from "../../helpContent"; import { type TextPromptNode } from "./types"; import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea"; @@ -15,9 +14,9 @@ import { NodeHeader } from "../components/NodeHeader"; import { useParams } from "react-router-dom"; import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; +import { useUpdate } from "@/routes/workflows/editor/useUpdate"; function TextPromptNode({ id, data }: NodeProps) { - const { updateNodeData } = useReactFlow(); const { editable, label } = data; const { blockLabel: urlBlockLabel } = useParams(); const { data: workflowRun } = useWorkflowRunQuery(); @@ -27,21 +26,8 @@ function TextPromptNode({ id, data }: NodeProps) { urlBlockLabel !== undefined && urlBlockLabel === label; const thisBlockIsPlaying = workflowRunIsRunningOrQueued && thisBlockIsTargetted; - const [inputs, setInputs] = useState({ - prompt: data.prompt, - jsonSchema: data.jsonSchema, - model: data.model, - }); - - function handleChange(key: string, value: unknown) { - if (!editable) { - return; - } - setInputs({ ...inputs, [key]: value }); - updateNodeData(id, { [key]: value }); - } - const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); + const update = useUpdate({ id, editable }); return (
@@ -91,9 +77,9 @@ function TextPromptNode({ id, data }: NodeProps) { { - handleChange("prompt", value); + update({ prompt: value }); }} - value={inputs.prompt} + value={data.prompt} placeholder="What do you want to generate?" className="nopan text-xs" /> @@ -101,16 +87,16 @@ function TextPromptNode({ id, data }: NodeProps) { { - handleChange("model", value); + update({ model: value }); }} /> { - handleChange("jsonSchema", value); + update({ jsonSchema: value }); }} suggestionContext={{}} /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/URLNode/URLNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/URLNode/URLNode.tsx index eddb0061..c3dd2feb 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/URLNode/URLNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/URLNode/URLNode.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { Flippable } from "@/components/Flippable"; -import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; +import { Handle, NodeProps, Position } from "@xyflow/react"; import type { URLNode } from "./types"; import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow"; import { useState } from "react"; @@ -14,9 +14,9 @@ import { NodeHeader } from "../components/NodeHeader"; import { useParams } from "react-router-dom"; import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; +import { useUpdate } from "@/routes/workflows/editor/useUpdate"; function URLNode({ id, data, type }: NodeProps) { - const { updateNodeData } = useReactFlow(); const [facing, setFacing] = useState<"front" | "back">("front"); const blockScriptStore = useBlockScriptStore(); const { editable, label } = data; @@ -30,18 +30,7 @@ function URLNode({ id, data, type }: NodeProps) { const thisBlockIsPlaying = workflowRunIsRunningOrQueued && thisBlockIsTargetted; const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); - - const [inputs, setInputs] = useState({ - url: data.url, - }); - - function handleChange(key: string, value: unknown) { - if (!editable) { - return; - } - setInputs({ ...inputs, [key]: value }); - updateNodeData(id, { [key]: value }); - } + const update = useUpdate({ id, editable }); useEffect(() => { setFacing(data.showCode ? "back" : "front"); @@ -94,9 +83,9 @@ function URLNode({ id, data, type }: NodeProps) { canWriteTitle={true} nodeId={id} onChange={(value) => { - handleChange("url", value); + update({ url: value }); }} - value={inputs.url} + value={data.url} placeholder={placeholders[type]["url"]} className="nopan text-xs" /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ValidationNode/ValidationNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/ValidationNode/ValidationNode.tsx index d0c92835..4a0e7e4e 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/ValidationNode/ValidationNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ValidationNode/ValidationNode.tsx @@ -15,14 +15,7 @@ import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTexta import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; import { useBlockScriptStore } from "@/store/BlockScriptStore"; -import { - Handle, - NodeProps, - Position, - useEdges, - useNodes, - useReactFlow, -} from "@xyflow/react"; +import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react"; import { useState } from "react"; import { helpTooltips } from "../../helpContent"; import { errorMappingExampleValue } from "../types"; @@ -37,12 +30,12 @@ import { NodeHeader } from "../components/NodeHeader"; import { useParams } from "react-router-dom"; import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; +import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { useRerender } from "@/hooks/useRerender"; import { DisableCache } from "../DisableCache"; function ValidationNode({ id, data, type }: NodeProps) { - const { updateNodeData } = useReactFlow(); const [facing, setFacing] = useState<"front" | "back">("front"); const blockScriptStore = useBlockScriptStore(); const { editable, label } = data; @@ -55,28 +48,12 @@ function ValidationNode({ id, data, type }: NodeProps) { urlBlockLabel !== undefined && urlBlockLabel === label; const thisBlockIsPlaying = workflowRunIsRunningOrQueued && thisBlockIsTargetted; - const [inputs, setInputs] = useState({ - completeCriterion: data.completeCriterion, - terminateCriterion: data.terminateCriterion, - errorCodeMapping: data.errorCodeMapping, - model: data.model, - disableCache: data.disableCache, - }); - const rerender = useRerender({ prefix: "accordian" }); const nodes = useNodes(); const edges = useEdges(); const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); - - function handleChange(key: string, value: unknown) { - if (!editable) { - return; - } - setInputs({ ...inputs, [key]: value }); - updateNodeData(id, { [key]: value }); - } - const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); + const update = useUpdate({ id, editable }); useEffect(() => { setFacing(data.showCode ? "back" : "front"); @@ -128,9 +105,9 @@ function ValidationNode({ id, data, type }: NodeProps) { { - handleChange("completeCriterion", value); + update({ completeCriterion: value }); }} - value={inputs.completeCriterion} + value={data.completeCriterion} className="nopan text-xs" />
@@ -139,9 +116,9 @@ function ValidationNode({ id, data, type }: NodeProps) { { - handleChange("terminateCriterion", value); + update({ terminateCriterion: value }); }} - value={inputs.terminateCriterion} + value={data.terminateCriterion} className="nopan text-xs" />
@@ -160,16 +137,16 @@ function ValidationNode({ id, data, type }: NodeProps) {
{ - handleChange("model", value); + update({ model: value }); }} /> { - updateNodeData(id, { parameterKeys }); + update({ parameterKeys }); }} />
@@ -186,35 +163,34 @@ function ValidationNode({ id, data, type }: NodeProps) { />
{ if (!editable) { return; } - handleChange( - "errorCodeMapping", - checked + update({ + errorCodeMapping: checked ? JSON.stringify( errorMappingExampleValue, null, 2, ) : "null", - ); + }); }} /> - {inputs.errorCodeMapping !== "null" && ( + {data.errorCodeMapping !== "null" && (
{ if (!editable) { return; } - handleChange("errorCodeMapping", value); + update({ errorCodeMapping: value }); }} className="nopan" fontSize={8} @@ -241,19 +217,19 @@ function ValidationNode({ id, data, type }: NodeProps) { if (!editable) { return; } - updateNodeData(id, { continueOnFailure: checked }); + update({ continueOnFailure: checked }); }} />
{ - handleChange("cacheActions", cacheActions); + update({ cacheActions }); }} onDisableCacheChange={(disableCache) => { - handleChange("disableCache", disableCache); + update({ disableCache }); }} /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ValidationNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/ValidationNode/types.ts index d8ff2b93..a0a16fc0 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/ValidationNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ValidationNode/types.ts @@ -6,6 +6,7 @@ export type ValidationNodeData = NodeBaseData & { terminateCriterion: string; errorCodeMapping: string; parameterKeys: Array; + cacheActions?: boolean; disableCache: boolean; }; @@ -20,6 +21,7 @@ export const validationNodeDefaultData: ValidationNodeData = { continueOnFailure: false, editable: true, parameterKeys: [], + cacheActions: false, disableCache: false, model: null, }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/WaitNode/WaitNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/WaitNode/WaitNode.tsx index 736930b8..285628f5 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/WaitNode/WaitNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/WaitNode/WaitNode.tsx @@ -1,7 +1,6 @@ import { HelpTooltip } from "@/components/HelpTooltip"; import { Label } from "@/components/ui/label"; -import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; -import { useState } from "react"; +import { Handle, NodeProps, Position } from "@xyflow/react"; import { helpTooltips } from "../../helpContent"; import type { WaitNode } from "./types"; import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow"; @@ -11,9 +10,9 @@ import { NodeHeader } from "../components/NodeHeader"; import { useParams } from "react-router-dom"; import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; +import { useUpdate } from "@/routes/workflows/editor/useUpdate"; function WaitNode({ id, data, type }: NodeProps) { - const { updateNodeData } = useReactFlow(); const { editable, label } = data; const { blockLabel: urlBlockLabel } = useParams(); const { data: workflowRun } = useWorkflowRunQuery(); @@ -23,20 +22,10 @@ function WaitNode({ id, data, type }: NodeProps) { urlBlockLabel !== undefined && urlBlockLabel === label; const thisBlockIsPlaying = workflowRunIsRunningOrQueued && thisBlockIsTargetted; - const [inputs, setInputs] = useState({ - waitInSeconds: data.waitInSeconds, - }); - - function handleChange(key: string, value: unknown) { - if (!editable) { - return; - } - setInputs({ ...inputs, [key]: value }); - updateNodeData(id, { [key]: value }); - } - const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); + const update = useUpdate({ id, editable }); + return (
) { ) : null}
{ - handleChange("waitInSeconds", event.target.value); + update({ waitInSeconds: event.target.value }); }} className="nopan text-xs" /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx index 21b9829e..f3826b64 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx @@ -91,7 +91,9 @@ const getPayload = (opts: { extraHttpHeaders = opts.workflowSettings.extraHttpHeaders === null ? null - : JSON.parse(opts.workflowSettings.extraHttpHeaders); + : typeof opts.workflowSettings.extraHttpHeaders === "object" + ? opts.workflowSettings.extraHttpHeaders + : JSON.parse(opts.workflowSettings.extraHttpHeaders); } catch (e: unknown) { toast({ variant: "warning", diff --git a/skyvern-frontend/src/routes/workflows/editor/useUpdate.ts b/skyvern-frontend/src/routes/workflows/editor/useUpdate.ts new file mode 100644 index 00000000..e4b83167 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/useUpdate.ts @@ -0,0 +1,38 @@ +import { useReactFlow } from "@xyflow/react"; +import { useCallback } from "react"; + +type UseUpdateOptions = { + id: string; + editable: boolean; +}; + +/** + * A reusable hook for updating node data in React Flow. + * + * @template T - The root data type that extends Record + * @param options - Configuration object containing node id and editable flag + * @returns An update function that accepts partial updates of type T + * + * @example + * ```tsx + * const update = useUpdate({ id, editable }); + * update({ waitInSeconds: "5" }); + * ``` + */ +export function useUpdate>({ + id, + editable, +}: UseUpdateOptions) { + const { updateNodeData } = useReactFlow(); + + const update = useCallback( + (updates: Partial) => { + if (!editable) return; + + updateNodeData(id, updates); + }, + [id, editable, updateNodeData], + ); + + return update; +} diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index 28a5ddd0..5f5a19d2 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -264,6 +264,7 @@ function convertToNode( prompt: block.prompt, url: block.url ?? "", maxSteps: block.max_steps, + cacheActions: block.cache_actions ?? false, disableCache: block.disable_cache ?? false, totpIdentifier: block.totp_identifier, totpVerificationUrl: block.totp_verification_url, @@ -1457,7 +1458,10 @@ function getWorkflowSettings(nodes: Array): WorkflowSettings { webhookCallbackUrl: data.webhookCallbackUrl, model: data.model, maxScreenshotScrolls: data.maxScreenshotScrolls, - extraHttpHeaders: data.extraHttpHeaders, + extraHttpHeaders: + data.extraHttpHeaders && typeof data.extraHttpHeaders === "object" + ? JSON.stringify(data.extraHttpHeaders) + : data.extraHttpHeaders, runWith: data.runWith, scriptCacheKey: data.scriptCacheKey, aiFallback: data.aiFallback, diff --git a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts index 0e692f37..170b9839 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts @@ -318,6 +318,7 @@ export type Taskv2Block = WorkflowBlockBase & { totp_verification_url: string | null; totp_identifier: string | null; max_steps: number | null; + cache_actions?: boolean; disable_cache: boolean; }; diff --git a/skyvern-frontend/src/store/WorkflowSettingsStore.ts b/skyvern-frontend/src/store/WorkflowSettingsStore.ts index d00d10c2..05d6f301 100644 --- a/skyvern-frontend/src/store/WorkflowSettingsStore.ts +++ b/skyvern-frontend/src/store/WorkflowSettingsStore.ts @@ -11,9 +11,14 @@ export interface WorkflowSettingsState { persistBrowserSession: boolean; model: WorkflowModel | null; maxScreenshotScrollingTimes: number | null; - extraHttpHeaders: string | null; + extraHttpHeaders: string | Record | null; setWorkflowSettings: ( - settings: Partial>, + settings: Partial< + Omit< + WorkflowSettingsState, + "setWorkflowSettings" | "resetWorkflowSettings" + > + >, ) => void; resetWorkflowSettings: () => void; }