Conditional Block (#4224)
This commit is contained in:
26
skyvern-frontend/src/components/icons/GitBranchIcon.tsx
Normal file
26
skyvern-frontend/src/components/icons/GitBranchIcon.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function GitBranchIcon({ className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M3 3V13.2C3 14.8802 3 15.7202 3.32698 16.362C3.6146 16.9265 4.07354 17.3854 4.63803 17.673C5.27976 18 6.11984 18 7.8 18H15M15 18C15 19.6569 16.3431 21 18 21C19.6569 21 21 19.6569 21 18C21 16.3431 19.6569 15 18 15C16.3431 15 15 16.3431 15 18ZM3 8L15 8M15 8C15 9.65686 16.3431 11 18 11C19.6569 11 21 9.65685 21 8C21 6.34315 19.6569 5 18 5C16.3431 5 15 6.34315 15 8Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export { GitBranchIcon };
|
||||
@@ -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" && (
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { create } from "zustand";
|
||||
import { WorkflowVersion } from "@/routes/workflows/hooks/useWorkflowVersionsQuery";
|
||||
|
||||
export type BranchContext = {
|
||||
conditionalNodeId: string;
|
||||
conditionalLabel: string;
|
||||
branchId: string;
|
||||
mergeLabel: string | null;
|
||||
};
|
||||
|
||||
type WorkflowPanelState = {
|
||||
active: boolean;
|
||||
content:
|
||||
@@ -15,6 +22,7 @@ type WorkflowPanelState = {
|
||||
parent?: string;
|
||||
connectingEdgeType?: string;
|
||||
disableLoop?: boolean;
|
||||
branchContext?: BranchContext;
|
||||
// For comparison panel
|
||||
version1?: WorkflowVersion;
|
||||
version2?: WorkflowVersion;
|
||||
|
||||
Reference in New Issue
Block a user