Conditional Block (#4224)
This commit is contained in:
@@ -4,7 +4,10 @@ import { ReactFlowProvider } from "@xyflow/react";
|
||||
|
||||
import { useWorkflowQuery } from "../hooks/useWorkflowQuery";
|
||||
import { WorkflowSettings } from "../types/workflowTypes";
|
||||
import { getElements } from "@/routes/workflows/editor/workflowEditorUtils";
|
||||
import {
|
||||
getElements,
|
||||
upgradeWorkflowBlocksV1toV2,
|
||||
} from "@/routes/workflows/editor/workflowEditorUtils";
|
||||
import { getInitialParameters } from "@/routes/workflows/editor/utils";
|
||||
import { Workspace } from "@/routes/workflows/editor/Workspace";
|
||||
import { useDebugSessionBlockOutputsQuery } from "../hooks/useDebugSessionBlockOutputsQuery";
|
||||
@@ -52,6 +55,13 @@ function Debugger() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Auto-upgrade v1 workflows to v2 by assigning sequential next_block_label values
|
||||
const workflowVersion = workflow.workflow_definition.version ?? 1;
|
||||
const blocksToRender =
|
||||
workflowVersion < 2
|
||||
? upgradeWorkflowBlocksV1toV2(workflow.workflow_definition.blocks)
|
||||
: workflow.workflow_definition.blocks;
|
||||
|
||||
const settings: WorkflowSettings = {
|
||||
persistBrowserSession: workflow.persist_browser_session,
|
||||
proxyLocation: workflow.proxy_location,
|
||||
@@ -68,11 +78,7 @@ function Debugger() {
|
||||
sequentialKey: workflow.sequential_key ?? null,
|
||||
};
|
||||
|
||||
const elements = getElements(
|
||||
workflow.workflow_definition.blocks,
|
||||
settings,
|
||||
true,
|
||||
);
|
||||
const elements = getElements(blocksToRender, settings, true);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen w-full">
|
||||
|
||||
@@ -309,9 +309,14 @@ function FlowRenderer({
|
||||
const [shouldConstrainPan, setShouldConstrainPan] = useState(false);
|
||||
const flowIsConstrained = debugStore.isDebugMode;
|
||||
|
||||
// Track if this is the initial load to prevent false "unsaved changes" detection
|
||||
const isInitialLoadRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (nodesInitialized) {
|
||||
setShouldConstrainPan(true);
|
||||
// Mark initial load as complete after nodes are initialized
|
||||
isInitialLoadRef.current = false;
|
||||
}
|
||||
}, [nodesInitialized]);
|
||||
|
||||
@@ -850,7 +855,10 @@ function FlowRenderer({
|
||||
if (dimensionChanges.length > 0) {
|
||||
doLayout(tempNodes, edges);
|
||||
}
|
||||
|
||||
// Only track changes after initial load is complete
|
||||
if (
|
||||
!isInitialLoadRef.current &&
|
||||
changes.some((change) => {
|
||||
return (
|
||||
change.type === "add" ||
|
||||
|
||||
@@ -2,7 +2,10 @@ import { ReactFlowProvider } from "@xyflow/react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { useWorkflowQuery } from "../hooks/useWorkflowQuery";
|
||||
import { getElements } from "./workflowEditorUtils";
|
||||
import {
|
||||
getElements,
|
||||
upgradeWorkflowBlocksV1toV2,
|
||||
} from "./workflowEditorUtils";
|
||||
import { LogoMinimized } from "@/components/LogoMinimized";
|
||||
import { WorkflowSettings } from "../types/workflowTypes";
|
||||
import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery";
|
||||
@@ -53,6 +56,13 @@ function WorkflowEditor() {
|
||||
globalWorkflow.workflow_permanent_id === workflowPermanentId,
|
||||
);
|
||||
|
||||
// Auto-upgrade v1 workflows to v2 by assigning sequential next_block_label values
|
||||
const workflowVersion = workflow.workflow_definition.version ?? 1;
|
||||
const blocksToRender =
|
||||
workflowVersion < 2
|
||||
? upgradeWorkflowBlocksV1toV2(workflow.workflow_definition.blocks)
|
||||
: workflow.workflow_definition.blocks;
|
||||
|
||||
const settings: WorkflowSettings = {
|
||||
persistBrowserSession: workflow.persist_browser_session,
|
||||
proxyLocation: workflow.proxy_location,
|
||||
@@ -69,11 +79,7 @@ function WorkflowEditor() {
|
||||
sequentialKey: workflow.sequential_key ?? null,
|
||||
};
|
||||
|
||||
const elements = getElements(
|
||||
workflow.workflow_definition.blocks,
|
||||
settings,
|
||||
!isGlobalWorkflow,
|
||||
);
|
||||
const elements = getElements(blocksToRender, settings, !isGlobalWorkflow);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen w-full">
|
||||
|
||||
@@ -17,7 +17,12 @@ import {
|
||||
ReloadIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { useEdgesState, useNodesState, Edge } from "@xyflow/react";
|
||||
import {
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
Edge,
|
||||
} from "@xyflow/react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
@@ -57,7 +62,10 @@ import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
||||
import { DebuggerRun } from "@/routes/workflows/debugger/DebuggerRun";
|
||||
import { DebuggerRunMinimal } from "@/routes/workflows/debugger/DebuggerRunMinimal";
|
||||
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
|
||||
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
||||
import {
|
||||
BranchContext,
|
||||
useWorkflowPanelStore,
|
||||
} from "@/store/WorkflowPanelStore";
|
||||
import {
|
||||
useWorkflowHasChangesStore,
|
||||
useWorkflowSave,
|
||||
@@ -68,6 +76,7 @@ import { cn } from "@/util/utils";
|
||||
|
||||
import { FlowRenderer, type FlowRendererProps } from "./FlowRenderer";
|
||||
import { AppNode, isWorkflowBlockNode, WorkflowBlockNode } from "./nodes";
|
||||
import { ConditionalNodeData } from "./nodes/ConditionalNode/types";
|
||||
import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel";
|
||||
import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel";
|
||||
import { WorkflowCacheKeyValuesPanel } from "./panels/WorkflowCacheKeyValuesPanel";
|
||||
@@ -105,6 +114,7 @@ export type AddNodeProps = {
|
||||
next: string | null;
|
||||
parent?: string;
|
||||
connectingEdgeType: string;
|
||||
branch?: BranchContext;
|
||||
};
|
||||
|
||||
interface Dom {
|
||||
@@ -214,6 +224,7 @@ function Workspace({
|
||||
useWorkflowPanelStore();
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
const { getNodes, getEdges } = useReactFlow();
|
||||
const saveWorkflow = useWorkflowSave({ status: "published" });
|
||||
|
||||
const { data: workflowRun } = useWorkflowRunQuery();
|
||||
@@ -259,6 +270,22 @@ function Workspace({
|
||||
splitLeft: useRef<HTMLInputElement>(null),
|
||||
};
|
||||
|
||||
// Track all used labels globally (including those in saved branch states)
|
||||
// Initialize with labels from initial nodes
|
||||
const usedLabelsRef = useRef<Set<string>>(
|
||||
new Set(
|
||||
initialNodes.filter(isWorkflowBlockNode).map((node) => node.data.label),
|
||||
),
|
||||
);
|
||||
|
||||
// Sync usedLabelsRef with current nodes to handle any external changes
|
||||
useEffect(() => {
|
||||
const currentLabels = nodes
|
||||
.filter(isWorkflowBlockNode)
|
||||
.map((node) => node.data.label);
|
||||
usedLabelsRef.current = new Set(currentLabels);
|
||||
}, [nodes]);
|
||||
|
||||
const handleOnSave = async () => {
|
||||
const errors = getWorkflowErrors(nodes);
|
||||
if (errors.length > 0) {
|
||||
@@ -628,11 +655,38 @@ function Workspace({
|
||||
};
|
||||
}, [dom.splitLeft]);
|
||||
|
||||
function doLayout(nodes: Array<AppNode>, edges: Array<Edge>) {
|
||||
const layoutedElements = layout(nodes, edges);
|
||||
setNodes(layoutedElements.nodes);
|
||||
setEdges(layoutedElements.edges);
|
||||
}
|
||||
const doLayout = useCallback(
|
||||
(nodes: Array<AppNode>, edges: Array<Edge>) => {
|
||||
const layoutedElements = layout(nodes, edges);
|
||||
setNodes(layoutedElements.nodes);
|
||||
setEdges(layoutedElements.edges);
|
||||
},
|
||||
[setNodes, setEdges],
|
||||
);
|
||||
|
||||
// Listen for conditional branch changes to trigger re-layout
|
||||
useEffect(() => {
|
||||
const handleBranchChange = () => {
|
||||
// Use a small delay to ensure visibility updates have propagated
|
||||
setTimeout(() => {
|
||||
// Get the latest nodes and edges (including visibility changes)
|
||||
const currentNodes = getNodes() as Array<AppNode>;
|
||||
const currentEdges = getEdges();
|
||||
|
||||
const layoutedElements = layout(currentNodes, currentEdges);
|
||||
setNodes(layoutedElements.nodes);
|
||||
setEdges(layoutedElements.edges);
|
||||
}, 10); // Small delay to ensure visibility updates complete
|
||||
};
|
||||
|
||||
window.addEventListener("conditional-branch-changed", handleBranchChange);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"conditional-branch-changed",
|
||||
handleBranchChange,
|
||||
);
|
||||
};
|
||||
}, [getNodes, getEdges, setNodes, setEdges]);
|
||||
|
||||
function addNode({
|
||||
nodeType,
|
||||
@@ -640,21 +694,35 @@ function Workspace({
|
||||
next,
|
||||
parent,
|
||||
connectingEdgeType,
|
||||
branch,
|
||||
}: AddNodeProps) {
|
||||
const newNodes: Array<AppNode> = [];
|
||||
const newEdges: Array<Edge> = [];
|
||||
const id = nanoid();
|
||||
const existingLabels = nodes
|
||||
.filter(isWorkflowBlockNode)
|
||||
.map((node) => node.data.label);
|
||||
// Use global label tracking instead of just current nodes
|
||||
const existingLabels = Array.from(usedLabelsRef.current);
|
||||
const newLabel = generateNodeLabel(existingLabels);
|
||||
const computedParentId = parent ?? branch?.conditionalNodeId;
|
||||
const node = createNode(
|
||||
{ id, parentId: parent },
|
||||
{ id, parentId: computedParentId },
|
||||
nodeType,
|
||||
generateNodeLabel(existingLabels),
|
||||
newLabel,
|
||||
);
|
||||
// Track the new label
|
||||
usedLabelsRef.current.add(newLabel);
|
||||
|
||||
if (branch && "data" in node) {
|
||||
node.data = {
|
||||
...node.data,
|
||||
conditionalBranchId: branch.branchId,
|
||||
conditionalLabel: branch.conditionalLabel,
|
||||
conditionalNodeId: branch.conditionalNodeId,
|
||||
conditionalMergeLabel: branch.mergeLabel ?? null,
|
||||
};
|
||||
}
|
||||
newNodes.push(node);
|
||||
if (previous) {
|
||||
const newEdge = {
|
||||
const newEdge: Edge = {
|
||||
id: nanoid(),
|
||||
type: "edgeWithAddButton",
|
||||
source: previous,
|
||||
@@ -662,11 +730,17 @@ function Workspace({
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
},
|
||||
data: branch
|
||||
? {
|
||||
conditionalNodeId: branch.conditionalNodeId,
|
||||
conditionalBranchId: branch.branchId,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
newEdges.push(newEdge);
|
||||
}
|
||||
if (next) {
|
||||
const newEdge = {
|
||||
const newEdge: Edge = {
|
||||
id: nanoid(),
|
||||
type: connectingEdgeType,
|
||||
source: id,
|
||||
@@ -674,6 +748,12 @@ function Workspace({
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
},
|
||||
data: branch
|
||||
? {
|
||||
conditionalNodeId: branch.conditionalNodeId,
|
||||
conditionalBranchId: branch.branchId,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
newEdges.push(newEdge);
|
||||
}
|
||||
@@ -698,8 +778,59 @@ function Workspace({
|
||||
newEdges.push(defaultEdge(startNodeId, adderNodeId));
|
||||
}
|
||||
|
||||
if (nodeType === "conditional" && "data" in node) {
|
||||
// Conditional blocks need StartNode and NodeAdderNode as children
|
||||
const startNodeId = nanoid();
|
||||
const adderNodeId = nanoid();
|
||||
newNodes.push(
|
||||
startNode(
|
||||
startNodeId,
|
||||
{
|
||||
withWorkflowSettings: false,
|
||||
editable: true,
|
||||
label: "__start_block__",
|
||||
showCode: false,
|
||||
parentNodeType: "conditional",
|
||||
},
|
||||
id,
|
||||
),
|
||||
);
|
||||
newNodes.push(nodeAdderNode(adderNodeId, id));
|
||||
|
||||
// Create an edge for each branch (initially all branches have START → NodeAdder)
|
||||
const conditionalData = node.data as ConditionalNodeData;
|
||||
conditionalData.branches.forEach((branch) => {
|
||||
const edge: Edge = {
|
||||
id: nanoid(),
|
||||
type: "default",
|
||||
source: startNodeId,
|
||||
target: adderNodeId,
|
||||
style: { strokeWidth: 2 },
|
||||
data: {
|
||||
conditionalNodeId: id,
|
||||
conditionalBranchId: branch.id,
|
||||
},
|
||||
};
|
||||
newEdges.push(edge);
|
||||
});
|
||||
}
|
||||
|
||||
const editedEdges = previous
|
||||
? edges.filter((edge) => edge.source !== previous)
|
||||
? edges.filter((edge) => {
|
||||
// Don't remove edges from the previous node
|
||||
if (edge.source !== previous) {
|
||||
return true;
|
||||
}
|
||||
// If we're in a branch, only remove the edge for this branch
|
||||
if (branch) {
|
||||
const edgeData = edge.data as
|
||||
| { conditionalBranchId?: string }
|
||||
| undefined;
|
||||
return edgeData?.conditionalBranchId !== branch.branchId;
|
||||
}
|
||||
// Otherwise remove all edges from previous
|
||||
return false;
|
||||
})
|
||||
: edges;
|
||||
|
||||
const previousNode = nodes.find((node) => node.id === previous);
|
||||
|
||||
@@ -8,15 +8,19 @@ import {
|
||||
} from "@xyflow/react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
BranchContext,
|
||||
useWorkflowPanelStore,
|
||||
} from "@/store/WorkflowPanelStore";
|
||||
import { useProcessRecordingMutation } from "@/routes/browserSessions/hooks/useProcessRecordingMutation";
|
||||
import { useDebugStore } from "@/store/useDebugStore";
|
||||
import { useRecordedBlocksStore } from "@/store/RecordedBlocksStore";
|
||||
import { useRecordingStore } from "@/store/useRecordingStore";
|
||||
import { useSettingsStore } from "@/store/SettingsStore";
|
||||
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
||||
import { cn } from "@/util/utils";
|
||||
|
||||
import { REACT_FLOW_EDGE_Z_INDEX } from "../constants";
|
||||
import type { NodeBaseData } from "../nodes/types";
|
||||
import { WorkflowAddMenu } from "../WorkflowAddMenu";
|
||||
import { WorkflowAdderBusy } from "../WorkflowAdderBusy";
|
||||
|
||||
@@ -78,14 +82,65 @@ function EdgeWithAddButton({
|
||||
|
||||
const isDisabled = !isBusy && recordingStore.isRecording;
|
||||
|
||||
const updateWorkflowPanelState = (active: boolean) => {
|
||||
const deriveBranchContext = (): BranchContext | undefined => {
|
||||
if (
|
||||
sourceNode &&
|
||||
"data" in sourceNode &&
|
||||
(sourceNode.data as NodeBaseData).conditionalBranchId &&
|
||||
(sourceNode.data as NodeBaseData).conditionalNodeId
|
||||
) {
|
||||
const sourceData = sourceNode.data as NodeBaseData;
|
||||
return {
|
||||
conditionalNodeId: sourceData.conditionalNodeId!,
|
||||
conditionalLabel: sourceData.conditionalLabel ?? sourceData.label,
|
||||
branchId: sourceData.conditionalBranchId!,
|
||||
mergeLabel: sourceData.conditionalMergeLabel ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// If source node doesn't have branch context, check if it's inside a conditional block
|
||||
// (e.g., StartNode or NodeAdderNode inside a conditional)
|
||||
if (sourceNode?.parentId) {
|
||||
const parentNode = nodes.find((n) => n.id === sourceNode.parentId);
|
||||
if (parentNode?.type === "conditional" && "data" in parentNode) {
|
||||
const conditionalData = parentNode.data as {
|
||||
activeBranchId: string | null;
|
||||
branches: Array<{ id: string }>;
|
||||
label: string;
|
||||
mergeLabel: string | null;
|
||||
};
|
||||
const activeBranchId = conditionalData.activeBranchId;
|
||||
const activeBranch = conditionalData.branches?.find(
|
||||
(b) => b.id === activeBranchId,
|
||||
);
|
||||
|
||||
if (activeBranch) {
|
||||
return {
|
||||
conditionalNodeId: parentNode.id,
|
||||
conditionalLabel: conditionalData.label,
|
||||
branchId: activeBranch.id,
|
||||
mergeLabel: conditionalData.mergeLabel ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const updateWorkflowPanelState = (
|
||||
active: boolean,
|
||||
branchContext?: BranchContext,
|
||||
) => {
|
||||
setWorkflowPanelState({
|
||||
active,
|
||||
content: "nodeLibrary",
|
||||
data: {
|
||||
previous: source,
|
||||
next: target,
|
||||
parent: sourceNode?.parentId,
|
||||
parent: branchContext?.conditionalNodeId ?? sourceNode?.parentId,
|
||||
connectingEdgeType: "edgeWithAddButton",
|
||||
branchContext,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -94,8 +149,8 @@ function EdgeWithAddButton({
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateWorkflowPanelState(true);
|
||||
const branchContext = deriveBranchContext();
|
||||
updateWorkflowPanelState(true, branchContext);
|
||||
};
|
||||
|
||||
const onRecord = () => {
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import {
|
||||
Handle,
|
||||
NodeProps,
|
||||
Position,
|
||||
useNodes,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import {
|
||||
PlusIcon,
|
||||
ChevronDownIcon,
|
||||
DotsVerticalIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/util/utils";
|
||||
import { useUpdate } from "../../useUpdate";
|
||||
import { NodeHeader } from "../components/NodeHeader";
|
||||
import { AppNode, isWorkflowBlockNode } from "..";
|
||||
import {
|
||||
getLoopNodeWidth,
|
||||
updateNodeAndDescendantsVisibility,
|
||||
} from "../../workflowEditorUtils";
|
||||
import type { ConditionalNode } from "./types";
|
||||
import {
|
||||
ConditionalNodeData,
|
||||
createBranchCondition,
|
||||
defaultBranchCriteria,
|
||||
} from "./types";
|
||||
import type { BranchCondition } from "../../../types/workflowTypes";
|
||||
import { HelpTooltip } from "@/components/HelpTooltip";
|
||||
|
||||
function ConditionalNodeComponent({ id, data }: NodeProps<ConditionalNode>) {
|
||||
const nodes = useNodes<AppNode>();
|
||||
const { setNodes, setEdges } = useReactFlow();
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
|
||||
const update = useUpdate<ConditionalNodeData>({
|
||||
id,
|
||||
editable: data.editable,
|
||||
});
|
||||
|
||||
const children = useMemo(() => {
|
||||
return nodes.filter((node) => node.parentId === id && !node.hidden);
|
||||
}, [nodes, id]);
|
||||
|
||||
const furthestDownChild: Node | null = useMemo(() => {
|
||||
return children.reduce(
|
||||
(acc, child) => {
|
||||
if (!acc) {
|
||||
return child;
|
||||
}
|
||||
if (
|
||||
child.position.y + (child.measured?.height ?? 0) >
|
||||
acc.position.y + (acc.measured?.height ?? 0)
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
null as Node | null,
|
||||
);
|
||||
}, [children]);
|
||||
|
||||
const childrenHeightExtent = useMemo(() => {
|
||||
return (
|
||||
(furthestDownChild?.measured?.height ?? 0) +
|
||||
(furthestDownChild?.position.y ?? 0) +
|
||||
24
|
||||
);
|
||||
}, [furthestDownChild]);
|
||||
|
||||
const conditionalNodeWidth = useMemo(() => {
|
||||
return node ? getLoopNodeWidth(node, nodes) : 450;
|
||||
}, [node, nodes]);
|
||||
|
||||
const orderedBranches = useMemo(() => {
|
||||
const defaultBranch = data.branches.find((branch) => branch.is_default);
|
||||
const nonDefault = data.branches.filter((branch) => !branch.is_default);
|
||||
return defaultBranch ? [...nonDefault, defaultBranch] : nonDefault;
|
||||
}, [data.branches]);
|
||||
|
||||
const activeBranch =
|
||||
orderedBranches.find((branch) => branch.id === data.activeBranchId) ??
|
||||
orderedBranches[0] ??
|
||||
null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!data.branches.some((branch) => branch.is_default)) {
|
||||
update({
|
||||
branches: [
|
||||
...data.branches,
|
||||
createBranchCondition({ is_default: true }),
|
||||
],
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data.branches]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data.activeBranchId && orderedBranches.length > 0) {
|
||||
update({
|
||||
activeBranchId: orderedBranches[0]?.id ?? null,
|
||||
});
|
||||
}
|
||||
}, [data.activeBranchId, orderedBranches, update]);
|
||||
|
||||
// Toggle visibility of branch nodes/edges when activeBranchId changes
|
||||
useEffect(() => {
|
||||
if (!data.activeBranchId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeBranchId = data.activeBranchId;
|
||||
let updatedNodesSnapshot: Array<AppNode> = [];
|
||||
|
||||
// Toggle node visibility with cascading to descendants
|
||||
setNodes((currentNodes) => {
|
||||
let updatedNodes = currentNodes as Array<AppNode>;
|
||||
|
||||
// First pass: Update direct children of this conditional
|
||||
updatedNodes = updatedNodes.map((n) => {
|
||||
// Only affect workflow block nodes that belong to this conditional
|
||||
if (!isWorkflowBlockNode(n)) {
|
||||
return n;
|
||||
}
|
||||
if (n.data.conditionalNodeId !== id) {
|
||||
return n;
|
||||
}
|
||||
if (!n.data.conditionalBranchId) {
|
||||
return n;
|
||||
}
|
||||
|
||||
// Hide nodes that don't match the active branch
|
||||
const shouldHide = n.data.conditionalBranchId !== activeBranchId;
|
||||
return { ...n, hidden: shouldHide };
|
||||
});
|
||||
|
||||
// Second pass: Cascade visibility to all descendants of affected nodes
|
||||
const affectedNodeIds = updatedNodes
|
||||
.filter(isWorkflowBlockNode)
|
||||
.filter((n) => n.data.conditionalNodeId === id)
|
||||
.map((n) => n.id);
|
||||
|
||||
affectedNodeIds.forEach((nodeId) => {
|
||||
const node = updatedNodes.find((n) => n.id === nodeId);
|
||||
if (node) {
|
||||
updatedNodes = updateNodeAndDescendantsVisibility(
|
||||
updatedNodes,
|
||||
nodeId,
|
||||
node.hidden ?? false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
updatedNodesSnapshot = updatedNodes;
|
||||
return updatedNodes;
|
||||
});
|
||||
|
||||
// Toggle edge visibility using callback (needs updated nodes)
|
||||
setEdges((currentEdges) => {
|
||||
return currentEdges.map((edge) => {
|
||||
const edgeData = edge.data as
|
||||
| {
|
||||
conditionalNodeId?: string;
|
||||
conditionalBranchId?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
// Only affect edges that belong to this conditional and have branch metadata
|
||||
if (
|
||||
edgeData?.conditionalNodeId === id &&
|
||||
edgeData?.conditionalBranchId
|
||||
) {
|
||||
const shouldHide = edgeData.conditionalBranchId !== activeBranchId;
|
||||
return { ...edge, hidden: shouldHide };
|
||||
}
|
||||
|
||||
// Also hide edges connected to hidden nodes (cascading)
|
||||
const sourceNode = updatedNodesSnapshot.find(
|
||||
(n: AppNode) => n.id === edge.source,
|
||||
);
|
||||
const targetNode = updatedNodesSnapshot.find(
|
||||
(n: AppNode) => n.id === edge.target,
|
||||
);
|
||||
if (sourceNode?.hidden || targetNode?.hidden) {
|
||||
return { ...edge, hidden: true };
|
||||
}
|
||||
|
||||
return edge;
|
||||
});
|
||||
});
|
||||
|
||||
// Trigger layout recalculation after visibility changes
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent("conditional-branch-changed"));
|
||||
}, 0);
|
||||
}, [data.activeBranchId, id, setNodes, setEdges]);
|
||||
|
||||
const handleAddCondition = () => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
const defaultBranch = data.branches.find((branch) => branch.is_default);
|
||||
const otherBranches = data.branches.filter((branch) => !branch.is_default);
|
||||
const newBranch = createBranchCondition();
|
||||
const updatedBranches = defaultBranch
|
||||
? [...otherBranches, newBranch, defaultBranch]
|
||||
: [...otherBranches, newBranch];
|
||||
|
||||
// Find the START and NodeAdder nodes inside this conditional
|
||||
const startNode = nodes.find(
|
||||
(n) => n.type === "start" && n.parentId === id,
|
||||
);
|
||||
const adderNode = nodes.find(
|
||||
(n) => n.type === "nodeAdder" && n.parentId === id,
|
||||
);
|
||||
|
||||
// Create a START → NodeAdder edge for the new branch
|
||||
if (startNode && adderNode) {
|
||||
setEdges((currentEdges) => [
|
||||
...currentEdges,
|
||||
{
|
||||
id: `${id}-${newBranch.id}-start-adder`,
|
||||
type: "default",
|
||||
source: startNode.id,
|
||||
target: adderNode.id,
|
||||
style: { strokeWidth: 2 },
|
||||
data: {
|
||||
conditionalNodeId: id,
|
||||
conditionalBranchId: newBranch.id,
|
||||
},
|
||||
hidden: false, // This branch will be active
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
update({
|
||||
branches: updatedBranches,
|
||||
activeBranchId: newBranch.id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectBranch = (branchId: string) => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
update({ activeBranchId: branchId });
|
||||
};
|
||||
|
||||
const handleRemoveBranch = (branchId: string) => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow removing if it's the last non-default branch
|
||||
const nonDefaultBranches = data.branches.filter((b) => !b.is_default);
|
||||
if (nonDefaultBranches.length <= 1) {
|
||||
return; // Need at least one non-default branch
|
||||
}
|
||||
|
||||
// Remove nodes that belong to this branch
|
||||
setNodes((currentNodes) => {
|
||||
return (currentNodes as Array<AppNode>).filter((n) => {
|
||||
if (isWorkflowBlockNode(n) && n.data.conditionalBranchId === branchId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// Remove edges that belong to this branch
|
||||
setEdges((currentEdges) => {
|
||||
return currentEdges.filter((edge) => {
|
||||
const edgeData = edge.data as
|
||||
| { conditionalBranchId?: string }
|
||||
| undefined;
|
||||
return edgeData?.conditionalBranchId !== branchId;
|
||||
});
|
||||
});
|
||||
|
||||
// Remove the branch from the branches array
|
||||
const updatedBranches = data.branches.filter((b) => b.id !== branchId);
|
||||
|
||||
// If the deleted branch was active, switch to the first branch
|
||||
const newActiveBranchId =
|
||||
data.activeBranchId === branchId
|
||||
? updatedBranches[0]?.id ?? null
|
||||
: data.activeBranchId;
|
||||
|
||||
update({
|
||||
branches: updatedBranches,
|
||||
activeBranchId: newActiveBranchId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoveBranchUp = (branchId: string) => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nonDefaultBranches = data.branches.filter((b) => !b.is_default);
|
||||
const currentIndex = nonDefaultBranches.findIndex((b) => b.id === branchId);
|
||||
if (currentIndex <= 0) {
|
||||
return; // Already at the top or not found
|
||||
}
|
||||
|
||||
// Swap within the non-default array
|
||||
const newNonDefaultBranches = [...nonDefaultBranches];
|
||||
[
|
||||
newNonDefaultBranches[currentIndex],
|
||||
newNonDefaultBranches[currentIndex - 1],
|
||||
] = [
|
||||
newNonDefaultBranches[currentIndex - 1]!,
|
||||
newNonDefaultBranches[currentIndex]!,
|
||||
];
|
||||
|
||||
// Reconstruct the array with default branch at the end
|
||||
const defaultBranch = data.branches.find((b) => b.is_default);
|
||||
const reorderedBranches = defaultBranch
|
||||
? [...newNonDefaultBranches, defaultBranch]
|
||||
: newNonDefaultBranches;
|
||||
|
||||
update({ branches: reorderedBranches });
|
||||
};
|
||||
|
||||
const handleMoveBranchDown = (branchId: string) => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nonDefaultBranches = data.branches.filter((b) => !b.is_default);
|
||||
const currentIndex = nonDefaultBranches.findIndex((b) => b.id === branchId);
|
||||
|
||||
if (currentIndex < 0 || currentIndex >= nonDefaultBranches.length - 1) {
|
||||
return; // Already at the bottom, not found, or is default branch
|
||||
}
|
||||
|
||||
// Swap with the branch below
|
||||
const newNonDefaultBranches = [...nonDefaultBranches];
|
||||
[
|
||||
newNonDefaultBranches[currentIndex],
|
||||
newNonDefaultBranches[currentIndex + 1],
|
||||
] = [
|
||||
newNonDefaultBranches[currentIndex + 1]!,
|
||||
newNonDefaultBranches[currentIndex]!,
|
||||
];
|
||||
|
||||
// Reconstruct with default branch at the end
|
||||
const defaultBranch = data.branches.find((b) => b.is_default);
|
||||
const reorderedBranches = defaultBranch
|
||||
? [...newNonDefaultBranches, defaultBranch]
|
||||
: newNonDefaultBranches;
|
||||
|
||||
update({ branches: reorderedBranches });
|
||||
};
|
||||
|
||||
const handleExpressionChange = (expression: string) => {
|
||||
if (!activeBranch || activeBranch.is_default) {
|
||||
return;
|
||||
}
|
||||
update({
|
||||
branches: data.branches.map((branch) => {
|
||||
if (branch.id !== activeBranch.id) {
|
||||
return branch;
|
||||
}
|
||||
return {
|
||||
...branch,
|
||||
criteria: {
|
||||
...(branch.criteria ?? { ...defaultBranchCriteria }),
|
||||
expression,
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
// Convert number to Excel-style letter (A, B, C... Z, AA, AB, AC...)
|
||||
const getExcelStyleLetter = (index: number): string => {
|
||||
let result = "";
|
||||
let num = index;
|
||||
|
||||
while (num >= 0) {
|
||||
result = String.fromCharCode(65 + (num % 26)) + result;
|
||||
num = Math.floor(num / 26) - 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Generate condition label: A • If, B • Else, C • Else If, etc.
|
||||
const getConditionLabel = (branch: BranchCondition, index: number) => {
|
||||
const letter = getExcelStyleLetter(index);
|
||||
|
||||
if (branch.is_default) {
|
||||
return `${letter} • Else`;
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
return `${letter} • If`;
|
||||
}
|
||||
|
||||
return `${letter} • Else If`;
|
||||
};
|
||||
|
||||
if (!node) {
|
||||
// If the node has been removed or is not yet available, bail out gracefully.
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id={`${id}-target`}
|
||||
className="opacity-0"
|
||||
/>
|
||||
<div
|
||||
className="rounded-xl border-2 border-dashed border-slate-600 p-2"
|
||||
style={{
|
||||
width: conditionalNodeWidth,
|
||||
height: childrenHeightExtent,
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4 transition-all",
|
||||
data.comparisonColor,
|
||||
)}
|
||||
>
|
||||
<NodeHeader
|
||||
blockLabel={data.label}
|
||||
editable={data.editable}
|
||||
nodeId={id}
|
||||
totpIdentifier={null}
|
||||
totpUrl={null}
|
||||
type="conditional"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const MAX_VISIBLE_TABS = 3;
|
||||
const totalBranches = orderedBranches.length;
|
||||
const nonDefaultBranches = data.branches.filter(
|
||||
(b) => !b.is_default,
|
||||
);
|
||||
|
||||
// Determine which branches to show in the 3 visible slots
|
||||
let visibleBranches: Array<BranchCondition>;
|
||||
let overflowBranches: Array<BranchCondition> = [];
|
||||
|
||||
if (totalBranches <= MAX_VISIBLE_TABS) {
|
||||
// Show all branches if 3 or fewer
|
||||
visibleBranches = orderedBranches;
|
||||
} else {
|
||||
// Show first 2 + dynamic 3rd slot
|
||||
const first2 = orderedBranches.slice(0, 2);
|
||||
const activeBranchIndex = orderedBranches.findIndex(
|
||||
(b) => b.id === activeBranch?.id,
|
||||
);
|
||||
|
||||
if (activeBranchIndex >= 2) {
|
||||
// Active branch is 3rd or beyond, show it in 3rd slot
|
||||
visibleBranches = [
|
||||
...first2,
|
||||
orderedBranches[activeBranchIndex]!,
|
||||
];
|
||||
// Overflow = all branches except the 3 visible ones
|
||||
overflowBranches = orderedBranches.filter(
|
||||
(_, i) => i >= 2 && i !== activeBranchIndex,
|
||||
);
|
||||
} else {
|
||||
// Active branch is in first 2, show 3rd branch normally
|
||||
visibleBranches = orderedBranches.slice(
|
||||
0,
|
||||
MAX_VISIBLE_TABS,
|
||||
);
|
||||
overflowBranches =
|
||||
orderedBranches.slice(MAX_VISIBLE_TABS);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleBranches.map((branch) => {
|
||||
const index = orderedBranches.findIndex(
|
||||
(b) => b.id === branch.id,
|
||||
);
|
||||
const canDelete =
|
||||
data.editable &&
|
||||
!branch.is_default &&
|
||||
nonDefaultBranches.length > 1;
|
||||
|
||||
const canReorder = !branch.is_default;
|
||||
const branchIndexInNonDefault =
|
||||
nonDefaultBranches.findIndex(
|
||||
(b) => b.id === branch.id,
|
||||
);
|
||||
const canMoveUp = branchIndexInNonDefault > 0;
|
||||
const canMoveDown =
|
||||
branchIndexInNonDefault >= 0 &&
|
||||
branchIndexInNonDefault <
|
||||
nonDefaultBranches.length - 1;
|
||||
|
||||
const showMenu = canReorder || canDelete;
|
||||
|
||||
return (
|
||||
<div key={branch.id} className="relative flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-auto rounded-full border p-0 text-xs font-normal transition-colors hover:bg-transparent",
|
||||
showMenu ? "px-3 py-1 pr-7" : "px-3 py-1",
|
||||
{
|
||||
"border-slate-50 bg-slate-50 text-slate-950 hover:bg-slate-50 hover:text-slate-950":
|
||||
branch.id === activeBranch?.id,
|
||||
"border-transparent bg-slate-elevation5 text-slate-300 hover:bg-slate-elevation4 hover:text-slate-300":
|
||||
branch.id !== activeBranch?.id,
|
||||
},
|
||||
)}
|
||||
onClick={() => handleSelectBranch(branch.id)}
|
||||
disabled={!data.editable}
|
||||
>
|
||||
{getConditionLabel(branch, index)}
|
||||
</Button>
|
||||
{showMenu && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"absolute right-1 top-1/2 size-auto -translate-y-1/2 rounded-full p-0.5",
|
||||
{
|
||||
"text-slate-950 hover:bg-slate-300 hover:text-slate-950":
|
||||
branch.id === activeBranch?.id,
|
||||
"text-slate-300 hover:bg-slate-600 hover:text-slate-300":
|
||||
branch.id !== activeBranch?.id,
|
||||
},
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Branch options"
|
||||
>
|
||||
<DotsVerticalIcon className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canReorder && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
disabled={!canMoveUp}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMoveBranchUp(branch.id);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Move Up
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!canMoveDown}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMoveBranchDown(branch.id);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Move Down
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{canReorder && canDelete && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveBranch(branch.id);
|
||||
}}
|
||||
className="cursor-pointer text-red-400 focus:text-red-400"
|
||||
>
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{overflowBranches.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto gap-1 rounded-full border border-transparent bg-slate-elevation5 p-0 px-3 py-1 text-xs font-normal text-slate-300 transition-colors hover:bg-slate-elevation4 hover:text-slate-300"
|
||||
disabled={!data.editable}
|
||||
>
|
||||
{overflowBranches.length} More
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{overflowBranches.map((branch) => {
|
||||
const index = orderedBranches.findIndex(
|
||||
(b) => b.id === branch.id,
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={branch.id}
|
||||
onClick={() =>
|
||||
handleSelectBranch(branch.id)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{getConditionLabel(branch, index)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Add new condition button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddCondition}
|
||||
disabled={!data.editable}
|
||||
className="size-7 rounded-full border border-transparent bg-slate-elevation5 p-0 text-slate-300 hover:bg-slate-elevation4 hover:text-slate-300"
|
||||
title="Add new condition"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{activeBranch && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-xs text-slate-300">
|
||||
{activeBranch.is_default ? "Else branch" : "Expression"}
|
||||
</Label>
|
||||
{!activeBranch.is_default && (
|
||||
<HelpTooltip
|
||||
content={`Jinja: {{ y > 100 }}
|
||||
Natural language: y is greater than 100`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
value={
|
||||
activeBranch.is_default
|
||||
? "Executed when no other condition matches"
|
||||
: activeBranch.criteria?.expression ?? ""
|
||||
}
|
||||
disabled={!data.editable || activeBranch.is_default}
|
||||
onChange={(event) => {
|
||||
handleExpressionChange(event.target.value);
|
||||
}}
|
||||
placeholder="Enter condition to evaluate (Jinja or natural language)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id={`${id}-source`}
|
||||
className="opacity-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ConditionalNodeComponent as ConditionalNode };
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Node } from "@xyflow/react";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import {
|
||||
BranchCondition,
|
||||
BranchCriteria,
|
||||
BranchCriteriaTypes,
|
||||
} from "@/routes/workflows/types/workflowTypes";
|
||||
import { NodeBaseData } from "../types";
|
||||
|
||||
export type ConditionalNodeData = NodeBaseData & {
|
||||
branches: Array<BranchCondition>;
|
||||
activeBranchId: string | null;
|
||||
mergeLabel: string | null;
|
||||
};
|
||||
|
||||
export type ConditionalNode = Node<ConditionalNodeData, "conditional">;
|
||||
|
||||
export const defaultBranchCriteria: BranchCriteria = {
|
||||
criteria_type: BranchCriteriaTypes.Jinja2Template,
|
||||
expression: "",
|
||||
description: null,
|
||||
};
|
||||
|
||||
export function createBranchCondition(
|
||||
overrides: Partial<BranchCondition> = {},
|
||||
): BranchCondition {
|
||||
return {
|
||||
id: overrides.id ?? nanoid(),
|
||||
criteria:
|
||||
overrides.is_default ?? false
|
||||
? null
|
||||
: overrides.criteria
|
||||
? {
|
||||
...overrides.criteria,
|
||||
}
|
||||
: { ...defaultBranchCriteria },
|
||||
next_block_label: overrides.next_block_label ?? null,
|
||||
description: overrides.description ?? null,
|
||||
is_default: overrides.is_default ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
const initialBranches: Array<BranchCondition> = [
|
||||
createBranchCondition(),
|
||||
createBranchCondition({ is_default: true }),
|
||||
];
|
||||
|
||||
export const conditionalNodeDefaultData: ConditionalNodeData = {
|
||||
debuggable: true,
|
||||
editable: true,
|
||||
label: "",
|
||||
continueOnFailure: false,
|
||||
model: null,
|
||||
showCode: false,
|
||||
branches: initialBranches,
|
||||
activeBranchId: initialBranches[0]!.id,
|
||||
mergeLabel: null,
|
||||
};
|
||||
|
||||
export function isConditionalNode(node: Node): node is ConditionalNode {
|
||||
return node.type === "conditional";
|
||||
}
|
||||
|
||||
export function cloneBranchConditions(
|
||||
branches: Array<BranchCondition>,
|
||||
): Array<BranchCondition> {
|
||||
return branches.map((branch) =>
|
||||
createBranchCondition({
|
||||
id: branch.id,
|
||||
criteria: branch.criteria,
|
||||
next_block_label: branch.next_block_label,
|
||||
description: branch.description,
|
||||
is_default: branch.is_default,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function createDefaultBranchConditions(): Array<BranchCondition> {
|
||||
return [createBranchCondition(), createBranchCondition({ is_default: true })];
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { PlusIcon } from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position, useEdges } from "@xyflow/react";
|
||||
import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react";
|
||||
|
||||
import { useProcessRecordingMutation } from "@/routes/browserSessions/hooks/useProcessRecordingMutation";
|
||||
import { useDebugStore } from "@/store/useDebugStore";
|
||||
import {
|
||||
BranchContext,
|
||||
useWorkflowPanelStore,
|
||||
} from "@/store/WorkflowPanelStore";
|
||||
import type { NodeBaseData } from "../types";
|
||||
import { useRecordedBlocksStore } from "@/store/RecordedBlocksStore";
|
||||
import { useRecordingStore } from "@/store/useRecordingStore";
|
||||
import { useSettingsStore } from "@/store/SettingsStore";
|
||||
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
||||
import { cn } from "@/util/utils";
|
||||
|
||||
import type { NodeAdderNode } from "./types";
|
||||
@@ -15,6 +19,7 @@ import { WorkflowAdderBusy } from "../../WorkflowAdderBusy";
|
||||
|
||||
function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
|
||||
const edges = useEdges();
|
||||
const nodes = useNodes();
|
||||
const debugStore = useDebugStore();
|
||||
const recordingStore = useRecordingStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -26,13 +31,89 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
|
||||
(state) => state.setRecordedBlocks,
|
||||
);
|
||||
|
||||
const previous = edges.find((edge) => edge.target === id)?.source ?? null;
|
||||
const deriveBranchContext = (previousNodeId: string | undefined) => {
|
||||
const previousNode = nodes.find((node) => node.id === previousNodeId);
|
||||
if (
|
||||
previousNode &&
|
||||
"data" in previousNode &&
|
||||
(previousNode.data as NodeBaseData).conditionalBranchId &&
|
||||
(previousNode.data as NodeBaseData).conditionalNodeId
|
||||
) {
|
||||
const prevData = previousNode.data as NodeBaseData;
|
||||
return {
|
||||
conditionalNodeId: prevData.conditionalNodeId!,
|
||||
conditionalLabel: prevData.conditionalLabel ?? prevData.label,
|
||||
branchId: prevData.conditionalBranchId!,
|
||||
mergeLabel: prevData.conditionalMergeLabel ?? null,
|
||||
} satisfies BranchContext;
|
||||
}
|
||||
|
||||
// If previous node doesn't have branch context, check if this NodeAdderNode is inside a conditional block
|
||||
if (parentId) {
|
||||
const parentNode = nodes.find((n) => n.id === parentId);
|
||||
if (parentNode?.type === "conditional" && "data" in parentNode) {
|
||||
const conditionalData = parentNode.data as {
|
||||
activeBranchId: string | null;
|
||||
branches: Array<{ id: string }>;
|
||||
label: string;
|
||||
mergeLabel: string | null;
|
||||
};
|
||||
const activeBranchId = conditionalData.activeBranchId;
|
||||
const activeBranch = conditionalData.branches?.find(
|
||||
(b) => b.id === activeBranchId,
|
||||
);
|
||||
|
||||
if (activeBranch) {
|
||||
return {
|
||||
conditionalNodeId: parentNode.id,
|
||||
conditionalLabel: conditionalData.label,
|
||||
branchId: activeBranch.id,
|
||||
mergeLabel: conditionalData.mergeLabel ?? null,
|
||||
} satisfies BranchContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Find the edge that targets this NodeAdder
|
||||
// If inside a conditional, find the edge for the active branch
|
||||
const previous = (() => {
|
||||
const incomingEdges = edges.filter((edge) => edge.target === id);
|
||||
|
||||
// If inside a conditional, filter by active branch
|
||||
if (parentId) {
|
||||
const parentNode = nodes.find((n) => n.id === parentId);
|
||||
if (parentNode?.type === "conditional" && "data" in parentNode) {
|
||||
const conditionalData = parentNode.data as {
|
||||
activeBranchId: string | null;
|
||||
};
|
||||
const activeBranchId = conditionalData.activeBranchId;
|
||||
|
||||
// Find edge for active branch
|
||||
const branchEdge = incomingEdges.find((edge) => {
|
||||
const edgeData = edge.data as
|
||||
| { conditionalBranchId?: string }
|
||||
| undefined;
|
||||
return edgeData?.conditionalBranchId === activeBranchId;
|
||||
});
|
||||
|
||||
if (branchEdge) {
|
||||
return branchEdge.source;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise return the first edge
|
||||
return incomingEdges[0]?.source;
|
||||
})();
|
||||
|
||||
const processRecordingMutation = useProcessRecordingMutation({
|
||||
browserSessionId: settingsStore.browserSessionId,
|
||||
onSuccess: (result) => {
|
||||
setRecordedBlocks(result, {
|
||||
previous,
|
||||
previous: previous ?? null,
|
||||
next: id,
|
||||
parent: parentId,
|
||||
connectingEdgeType: "default",
|
||||
@@ -53,17 +134,19 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
|
||||
|
||||
const isDisabled = !isBusy && recordingStore.isRecording;
|
||||
|
||||
const updateWorkflowPanelState = (active: boolean) => {
|
||||
const previous = edges.find((edge) => edge.target === id)?.source;
|
||||
|
||||
const updateWorkflowPanelState = (
|
||||
active: boolean,
|
||||
branchContext?: BranchContext,
|
||||
) => {
|
||||
setWorkflowPanelState({
|
||||
active,
|
||||
content: "nodeLibrary",
|
||||
data: {
|
||||
previous: previous ?? null,
|
||||
next: id,
|
||||
parent: parentId,
|
||||
parent: branchContext?.conditionalNodeId ?? parentId,
|
||||
connectingEdgeType: "default",
|
||||
branchContext,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -72,8 +155,8 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateWorkflowPanelState(true);
|
||||
const branchContext = deriveBranchContext(previous);
|
||||
updateWorkflowPanelState(true, branchContext);
|
||||
};
|
||||
|
||||
const onRecord = () => {
|
||||
|
||||
@@ -52,7 +52,7 @@ interface StartSettings {
|
||||
extraHttpHeaders: string | Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function StartNode({ id, data }: NodeProps<StartNode>) {
|
||||
function StartNode({ id, data, parentId }: NodeProps<StartNode>) {
|
||||
const workflowSettingsStore = useWorkflowSettingsStore();
|
||||
const reactFlowInstance = useReactFlow();
|
||||
const [facing, setFacing] = useState<"front" | "back">("front");
|
||||
@@ -63,6 +63,10 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
|
||||
const toggleScriptForNodeCallback = useToggleScriptForNodeCallback();
|
||||
const isRecording = recordingStore.isRecording;
|
||||
|
||||
const parentNode = parentId ? reactFlowInstance.getNode(parentId) : null;
|
||||
const isInsideConditional = parentNode?.type === "conditional";
|
||||
const isInsideLoop = parentNode?.type === "loop";
|
||||
|
||||
const makeStartSettings = (data: StartNode["data"]): StartSettings => {
|
||||
return {
|
||||
webhookCallbackUrl: data.withWorkflowSettings
|
||||
@@ -410,18 +414,25 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
|
||||
id="a"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<div className="w-[30rem] rounded-lg bg-slate-elevation3 px-6 py-4 text-center">
|
||||
<div className="w-[30rem] rounded-lg bg-slate-elevation4 px-6 py-4 text-center text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">
|
||||
Start
|
||||
<div className="mt-4 flex gap-3 rounded-md bg-slate-800 p-3">
|
||||
<span className="rounded bg-slate-700 p-1 text-lg">💡</span>
|
||||
<div className="space-y-1 text-left text-xs text-slate-400">
|
||||
Use{" "}
|
||||
<code className="text-white">
|
||||
{{ current_value }}
|
||||
</code>{" "}
|
||||
to get the current loop value for a given iteration.
|
||||
{isInsideLoop && (
|
||||
<div className="mt-4 flex gap-3 rounded-md bg-slate-800 p-3 normal-case tracking-normal">
|
||||
<span className="rounded bg-slate-700 p-1 text-lg">💡</span>
|
||||
<div className="space-y-1 text-left font-normal text-slate-400">
|
||||
Use{" "}
|
||||
<code className="text-white">
|
||||
{{ current_value }}
|
||||
</code>{" "}
|
||||
to get the current loop value for a given iteration.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isInsideConditional && (
|
||||
<div className="mt-4 rounded-md border border-dashed border-slate-500 p-4 text-center font-normal normal-case tracking-normal text-slate-300">
|
||||
Start adding blocks to be executed for the selected condition
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ export type OtherStartNodeData = {
|
||||
editable: boolean;
|
||||
label: "__start_block__";
|
||||
showCode: boolean;
|
||||
parentNodeType?: "loop" | "conditional";
|
||||
};
|
||||
|
||||
export type StartNodeData = WorkflowStartNodeData | OtherStartNodeData;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
UploadIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { ExtractIcon } from "@/components/icons/ExtractIcon";
|
||||
import { GitBranchIcon } from "@/components/icons/GitBranchIcon";
|
||||
import { RobotIcon } from "@/components/icons/RobotIcon";
|
||||
|
||||
type Props = {
|
||||
@@ -32,6 +33,9 @@ function WorkflowBlockIcon({ workflowBlockType, className }: Props) {
|
||||
case "code": {
|
||||
return <CodeIcon className={className} />;
|
||||
}
|
||||
case "conditional": {
|
||||
return <GitBranchIcon className={className} />;
|
||||
}
|
||||
case "download_to_s3": {
|
||||
return <DownloadIcon className={className} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { memo } from "react";
|
||||
import { CodeBlockNode as CodeBlockNodeComponent } from "./CodeBlockNode/CodeBlockNode";
|
||||
import { CodeBlockNode } from "./CodeBlockNode/types";
|
||||
import { ConditionalNode as ConditionalNodeComponent } from "./ConditionalNode/ConditionalNode";
|
||||
import type { ConditionalNode } from "./ConditionalNode/types";
|
||||
import { LoopNode as LoopNodeComponent } from "./LoopNode/LoopNode";
|
||||
import type { LoopNode } from "./LoopNode/types";
|
||||
import { SendEmailNode as SendEmailNodeComponent } from "./SendEmailNode/SendEmailNode";
|
||||
@@ -50,6 +52,7 @@ export type UtilityNode = StartNode | NodeAdderNode;
|
||||
|
||||
export type WorkflowBlockNode =
|
||||
| LoopNode
|
||||
| ConditionalNode
|
||||
| TaskNode
|
||||
| TextPromptNode
|
||||
| SendEmailNode
|
||||
@@ -83,6 +86,7 @@ export type AppNode = UtilityNode | WorkflowBlockNode;
|
||||
|
||||
export const nodeTypes = {
|
||||
loop: memo(LoopNodeComponent),
|
||||
conditional: memo(ConditionalNodeComponent),
|
||||
task: memo(TaskNodeComponent),
|
||||
textPrompt: memo(TextPromptNodeComponent),
|
||||
sendEmail: memo(SendEmailNodeComponent),
|
||||
|
||||
@@ -10,6 +10,14 @@ export type NodeBaseData = {
|
||||
model: WorkflowModel | null;
|
||||
showCode?: boolean;
|
||||
comparisonColor?: string;
|
||||
/**
|
||||
* Optional metadata used for conditional branches.
|
||||
* These values are only set on nodes that live within a conditional block.
|
||||
*/
|
||||
conditionalBranchId?: string | null;
|
||||
conditionalLabel?: string | null;
|
||||
conditionalNodeId?: string | null;
|
||||
conditionalMergeLabel?: string | null;
|
||||
};
|
||||
|
||||
export const errorMappingExampleValue = {
|
||||
@@ -38,6 +46,7 @@ export const workflowBlockTitle: {
|
||||
} = {
|
||||
action: "Browser Action",
|
||||
code: "Code",
|
||||
conditional: "Conditional",
|
||||
download_to_s3: "Download",
|
||||
extraction: "Extraction",
|
||||
file_download: "File Download",
|
||||
|
||||
@@ -11,6 +11,14 @@ import { WorkflowBlockNode } from "../nodes";
|
||||
import { WorkflowBlockIcon } from "../nodes/WorkflowBlockIcon";
|
||||
import { AddNodeProps } from "../Workspace";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useNodes } from "@xyflow/react";
|
||||
import { AppNode } from "../nodes";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const enableCodeBlock =
|
||||
import.meta.env.VITE_ENABLE_CODE_BLOCK?.toLowerCase() === "true";
|
||||
@@ -135,6 +143,17 @@ const nodeLibraryItems: Array<{
|
||||
title: "Text Prompt Block",
|
||||
description: "Process text with LLM",
|
||||
},
|
||||
{
|
||||
nodeType: "conditional",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.Conditional}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "Conditional Block",
|
||||
description: "Branch execution based on conditions",
|
||||
},
|
||||
{
|
||||
nodeType: "sendEmail",
|
||||
icon: (
|
||||
@@ -268,6 +287,7 @@ function WorkflowNodeLibraryPanel({
|
||||
onNodeClick,
|
||||
first,
|
||||
}: Props) {
|
||||
const nodes = useNodes() as Array<AppNode>;
|
||||
const workflowPanelData = useWorkflowPanelStore(
|
||||
(state) => state.workflowPanelState.data,
|
||||
);
|
||||
@@ -280,6 +300,46 @@ function WorkflowNodeLibraryPanel({
|
||||
const [search, setSearch] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Determine parent context to check if certain blocks should be disabled
|
||||
const parentNode = workflowPanelData?.parent
|
||||
? nodes.find((n) => n.id === workflowPanelData.parent)
|
||||
: null;
|
||||
const parentType = parentNode?.type;
|
||||
|
||||
// Check if a node type should be disabled based on parent context
|
||||
const isBlockDisabled = (
|
||||
nodeType: NonNullable<WorkflowBlockNode["type"]>,
|
||||
): { disabled: boolean; reason: string } => {
|
||||
// Disable conditional inside conditional
|
||||
if (nodeType === "conditional" && parentType === "conditional") {
|
||||
return {
|
||||
disabled: true,
|
||||
reason:
|
||||
"We're working on supporting nested conditionals. Soon you'll be able to use this feature!",
|
||||
};
|
||||
}
|
||||
|
||||
// Disable conditional inside loop
|
||||
if (nodeType === "conditional" && parentType === "loop") {
|
||||
return {
|
||||
disabled: true,
|
||||
reason:
|
||||
"We're working on supporting conditionals inside loops. Soon you'll be able to use this feature!",
|
||||
};
|
||||
}
|
||||
|
||||
// Disable loop inside conditional
|
||||
if (nodeType === "loop" && parentType === "conditional") {
|
||||
return {
|
||||
disabled: true,
|
||||
reason:
|
||||
"We're working on supporting loops inside conditionals. Soon you'll be able to use this feature!",
|
||||
};
|
||||
}
|
||||
|
||||
return { disabled: false, reason: "" };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Focus the input when the panel becomes active
|
||||
if (workflowPanelActive && inputRef.current) {
|
||||
@@ -374,39 +434,64 @@ function WorkflowNodeLibraryPanel({
|
||||
<ScrollAreaViewport className="h-full">
|
||||
<div className="space-y-2">
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map((item) => (
|
||||
<div
|
||||
key={item.nodeType}
|
||||
className="flex cursor-pointer items-center justify-between rounded-sm bg-slate-elevation4 p-4 hover:bg-slate-elevation5"
|
||||
onClick={() => {
|
||||
onNodeClick({
|
||||
nodeType: item.nodeType,
|
||||
next: workflowPanelData?.next ?? null,
|
||||
parent: workflowPanelData?.parent,
|
||||
previous: workflowPanelData?.previous ?? null,
|
||||
connectingEdgeType:
|
||||
workflowPanelData?.connectingEdgeType ??
|
||||
"edgeWithAddButton",
|
||||
});
|
||||
closeWorkflowPanel();
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex h-[2.75rem] w-[2.75rem] shrink-0 items-center justify-center rounded border border-slate-600">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{item.description}
|
||||
</span>
|
||||
filteredItems.map((item) => {
|
||||
const { disabled, reason } = isBlockDisabled(item.nodeType);
|
||||
const itemContent = (
|
||||
<div
|
||||
key={item.nodeType}
|
||||
className={`flex items-center justify-between rounded-sm bg-slate-elevation4 p-4 ${
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "cursor-pointer hover:bg-slate-elevation5"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
onNodeClick({
|
||||
nodeType: item.nodeType,
|
||||
next: workflowPanelData?.next ?? null,
|
||||
parent: workflowPanelData?.parent,
|
||||
previous: workflowPanelData?.previous ?? null,
|
||||
connectingEdgeType:
|
||||
workflowPanelData?.connectingEdgeType ??
|
||||
"edgeWithAddButton",
|
||||
branch: workflowPanelData?.branchContext,
|
||||
});
|
||||
closeWorkflowPanel();
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex h-[2.75rem] w-[2.75rem] shrink-0 items-center justify-center rounded border border-slate-600">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<PlusIcon className="size-6 shrink-0" />
|
||||
</div>
|
||||
<PlusIcon className="size-6 shrink-0" />
|
||||
</div>
|
||||
))
|
||||
);
|
||||
|
||||
// Wrap with tooltip if disabled
|
||||
if (disabled) {
|
||||
return (
|
||||
<TooltipProvider key={item.nodeType}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{itemContent}</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p className="max-w-xs">{reason}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return itemContent;
|
||||
})
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-slate-400">
|
||||
No results found
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,10 @@ export type WorkflowRunBlock = {
|
||||
body?: string | null;
|
||||
prompt?: string | null;
|
||||
wait_sec?: number | null;
|
||||
executed_branch_id?: string | null;
|
||||
executed_branch_expression?: string | null;
|
||||
executed_branch_result?: boolean | null;
|
||||
executed_branch_next_block?: string | null;
|
||||
created_at: string;
|
||||
modified_at: string;
|
||||
duration: number | null;
|
||||
|
||||
@@ -192,6 +192,7 @@ export type Parameter =
|
||||
export type WorkflowBlock =
|
||||
| TaskBlock
|
||||
| ForLoopBlock
|
||||
| ConditionalBlock
|
||||
| TextPromptBlock
|
||||
| CodeBlock
|
||||
| UploadToS3Block
|
||||
@@ -215,6 +216,7 @@ export type WorkflowBlock =
|
||||
export const WorkflowBlockTypes = {
|
||||
Task: "task",
|
||||
ForLoop: "for_loop",
|
||||
Conditional: "conditional",
|
||||
Code: "code",
|
||||
TextPrompt: "text_prompt",
|
||||
DownloadToS3: "download_to_s3",
|
||||
@@ -292,6 +294,32 @@ export type WorkflowBlockBase = {
|
||||
next_block_label?: string | null;
|
||||
};
|
||||
|
||||
export const BranchCriteriaTypes = {
|
||||
Jinja2Template: "jinja2_template",
|
||||
} as const;
|
||||
|
||||
export type BranchCriteriaType =
|
||||
(typeof BranchCriteriaTypes)[keyof typeof BranchCriteriaTypes];
|
||||
|
||||
export type BranchCriteria = {
|
||||
criteria_type: BranchCriteriaType;
|
||||
expression: string;
|
||||
description: string | null;
|
||||
};
|
||||
|
||||
export type BranchCondition = {
|
||||
id: string;
|
||||
criteria: BranchCriteria | null;
|
||||
next_block_label: string | null;
|
||||
description: string | null;
|
||||
is_default: boolean;
|
||||
};
|
||||
|
||||
export type ConditionalBlock = WorkflowBlockBase & {
|
||||
block_type: "conditional";
|
||||
branch_conditions: Array<BranchCondition>;
|
||||
};
|
||||
|
||||
export type TaskBlock = WorkflowBlockBase & {
|
||||
block_type: "task";
|
||||
url: string | null;
|
||||
|
||||
@@ -128,6 +128,7 @@ export type BlockYAML =
|
||||
| SendEmailBlockYAML
|
||||
| FileUrlParserBlockYAML
|
||||
| ForLoopBlockYAML
|
||||
| ConditionalBlockYAML
|
||||
| ValidationBlockYAML
|
||||
| HumanInteractionBlockYAML
|
||||
| ActionBlockYAML
|
||||
@@ -359,6 +360,25 @@ export type ForLoopBlockYAML = BlockYAMLBase & {
|
||||
complete_if_empty: boolean;
|
||||
};
|
||||
|
||||
export type BranchCriteriaYAML = {
|
||||
criteria_type: string;
|
||||
expression: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type BranchConditionYAML = {
|
||||
id: string;
|
||||
criteria: BranchCriteriaYAML | null;
|
||||
next_block_label: string | null;
|
||||
description?: string | null;
|
||||
is_default: boolean;
|
||||
};
|
||||
|
||||
export type ConditionalBlockYAML = BlockYAMLBase & {
|
||||
block_type: "conditional";
|
||||
branch_conditions: Array<BranchConditionYAML>;
|
||||
};
|
||||
|
||||
export type PDFParserBlockYAML = BlockYAMLBase & {
|
||||
block_type: "pdf_parser";
|
||||
file_url: string;
|
||||
|
||||
@@ -171,6 +171,33 @@ function WorkflowRunTimelineBlockItem({
|
||||
{block.description ? (
|
||||
<div className="text-xs text-slate-400">{block.description}</div>
|
||||
) : null}
|
||||
{block.block_type === "conditional" && block.executed_branch_id && (
|
||||
<div className="space-y-1 rounded bg-slate-elevation5 px-3 py-2 text-xs">
|
||||
{block.executed_branch_expression !== null &&
|
||||
block.executed_branch_expression !== undefined ? (
|
||||
<div className="text-slate-300">
|
||||
Condition{" "}
|
||||
<code className="rounded bg-slate-elevation3 px-1.5 py-0.5 font-mono text-slate-200">
|
||||
{block.executed_branch_expression}
|
||||
</code>{" "}
|
||||
evaluated to{" "}
|
||||
<span className="font-medium text-success">True</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-slate-300">
|
||||
No conditions matched, executing default branch
|
||||
</div>
|
||||
)}
|
||||
{block.executed_branch_next_block && (
|
||||
<div className="text-slate-400">
|
||||
→ Executing next block:{" "}
|
||||
<span className="font-medium text-slate-300">
|
||||
{block.executed_branch_next_block}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{block.block_type === "human_interaction" && (
|
||||
|
||||
Reference in New Issue
Block a user