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

@@ -3,8 +3,9 @@ import { json } from "@codemirror/lang-json";
import { python } from "@codemirror/lang-python";
import { html } from "@codemirror/lang-html";
import { tokyoNightStorm } from "@uiw/codemirror-theme-tokyo-night-storm";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/util/utils";
import { useDebouncedCallback } from "use-debounce";
import "./code-mirror-overrides.css";
@@ -50,6 +51,20 @@ function CodeEditor({
fullHeight = false,
}: Props) {
const viewRef = useRef<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
? [getLanguageExtension(language), lineWrap ? EditorView.lineWrapping : []]
@@ -103,8 +118,8 @@ function CodeEditor({
return (
<CodeMirror
value={value}
onChange={onChange}
value={internalValue}
onChange={handleChange}
extensions={extensions}
theme={tokyoNightStorm}
minHeight={minHeight}
@@ -118,6 +133,9 @@ function CodeEditor({
onUpdate={(viewUpdate) => {
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 { useAutoPan } from "./useAutoPan";
const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0));
function convertToParametersYAML(
parameters: ParametersState,
): Array<
@@ -278,7 +276,6 @@ function FlowRenderer({
const parameters = useWorkflowParametersStore((state) => state.parameters);
const nodesInitialized = useNodesInitialized();
const [shouldConstrainPan, setShouldConstrainPan] = useState(false);
const onNodesChangeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const flowIsConstrained = debugStore.isDebugMode;
useEffect(() => {
@@ -672,6 +669,7 @@ function FlowRenderer({
}
}
});
if (dimensionChanges.length > 0) {
doLayout(tempNodes, edges);
}
@@ -687,20 +685,23 @@ function FlowRenderer({
workflowChangesStore.setHasChanges(true);
}
// only allow one update in _this_ render cycle
if (onNodesChangeTimeoutRef.current === null) {
onNodesChange(changes);
onNodesChangeTimeoutRef.current = setTimeout(() => {
onNodesChangeTimeoutRef.current = null;
}, 0);
} else {
// if we have an update in this render cycle already, then to
// prevent max recursion errors, defer the update to next render
// cycle
nextTick().then(() => {
onNodesChange(changes);
});
}
onNodesChange(changes);
// NOTE: should no longer be needed (woot!) - delete if true (want real-world testing first)
// // only allow one update in _this_ render cycle
// if (onNodesChangeTimeoutRef.current === null) {
// onNodesChange(changes);
// onNodesChangeTimeoutRef.current = setTimeout(() => {
// onNodesChangeTimeoutRef.current = null;
// }, 0);
// } else {
// // if we have an update in this render cycle already, then to
// // prevent max recursion errors, defer the update to next render
// // cycle
// nextTick().then(() => {
// onNodesChange(changes);
// });
// }
}}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
import { HelpTooltip } from "@/components/HelpTooltip";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import { useState } from "react";
import { Handle, NodeProps, Position } from "@xyflow/react";
import { helpTooltips } from "../../helpContent";
import { type SendEmailNode } from "./types";
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
@@ -13,9 +12,9 @@ import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
const { updateNodeData } = useReactFlow();
const { editable, label } = data;
const { blockLabel: urlBlockLabel } = useParams();
const { data: workflowRun } = useWorkflowRunQuery();
@@ -25,22 +24,8 @@ function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
urlBlockLabel !== undefined && urlBlockLabel === label;
const thisBlockIsPlaying =
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 update = useUpdate<SendEmailNode["data"]>({ id, editable });
return (
<div>
@@ -86,9 +71,9 @@ function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
<WorkflowBlockInput
nodeId={id}
onChange={(value) => {
handleChange("recipients", value);
update({ recipients: value });
}}
value={inputs.recipients}
value={data.recipients}
placeholder="example@gmail.com, example2@gmail.com..."
className="nopan text-xs"
/>
@@ -99,9 +84,9 @@ function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
<WorkflowBlockInput
nodeId={id}
onChange={(value) => {
handleChange("subject", value);
update({ subject: value });
}}
value={inputs.subject}
value={data.subject}
placeholder="What is the gist?"
className="nopan text-xs"
/>
@@ -111,9 +96,9 @@ function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
<WorkflowBlockInputTextarea
nodeId={id}
onChange={(value) => {
handleChange("body", value);
update({ body: value });
}}
value={inputs.body}
value={data.body}
placeholder="What would you like to say?"
className="nopan text-xs"
/>
@@ -128,9 +113,9 @@ function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
</div>
<WorkflowBlockInput
nodeId={id}
value={inputs.fileAttachments}
value={data.fileAttachments}
onChange={(value) => {
handleChange("fileAttachments", value);
update({ fileAttachments: value });
}}
disabled
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 type { StartNode } from "./types";
import {
@@ -16,16 +15,13 @@ import {
} from "@/components/ui/select";
import { useEffect, useState } from "react";
import { ProxyLocation } from "@/api/types";
import { useQuery } from "@tanstack/react-query";
import { Label } from "@/components/ui/label";
import { HelpTooltip } from "@/components/HelpTooltip";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
import { Input } from "@/components/ui/input";
import { ProxySelector } from "@/components/ProxySelector";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { ModelsResponse } from "@/api/types";
import { ModelSelector } from "@/components/ModelSelector";
import { WorkflowModel } from "@/routes/workflows/types/workflowTypes";
import { MAX_SCREENSHOT_SCROLLS_DEFAULT } from "../Taskv2Node/types";
@@ -41,78 +37,58 @@ import { Flippable } from "@/components/Flippable";
import { useRerender } from "@/hooks/useRerender";
import { useBlockScriptStore } from "@/store/BlockScriptStore";
import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
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>) {
const workflowSettingsStore = useWorkflowSettingsStore();
const credentialGetter = useCredentialGetter();
const { updateNodeData } = 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 blockScriptStore = useBlockScriptStore();
const script = blockScriptStore.scripts.__start_block__;
const rerender = useRerender({ prefix: "accordion" });
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(() => {
setFacing(data.showCode ? "back" : "front");
}, [data.showCode]);
useEffect(() => {
workflowSettingsStore.setWorkflowSettings(inputs);
workflowSettingsStore.setWorkflowSettings(makeStartSettings(data));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputs]);
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 });
}
}, [data]);
function nodeIsFlippable(node: Node) {
return (
@@ -183,9 +159,9 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<div className="space-y-2">
<ModelSelector
className="nopan w-52 text-xs"
value={inputs.model}
value={data.model}
onChange={(value) => {
handleChange("model", value);
update({ model: value });
}}
/>
</div>
@@ -195,13 +171,12 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<HelpTooltip content="The URL of a webhook endpoint to send the workflow results" />
</div>
<Input
value={inputs.webhookCallbackUrl}
value={data.webhookCallbackUrl}
placeholder="https://"
onChange={(event) => {
handleChange(
"webhookCallbackUrl",
event.target.value,
);
update({
webhookCallbackUrl: event.target.value,
});
}}
/>
</div>
@@ -211,9 +186,9 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<HelpTooltip content="Route Skyvern through one of our available proxies." />
</div>
<ProxySelector
value={inputs.proxyLocation}
value={data.proxyLocation}
onChange={(value) => {
handleChange("proxyLocation", value);
update({ proxyLocation: value });
}}
/>
</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'." />
</div>
<Select
value={inputs.runWith ?? "agent"}
value={data.runWith ?? "agent"}
onValueChange={(value) => {
handleChange("runWith", value);
update({ runWith: value });
}}
>
<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." />
<Switch
className="ml-auto"
checked={inputs.aiFallback}
checked={data.aiFallback}
onCheckedChange={(value) => {
handleChange("aiFallback", value);
update({ aiFallback: value });
}}
/>
</div>
@@ -263,9 +238,9 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
nodeId={id}
onChange={(value) => {
const v = value.length ? value : null;
handleChange("scriptCacheKey", v);
update({ scriptCacheKey: v });
}}
value={inputs.scriptCacheKey ?? ""}
value={data.scriptCacheKey ?? ""}
placeholder={placeholders["scripts"]["scriptKey"]}
className="nopan text-xs"
/>
@@ -280,14 +255,14 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<HelpTooltip content="Run the workflow in a sequential order" />
<Switch
className="ml-auto"
checked={inputs.runSequentially}
checked={data.runSequentially}
onCheckedChange={(value) => {
handleChange("runSequentially", value);
update({ runSequentially: value });
}}
/>
</div>
</div>
{inputs.runSequentially && (
{data.runSequentially && (
<div className="flex flex-col gap-4 rounded-md bg-slate-elevation4 p-4 pl-4">
<div className="space-y-2">
<div className="flex gap-2">
@@ -298,9 +273,9 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
nodeId={id}
onChange={(value) => {
const v = value.length ? value : null;
handleChange("sequentialKey", v);
update({ sequentialKey: v });
}}
value={inputs.sequentialKey ?? ""}
value={data.sequentialKey ?? ""}
placeholder={placeholders["sequentialKey"]}
className="nopan text-xs"
/>
@@ -314,9 +289,9 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<HelpTooltip content="Persist session information across workflow runs" />
<Switch
className="ml-auto"
checked={inputs.persistBrowserSession}
checked={data.persistBrowserSession}
onCheckedChange={(value) => {
handleChange("persistBrowserSession", value);
update({ persistBrowserSession: value });
}}
/>
</div>
@@ -327,10 +302,28 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<HelpTooltip content="Specify some self-defined HTTP requests headers" />
</div>
<KeyValueInput
value={inputs.extraHttpHeaders ?? null}
onChange={(val) =>
handleChange("extraHttpHeaders", val || "{}")
value={
data.extraHttpHeaders &&
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"
/>
</div>
@@ -342,7 +335,7 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
/>
</div>
<Input
value={inputs.maxScreenshotScrolls ?? ""}
value={data.maxScreenshotScrolls ?? ""}
placeholder={`Default: ${MAX_SCREENSHOT_SCROLLS_DEFAULT}`}
onChange={(event) => {
const value =
@@ -350,7 +343,7 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
? null
: Number(event.target.value);
handleChange("maxScreenshotScrolls", value);
update({ maxScreenshotScrolls: value });
}}
/>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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