Rework state management arch for blocks (fix rando max recursion errors, maybe other bugs) (#3755)

This commit is contained in:
Jonathan Dobson
2025-10-17 12:02:03 -04:00
committed by GitHub
parent fd515adb9c
commit e3ecc4b657
32 changed files with 622 additions and 938 deletions

View File

@@ -66,7 +66,6 @@ function ModelSelector({
const newValue = v === constants.SkyvernOptimized ? null : v; const newValue = v === constants.SkyvernOptimized ? null : v;
const modelName = newValue ? reverseMap[newValue] : null; const modelName = newValue ? reverseMap[newValue] : null;
const value = modelName ? { model_name: modelName } : null; const value = modelName ? { model_name: modelName } : null;
console.log({ v, newValue, modelName, value });
onChange(value); onChange(value);
}} }}
> >

View File

@@ -5,6 +5,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { WorkflowBlockParameterSelect } from "@/routes/workflows/editor/nodes/WorkflowBlockParameterSelect"; import { WorkflowBlockParameterSelect } from "@/routes/workflows/editor/nodes/WorkflowBlockParameterSelect";
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore"; import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
type Props = Omit< type Props = Omit<
React.ComponentProps<typeof AutoResizingTextarea>, React.ComponentProps<typeof AutoResizingTextarea>,
@@ -29,14 +30,14 @@ function WorkflowBlockInputTextarea(props: Props) {
setInternalValue(props.value ?? ""); setInternalValue(props.value ?? "");
}, [props.value]); }, [props.value]);
const doOnChange = (value: string) => { const doOnChange = useDebouncedCallback((value: string) => {
onChange(value); onChange(value);
if (canWriteTitle) { if (canWriteTitle) {
maybeWriteTitle(value); maybeWriteTitle(value);
maybeAcceptTitle(); maybeAcceptTitle();
} }
}; }, 300);
const handleTextareaSelect = () => { const handleTextareaSelect = () => {
if (textareaRef.current) { if (textareaRef.current) {
@@ -76,12 +77,13 @@ function WorkflowBlockInputTextarea(props: Props) {
{...textAreaProps} {...textAreaProps}
value={internalValue} value={internalValue}
ref={textareaRef} ref={textareaRef}
onBlur={(event) => { onBlur={() => {
doOnChange(event.target.value); doOnChange.flush();
}} }}
onChange={(event) => { onChange={(event) => {
setInternalValue(event.target.value); setInternalValue(event.target.value);
handleTextareaSelect(); handleTextareaSelect();
doOnChange(event.target.value);
}} }}
onClick={handleTextareaSelect} onClick={handleTextareaSelect}
onKeyUp={handleTextareaSelect} onKeyUp={handleTextareaSelect}

View File

@@ -3,8 +3,9 @@ import { json } from "@codemirror/lang-json";
import { python } from "@codemirror/lang-python"; import { python } from "@codemirror/lang-python";
import { html } from "@codemirror/lang-html"; import { html } from "@codemirror/lang-html";
import { tokyoNightStorm } from "@uiw/codemirror-theme-tokyo-night-storm"; 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 { cn } from "@/util/utils";
import { useDebouncedCallback } from "use-debounce";
import "./code-mirror-overrides.css"; import "./code-mirror-overrides.css";
@@ -50,6 +51,20 @@ function CodeEditor({
fullHeight = false, fullHeight = false,
}: Props) { }: Props) {
const viewRef = useRef<EditorView | null>(null); const viewRef = useRef<EditorView | null>(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 const extensions = language
? [getLanguageExtension(language), lineWrap ? EditorView.lineWrapping : []] ? [getLanguageExtension(language), lineWrap ? EditorView.lineWrapping : []]
@@ -103,8 +118,8 @@ function CodeEditor({
return ( return (
<CodeMirror <CodeMirror
value={value} value={internalValue}
onChange={onChange} onChange={handleChange}
extensions={extensions} extensions={extensions}
theme={tokyoNightStorm} theme={tokyoNightStorm}
minHeight={minHeight} minHeight={minHeight}
@@ -118,6 +133,9 @@ function CodeEditor({
onUpdate={(viewUpdate) => { onUpdate={(viewUpdate) => {
if (!viewRef.current) viewRef.current = viewUpdate.view; if (!viewRef.current) viewRef.current = viewUpdate.view;
}} }}
onBlur={() => {
debouncedOnChange.flush();
}}
/> />
); );
} }

View File

@@ -83,8 +83,6 @@ import { getWorkflowErrors } from "./workflowEditorUtils";
import { toast } from "@/components/ui/use-toast"; import { toast } from "@/components/ui/use-toast";
import { useAutoPan } from "./useAutoPan"; import { useAutoPan } from "./useAutoPan";
const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0));
function convertToParametersYAML( function convertToParametersYAML(
parameters: ParametersState, parameters: ParametersState,
): Array< ): Array<
@@ -278,7 +276,6 @@ function FlowRenderer({
const parameters = useWorkflowParametersStore((state) => state.parameters); const parameters = useWorkflowParametersStore((state) => state.parameters);
const nodesInitialized = useNodesInitialized(); const nodesInitialized = useNodesInitialized();
const [shouldConstrainPan, setShouldConstrainPan] = useState(false); const [shouldConstrainPan, setShouldConstrainPan] = useState(false);
const onNodesChangeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const flowIsConstrained = debugStore.isDebugMode; const flowIsConstrained = debugStore.isDebugMode;
useEffect(() => { useEffect(() => {
@@ -672,6 +669,7 @@ function FlowRenderer({
} }
} }
}); });
if (dimensionChanges.length > 0) { if (dimensionChanges.length > 0) {
doLayout(tempNodes, edges); doLayout(tempNodes, edges);
} }
@@ -687,20 +685,23 @@ function FlowRenderer({
workflowChangesStore.setHasChanges(true); workflowChangesStore.setHasChanges(true);
} }
// only allow one update in _this_ render cycle onNodesChange(changes);
if (onNodesChangeTimeoutRef.current === null) {
onNodesChange(changes); // NOTE: should no longer be needed (woot!) - delete if true (want real-world testing first)
onNodesChangeTimeoutRef.current = setTimeout(() => { // // only allow one update in _this_ render cycle
onNodesChangeTimeoutRef.current = null; // if (onNodesChangeTimeoutRef.current === null) {
}, 0); // onNodesChange(changes);
} else { // onNodesChangeTimeoutRef.current = setTimeout(() => {
// if we have an update in this render cycle already, then to // onNodesChangeTimeoutRef.current = null;
// prevent max recursion errors, defer the update to next render // }, 0);
// cycle // } else {
nextTick().then(() => { // // if we have an update in this render cycle already, then to
onNodesChange(changes); // // prevent max recursion errors, defer the update to next render
}); // // cycle
} // nextTick().then(() => {
// onNodesChange(changes);
// });
// }
}} }}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}

View File

@@ -8,14 +8,7 @@ import {
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react";
Handle,
NodeProps,
Position,
useEdges,
useNodes,
useReactFlow,
} from "@xyflow/react";
import { useState } from "react"; import { useState } from "react";
import type { ActionNode } from "./types"; import type { ActionNode } from "./types";
import { HelpTooltip } from "@/components/HelpTooltip"; import { HelpTooltip } from "@/components/HelpTooltip";
@@ -40,6 +33,7 @@ import { useParams } from "react-router-dom";
import { NodeHeader } from "../components/NodeHeader"; import { NodeHeader } from "../components/NodeHeader";
import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
import { DisableCache } from "../DisableCache"; import { DisableCache } from "../DisableCache";
@@ -51,25 +45,10 @@ const navigationGoalTooltip =
const navigationGoalPlaceholder = 'Input {{ name }} into "Name" field.'; const navigationGoalPlaceholder = 'Input {{ name }} into "Name" field.';
function ActionNode({ id, data, type }: NodeProps<ActionNode>) { function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
const { updateNodeData } = useReactFlow();
const [facing, setFacing] = useState<"front" | "back">("front"); const [facing, setFacing] = useState<"front" | "back">("front");
const blockScriptStore = useBlockScriptStore(); const blockScriptStore = useBlockScriptStore();
const { editable, label } = data; const { editable, label } = data;
const script = blockScriptStore.scripts[label]; 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 { blockLabel: urlBlockLabel } = useParams();
const { data: workflowRun } = useWorkflowRunQuery(); const { data: workflowRun } = useWorkflowRunQuery();
const workflowRunIsRunningOrQueued = const workflowRunIsRunningOrQueued =
@@ -79,19 +58,10 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; workflowRunIsRunningOrQueued && thisBlockIsTargetted;
const rerender = useRerender({ prefix: "accordian" }); const rerender = useRerender({ prefix: "accordian" });
const nodes = useNodes<AppNode>(); const nodes = useNodes<AppNode>();
const edges = useEdges(); const edges = useEdges();
const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id);
const update = useUpdate<ActionNode["data"]>({ id, editable });
function handleChange(key: string, value: unknown) {
if (!editable) {
return;
}
setInputs({ ...inputs, [key]: value });
updateNodeData(id, { [key]: value });
}
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
useEffect(() => { useEffect(() => {
@@ -128,8 +98,8 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
blockLabel={label} blockLabel={label}
editable={editable} editable={editable}
nodeId={id} nodeId={id}
totpIdentifier={inputs.totpIdentifier} totpIdentifier={data.totpIdentifier}
totpUrl={inputs.totpVerificationUrl} totpUrl={data.totpVerificationUrl}
type={type} type={type}
/> />
<div <div
@@ -154,9 +124,9 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
canWriteTitle={true} canWriteTitle={true}
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("url", value); update({ url: value });
}} }}
value={inputs.url} value={data.url}
placeholder={placeholders["action"]["url"]} placeholder={placeholders["action"]["url"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -171,9 +141,9 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("navigationGoal", value); update({ navigationGoal: value });
}} }}
value={inputs.navigationGoal} value={data.navigationGoal}
placeholder={navigationGoalPlaceholder} placeholder={navigationGoalPlaceholder}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -203,16 +173,16 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
<div className="space-y-2"> <div className="space-y-2">
<ModelSelector <ModelSelector
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
value={inputs.model} value={data.model}
onChange={(value) => { onChange={(value) => {
handleChange("model", value); update({ model: value });
}} }}
/> />
<ParametersMultiSelect <ParametersMultiSelect
availableOutputParameters={outputParameterKeys} availableOutputParameters={outputParameterKeys}
parameters={data.parameterKeys} parameters={data.parameterKeys}
onParametersChange={(parameterKeys) => { onParametersChange={(parameterKeys) => {
updateNodeData(id, { parameterKeys }); update({ parameterKeys });
}} }}
/> />
</div> </div>
@@ -223,9 +193,9 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
</Label> </Label>
</div> </div>
<RunEngineSelector <RunEngineSelector
value={inputs.engine} value={data.engine}
onChange={(value) => { onChange={(value) => {
handleChange("engine", value); update({ engine: value });
}} }}
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
/> />
@@ -241,35 +211,34 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
/> />
</div> </div>
<Checkbox <Checkbox
checked={inputs.errorCodeMapping !== "null"} checked={data.errorCodeMapping !== "null"}
disabled={!editable} disabled={!editable}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
if (!editable) { if (!editable) {
return; return;
} }
handleChange( update({
"errorCodeMapping", errorCodeMapping: checked
checked
? JSON.stringify( ? JSON.stringify(
errorMappingExampleValue, errorMappingExampleValue,
null, null,
2, 2,
) )
: "null", : "null",
); });
}} }}
/> />
</div> </div>
{inputs.errorCodeMapping !== "null" && ( {data.errorCodeMapping !== "null" && (
<div> <div>
<CodeEditor <CodeEditor
language="json" language="json"
value={inputs.errorCodeMapping} value={data.errorCodeMapping}
onChange={(value) => { onChange={(value) => {
if (!editable) { if (!editable) {
return; return;
} }
handleChange("errorCodeMapping", value); update({ errorCodeMapping: value });
}} }}
className="nopan" className="nopan"
fontSize={8} fontSize={8}
@@ -289,25 +258,25 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
</div> </div>
<div className="w-52"> <div className="w-52">
<Switch <Switch
checked={inputs.continueOnFailure} checked={data.continueOnFailure}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
if (!editable) { if (!editable) {
return; return;
} }
handleChange("continueOnFailure", checked); update({ continueOnFailure: checked });
}} }}
/> />
</div> </div>
</div> </div>
<DisableCache <DisableCache
cacheActions={inputs.cacheActions} cacheActions={data.cacheActions}
disableCache={inputs.disableCache} disableCache={data.disableCache}
editable={editable} editable={editable}
onCacheActionsChange={(cacheActions) => { onCacheActionsChange={(cacheActions) => {
handleChange("cacheActions", cacheActions); update({ cacheActions });
}} }}
onDisableCacheChange={(disableCache) => { onDisableCacheChange={(disableCache) => {
handleChange("disableCache", disableCache); update({ disableCache });
}} }}
/> />
<Separator /> <Separator />
@@ -322,12 +291,12 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
</div> </div>
<div className="w-52"> <div className="w-52">
<Switch <Switch
checked={inputs.allowDownloads} checked={data.allowDownloads}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
if (!editable) { if (!editable) {
return; return;
} }
handleChange("allowDownloads", checked); update({ allowDownloads: checked });
}} }}
/> />
</div> </div>
@@ -346,9 +315,9 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
type="text" type="text"
placeholder={placeholders["action"]["downloadSuffix"]} placeholder={placeholders["action"]["downloadSuffix"]}
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
value={inputs.downloadSuffix ?? ""} value={data.downloadSuffix ?? ""}
onChange={(value) => { onChange={(value) => {
handleChange("downloadSuffix", value); update({ downloadSuffix: value });
}} }}
/> />
</div> </div>
@@ -365,9 +334,9 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("totpIdentifier", value); update({ totpIdentifier: value });
}} }}
value={inputs.totpIdentifier ?? ""} value={data.totpIdentifier ?? ""}
placeholder={placeholders["action"]["totpIdentifier"]} placeholder={placeholders["action"]["totpIdentifier"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -384,9 +353,9 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("totpVerificationUrl", value); update({ totpVerificationUrl: value });
}} }}
value={inputs.totpVerificationUrl ?? ""} value={data.totpVerificationUrl ?? ""}
placeholder={placeholders["task"]["totpVerificationUrl"]} placeholder={placeholders["task"]["totpVerificationUrl"]}
className="nopan text-xs" className="nopan text-xs"
/> />

View File

@@ -1,18 +1,19 @@
import { useParams } from "react-router-dom";
import { Handle, NodeProps, Position } from "@xyflow/react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { WorkflowBlockInputSet } from "@/components/WorkflowBlockInputSet"; import { WorkflowBlockInputSet } from "@/components/WorkflowBlockInputSet";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; 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 { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
import { deepEqualStringArrays } from "@/util/equality"; 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<CodeBlockNode>) { function CodeBlockNode({ id, data }: NodeProps<CodeBlockNode>) {
const { updateNodeData } = useReactFlow();
const { editable, label } = data; const { editable, label } = data;
const { blockLabel: urlBlockLabel } = useParams(); const { blockLabel: urlBlockLabel } = useParams();
const { data: workflowRun } = useWorkflowRunQuery(); const { data: workflowRun } = useWorkflowRunQuery();
@@ -22,10 +23,7 @@ function CodeBlockNode({ id, data }: NodeProps<CodeBlockNode>) {
urlBlockLabel !== undefined && urlBlockLabel === label; urlBlockLabel !== undefined && urlBlockLabel === label;
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; workflowRunIsRunningOrQueued && thisBlockIsTargetted;
const [inputs, setInputs] = useState({ const update = useUpdate<CodeBlockNode["data"]>({ id, editable });
code: data.code,
parameterKeys: data.parameterKeys,
});
return ( return (
<div> <div>
@@ -65,36 +63,23 @@ function CodeBlockNode({ id, data }: NodeProps<CodeBlockNode>) {
<WorkflowBlockInputSet <WorkflowBlockInputSet
nodeId={id} nodeId={id}
onChange={(parameterKeys) => { onChange={(parameterKeys) => {
const differs = !deepEqualStringArrays( const newParameterKeys = Array.from(parameterKeys);
inputs.parameterKeys, if (
Array.from(parameterKeys), !deepEqualStringArrays(data.parameterKeys, newParameterKeys)
); ) {
update({ parameterKeys: newParameterKeys });
if (!differs) {
return;
} }
setInputs({
...inputs,
parameterKeys: Array.from(parameterKeys),
});
updateNodeData(id, { parameterKeys: Array.from(parameterKeys) });
}} }}
values={new Set(inputs.parameterKeys ?? [])} values={new Set(data.parameterKeys ?? [])}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs text-slate-300">Code Input</Label> <Label className="text-xs text-slate-300">Code Input</Label>
<CodeEditor <CodeEditor
language="python" language="python"
value={inputs.code} value={data.code}
onChange={(value) => { onChange={(value) => {
if (!data.editable) { update({ code: value });
return;
}
setInputs({ ...inputs, code: value });
updateNodeData(id, { code: value });
}} }}
className="nopan" className="nopan"
fontSize={8} fontSize={8}

View File

@@ -11,14 +11,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react";
Handle,
NodeProps,
Position,
useEdges,
useNodes,
useReactFlow,
} from "@xyflow/react";
import { useState } from "react"; import { useState } from "react";
import { dataSchemaExampleValue } from "../types"; import { dataSchemaExampleValue } from "../types";
import type { ExtractionNode } from "./types"; import type { ExtractionNode } from "./types";
@@ -40,12 +33,12 @@ import { NodeTabs } from "../components/NodeTabs";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
import { useRerender } from "@/hooks/useRerender"; import { useRerender } from "@/hooks/useRerender";
import { DisableCache } from "../DisableCache"; import { DisableCache } from "../DisableCache";
function ExtractionNode({ id, data, type }: NodeProps<ExtractionNode>) { function ExtractionNode({ id, data, type }: NodeProps<ExtractionNode>) {
const { updateNodeData } = useReactFlow();
const [facing, setFacing] = useState<"front" | "back">("front"); const [facing, setFacing] = useState<"front" | "back">("front");
const blockScriptStore = useBlockScriptStore(); const blockScriptStore = useBlockScriptStore();
const { editable, label } = data; const { editable, label } = data;
@@ -58,31 +51,12 @@ function ExtractionNode({ id, data, type }: NodeProps<ExtractionNode>) {
urlBlockLabel !== undefined && urlBlockLabel === label; urlBlockLabel !== undefined && urlBlockLabel === label;
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; 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<AppNode>(); const nodes = useNodes<AppNode>();
const edges = useEdges(); const edges = useEdges();
const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id);
const rerender = useRerender({ prefix: "accordian" }); const rerender = useRerender({ prefix: "accordian" });
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const update = useUpdate<ExtractionNode["data"]>({ id, editable });
function handleChange(key: string, value: unknown) {
if (!editable) {
return;
}
setInputs({ ...inputs, [key]: value });
updateNodeData(id, { [key]: value });
}
useEffect(() => { useEffect(() => {
setFacing(data.showCode ? "back" : "front"); setFacing(data.showCode ? "back" : "front");
@@ -144,22 +118,22 @@ function ExtractionNode({ id, data, type }: NodeProps<ExtractionNode>) {
if (!editable) { if (!editable) {
return; return;
} }
handleChange("dataExtractionGoal", value); update({ dataExtractionGoal: value });
}} }}
value={inputs.dataExtractionGoal} value={data.dataExtractionGoal}
placeholder={placeholders["extraction"]["dataExtractionGoal"]} placeholder={placeholders["extraction"]["dataExtractionGoal"]}
className="nopan text-xs" className="nopan text-xs"
/> />
</div> </div>
<WorkflowDataSchemaInputGroup <WorkflowDataSchemaInputGroup
value={inputs.dataSchema} value={data.dataSchema}
onChange={(value) => { onChange={(value) => {
handleChange("dataSchema", value); update({ dataSchema: value });
}} }}
exampleValue={dataSchemaExampleValue} exampleValue={dataSchemaExampleValue}
suggestionContext={{ suggestionContext={{
data_extraction_goal: inputs.dataExtractionGoal, data_extraction_goal: data.dataExtractionGoal,
current_schema: inputs.dataSchema, current_schema: data.dataSchema,
}} }}
/> />
<Separator /> <Separator />
@@ -177,16 +151,16 @@ function ExtractionNode({ id, data, type }: NodeProps<ExtractionNode>) {
<div className="space-y-2"> <div className="space-y-2">
<ModelSelector <ModelSelector
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
value={inputs.model} value={data.model}
onChange={(value) => { onChange={(value) => {
handleChange("model", value); update({ model: value });
}} }}
/> />
<ParametersMultiSelect <ParametersMultiSelect
availableOutputParameters={outputParameterKeys} availableOutputParameters={outputParameterKeys}
parameters={data.parameterKeys} parameters={data.parameterKeys}
onParametersChange={(parameterKeys) => { onParametersChange={(parameterKeys) => {
updateNodeData(id, { parameterKeys }); update({ parameterKeys });
}} }}
/> />
</div> </div>
@@ -197,9 +171,9 @@ function ExtractionNode({ id, data, type }: NodeProps<ExtractionNode>) {
</Label> </Label>
</div> </div>
<RunEngineSelector <RunEngineSelector
value={inputs.engine} value={data.engine}
onChange={(value) => { onChange={(value) => {
handleChange("engine", value); update({ engine: value });
}} }}
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
/> />
@@ -220,7 +194,7 @@ function ExtractionNode({ id, data, type }: NodeProps<ExtractionNode>) {
} }
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
min="0" min="0"
value={inputs.maxStepsOverride ?? ""} value={data.maxStepsOverride ?? ""}
onChange={(event) => { onChange={(event) => {
if (!editable) { if (!editable) {
return; return;
@@ -229,7 +203,7 @@ function ExtractionNode({ id, data, type }: NodeProps<ExtractionNode>) {
event.target.value === "" event.target.value === ""
? null ? null
: Number(event.target.value); : Number(event.target.value);
handleChange("maxStepsOverride", value); update({ maxStepsOverride: value });
}} }}
/> />
</div> </div>
@@ -247,25 +221,25 @@ function ExtractionNode({ id, data, type }: NodeProps<ExtractionNode>) {
</div> </div>
<div className="w-52"> <div className="w-52">
<Switch <Switch
checked={inputs.continueOnFailure} checked={data.continueOnFailure}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
if (!editable) { if (!editable) {
return; return;
} }
handleChange("continueOnFailure", checked); update({ continueOnFailure: checked });
}} }}
/> />
</div> </div>
</div> </div>
<DisableCache <DisableCache
cacheActions={inputs.cacheActions} cacheActions={data.cacheActions}
disableCache={inputs.disableCache} disableCache={data.disableCache}
editable={editable} editable={editable}
onCacheActionsChange={(cacheActions) => { onCacheActionsChange={(cacheActions) => {
handleChange("cacheActions", cacheActions); update({ cacheActions });
}} }}
onDisableCacheChange={(disableCache) => { onDisableCacheChange={(disableCache) => {
handleChange("disableCache", disableCache); update({ disableCache });
}} }}
/> />
</div> </div>

View File

@@ -16,14 +16,7 @@ import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTexta
import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor"; import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { useBlockScriptStore } from "@/store/BlockScriptStore"; import { useBlockScriptStore } from "@/store/BlockScriptStore";
import { import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react";
Handle,
NodeProps,
Position,
useEdges,
useNodes,
useReactFlow,
} from "@xyflow/react";
import { useState } from "react"; import { useState } from "react";
import { helpTooltips, placeholders } from "../../helpContent"; import { helpTooltips, placeholders } from "../../helpContent";
import { errorMappingExampleValue } from "../types"; import { errorMappingExampleValue } from "../types";
@@ -39,6 +32,7 @@ import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
import { useRerender } from "@/hooks/useRerender"; import { useRerender } from "@/hooks/useRerender";
import { BROWSER_DOWNLOAD_TIMEOUT_SECONDS } from "@/api/types"; import { BROWSER_DOWNLOAD_TIMEOUT_SECONDS } from "@/api/types";
@@ -52,7 +46,6 @@ const navigationGoalTooltip =
const navigationGoalPlaceholder = "Tell Skyvern which file to download."; const navigationGoalPlaceholder = "Tell Skyvern which file to download.";
function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) { function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
const { updateNodeData } = useReactFlow();
const [facing, setFacing] = useState<"front" | "back">("front"); const [facing, setFacing] = useState<"front" | "back">("front");
const blockScriptStore = useBlockScriptStore(); const blockScriptStore = useBlockScriptStore();
const { editable, label } = data; const { editable, label } = data;
@@ -65,34 +58,12 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
urlBlockLabel !== undefined && urlBlockLabel === label; urlBlockLabel !== undefined && urlBlockLabel === label;
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; 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 rerender = useRerender({ prefix: "accordian" });
const nodes = useNodes<AppNode>(); const nodes = useNodes<AppNode>();
const edges = useEdges(); const edges = useEdges();
const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id);
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
function handleChange(key: string, value: unknown) { const update = useUpdate<FileDownloadNode["data"]>({ id, editable });
if (!editable) {
return;
}
setInputs({ ...inputs, [key]: value });
updateNodeData(id, { [key]: value });
}
useEffect(() => { useEffect(() => {
setFacing(data.showCode ? "back" : "front"); setFacing(data.showCode ? "back" : "front");
@@ -127,8 +98,8 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
blockLabel={label} blockLabel={label}
editable={editable} editable={editable}
nodeId={id} nodeId={id}
totpIdentifier={inputs.totpIdentifier} totpIdentifier={data.totpIdentifier}
totpUrl={inputs.totpVerificationUrl} totpUrl={data.totpVerificationUrl}
type="file_download" // sic: the naming for this block is not consistent type="file_download" // sic: the naming for this block is not consistent
/> />
<div className="space-y-4"> <div className="space-y-4">
@@ -148,9 +119,9 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
canWriteTitle={true} canWriteTitle={true}
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("url", value); update({ url: value });
}} }}
value={inputs.url} value={data.url}
placeholder={urlPlaceholder} placeholder={urlPlaceholder}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -163,9 +134,9 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("navigationGoal", value); update({ navigationGoal: value });
}} }}
value={inputs.navigationGoal} value={data.navigationGoal}
placeholder={navigationGoalPlaceholder} placeholder={navigationGoalPlaceholder}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -181,7 +152,7 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
<Input <Input
className="ml-auto w-16 text-right" className="ml-auto w-16 text-right"
value={inputs.downloadTimeout ?? undefined} value={data.downloadTimeout ?? undefined}
placeholder={`${BROWSER_DOWNLOAD_TIMEOUT_SECONDS}`} placeholder={`${BROWSER_DOWNLOAD_TIMEOUT_SECONDS}`}
onChange={(event) => { onChange={(event) => {
const value = const value =
@@ -190,7 +161,7 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
: Number(event.target.value); : Number(event.target.value);
if (value) { if (value) {
handleChange("downloadTimeout", value); update({ downloadTimeout: value });
} }
}} }}
/> />
@@ -215,16 +186,16 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
<div className="space-y-2"> <div className="space-y-2">
<ModelSelector <ModelSelector
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
value={inputs.model} value={data.model}
onChange={(value) => { onChange={(value) => {
handleChange("model", value); update({ model: value });
}} }}
/> />
<ParametersMultiSelect <ParametersMultiSelect
availableOutputParameters={outputParameterKeys} availableOutputParameters={outputParameterKeys}
parameters={data.parameterKeys} parameters={data.parameterKeys}
onParametersChange={(parameterKeys) => { onParametersChange={(parameterKeys) => {
updateNodeData(id, { parameterKeys }); update({ parameterKeys });
}} }}
/> />
</div> </div>
@@ -235,9 +206,9 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
</Label> </Label>
</div> </div>
<RunEngineSelector <RunEngineSelector
value={inputs.engine} value={data.engine}
onChange={(value) => { onChange={(value) => {
handleChange("engine", value); update({ engine: value });
}} }}
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
/> />
@@ -256,13 +227,13 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
placeholder={placeholders["download"]["maxStepsOverride"]} placeholder={placeholders["download"]["maxStepsOverride"]}
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
min="0" min="0"
value={inputs.maxStepsOverride ?? ""} value={data.maxStepsOverride ?? ""}
onChange={(event) => { onChange={(event) => {
const value = const value =
event.target.value === "" event.target.value === ""
? null ? null
: Number(event.target.value); : Number(event.target.value);
handleChange("maxStepsOverride", value); update({ maxStepsOverride: value });
}} }}
/> />
</div> </div>
@@ -277,29 +248,28 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
/> />
</div> </div>
<Checkbox <Checkbox
checked={inputs.errorCodeMapping !== "null"} checked={data.errorCodeMapping !== "null"}
disabled={!editable} disabled={!editable}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
handleChange( update({
"errorCodeMapping", errorCodeMapping: checked
checked
? JSON.stringify( ? JSON.stringify(
errorMappingExampleValue, errorMappingExampleValue,
null, null,
2, 2,
) )
: "null", : "null",
); });
}} }}
/> />
</div> </div>
{inputs.errorCodeMapping !== "null" && ( {data.errorCodeMapping !== "null" && (
<div> <div>
<CodeEditor <CodeEditor
language="json" language="json"
value={inputs.errorCodeMapping} value={data.errorCodeMapping}
onChange={(value) => { onChange={(value) => {
handleChange("errorCodeMapping", value); update({ errorCodeMapping: value });
}} }}
className="nopan" className="nopan"
fontSize={8} fontSize={8}
@@ -319,22 +289,22 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
</div> </div>
<div className="w-52"> <div className="w-52">
<Switch <Switch
checked={inputs.continueOnFailure} checked={data.continueOnFailure}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
handleChange("continueOnFailure", checked); update({ continueOnFailure: checked });
}} }}
/> />
</div> </div>
</div> </div>
<DisableCache <DisableCache
cacheActions={inputs.cacheActions} cacheActions={data.cacheActions}
disableCache={inputs.disableCache} disableCache={data.disableCache}
editable={editable} editable={editable}
onCacheActionsChange={(cacheActions) => { onCacheActionsChange={(cacheActions) => {
handleChange("cacheActions", cacheActions); update({ cacheActions });
}} }}
onDisableCacheChange={(disableCache) => { onDisableCacheChange={(disableCache) => {
handleChange("disableCache", disableCache); update({ disableCache });
}} }}
/> />
<Separator /> <Separator />
@@ -350,9 +320,9 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("downloadSuffix", value); update({ downloadSuffix: value });
}} }}
value={inputs.downloadSuffix ?? ""} value={data.downloadSuffix ?? ""}
placeholder={placeholders["download"]["downloadSuffix"]} placeholder={placeholders["download"]["downloadSuffix"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -370,9 +340,9 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("totpIdentifier", value); update({ totpIdentifier: value });
}} }}
value={inputs.totpIdentifier ?? ""} value={data.totpIdentifier ?? ""}
placeholder={placeholders["download"]["totpIdentifier"]} placeholder={placeholders["download"]["totpIdentifier"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -389,9 +359,9 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("totpVerificationUrl", value); update({ totpVerificationUrl: value });
}} }}
value={inputs.totpVerificationUrl ?? ""} value={data.totpVerificationUrl ?? ""}
placeholder={placeholders["task"]["totpVerificationUrl"]} placeholder={placeholders["task"]["totpVerificationUrl"]}
className="nopan text-xs" className="nopan text-xs"
/> />

View File

@@ -1,7 +1,6 @@
import { HelpTooltip } from "@/components/HelpTooltip"; import { HelpTooltip } from "@/components/HelpTooltip";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; import { Handle, NodeProps, Position } from "@xyflow/react";
import { useState } from "react";
import { helpTooltips } from "../../helpContent"; import { helpTooltips } from "../../helpContent";
import { type FileParserNode } from "./types"; import { type FileParserNode } from "./types";
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput"; import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
@@ -13,10 +12,10 @@ import { WorkflowDataSchemaInputGroup } from "@/components/DataSchemaInputGroup/
import { dataSchemaExampleForFileExtraction } from "../types"; import { dataSchemaExampleForFileExtraction } from "../types";
import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
import { ModelSelector } from "@/components/ModelSelector"; import { ModelSelector } from "@/components/ModelSelector";
function FileParserNode({ id, data }: NodeProps<FileParserNode>) { function FileParserNode({ id, data }: NodeProps<FileParserNode>) {
const { updateNodeData } = useReactFlow();
const { editable, label } = data; const { editable, label } = data;
const { blockLabel: urlBlockLabel } = useParams(); const { blockLabel: urlBlockLabel } = useParams();
const { data: workflowRun } = useWorkflowRunQuery(); const { data: workflowRun } = useWorkflowRunQuery();
@@ -26,21 +25,8 @@ function FileParserNode({ id, data }: NodeProps<FileParserNode>) {
urlBlockLabel !== undefined && urlBlockLabel === label; urlBlockLabel !== undefined && urlBlockLabel === label;
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; 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 isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const update = useUpdate<FileParserNode["data"]>({ id, editable });
return ( return (
<div> <div>
@@ -90,26 +76,26 @@ function FileParserNode({ id, data }: NodeProps<FileParserNode>) {
<WorkflowBlockInput <WorkflowBlockInput
nodeId={id} nodeId={id}
value={inputs.fileUrl} value={data.fileUrl}
onChange={(value) => { onChange={(value) => {
handleChange("fileUrl", value); update({ fileUrl: value });
}} }}
className="nopan text-xs" className="nopan text-xs"
/> />
</div> </div>
<WorkflowDataSchemaInputGroup <WorkflowDataSchemaInputGroup
exampleValue={dataSchemaExampleForFileExtraction} exampleValue={dataSchemaExampleForFileExtraction}
value={inputs.jsonSchema} value={data.jsonSchema}
onChange={(value) => { onChange={(value) => {
handleChange("jsonSchema", value); update({ jsonSchema: value });
}} }}
suggestionContext={{}} suggestionContext={{}}
/> />
<ModelSelector <ModelSelector
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
value={inputs.model} value={data.model}
onChange={(value) => { onChange={(value) => {
handleChange("model", value); update({ model: value });
}} }}
/> />
</div> </div>

View File

@@ -1,11 +1,10 @@
import { HelpTooltip } from "@/components/HelpTooltip"; import { HelpTooltip } from "@/components/HelpTooltip";
import { Label } from "@/components/ui/label"; 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 { helpTooltips } from "../../helpContent";
import { type FileUploadNode } from "./types"; import { type FileUploadNode } from "./types";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea"; import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput"; import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
import { useState } from "react";
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader"; import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
@@ -18,9 +17,9 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
function FileUploadNode({ id, data }: NodeProps<FileUploadNode>) { function FileUploadNode({ id, data }: NodeProps<FileUploadNode>) {
const { updateNodeData } = useReactFlow();
const { editable, label } = data; const { editable, label } = data;
const { blockLabel: urlBlockLabel } = useParams(); const { blockLabel: urlBlockLabel } = useParams();
const { data: workflowRun } = useWorkflowRunQuery(); const { data: workflowRun } = useWorkflowRunQuery();
@@ -30,26 +29,7 @@ function FileUploadNode({ id, data }: NodeProps<FileUploadNode>) {
urlBlockLabel !== undefined && urlBlockLabel === label; urlBlockLabel !== undefined && urlBlockLabel === label;
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; workflowRunIsRunningOrQueued && thisBlockIsTargetted;
const update = useUpdate<FileUploadNode["data"]>({ id, editable });
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 });
}
return ( return (
<div> <div>
@@ -92,8 +72,10 @@ function FileUploadNode({ id, data }: NodeProps<FileUploadNode>) {
/> />
</div> </div>
<Select <Select
value={inputs.storageType} value={data.storageType}
onValueChange={(value) => handleChange("storageType", value)} onValueChange={(value) =>
value && update({ storageType: value as "s3" | "azure" })
}
disabled={!editable} disabled={!editable}
> >
<SelectTrigger className="nopan text-xs"> <SelectTrigger className="nopan text-xs">
@@ -106,7 +88,7 @@ function FileUploadNode({ id, data }: NodeProps<FileUploadNode>) {
</Select> </Select>
</div> </div>
{inputs.storageType === "s3" && ( {data.storageType === "s3" && (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -120,9 +102,9 @@ function FileUploadNode({ id, data }: NodeProps<FileUploadNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("awsAccessKeyId", value); update({ awsAccessKeyId: value });
}} }}
value={inputs.awsAccessKeyId as string} value={data.awsAccessKeyId as string}
className="nopan text-xs" className="nopan text-xs"
/> />
</div> </div>
@@ -140,10 +122,10 @@ function FileUploadNode({ id, data }: NodeProps<FileUploadNode>) {
<WorkflowBlockInput <WorkflowBlockInput
nodeId={id} nodeId={id}
type="password" type="password"
value={inputs.awsSecretAccessKey as string} value={data.awsSecretAccessKey as string}
className="nopan text-xs" className="nopan text-xs"
onChange={(value) => { onChange={(value) => {
handleChange("awsSecretAccessKey", value); update({ awsSecretAccessKey: value });
}} }}
/> />
</div> </div>
@@ -157,9 +139,9 @@ function FileUploadNode({ id, data }: NodeProps<FileUploadNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("s3Bucket", value); update({ s3Bucket: value });
}} }}
value={inputs.s3Bucket as string} value={data.s3Bucket as string}
className="nopan text-xs" className="nopan text-xs"
/> />
</div> </div>
@@ -173,9 +155,9 @@ function FileUploadNode({ id, data }: NodeProps<FileUploadNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("regionName", value); update({ regionName: value });
}} }}
value={inputs.regionName as string} value={data.regionName as string}
className="nopan text-xs" className="nopan text-xs"
/> />
</div> </div>
@@ -189,16 +171,16 @@ function FileUploadNode({ id, data }: NodeProps<FileUploadNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("path", value); update({ path: value });
}} }}
value={inputs.path as string} value={data.path as string}
className="nopan text-xs" className="nopan text-xs"
/> />
</div> </div>
</> </>
)} )}
{inputs.storageType === "azure" && ( {data.storageType === "azure" && (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -214,9 +196,9 @@ function FileUploadNode({ id, data }: NodeProps<FileUploadNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("azureStorageAccountName", value); update({ azureStorageAccountName: value });
}} }}
value={inputs.azureStorageAccountName as string} value={data.azureStorageAccountName as string}
className="nopan text-xs" className="nopan text-xs"
/> />
</div> </div>
@@ -234,10 +216,10 @@ function FileUploadNode({ id, data }: NodeProps<FileUploadNode>) {
<WorkflowBlockInput <WorkflowBlockInput
nodeId={id} nodeId={id}
type="password" type="password"
value={inputs.azureStorageAccountKey as string} value={data.azureStorageAccountKey as string}
className="nopan text-xs" className="nopan text-xs"
onChange={(value) => { onChange={(value) => {
handleChange("azureStorageAccountKey", value); update({ azureStorageAccountKey: value });
}} }}
/> />
</div> </div>
@@ -255,9 +237,9 @@ function FileUploadNode({ id, data }: NodeProps<FileUploadNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("azureBlobContainerName", value); update({ azureBlobContainerName: value });
}} }}
value={inputs.azureBlobContainerName as string} value={data.azureBlobContainerName as string}
className="nopan text-xs" className="nopan text-xs"
/> />
</div> </div>
@@ -271,9 +253,9 @@ function FileUploadNode({ id, data }: NodeProps<FileUploadNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("path", value); update({ path: value });
}} }}
value={inputs.path as string} value={data.path as string}
className="nopan text-xs" className="nopan text-xs"
/> />
</div> </div>

View File

@@ -8,15 +8,8 @@ import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler"; import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react";
Handle, import { useCallback } from "react";
NodeProps,
Position,
useEdges,
useNodes,
useReactFlow,
} from "@xyflow/react";
import { useState } from "react";
import { EditableNodeTitle } from "../components/EditableNodeTitle"; import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu"; import { NodeActionMenu } from "../NodeActionMenu";
import type { HttpRequestNode as HttpRequestNodeType } from "./types"; import type { HttpRequestNode as HttpRequestNodeType } from "./types";
@@ -31,6 +24,7 @@ import { getAvailableOutputParameterKeys } from "../../workflowEditorUtils";
import { ParametersMultiSelect } from "../TaskNode/ParametersMultiSelect"; import { ParametersMultiSelect } from "../TaskNode/ParametersMultiSelect";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow"; import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -67,83 +61,59 @@ const followRedirectsTooltip =
"Whether to automatically follow HTTP redirects."; "Whether to automatically follow HTTP redirects.";
function HttpRequestNode({ id, data }: NodeProps<HttpRequestNodeType>) { function HttpRequestNode({ id, data }: NodeProps<HttpRequestNodeType>) {
const { updateNodeData } = useReactFlow();
const { editable } = data; const { editable } = data;
const [label, setLabel] = useNodeLabelChangeHandler({ const [label, setLabel] = useNodeLabelChangeHandler({
id, id,
initialValue: data.label, 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 deleteNodeCallback = useDeleteNodeCallback();
const rerender = useRerender({ prefix: "accordian" }); const rerender = useRerender({ prefix: "accordian" });
const nodes = useNodes<AppNode>(); const nodes = useNodes<AppNode>();
const edges = useEdges(); const edges = useEdges();
const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id);
const update = useUpdate<HttpRequestNodeType["data"]>({ id, editable });
function handleChange(key: string, value: unknown) { const handleCurlImport = useCallback(
if (!editable) { (importedData: {
return; method: string;
} url: string;
setInputs({ ...inputs, [key]: value }); headers: string;
updateNodeData(id, { [key]: value }); 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: { const handleQuickHeaders = useCallback(
method: string; (headers: Record<string, string>) => {
url: string; try {
headers: string; const existingHeaders = JSON.parse(data.headers || "{}");
body: string; const mergedHeaders = { ...existingHeaders, ...headers };
timeout: number; const newHeadersString = JSON.stringify(mergedHeaders, null, 2);
followRedirects: boolean; update({ headers: newHeadersString });
}) => { } catch (error) {
const newInputs = { // If existing headers are invalid, just use the new ones
...inputs, const newHeadersString = JSON.stringify(headers, null, 2);
method: importedData.method, update({ headers: newHeadersString });
url: importedData.url, }
headers: importedData.headers, },
body: importedData.body, [data.headers, update],
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<string, string>) => {
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 isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const showBodyEditor = const showBodyEditor =
inputs.method !== "GET" && data.method !== "GET" && data.method !== "HEAD" && data.method !== "DELETE";
inputs.method !== "HEAD" &&
inputs.method !== "DELETE";
return ( return (
<div> <div>
@@ -210,13 +180,13 @@ function HttpRequestNode({ id, data }: NodeProps<HttpRequestNodeType>) {
<HelpTooltip content={methodTooltip} /> <HelpTooltip content={methodTooltip} />
</div> </div>
<Select <Select
value={inputs.method} value={data.method}
onValueChange={(value) => handleChange("method", value)} onValueChange={(value) => update({ method: value })}
disabled={!editable} disabled={!editable}
> >
<SelectTrigger className="nopan text-xs"> <SelectTrigger className="nopan text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MethodBadge method={inputs.method} /> <MethodBadge method={data.method} />
</div> </div>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -247,13 +217,13 @@ function HttpRequestNode({ id, data }: NodeProps<HttpRequestNodeType>) {
canWriteTitle={true} canWriteTitle={true}
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("url", value); update({ url: value });
}} }}
value={inputs.url} value={data.url}
placeholder={placeholders["httpRequest"]["url"]} placeholder={placeholders["httpRequest"]["url"]}
className="nopan text-xs" className="nopan text-xs"
/> />
<UrlValidator url={inputs.url} /> <UrlValidator url={data.url} />
</div> </div>
</div> </div>
@@ -279,9 +249,9 @@ function HttpRequestNode({ id, data }: NodeProps<HttpRequestNodeType>) {
<CodeEditor <CodeEditor
className="w-full" className="w-full"
language="json" language="json"
value={inputs.headers} value={data.headers}
onChange={(value) => { onChange={(value) => {
handleChange("headers", value || "{}"); update({ headers: value || "{}" });
}} }}
readOnly={!editable} readOnly={!editable}
minHeight="80px" minHeight="80px"
@@ -299,9 +269,9 @@ function HttpRequestNode({ id, data }: NodeProps<HttpRequestNodeType>) {
<CodeEditor <CodeEditor
className="w-full" className="w-full"
language="json" language="json"
value={inputs.body} value={data.body}
onChange={(value) => { onChange={(value) => {
handleChange("body", value || "{}"); update({ body: value || "{}" });
}} }}
readOnly={!editable} readOnly={!editable}
minHeight="100px" minHeight="100px"
@@ -312,10 +282,10 @@ function HttpRequestNode({ id, data }: NodeProps<HttpRequestNodeType>) {
{/* Request Preview */} {/* Request Preview */}
<RequestPreview <RequestPreview
method={inputs.method} method={data.method}
url={inputs.url} url={data.url}
headers={inputs.headers} headers={data.headers}
body={inputs.body} body={data.body}
/> />
</div> </div>
@@ -336,7 +306,7 @@ function HttpRequestNode({ id, data }: NodeProps<HttpRequestNodeType>) {
availableOutputParameters={outputParameterKeys} availableOutputParameters={outputParameterKeys}
parameters={data.parameterKeys} parameters={data.parameterKeys}
onParametersChange={(parameterKeys) => { onParametersChange={(parameterKeys) => {
updateNodeData(id, { parameterKeys }); update({ parameterKeys });
}} }}
/> />
<div className="flex gap-4"> <div className="flex gap-4">
@@ -349,9 +319,11 @@ function HttpRequestNode({ id, data }: NodeProps<HttpRequestNodeType>) {
type="number" type="number"
min="1" min="1"
max="300" max="300"
value={inputs.timeout} value={data.timeout}
onChange={(e) => onChange={(e) =>
handleChange("timeout", parseInt(e.target.value) || 30) update({
timeout: parseInt(e.target.value) || 30,
})
} }
className="nopan text-xs" className="nopan text-xs"
disabled={!editable} disabled={!editable}
@@ -369,9 +341,9 @@ function HttpRequestNode({ id, data }: NodeProps<HttpRequestNodeType>) {
Automatically follow HTTP redirects Automatically follow HTTP redirects
</span> </span>
<Switch <Switch
checked={inputs.followRedirects} checked={data.followRedirects}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
handleChange("followRedirects", checked) update({ followRedirects: checked })
} }
disabled={!editable} disabled={!editable}
/> />
@@ -390,9 +362,9 @@ function HttpRequestNode({ id, data }: NodeProps<HttpRequestNodeType>) {
</div> </div>
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<Switch <Switch
checked={inputs.continueOnFailure} checked={data.continueOnFailure}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
handleChange("continueOnFailure", checked) update({ continueOnFailure: checked })
} }
disabled={!editable} disabled={!editable}
/> />

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { Flippable } from "@/components/Flippable"; import { Flippable } from "@/components/Flippable";
import { HelpTooltip } from "@/components/HelpTooltip"; import { HelpTooltip } from "@/components/HelpTooltip";
import { import {
@@ -16,15 +16,7 @@ import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTexta
import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor"; import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { useBlockScriptStore } from "@/store/BlockScriptStore"; import { useBlockScriptStore } from "@/store/BlockScriptStore";
import { import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react";
Handle,
NodeProps,
Position,
useEdges,
useNodes,
useReactFlow,
} from "@xyflow/react";
import { useState } from "react";
import { helpTooltips, placeholders } from "../../helpContent"; import { helpTooltips, placeholders } from "../../helpContent";
import { errorMappingExampleValue } from "../types"; import { errorMappingExampleValue } from "../types";
import type { LoginNode } from "./types"; import type { LoginNode } from "./types";
@@ -40,13 +32,12 @@ import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
import { useRerender } from "@/hooks/useRerender"; import { useRerender } from "@/hooks/useRerender";
import { DisableCache } from "../DisableCache"; import { DisableCache } from "../DisableCache";
function LoginNode({ id, data, type }: NodeProps<LoginNode>) { function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
const { updateNodeData } = useReactFlow();
const [facing, setFacing] = useState<"front" | "back">("front");
const blockScriptStore = useBlockScriptStore(); const blockScriptStore = useBlockScriptStore();
const { editable, label } = data; const { editable, label } = data;
const script = blockScriptStore.scripts[label]; const script = blockScriptStore.scripts[label];
@@ -58,36 +49,15 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
urlBlockLabel !== undefined && urlBlockLabel === label; urlBlockLabel !== undefined && urlBlockLabel === label;
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; 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,
totpVerificationUrl: data.totpVerificationUrl,
totpIdentifier: data.totpIdentifier,
completeCriterion: data.completeCriterion,
terminateCriterion: data.terminateCriterion,
engine: data.engine,
model: data.model,
});
const rerender = useRerender({ prefix: "accordian" }); const rerender = useRerender({ prefix: "accordian" });
const nodes = useNodes<AppNode>(); const nodes = useNodes<AppNode>();
const edges = useEdges(); const edges = useEdges();
const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id);
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const update = useUpdate<LoginNode["data"]>({ id, editable });
function handleChange(key: string, value: unknown) { // Manage flippable facing state
if (!editable) { const [facing, setFacing] = useState<"front" | "back">("front");
return;
}
setInputs({ ...inputs, [key]: value });
updateNodeData(id, { [key]: value });
}
useEffect(() => { useEffect(() => {
setFacing(data.showCode ? "back" : "front"); setFacing(data.showCode ? "back" : "front");
}, [data.showCode]); }, [data.showCode]);
@@ -122,8 +92,8 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
blockLabel={label} blockLabel={label}
editable={editable} editable={editable}
nodeId={id} nodeId={id}
totpIdentifier={inputs.totpIdentifier} totpIdentifier={data.totpIdentifier}
totpUrl={inputs.totpVerificationUrl} totpUrl={data.totpVerificationUrl}
type={type} type={type}
/> />
<div className="space-y-4"> <div className="space-y-4">
@@ -143,10 +113,8 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
canWriteTitle={true} canWriteTitle={true}
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => update({ url: value })}
handleChange("url", value); value={data.url}
}}
value={inputs.url}
placeholder={placeholders["login"]["url"]} placeholder={placeholders["login"]["url"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -161,9 +129,9 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("navigationGoal", value); update({ navigationGoal: value });
}} }}
value={inputs.navigationGoal} value={data.navigationGoal}
placeholder={placeholders["login"]["navigationGoal"]} placeholder={placeholders["login"]["navigationGoal"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -181,7 +149,7 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
if (!editable) { if (!editable) {
return; return;
} }
updateNodeData(id, { parameterKeys: [value] }); update({ parameterKeys: [value] });
}} }}
/> />
</div> </div>
@@ -201,16 +169,16 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
<div className="space-y-2"> <div className="space-y-2">
<ModelSelector <ModelSelector
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
value={inputs.model} value={data.model}
onChange={(value) => { onChange={(value) => {
handleChange("model", value); update({ model: value });
}} }}
/> />
<ParametersMultiSelect <ParametersMultiSelect
availableOutputParameters={outputParameterKeys} availableOutputParameters={outputParameterKeys}
parameters={data.parameterKeys} parameters={data.parameterKeys}
onParametersChange={(parameterKeys) => { onParametersChange={(parameterKeys) => {
updateNodeData(id, { parameterKeys }); update({ parameterKeys });
}} }}
/> />
</div> </div>
@@ -221,9 +189,9 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("completeCriterion", value); update({ completeCriterion: value });
}} }}
value={inputs.completeCriterion} value={data.completeCriterion}
className="nopan text-xs" className="nopan text-xs"
/> />
</div> </div>
@@ -235,9 +203,9 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
</Label> </Label>
</div> </div>
<RunEngineSelector <RunEngineSelector
value={inputs.engine} value={data.engine}
onChange={(value) => { onChange={(value) => {
handleChange("engine", value); update({ engine: value });
}} }}
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
/> />
@@ -256,13 +224,13 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
placeholder={placeholders["login"]["maxStepsOverride"]} placeholder={placeholders["login"]["maxStepsOverride"]}
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
min="0" min="0"
value={inputs.maxStepsOverride ?? ""} value={data.maxStepsOverride ?? ""}
onChange={(event) => { onChange={(event) => {
const value = const value =
event.target.value === "" event.target.value === ""
? null ? null
: Number(event.target.value); : Number(event.target.value);
handleChange("maxStepsOverride", value); update({ maxStepsOverride: value });
}} }}
/> />
</div> </div>
@@ -277,29 +245,28 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
/> />
</div> </div>
<Checkbox <Checkbox
checked={inputs.errorCodeMapping !== "null"} checked={data.errorCodeMapping !== "null"}
disabled={!editable} disabled={!editable}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
handleChange( update({
"errorCodeMapping", errorCodeMapping: checked
checked
? JSON.stringify( ? JSON.stringify(
errorMappingExampleValue, errorMappingExampleValue,
null, null,
2, 2,
) )
: "null", : "null",
); });
}} }}
/> />
</div> </div>
{inputs.errorCodeMapping !== "null" && ( {data.errorCodeMapping !== "null" && (
<div> <div>
<CodeEditor <CodeEditor
language="json" language="json"
value={inputs.errorCodeMapping} value={data.errorCodeMapping}
onChange={(value) => { onChange={(value) => {
handleChange("errorCodeMapping", value); update({ errorCodeMapping: value });
}} }}
className="nopan" className="nopan"
fontSize={8} fontSize={8}
@@ -319,22 +286,22 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
</div> </div>
<div className="w-52"> <div className="w-52">
<Switch <Switch
checked={inputs.continueOnFailure} checked={data.continueOnFailure}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
handleChange("continueOnFailure", checked); update({ continueOnFailure: checked });
}} }}
/> />
</div> </div>
</div> </div>
<DisableCache <DisableCache
cacheActions={inputs.cacheActions} cacheActions={data.cacheActions}
disableCache={inputs.disableCache} disableCache={data.disableCache}
editable={editable} editable={editable}
onCacheActionsChange={(cacheActions) => { onCacheActionsChange={(cacheActions) => {
handleChange("cacheActions", cacheActions); update({ cacheActions });
}} }}
onDisableCacheChange={(disableCache) => { onDisableCacheChange={(disableCache) => {
handleChange("disableCache", disableCache); update({ disableCache });
}} }}
/> />
<Separator /> <Separator />
@@ -350,9 +317,9 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("totpIdentifier", value); update({ totpIdentifier: value });
}} }}
value={inputs.totpIdentifier ?? ""} value={data.totpIdentifier ?? ""}
placeholder={placeholders["login"]["totpIdentifier"]} placeholder={placeholders["login"]["totpIdentifier"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -369,9 +336,9 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("totpVerificationUrl", value); update({ totpVerificationUrl: value });
}} }}
value={inputs.totpVerificationUrl ?? ""} value={data.totpVerificationUrl ?? ""}
placeholder={placeholders["login"]["totpVerificationUrl"]} placeholder={placeholders["login"]["totpVerificationUrl"]}
className="nopan text-xs" className="nopan text-xs"
/> />

View File

@@ -2,17 +2,10 @@ import { HelpTooltip } from "@/components/HelpTooltip";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput"; import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
import type { Node } from "@xyflow/react"; import type { Node } from "@xyflow/react";
import { import { Handle, NodeProps, Position, useNodes } from "@xyflow/react";
Handle,
NodeProps,
Position,
useNodes,
useReactFlow,
} from "@xyflow/react";
import { AppNode } from ".."; import { AppNode } from "..";
import { helpTooltips } from "../../helpContent"; import { helpTooltips } from "../../helpContent";
import type { LoopNode } from "./types"; import type { LoopNode } from "./types";
import { useState } from "react";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow"; import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { getLoopNodeWidth } from "../../workflowEditorUtils"; import { getLoopNodeWidth } from "../../workflowEditorUtils";
@@ -21,9 +14,9 @@ import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
function LoopNode({ id, data }: NodeProps<LoopNode>) { function LoopNode({ id, data }: NodeProps<LoopNode>) {
const { updateNodeData } = useReactFlow();
const nodes = useNodes<AppNode>(); const nodes = useNodes<AppNode>();
const node = nodes.find((n) => n.id === id); const node = nodes.find((n) => n.id === id);
if (!node) { if (!node) {
@@ -38,13 +31,10 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
urlBlockLabel !== undefined && urlBlockLabel === label; urlBlockLabel !== undefined && urlBlockLabel === label;
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; workflowRunIsRunningOrQueued && thisBlockIsTargetted;
const [inputs, setInputs] = useState({ const update = useUpdate<LoopNode["data"]>({ id, editable });
loopVariableReference: data.loopVariableReference,
});
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const children = nodes.filter((node) => node.parentId === id); const children = nodes.filter((node) => node.parentId === id);
const furthestDownChild: Node | null = children.reduce( const furthestDownChild: Node | null = children.reduce(
(acc, child) => { (acc, child) => {
if (!acc) { if (!acc) {
@@ -64,13 +54,6 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
24; 24;
const loopNodeWidth = getLoopNodeWidth(node, nodes); const loopNodeWidth = getLoopNodeWidth(node, nodes);
function handleChange(key: string, value: unknown) {
if (!data.editable) {
return;
}
setInputs({ ...inputs, [key]: value });
updateNodeData(id, { [key]: value });
}
return ( return (
<div> <div>
@@ -127,9 +110,9 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
</div> </div>
<WorkflowBlockInput <WorkflowBlockInput
nodeId={id} nodeId={id}
value={inputs.loopVariableReference} value={data.loopVariableReference}
onChange={(value) => { onChange={(value) => {
handleChange("loopVariableReference", value); update({ loopVariableReference: value });
}} }}
/> />
</div> </div>
@@ -141,7 +124,10 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
checked={data.completeIfEmpty} checked={data.completeIfEmpty}
disabled={!data.editable} disabled={!data.editable}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
handleChange("completeIfEmpty", checked); update({
completeIfEmpty:
checked === "indeterminate" ? false : checked,
});
}} }}
/> />
<Label className="text-xs text-slate-300"> <Label className="text-xs text-slate-300">
@@ -155,7 +141,10 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
checked={data.continueOnFailure} checked={data.continueOnFailure}
disabled={!data.editable} disabled={!data.editable}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
handleChange("continueOnFailure", checked); update({
continueOnFailure:
checked === "indeterminate" ? false : checked,
});
}} }}
/> />
<Label className="text-xs text-slate-300"> <Label className="text-xs text-slate-300">

View File

@@ -6,6 +6,7 @@ export type LoopNodeData = NodeBaseData & {
loopValue: string; loopValue: string;
loopVariableReference: string; loopVariableReference: string;
completeIfEmpty: boolean; completeIfEmpty: boolean;
continueOnFailure: boolean;
}; };
export type LoopNode = Node<LoopNodeData, "loop">; export type LoopNode = Node<LoopNodeData, "loop">;

View File

@@ -18,14 +18,7 @@ import { useRerender } from "@/hooks/useRerender";
import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor"; import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { useBlockScriptStore } from "@/store/BlockScriptStore"; import { useBlockScriptStore } from "@/store/BlockScriptStore";
import { import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react";
Handle,
NodeProps,
Position,
useEdges,
useNodes,
useReactFlow,
} from "@xyflow/react";
import { useState } from "react"; import { useState } from "react";
import { helpTooltips, placeholders } from "../../helpContent"; import { helpTooltips, placeholders } from "../../helpContent";
import { errorMappingExampleValue } from "../types"; import { errorMappingExampleValue } from "../types";
@@ -41,12 +34,12 @@ import { useParams } from "react-router-dom";
import { NodeHeader } from "../components/NodeHeader"; import { NodeHeader } from "../components/NodeHeader";
import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
import { DisableCache } from "../DisableCache"; import { DisableCache } from "../DisableCache";
function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) { function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
const { blockLabel: urlBlockLabel } = useParams(); const { blockLabel: urlBlockLabel } = useParams();
const { updateNodeData } = useReactFlow();
const [facing, setFacing] = useState<"front" | "back">("front"); const [facing, setFacing] = useState<"front" | "back">("front");
const blockScriptStore = useBlockScriptStore(); const blockScriptStore = useBlockScriptStore();
const { editable, label } = data; const { editable, label } = data;
@@ -59,38 +52,11 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; workflowRunIsRunningOrQueued && thisBlockIsTargetted;
const rerender = useRerender({ prefix: "accordian" }); const rerender = useRerender({ prefix: "accordian" });
const [inputs, setInputs] = useState({
allowDownloads: data.allowDownloads,
cacheActions: data.cacheActions,
disableCache: data.disableCache,
completeCriterion: data.completeCriterion,
continueOnFailure: data.continueOnFailure,
downloadSuffix: data.downloadSuffix,
engine: data.engine,
errorCodeMapping: data.errorCodeMapping,
includeActionHistoryInVerification: data.includeActionHistoryInVerification,
maxStepsOverride: data.maxStepsOverride,
model: data.model,
navigationGoal: data.navigationGoal,
terminateCriterion: data.terminateCriterion,
totpIdentifier: data.totpIdentifier,
totpVerificationUrl: data.totpVerificationUrl,
url: data.url,
});
const nodes = useNodes<AppNode>(); const nodes = useNodes<AppNode>();
const edges = useEdges(); const edges = useEdges();
const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id);
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const update = useUpdate<NavigationNode["data"]>({ id, editable });
function handleChange(key: string, value: unknown) {
if (!editable) {
return;
}
setInputs({ ...inputs, [key]: value });
updateNodeData(id, { [key]: value });
}
useEffect(() => { useEffect(() => {
setFacing(data.showCode ? "back" : "front"); setFacing(data.showCode ? "back" : "front");
@@ -127,8 +93,8 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
blockLabel={label} blockLabel={label}
editable={editable} editable={editable}
nodeId={id} nodeId={id}
totpIdentifier={inputs.totpIdentifier} totpIdentifier={data.totpIdentifier}
totpUrl={inputs.totpVerificationUrl} totpUrl={data.totpVerificationUrl}
type={type} type={type}
/> />
<div <div
@@ -153,9 +119,9 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
canWriteTitle={true} canWriteTitle={true}
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("url", value); update({ url: value });
}} }}
value={inputs.url} value={data.url}
placeholder={placeholders["navigation"]["url"]} placeholder={placeholders["navigation"]["url"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -172,9 +138,9 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("navigationGoal", value); update({ navigationGoal: value });
}} }}
value={inputs.navigationGoal} value={data.navigationGoal}
placeholder={placeholders["navigation"]["navigationGoal"]} placeholder={placeholders["navigation"]["navigationGoal"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -209,7 +175,7 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
availableOutputParameters={outputParameterKeys} availableOutputParameters={outputParameterKeys}
parameters={data.parameterKeys} parameters={data.parameterKeys}
onParametersChange={(parameterKeys) => { onParametersChange={(parameterKeys) => {
updateNodeData(id, { parameterKeys }); update({ parameterKeys });
}} }}
/> />
</div> </div>
@@ -220,18 +186,18 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("completeCriterion", value); update({ completeCriterion: value });
}} }}
value={inputs.completeCriterion} value={data.completeCriterion}
className="nopan text-xs" className="nopan text-xs"
/> />
</div> </div>
<Separator /> <Separator />
<ModelSelector <ModelSelector
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
value={inputs.model} value={data.model}
onChange={(value) => { onChange={(value) => {
handleChange("model", value); update({ model: value });
}} }}
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -241,9 +207,9 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
</Label> </Label>
</div> </div>
<RunEngineSelector <RunEngineSelector
value={inputs.engine} value={data.engine}
onChange={(value) => { onChange={(value) => {
handleChange("engine", value); update({ engine: value });
}} }}
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
/> />
@@ -264,13 +230,13 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
} }
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
min="0" min="0"
value={inputs.maxStepsOverride ?? ""} value={data.maxStepsOverride ?? ""}
onChange={(event) => { onChange={(event) => {
const value = const value =
event.target.value === "" event.target.value === ""
? null ? null
: Number(event.target.value); : Number(event.target.value);
handleChange("maxStepsOverride", value); update({ maxStepsOverride: value });
}} }}
/> />
</div> </div>
@@ -287,29 +253,28 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
/> />
</div> </div>
<Checkbox <Checkbox
checked={inputs.errorCodeMapping !== "null"} checked={data.errorCodeMapping !== "null"}
disabled={!editable} disabled={!editable}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
handleChange( update({
"errorCodeMapping", errorCodeMapping: checked
checked
? JSON.stringify( ? JSON.stringify(
errorMappingExampleValue, errorMappingExampleValue,
null, null,
2, 2,
) )
: "null", : "null",
); });
}} }}
/> />
</div> </div>
{inputs.errorCodeMapping !== "null" && ( {data.errorCodeMapping !== "null" && (
<div> <div>
<CodeEditor <CodeEditor
language="json" language="json"
value={inputs.errorCodeMapping} value={data.errorCodeMapping}
onChange={(value) => { onChange={(value) => {
handleChange("errorCodeMapping", value); update({ errorCodeMapping: value });
}} }}
className="nopan" className="nopan"
fontSize={8} fontSize={8}
@@ -333,12 +298,11 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
</div> </div>
<div className="w-52"> <div className="w-52">
<Switch <Switch
checked={inputs.includeActionHistoryInVerification} checked={data.includeActionHistoryInVerification}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
handleChange( update({
"includeActionHistoryInVerification", includeActionHistoryInVerification: checked,
checked, });
);
}} }}
/> />
</div> </div>
@@ -356,22 +320,22 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
</div> </div>
<div className="w-52"> <div className="w-52">
<Switch <Switch
checked={inputs.continueOnFailure} checked={data.continueOnFailure}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
handleChange("continueOnFailure", checked); update({ continueOnFailure: checked });
}} }}
/> />
</div> </div>
</div> </div>
<DisableCache <DisableCache
cacheActions={inputs.cacheActions} cacheActions={data.cacheActions}
disableCache={inputs.disableCache} disableCache={data.disableCache}
editable={editable} editable={editable}
onCacheActionsChange={(cacheActions) => { onCacheActionsChange={(cacheActions) => {
handleChange("cacheActions", cacheActions); update({ cacheActions });
}} }}
onDisableCacheChange={(disableCache) => { onDisableCacheChange={(disableCache) => {
handleChange("disableCache", disableCache); update({ disableCache });
}} }}
/> />
<Separator /> <Separator />
@@ -388,9 +352,9 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
</div> </div>
<div className="w-52"> <div className="w-52">
<Switch <Switch
checked={inputs.allowDownloads} checked={data.allowDownloads}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
handleChange("allowDownloads", checked); update({ allowDownloads: checked });
}} }}
/> />
</div> </div>
@@ -409,9 +373,9 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
type="text" type="text"
placeholder={placeholders["navigation"]["downloadSuffix"]} placeholder={placeholders["navigation"]["downloadSuffix"]}
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
value={inputs.downloadSuffix ?? ""} value={data.downloadSuffix ?? ""}
onChange={(value) => { onChange={(value) => {
handleChange("downloadSuffix", value); update({ downloadSuffix: value });
}} }}
/> />
</div> </div>
@@ -428,9 +392,9 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("totpIdentifier", value); update({ totpIdentifier: value });
}} }}
value={inputs.totpIdentifier ?? ""} value={data.totpIdentifier ?? ""}
placeholder={placeholders["navigation"]["totpIdentifier"]} placeholder={placeholders["navigation"]["totpIdentifier"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -447,9 +411,9 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("totpVerificationUrl", value); update({ totpVerificationUrl: value });
}} }}
value={inputs.totpVerificationUrl ?? ""} value={data.totpVerificationUrl ?? ""}
placeholder={placeholders["task"]["totpVerificationUrl"]} placeholder={placeholders["task"]["totpVerificationUrl"]}
className="nopan text-xs" className="nopan text-xs"
/> />

View File

@@ -1,8 +1,7 @@
import { HelpTooltip } from "@/components/HelpTooltip"; import { HelpTooltip } from "@/components/HelpTooltip";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput"; import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; import { Handle, NodeProps, Position } from "@xyflow/react";
import { useState } from "react";
import { helpTooltips } from "../../helpContent"; import { helpTooltips } from "../../helpContent";
import { dataSchemaExampleForFileExtraction } from "../types"; import { dataSchemaExampleForFileExtraction } from "../types";
import { type PDFParserNode } from "./types"; import { type PDFParserNode } from "./types";
@@ -13,10 +12,10 @@ import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
import { ModelSelector } from "@/components/ModelSelector"; import { ModelSelector } from "@/components/ModelSelector";
function PDFParserNode({ id, data }: NodeProps<PDFParserNode>) { function PDFParserNode({ id, data }: NodeProps<PDFParserNode>) {
const { updateNodeData } = useReactFlow();
const { editable, label } = data; const { editable, label } = data;
const { blockLabel: urlBlockLabel } = useParams(); const { blockLabel: urlBlockLabel } = useParams();
const { data: workflowRun } = useWorkflowRunQuery(); const { data: workflowRun } = useWorkflowRunQuery();
@@ -26,21 +25,8 @@ function PDFParserNode({ id, data }: NodeProps<PDFParserNode>) {
urlBlockLabel !== undefined && urlBlockLabel === label; urlBlockLabel !== undefined && urlBlockLabel === label;
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; 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 isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const update = useUpdate<PDFParserNode["data"]>({ id, editable });
return ( return (
<div> <div>
@@ -89,26 +75,26 @@ function PDFParserNode({ id, data }: NodeProps<PDFParserNode>) {
</div> </div>
<WorkflowBlockInput <WorkflowBlockInput
nodeId={id} nodeId={id}
value={inputs.fileUrl} value={data.fileUrl}
onChange={(value) => { onChange={(value) => {
handleChange("fileUrl", value); update({ fileUrl: value });
}} }}
className="nopan text-xs" className="nopan text-xs"
/> />
</div> </div>
<WorkflowDataSchemaInputGroup <WorkflowDataSchemaInputGroup
exampleValue={dataSchemaExampleForFileExtraction} exampleValue={dataSchemaExampleForFileExtraction}
value={inputs.jsonSchema} value={data.jsonSchema}
onChange={(value) => { onChange={(value) => {
handleChange("jsonSchema", value); update({ jsonSchema: value });
}} }}
suggestionContext={{}} suggestionContext={{}}
/> />
<ModelSelector <ModelSelector
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
value={inputs.model} value={data.model}
onChange={(value) => { onChange={(value) => {
handleChange("model", value); update({ model: value });
}} }}
/> />
</div> </div>

View File

@@ -1,8 +1,7 @@
import { HelpTooltip } from "@/components/HelpTooltip"; import { HelpTooltip } from "@/components/HelpTooltip";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; import { Handle, NodeProps, Position } from "@xyflow/react";
import { useState } from "react";
import { helpTooltips } from "../../helpContent"; import { helpTooltips } from "../../helpContent";
import { type SendEmailNode } from "./types"; import { type SendEmailNode } from "./types";
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput"; import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
@@ -13,9 +12,9 @@ import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) { function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
const { updateNodeData } = useReactFlow();
const { editable, label } = data; const { editable, label } = data;
const { blockLabel: urlBlockLabel } = useParams(); const { blockLabel: urlBlockLabel } = useParams();
const { data: workflowRun } = useWorkflowRunQuery(); const { data: workflowRun } = useWorkflowRunQuery();
@@ -25,22 +24,8 @@ function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
urlBlockLabel !== undefined && urlBlockLabel === label; urlBlockLabel !== undefined && urlBlockLabel === label;
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; workflowRunIsRunningOrQueued && thisBlockIsTargetted;
const [inputs, setInputs] = useState({
recipients: data.recipients,
subject: data.subject,
body: data.body,
fileAttachments: data.fileAttachments,
});
function handleChange(key: string, value: unknown) {
if (!data.editable) {
return;
}
setInputs({ ...inputs, [key]: value });
updateNodeData(id, { [key]: value });
}
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const update = useUpdate<SendEmailNode["data"]>({ id, editable });
return ( return (
<div> <div>
@@ -86,9 +71,9 @@ function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
<WorkflowBlockInput <WorkflowBlockInput
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("recipients", value); update({ recipients: value });
}} }}
value={inputs.recipients} value={data.recipients}
placeholder="example@gmail.com, example2@gmail.com..." placeholder="example@gmail.com, example2@gmail.com..."
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -99,9 +84,9 @@ function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
<WorkflowBlockInput <WorkflowBlockInput
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("subject", value); update({ subject: value });
}} }}
value={inputs.subject} value={data.subject}
placeholder="What is the gist?" placeholder="What is the gist?"
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -111,9 +96,9 @@ function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("body", value); update({ body: value });
}} }}
value={inputs.body} value={data.body}
placeholder="What would you like to say?" placeholder="What would you like to say?"
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -128,9 +113,9 @@ function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
</div> </div>
<WorkflowBlockInput <WorkflowBlockInput
nodeId={id} nodeId={id}
value={inputs.fileAttachments} value={data.fileAttachments}
onChange={(value) => { onChange={(value) => {
handleChange("fileAttachments", value); update({ fileAttachments: value });
}} }}
disabled disabled
className="nopan text-xs" className="nopan text-xs"

View File

@@ -1,4 +1,3 @@
import { getClient } from "@/api/AxiosClient";
import { Handle, Node, NodeProps, Position, useReactFlow } from "@xyflow/react"; import { Handle, Node, NodeProps, Position, useReactFlow } from "@xyflow/react";
import type { StartNode } from "./types"; import type { StartNode } from "./types";
import { import {
@@ -16,16 +15,13 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ProxyLocation } from "@/api/types"; import { ProxyLocation } from "@/api/types";
import { useQuery } from "@tanstack/react-query";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { HelpTooltip } from "@/components/HelpTooltip"; import { HelpTooltip } from "@/components/HelpTooltip";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea"; import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ProxySelector } from "@/components/ProxySelector"; import { ProxySelector } from "@/components/ProxySelector";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { ModelsResponse } from "@/api/types";
import { ModelSelector } from "@/components/ModelSelector"; import { ModelSelector } from "@/components/ModelSelector";
import { WorkflowModel } from "@/routes/workflows/types/workflowTypes"; import { WorkflowModel } from "@/routes/workflows/types/workflowTypes";
import { MAX_SCREENSHOT_SCROLLS_DEFAULT } from "../Taskv2Node/types"; import { MAX_SCREENSHOT_SCROLLS_DEFAULT } from "../Taskv2Node/types";
@@ -41,78 +37,58 @@ import { Flippable } from "@/components/Flippable";
import { useRerender } from "@/hooks/useRerender"; import { useRerender } from "@/hooks/useRerender";
import { useBlockScriptStore } from "@/store/BlockScriptStore"; import { useBlockScriptStore } from "@/store/BlockScriptStore";
import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor"; import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
interface StartSettings {
webhookCallbackUrl: string;
proxyLocation: ProxyLocation;
persistBrowserSession: boolean;
model: WorkflowModel | null;
maxScreenshotScrollingTimes: number | null;
extraHttpHeaders: string | Record<string, unknown> | null;
}
function StartNode({ id, data }: NodeProps<StartNode>) { function StartNode({ id, data }: NodeProps<StartNode>) {
const workflowSettingsStore = useWorkflowSettingsStore(); const workflowSettingsStore = useWorkflowSettingsStore();
const credentialGetter = useCredentialGetter();
const { updateNodeData } = useReactFlow();
const reactFlowInstance = useReactFlow(); const reactFlowInstance = useReactFlow();
const { data: availableModels } = useQuery<ModelsResponse>({
queryKey: ["models"],
queryFn: async () => {
const client = await getClient(credentialGetter);
return client.get("/models").then((res) => res.data);
},
});
const modelNames = availableModels?.models ?? {};
const firstKey = Object.keys(modelNames)[0];
const workflowModel: WorkflowModel | null = firstKey
? { model_name: modelNames[firstKey] || "" }
: null;
const [inputs, setInputs] = useState({
webhookCallbackUrl: data.withWorkflowSettings
? data.webhookCallbackUrl
: "",
proxyLocation: data.withWorkflowSettings
? data.proxyLocation
: ProxyLocation.Residential,
persistBrowserSession: data.withWorkflowSettings
? data.persistBrowserSession
: false,
model: data.withWorkflowSettings ? data.model : workflowModel,
maxScreenshotScrolls: data.withWorkflowSettings
? data.maxScreenshotScrolls
: null,
extraHttpHeaders: data.withWorkflowSettings ? data.extraHttpHeaders : null,
runWith: data.withWorkflowSettings ? data.runWith : "agent",
scriptCacheKey: data.withWorkflowSettings ? data.scriptCacheKey : null,
aiFallback: data.withWorkflowSettings ? data.aiFallback : true,
runSequentially: data.withWorkflowSettings ? data.runSequentially : false,
sequentialKey: data.withWorkflowSettings ? data.sequentialKey : null,
});
const [facing, setFacing] = useState<"front" | "back">("front"); const [facing, setFacing] = useState<"front" | "back">("front");
const blockScriptStore = useBlockScriptStore(); const blockScriptStore = useBlockScriptStore();
const script = blockScriptStore.scripts.__start_block__; const script = blockScriptStore.scripts.__start_block__;
const rerender = useRerender({ prefix: "accordion" }); const rerender = useRerender({ prefix: "accordion" });
const toggleScriptForNodeCallback = useToggleScriptForNodeCallback(); const toggleScriptForNodeCallback = useToggleScriptForNodeCallback();
const makeStartSettings = (data: StartNode["data"]): StartSettings => {
return {
webhookCallbackUrl: data.withWorkflowSettings
? data.webhookCallbackUrl
: "",
proxyLocation: data.withWorkflowSettings
? data.proxyLocation
: ProxyLocation.Residential,
persistBrowserSession: data.withWorkflowSettings
? data.persistBrowserSession
: false,
model: data.withWorkflowSettings ? data.model : null,
maxScreenshotScrollingTimes: data.withWorkflowSettings
? data.maxScreenshotScrolls
: null,
extraHttpHeaders: data.withWorkflowSettings
? data.extraHttpHeaders
: null,
};
};
const update = useUpdate<StartNode["data"]>({ id, editable: true });
useEffect(() => { useEffect(() => {
setFacing(data.showCode ? "back" : "front"); setFacing(data.showCode ? "back" : "front");
}, [data.showCode]); }, [data.showCode]);
useEffect(() => { useEffect(() => {
workflowSettingsStore.setWorkflowSettings(inputs); workflowSettingsStore.setWorkflowSettings(makeStartSettings(data));
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputs]); }, [data]);
function handleChange(key: string, value: unknown) {
if (!data.editable) {
return;
}
if (inputs[key as keyof typeof inputs] === value) {
return;
}
setInputs({ ...inputs, [key]: value });
updateNodeData(id, { [key]: value });
}
function nodeIsFlippable(node: Node) { function nodeIsFlippable(node: Node) {
return ( return (
@@ -183,9 +159,9 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<div className="space-y-2"> <div className="space-y-2">
<ModelSelector <ModelSelector
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
value={inputs.model} value={data.model}
onChange={(value) => { onChange={(value) => {
handleChange("model", value); update({ model: value });
}} }}
/> />
</div> </div>
@@ -195,13 +171,12 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<HelpTooltip content="The URL of a webhook endpoint to send the workflow results" /> <HelpTooltip content="The URL of a webhook endpoint to send the workflow results" />
</div> </div>
<Input <Input
value={inputs.webhookCallbackUrl} value={data.webhookCallbackUrl}
placeholder="https://" placeholder="https://"
onChange={(event) => { onChange={(event) => {
handleChange( update({
"webhookCallbackUrl", webhookCallbackUrl: event.target.value,
event.target.value, });
);
}} }}
/> />
</div> </div>
@@ -211,9 +186,9 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<HelpTooltip content="Route Skyvern through one of our available proxies." /> <HelpTooltip content="Route Skyvern through one of our available proxies." />
</div> </div>
<ProxySelector <ProxySelector
value={inputs.proxyLocation} value={data.proxyLocation}
onChange={(value) => { onChange={(value) => {
handleChange("proxyLocation", value); update({ proxyLocation: value });
}} }}
/> />
</div> </div>
@@ -225,9 +200,9 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<HelpTooltip content="If code has been generated and saved from a previously successful run, set this to 'Code' to use that code when executing the workflow. To avoid using code, set this to 'Skyvern Agent'." /> <HelpTooltip content="If code has been generated and saved from a previously successful run, set this to 'Code' to use that code when executing the workflow. To avoid using code, set this to 'Skyvern Agent'." />
</div> </div>
<Select <Select
value={inputs.runWith ?? "agent"} value={data.runWith ?? "agent"}
onValueChange={(value) => { onValueChange={(value) => {
handleChange("runWith", value); update({ runWith: value });
}} }}
> >
<SelectTrigger className="w-48"> <SelectTrigger className="w-48">
@@ -247,9 +222,9 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<HelpTooltip content="If a run with code fails, fallback to AI and regenerate the code." /> <HelpTooltip content="If a run with code fails, fallback to AI and regenerate the code." />
<Switch <Switch
className="ml-auto" className="ml-auto"
checked={inputs.aiFallback} checked={data.aiFallback}
onCheckedChange={(value) => { onCheckedChange={(value) => {
handleChange("aiFallback", value); update({ aiFallback: value });
}} }}
/> />
</div> </div>
@@ -263,9 +238,9 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
const v = value.length ? value : null; const v = value.length ? value : null;
handleChange("scriptCacheKey", v); update({ scriptCacheKey: v });
}} }}
value={inputs.scriptCacheKey ?? ""} value={data.scriptCacheKey ?? ""}
placeholder={placeholders["scripts"]["scriptKey"]} placeholder={placeholders["scripts"]["scriptKey"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -280,14 +255,14 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<HelpTooltip content="Run the workflow in a sequential order" /> <HelpTooltip content="Run the workflow in a sequential order" />
<Switch <Switch
className="ml-auto" className="ml-auto"
checked={inputs.runSequentially} checked={data.runSequentially}
onCheckedChange={(value) => { onCheckedChange={(value) => {
handleChange("runSequentially", value); update({ runSequentially: value });
}} }}
/> />
</div> </div>
</div> </div>
{inputs.runSequentially && ( {data.runSequentially && (
<div className="flex flex-col gap-4 rounded-md bg-slate-elevation4 p-4 pl-4"> <div className="flex flex-col gap-4 rounded-md bg-slate-elevation4 p-4 pl-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex gap-2"> <div className="flex gap-2">
@@ -298,9 +273,9 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
const v = value.length ? value : null; const v = value.length ? value : null;
handleChange("sequentialKey", v); update({ sequentialKey: v });
}} }}
value={inputs.sequentialKey ?? ""} value={data.sequentialKey ?? ""}
placeholder={placeholders["sequentialKey"]} placeholder={placeholders["sequentialKey"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -314,9 +289,9 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<HelpTooltip content="Persist session information across workflow runs" /> <HelpTooltip content="Persist session information across workflow runs" />
<Switch <Switch
className="ml-auto" className="ml-auto"
checked={inputs.persistBrowserSession} checked={data.persistBrowserSession}
onCheckedChange={(value) => { onCheckedChange={(value) => {
handleChange("persistBrowserSession", value); update({ persistBrowserSession: value });
}} }}
/> />
</div> </div>
@@ -327,10 +302,28 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<HelpTooltip content="Specify some self-defined HTTP requests headers" /> <HelpTooltip content="Specify some self-defined HTTP requests headers" />
</div> </div>
<KeyValueInput <KeyValueInput
value={inputs.extraHttpHeaders ?? null} value={
onChange={(val) => data.extraHttpHeaders &&
handleChange("extraHttpHeaders", val || "{}") typeof data.extraHttpHeaders === "object"
? JSON.stringify(data.extraHttpHeaders)
: data.extraHttpHeaders ?? null
} }
onChange={(val) => {
const v =
val === null
? "{}"
: typeof val === "string"
? val.trim()
: JSON.stringify(val);
const normalized = v === "" ? "{}" : v;
if (normalized === data.extraHttpHeaders) {
return;
}
update({ extraHttpHeaders: normalized });
}}
addButtonText="Add Header" addButtonText="Add Header"
/> />
</div> </div>
@@ -342,7 +335,7 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
/> />
</div> </div>
<Input <Input
value={inputs.maxScreenshotScrolls ?? ""} value={data.maxScreenshotScrolls ?? ""}
placeholder={`Default: ${MAX_SCREENSHOT_SCROLLS_DEFAULT}`} placeholder={`Default: ${MAX_SCREENSHOT_SCROLLS_DEFAULT}`}
onChange={(event) => { onChange={(event) => {
const value = const value =
@@ -350,7 +343,7 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
? null ? null
: Number(event.target.value); : Number(event.target.value);
handleChange("maxScreenshotScrolls", value); update({ maxScreenshotScrolls: value });
}} }}
/> />
</div> </div>

View File

@@ -10,7 +10,7 @@ export type WorkflowStartNodeData = {
persistBrowserSession: boolean; persistBrowserSession: boolean;
model: WorkflowModel | null; model: WorkflowModel | null;
maxScreenshotScrolls: number | null; maxScreenshotScrolls: number | null;
extraHttpHeaders: string | null; extraHttpHeaders: string | Record<string, unknown> | null;
editable: boolean; editable: boolean;
runWith: string | null; runWith: string | null;
scriptCacheKey: string | null; scriptCacheKey: string | null;

View File

@@ -17,14 +17,7 @@ import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTexta
import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor"; import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { useBlockScriptStore } from "@/store/BlockScriptStore"; import { useBlockScriptStore } from "@/store/BlockScriptStore";
import { import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react";
Handle,
NodeProps,
Position,
useEdges,
useNodes,
useReactFlow,
} from "@xyflow/react";
import { useState } from "react"; import { useState } from "react";
import { AppNode } from ".."; import { AppNode } from "..";
import { helpTooltips, placeholders } from "../../helpContent"; import { helpTooltips, placeholders } from "../../helpContent";
@@ -41,12 +34,12 @@ import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
import { useRerender } from "@/hooks/useRerender"; import { useRerender } from "@/hooks/useRerender";
import { DisableCache } from "../DisableCache"; import { DisableCache } from "../DisableCache";
function TaskNode({ id, data, type }: NodeProps<TaskNode>) { function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
const { updateNodeData } = useReactFlow();
const [facing, setFacing] = useState<"front" | "back">("front"); const [facing, setFacing] = useState<"front" | "back">("front");
const blockScriptStore = useBlockScriptStore(); const blockScriptStore = useBlockScriptStore();
const { editable, label } = data; const { editable, label } = data;
@@ -59,41 +52,12 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
urlBlockLabel !== undefined && urlBlockLabel === label; urlBlockLabel !== undefined && urlBlockLabel === label;
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; workflowRunIsRunningOrQueued && thisBlockIsTargetted;
const rerender = useRerender({ prefix: "accordian" }); const rerender = useRerender({ prefix: "accordian" });
const nodes = useNodes<AppNode>(); const nodes = useNodes<AppNode>();
const edges = useEdges(); const edges = useEdges();
const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id);
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const update = useUpdate<TaskNode["data"]>({ id, editable });
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 });
}
useEffect(() => { useEffect(() => {
setFacing(data.showCode ? "back" : "front"); setFacing(data.showCode ? "back" : "front");
@@ -129,8 +93,8 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
blockLabel={label} blockLabel={label}
editable={editable} editable={editable}
nodeId={id} nodeId={id}
totpIdentifier={inputs.totpIdentifier} totpIdentifier={data.totpIdentifier}
totpUrl={inputs.totpVerificationUrl} totpUrl={data.totpVerificationUrl}
type={type} type={type}
/> />
<Accordion <Accordion
@@ -158,9 +122,9 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
canWriteTitle={true} canWriteTitle={true}
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("url", value); update({ url: value });
}} }}
value={inputs.url} value={data.url}
placeholder={placeholders["task"]["url"]} placeholder={placeholders["task"]["url"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -175,9 +139,9 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("navigationGoal", value); update({ navigationGoal: value });
}} }}
value={inputs.navigationGoal} value={data.navigationGoal}
placeholder={placeholders["task"]["navigationGoal"]} placeholder={placeholders["task"]["navigationGoal"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -187,7 +151,7 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
availableOutputParameters={outputParameterKeys} availableOutputParameters={outputParameterKeys}
parameters={data.parameterKeys} parameters={data.parameterKeys}
onParametersChange={(parameterKeys) => { onParametersChange={(parameterKeys) => {
updateNodeData(id, { parameterKeys }); update({ parameterKeys });
}} }}
/> />
</div> </div>
@@ -210,9 +174,9 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("dataExtractionGoal", value); update({ dataExtractionGoal: value });
}} }}
value={inputs.dataExtractionGoal} value={data.dataExtractionGoal}
placeholder={placeholders["task"]["dataExtractionGoal"]} placeholder={placeholders["task"]["dataExtractionGoal"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -220,13 +184,13 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
<WorkflowDataSchemaInputGroup <WorkflowDataSchemaInputGroup
exampleValue={dataSchemaExampleValue} exampleValue={dataSchemaExampleValue}
onChange={(value) => { onChange={(value) => {
handleChange("dataSchema", value); update({ dataSchema: value });
}} }}
value={inputs.dataSchema} value={data.dataSchema}
suggestionContext={{ suggestionContext={{
data_extraction_goal: inputs.dataExtractionGoal, data_extraction_goal: data.dataExtractionGoal,
current_schema: inputs.dataSchema, current_schema: data.dataSchema,
navigation_goal: inputs.navigationGoal, navigation_goal: data.navigationGoal,
}} }}
/> />
</div> </div>
@@ -243,18 +207,18 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("completeCriterion", value); update({ completeCriterion: value });
}} }}
value={inputs.completeCriterion} value={data.completeCriterion}
className="nopan text-xs" className="nopan text-xs"
/> />
</div> </div>
<Separator /> <Separator />
<ModelSelector <ModelSelector
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
value={inputs.model} value={data.model}
onChange={(value) => { onChange={(value) => {
handleChange("model", value); update({ model: value });
}} }}
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -264,9 +228,9 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
</Label> </Label>
</div> </div>
<RunEngineSelector <RunEngineSelector
value={inputs.engine} value={data.engine}
onChange={(value) => { onChange={(value) => {
handleChange("engine", value); update({ engine: value });
}} }}
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
/> />
@@ -285,13 +249,13 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
placeholder={placeholders["task"]["maxStepsOverride"]} placeholder={placeholders["task"]["maxStepsOverride"]}
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
min="0" min="0"
value={inputs.maxStepsOverride ?? ""} value={data.maxStepsOverride ?? ""}
onChange={(event) => { onChange={(event) => {
const value = const value =
event.target.value === "" event.target.value === ""
? null ? null
: Number(event.target.value); : Number(event.target.value);
handleChange("maxStepsOverride", value); update({ maxStepsOverride: value });
}} }}
/> />
</div> </div>
@@ -306,29 +270,28 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
/> />
</div> </div>
<Checkbox <Checkbox
checked={inputs.errorCodeMapping !== "null"} checked={data.errorCodeMapping !== "null"}
disabled={!editable} disabled={!editable}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
handleChange( update({
"errorCodeMapping", errorCodeMapping: checked
checked
? JSON.stringify( ? JSON.stringify(
errorMappingExampleValue, errorMappingExampleValue,
null, null,
2, 2,
) )
: "null", : "null",
); });
}} }}
/> />
</div> </div>
{inputs.errorCodeMapping !== "null" && ( {data.errorCodeMapping !== "null" && (
<div> <div>
<CodeEditor <CodeEditor
language="json" language="json"
value={inputs.errorCodeMapping} value={data.errorCodeMapping}
onChange={(value) => { onChange={(value) => {
handleChange("errorCodeMapping", value); update({ errorCodeMapping: value });
}} }}
className="nopan" className="nopan"
fontSize={8} fontSize={8}
@@ -352,12 +315,11 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
</div> </div>
<div className="w-52"> <div className="w-52">
<Switch <Switch
checked={inputs.includeActionHistoryInVerification} checked={data.includeActionHistoryInVerification}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
handleChange( update({
"includeActionHistoryInVerification", includeActionHistoryInVerification: checked,
checked, });
);
}} }}
/> />
</div> </div>
@@ -373,22 +335,22 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
</div> </div>
<div className="w-52"> <div className="w-52">
<Switch <Switch
checked={inputs.continueOnFailure} checked={data.continueOnFailure}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
handleChange("continueOnFailure", checked); update({ continueOnFailure: checked });
}} }}
/> />
</div> </div>
</div> </div>
<DisableCache <DisableCache
cacheActions={inputs.cacheActions} cacheActions={data.cacheActions}
disableCache={inputs.disableCache} disableCache={data.disableCache}
editable={editable} editable={editable}
onCacheActionsChange={(cacheActions) => { onCacheActionsChange={(cacheActions) => {
handleChange("cacheActions", cacheActions); update({ cacheActions });
}} }}
onDisableCacheChange={(disableCache) => { onDisableCacheChange={(disableCache) => {
handleChange("disableCache", disableCache); update({ disableCache });
}} }}
/> />
<Separator /> <Separator />
@@ -403,9 +365,9 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
</div> </div>
<div className="w-52"> <div className="w-52">
<Switch <Switch
checked={inputs.allowDownloads} checked={data.allowDownloads}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
handleChange("allowDownloads", checked); update({ allowDownloads: checked });
}} }}
/> />
</div> </div>
@@ -424,9 +386,9 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
type="text" type="text"
placeholder={placeholders["task"]["downloadSuffix"]} placeholder={placeholders["task"]["downloadSuffix"]}
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
value={inputs.downloadSuffix ?? ""} value={data.downloadSuffix ?? ""}
onChange={(value) => { onChange={(value) => {
handleChange("downloadSuffix", value); update({ downloadSuffix: value });
}} }}
/> />
</div> </div>
@@ -443,9 +405,9 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("totpIdentifier", value); update({ totpIdentifier: value });
}} }}
value={inputs.totpIdentifier ?? ""} value={data.totpIdentifier ?? ""}
placeholder={placeholders["task"]["totpIdentifier"]} placeholder={placeholders["task"]["totpIdentifier"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -462,9 +424,9 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("totpVerificationUrl", value); update({ totpVerificationUrl: value });
}} }}
value={inputs.totpVerificationUrl ?? ""} value={data.totpVerificationUrl ?? ""}
placeholder={placeholders["task"]["totpVerificationUrl"]} placeholder={placeholders["task"]["totpVerificationUrl"]}
className="nopan text-xs" className="nopan text-xs"
/> />

View File

@@ -11,7 +11,7 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; 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 { helpTooltips, placeholders } from "../../helpContent";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow"; import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { MAX_STEPS_DEFAULT, type Taskv2Node } from "./types"; 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 { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useRerender } from "@/hooks/useRerender"; import { useRerender } from "@/hooks/useRerender";
import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor"; import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
import { useBlockScriptStore } from "@/store/BlockScriptStore"; import { useBlockScriptStore } from "@/store/BlockScriptStore";
import { DisableCache } from "../DisableCache"; import { DisableCache } from "../DisableCache";
@@ -38,30 +39,12 @@ function Taskv2Node({ id, data, type }: NodeProps<Taskv2Node>) {
urlBlockLabel !== undefined && urlBlockLabel === label; urlBlockLabel !== undefined && urlBlockLabel === label;
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; workflowRunIsRunningOrQueued && thisBlockIsTargetted;
const { updateNodeData } = useReactFlow();
const [facing, setFacing] = useState<"front" | "back">("front"); const [facing, setFacing] = useState<"front" | "back">("front");
const blockScriptStore = useBlockScriptStore(); const blockScriptStore = useBlockScriptStore();
const script = blockScriptStore.scripts[label]; const script = blockScriptStore.scripts[label];
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const rerender = useRerender({ prefix: "accordian" }); const rerender = useRerender({ prefix: "accordian" });
const update = useUpdate<Taskv2Node["data"]>({ id, editable });
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 });
}
useEffect(() => { useEffect(() => {
setFacing(data.showCode ? "back" : "front"); setFacing(data.showCode ? "back" : "front");
@@ -96,8 +79,8 @@ function Taskv2Node({ id, data, type }: NodeProps<Taskv2Node>) {
blockLabel={label} blockLabel={label}
editable={editable} editable={editable}
nodeId={id} nodeId={id}
totpIdentifier={inputs.totpIdentifier} totpIdentifier={data.totpIdentifier}
totpUrl={inputs.totpVerificationUrl} totpUrl={data.totpVerificationUrl}
type="task_v2" // sic: the naming is not consistent type="task_v2" // sic: the naming is not consistent
/> />
<div className="space-y-4"> <div className="space-y-4">
@@ -113,9 +96,9 @@ function Taskv2Node({ id, data, type }: NodeProps<Taskv2Node>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("prompt", value); update({ prompt: value });
}} }}
value={inputs.prompt} value={data.prompt}
placeholder={placeholders[type]["prompt"]} placeholder={placeholders[type]["prompt"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -126,9 +109,9 @@ function Taskv2Node({ id, data, type }: NodeProps<Taskv2Node>) {
canWriteTitle={true} canWriteTitle={true}
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("url", value); update({ url: value });
}} }}
value={inputs.url} value={data.url}
placeholder={placeholders[type]["url"]} placeholder={placeholders[type]["url"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -148,9 +131,9 @@ function Taskv2Node({ id, data, type }: NodeProps<Taskv2Node>) {
<div className="space-y-4"> <div className="space-y-4">
<ModelSelector <ModelSelector
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
value={inputs.model} value={data.model}
onChange={(value) => { onChange={(value) => {
handleChange("model", value); update({ model: value });
}} }}
/> />
<div className="space-y-2"> <div className="space-y-2">
@@ -166,19 +149,21 @@ function Taskv2Node({ id, data, type }: NodeProps<Taskv2Node>) {
className="nopan text-xs" className="nopan text-xs"
value={data.maxSteps ?? MAX_STEPS_DEFAULT} value={data.maxSteps ?? MAX_STEPS_DEFAULT}
onChange={(event) => { onChange={(event) => {
handleChange("maxSteps", Number(event.target.value)); update({
maxSteps: Number(event.target.value),
});
}} }}
/> />
</div> </div>
<Separator /> <Separator />
<DisableCache <DisableCache
disableCache={inputs.disableCache} disableCache={data.disableCache}
editable={editable} editable={editable}
onCacheActionsChange={(cacheActions) => { onCacheActionsChange={(cacheActions) => {
handleChange("cacheActions", cacheActions); update({ cacheActions });
}} }}
onDisableCacheChange={(disableCache) => { onDisableCacheChange={(disableCache) => {
handleChange("disableCache", disableCache); update({ disableCache });
}} }}
/> />
<Separator /> <Separator />
@@ -194,9 +179,9 @@ function Taskv2Node({ id, data, type }: NodeProps<Taskv2Node>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("totpIdentifier", value); update({ totpIdentifier: value });
}} }}
value={inputs.totpIdentifier ?? ""} value={data.totpIdentifier ?? ""}
placeholder={placeholders["navigation"]["totpIdentifier"]} placeholder={placeholders["navigation"]["totpIdentifier"]}
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -213,9 +198,9 @@ function Taskv2Node({ id, data, type }: NodeProps<Taskv2Node>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("totpVerificationUrl", value); update({ totpVerificationUrl: value });
}} }}
value={inputs.totpVerificationUrl ?? ""} value={data.totpVerificationUrl ?? ""}
placeholder={placeholders["task"]["totpVerificationUrl"]} placeholder={placeholders["task"]["totpVerificationUrl"]}
className="nopan text-xs" className="nopan text-xs"
/> />

View File

@@ -11,6 +11,7 @@ export type Taskv2NodeData = NodeBaseData & {
totpVerificationUrl: string | null; totpVerificationUrl: string | null;
totpIdentifier: string | null; totpIdentifier: string | null;
maxSteps: number | null; maxSteps: number | null;
cacheActions: boolean;
disableCache: boolean; disableCache: boolean;
maxScreenshotScrolls: number | null; maxScreenshotScrolls: number | null;
}; };
@@ -27,6 +28,7 @@ export const taskv2NodeDefaultData: Taskv2NodeData = {
totpIdentifier: null, totpIdentifier: null,
totpVerificationUrl: null, totpVerificationUrl: null,
maxSteps: MAX_STEPS_DEFAULT, maxSteps: MAX_STEPS_DEFAULT,
cacheActions: false,
disableCache: false, disableCache: false,
model: null, model: null,
maxScreenshotScrolls: null, maxScreenshotScrolls: null,

View File

@@ -1,8 +1,7 @@
import { HelpTooltip } from "@/components/HelpTooltip"; import { HelpTooltip } from "@/components/HelpTooltip";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; import { Handle, NodeProps, Position } from "@xyflow/react";
import { useState } from "react";
import { helpTooltips } from "../../helpContent"; import { helpTooltips } from "../../helpContent";
import { type TextPromptNode } from "./types"; import { type TextPromptNode } from "./types";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea"; import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
@@ -15,9 +14,9 @@ import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
function TextPromptNode({ id, data }: NodeProps<TextPromptNode>) { function TextPromptNode({ id, data }: NodeProps<TextPromptNode>) {
const { updateNodeData } = useReactFlow();
const { editable, label } = data; const { editable, label } = data;
const { blockLabel: urlBlockLabel } = useParams(); const { blockLabel: urlBlockLabel } = useParams();
const { data: workflowRun } = useWorkflowRunQuery(); const { data: workflowRun } = useWorkflowRunQuery();
@@ -27,21 +26,8 @@ function TextPromptNode({ id, data }: NodeProps<TextPromptNode>) {
urlBlockLabel !== undefined && urlBlockLabel === label; urlBlockLabel !== undefined && urlBlockLabel === label;
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; 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 isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const update = useUpdate<TextPromptNode["data"]>({ id, editable });
return ( return (
<div> <div>
@@ -91,9 +77,9 @@ function TextPromptNode({ id, data }: NodeProps<TextPromptNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("prompt", value); update({ prompt: value });
}} }}
value={inputs.prompt} value={data.prompt}
placeholder="What do you want to generate?" placeholder="What do you want to generate?"
className="nopan text-xs" className="nopan text-xs"
/> />
@@ -101,16 +87,16 @@ function TextPromptNode({ id, data }: NodeProps<TextPromptNode>) {
<Separator /> <Separator />
<ModelSelector <ModelSelector
className="nopan w-52 text-xs" className="nopan w-52 text-xs"
value={inputs.model} value={data.model}
onChange={(value) => { onChange={(value) => {
handleChange("model", value); update({ model: value });
}} }}
/> />
<WorkflowDataSchemaInputGroup <WorkflowDataSchemaInputGroup
exampleValue={dataSchemaExampleValue} exampleValue={dataSchemaExampleValue}
value={inputs.jsonSchema} value={data.jsonSchema}
onChange={(value) => { onChange={(value) => {
handleChange("jsonSchema", value); update({ jsonSchema: value });
}} }}
suggestionContext={{}} suggestionContext={{}}
/> />

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Flippable } from "@/components/Flippable"; 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 type { URLNode } from "./types";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow"; import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { useState } from "react"; import { useState } from "react";
@@ -14,9 +14,9 @@ import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
function URLNode({ id, data, type }: NodeProps<URLNode>) { function URLNode({ id, data, type }: NodeProps<URLNode>) {
const { updateNodeData } = useReactFlow();
const [facing, setFacing] = useState<"front" | "back">("front"); const [facing, setFacing] = useState<"front" | "back">("front");
const blockScriptStore = useBlockScriptStore(); const blockScriptStore = useBlockScriptStore();
const { editable, label } = data; const { editable, label } = data;
@@ -30,18 +30,7 @@ function URLNode({ id, data, type }: NodeProps<URLNode>) {
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; workflowRunIsRunningOrQueued && thisBlockIsTargetted;
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const update = useUpdate<URLNode["data"]>({ id, editable });
const [inputs, setInputs] = useState({
url: data.url,
});
function handleChange(key: string, value: unknown) {
if (!editable) {
return;
}
setInputs({ ...inputs, [key]: value });
updateNodeData(id, { [key]: value });
}
useEffect(() => { useEffect(() => {
setFacing(data.showCode ? "back" : "front"); setFacing(data.showCode ? "back" : "front");
@@ -94,9 +83,9 @@ function URLNode({ id, data, type }: NodeProps<URLNode>) {
canWriteTitle={true} canWriteTitle={true}
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("url", value); update({ url: value });
}} }}
value={inputs.url} value={data.url}
placeholder={placeholders[type]["url"]} placeholder={placeholders[type]["url"]}
className="nopan text-xs" className="nopan text-xs"
/> />

View File

@@ -15,14 +15,7 @@ import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTexta
import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor"; import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { useBlockScriptStore } from "@/store/BlockScriptStore"; import { useBlockScriptStore } from "@/store/BlockScriptStore";
import { import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react";
Handle,
NodeProps,
Position,
useEdges,
useNodes,
useReactFlow,
} from "@xyflow/react";
import { useState } from "react"; import { useState } from "react";
import { helpTooltips } from "../../helpContent"; import { helpTooltips } from "../../helpContent";
import { errorMappingExampleValue } from "../types"; import { errorMappingExampleValue } from "../types";
@@ -37,12 +30,12 @@ import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
import { useRerender } from "@/hooks/useRerender"; import { useRerender } from "@/hooks/useRerender";
import { DisableCache } from "../DisableCache"; import { DisableCache } from "../DisableCache";
function ValidationNode({ id, data, type }: NodeProps<ValidationNode>) { function ValidationNode({ id, data, type }: NodeProps<ValidationNode>) {
const { updateNodeData } = useReactFlow();
const [facing, setFacing] = useState<"front" | "back">("front"); const [facing, setFacing] = useState<"front" | "back">("front");
const blockScriptStore = useBlockScriptStore(); const blockScriptStore = useBlockScriptStore();
const { editable, label } = data; const { editable, label } = data;
@@ -55,28 +48,12 @@ function ValidationNode({ id, data, type }: NodeProps<ValidationNode>) {
urlBlockLabel !== undefined && urlBlockLabel === label; urlBlockLabel !== undefined && urlBlockLabel === label;
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; 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 rerender = useRerender({ prefix: "accordian" });
const nodes = useNodes<AppNode>(); const nodes = useNodes<AppNode>();
const edges = useEdges(); const edges = useEdges();
const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); 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 isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const update = useUpdate<ValidationNode["data"]>({ id, editable });
useEffect(() => { useEffect(() => {
setFacing(data.showCode ? "back" : "front"); setFacing(data.showCode ? "back" : "front");
@@ -128,9 +105,9 @@ function ValidationNode({ id, data, type }: NodeProps<ValidationNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("completeCriterion", value); update({ completeCriterion: value });
}} }}
value={inputs.completeCriterion} value={data.completeCriterion}
className="nopan text-xs" className="nopan text-xs"
/> />
</div> </div>
@@ -139,9 +116,9 @@ function ValidationNode({ id, data, type }: NodeProps<ValidationNode>) {
<WorkflowBlockInputTextarea <WorkflowBlockInputTextarea
nodeId={id} nodeId={id}
onChange={(value) => { onChange={(value) => {
handleChange("terminateCriterion", value); update({ terminateCriterion: value });
}} }}
value={inputs.terminateCriterion} value={data.terminateCriterion}
className="nopan text-xs" className="nopan text-xs"
/> />
</div> </div>
@@ -160,16 +137,16 @@ function ValidationNode({ id, data, type }: NodeProps<ValidationNode>) {
<div className="space-y-2"> <div className="space-y-2">
<ModelSelector <ModelSelector
className="nopan mr-[1px] w-52 text-xs" className="nopan mr-[1px] w-52 text-xs"
value={inputs.model} value={data.model}
onChange={(value) => { onChange={(value) => {
handleChange("model", value); update({ model: value });
}} }}
/> />
<ParametersMultiSelect <ParametersMultiSelect
availableOutputParameters={outputParameterKeys} availableOutputParameters={outputParameterKeys}
parameters={data.parameterKeys} parameters={data.parameterKeys}
onParametersChange={(parameterKeys) => { onParametersChange={(parameterKeys) => {
updateNodeData(id, { parameterKeys }); update({ parameterKeys });
}} }}
/> />
</div> </div>
@@ -186,35 +163,34 @@ function ValidationNode({ id, data, type }: NodeProps<ValidationNode>) {
/> />
</div> </div>
<Checkbox <Checkbox
checked={inputs.errorCodeMapping !== "null"} checked={data.errorCodeMapping !== "null"}
disabled={!editable} disabled={!editable}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
if (!editable) { if (!editable) {
return; return;
} }
handleChange( update({
"errorCodeMapping", errorCodeMapping: checked
checked
? JSON.stringify( ? JSON.stringify(
errorMappingExampleValue, errorMappingExampleValue,
null, null,
2, 2,
) )
: "null", : "null",
); });
}} }}
/> />
</div> </div>
{inputs.errorCodeMapping !== "null" && ( {data.errorCodeMapping !== "null" && (
<div> <div>
<CodeEditor <CodeEditor
language="json" language="json"
value={inputs.errorCodeMapping} value={data.errorCodeMapping}
onChange={(value) => { onChange={(value) => {
if (!editable) { if (!editable) {
return; return;
} }
handleChange("errorCodeMapping", value); update({ errorCodeMapping: value });
}} }}
className="nopan" className="nopan"
fontSize={8} fontSize={8}
@@ -241,19 +217,19 @@ function ValidationNode({ id, data, type }: NodeProps<ValidationNode>) {
if (!editable) { if (!editable) {
return; return;
} }
updateNodeData(id, { continueOnFailure: checked }); update({ continueOnFailure: checked });
}} }}
/> />
</div> </div>
</div> </div>
<DisableCache <DisableCache
disableCache={inputs.disableCache} disableCache={data.disableCache}
editable={editable} editable={editable}
onCacheActionsChange={(cacheActions) => { onCacheActionsChange={(cacheActions) => {
handleChange("cacheActions", cacheActions); update({ cacheActions });
}} }}
onDisableCacheChange={(disableCache) => { onDisableCacheChange={(disableCache) => {
handleChange("disableCache", disableCache); update({ disableCache });
}} }}
/> />
</div> </div>

View File

@@ -6,6 +6,7 @@ export type ValidationNodeData = NodeBaseData & {
terminateCriterion: string; terminateCriterion: string;
errorCodeMapping: string; errorCodeMapping: string;
parameterKeys: Array<string>; parameterKeys: Array<string>;
cacheActions?: boolean;
disableCache: boolean; disableCache: boolean;
}; };
@@ -20,6 +21,7 @@ export const validationNodeDefaultData: ValidationNodeData = {
continueOnFailure: false, continueOnFailure: false,
editable: true, editable: true,
parameterKeys: [], parameterKeys: [],
cacheActions: false,
disableCache: false, disableCache: false,
model: null, model: null,
}; };

View File

@@ -1,7 +1,6 @@
import { HelpTooltip } from "@/components/HelpTooltip"; import { HelpTooltip } from "@/components/HelpTooltip";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; import { Handle, NodeProps, Position } from "@xyflow/react";
import { useState } from "react";
import { helpTooltips } from "../../helpContent"; import { helpTooltips } from "../../helpContent";
import type { WaitNode } from "./types"; import type { WaitNode } from "./types";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow"; import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
@@ -11,9 +10,9 @@ import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
function WaitNode({ id, data, type }: NodeProps<WaitNode>) { function WaitNode({ id, data, type }: NodeProps<WaitNode>) {
const { updateNodeData } = useReactFlow();
const { editable, label } = data; const { editable, label } = data;
const { blockLabel: urlBlockLabel } = useParams(); const { blockLabel: urlBlockLabel } = useParams();
const { data: workflowRun } = useWorkflowRunQuery(); const { data: workflowRun } = useWorkflowRunQuery();
@@ -23,20 +22,10 @@ function WaitNode({ id, data, type }: NodeProps<WaitNode>) {
urlBlockLabel !== undefined && urlBlockLabel === label; urlBlockLabel !== undefined && urlBlockLabel === label;
const thisBlockIsPlaying = const thisBlockIsPlaying =
workflowRunIsRunningOrQueued && thisBlockIsTargetted; 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 isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const update = useUpdate<WaitNode["data"]>({ id, editable });
return ( return (
<div> <div>
<Handle <Handle
@@ -84,9 +73,9 @@ function WaitNode({ id, data, type }: NodeProps<WaitNode>) {
) : null} ) : null}
</div> </div>
<Input <Input
value={inputs.waitInSeconds} value={data.waitInSeconds}
onChange={(event) => { onChange={(event) => {
handleChange("waitInSeconds", event.target.value); update({ waitInSeconds: event.target.value });
}} }}
className="nopan text-xs" className="nopan text-xs"
/> />

View File

@@ -91,7 +91,9 @@ const getPayload = (opts: {
extraHttpHeaders = extraHttpHeaders =
opts.workflowSettings.extraHttpHeaders === null opts.workflowSettings.extraHttpHeaders === null
? null ? null
: JSON.parse(opts.workflowSettings.extraHttpHeaders); : typeof opts.workflowSettings.extraHttpHeaders === "object"
? opts.workflowSettings.extraHttpHeaders
: JSON.parse(opts.workflowSettings.extraHttpHeaders);
} catch (e: unknown) { } catch (e: unknown) {
toast({ toast({
variant: "warning", variant: "warning",

View File

@@ -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<string, unknown>
* @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<WaitNode["data"]>({ id, editable });
* update({ waitInSeconds: "5" });
* ```
*/
export function useUpdate<T extends Record<string, unknown>>({
id,
editable,
}: UseUpdateOptions) {
const { updateNodeData } = useReactFlow();
const update = useCallback(
(updates: Partial<T>) => {
if (!editable) return;
updateNodeData(id, updates);
},
[id, editable, updateNodeData],
);
return update;
}

View File

@@ -264,6 +264,7 @@ function convertToNode(
prompt: block.prompt, prompt: block.prompt,
url: block.url ?? "", url: block.url ?? "",
maxSteps: block.max_steps, maxSteps: block.max_steps,
cacheActions: block.cache_actions ?? false,
disableCache: block.disable_cache ?? false, disableCache: block.disable_cache ?? false,
totpIdentifier: block.totp_identifier, totpIdentifier: block.totp_identifier,
totpVerificationUrl: block.totp_verification_url, totpVerificationUrl: block.totp_verification_url,
@@ -1457,7 +1458,10 @@ function getWorkflowSettings(nodes: Array<AppNode>): WorkflowSettings {
webhookCallbackUrl: data.webhookCallbackUrl, webhookCallbackUrl: data.webhookCallbackUrl,
model: data.model, model: data.model,
maxScreenshotScrolls: data.maxScreenshotScrolls, maxScreenshotScrolls: data.maxScreenshotScrolls,
extraHttpHeaders: data.extraHttpHeaders, extraHttpHeaders:
data.extraHttpHeaders && typeof data.extraHttpHeaders === "object"
? JSON.stringify(data.extraHttpHeaders)
: data.extraHttpHeaders,
runWith: data.runWith, runWith: data.runWith,
scriptCacheKey: data.scriptCacheKey, scriptCacheKey: data.scriptCacheKey,
aiFallback: data.aiFallback, aiFallback: data.aiFallback,

View File

@@ -318,6 +318,7 @@ export type Taskv2Block = WorkflowBlockBase & {
totp_verification_url: string | null; totp_verification_url: string | null;
totp_identifier: string | null; totp_identifier: string | null;
max_steps: number | null; max_steps: number | null;
cache_actions?: boolean;
disable_cache: boolean; disable_cache: boolean;
}; };

View File

@@ -11,9 +11,14 @@ export interface WorkflowSettingsState {
persistBrowserSession: boolean; persistBrowserSession: boolean;
model: WorkflowModel | null; model: WorkflowModel | null;
maxScreenshotScrollingTimes: number | null; maxScreenshotScrollingTimes: number | null;
extraHttpHeaders: string | null; extraHttpHeaders: string | Record<string, unknown> | null;
setWorkflowSettings: ( setWorkflowSettings: (
settings: Partial<Omit<WorkflowSettingsState, "setWorkflowSettings">>, settings: Partial<
Omit<
WorkflowSettingsState,
"setWorkflowSettings" | "resetWorkflowSettings"
>
>,
) => void; ) => void;
resetWorkflowSettings: () => void; resetWorkflowSettings: () => void;
} }