feat: add validation for default condition branch position and enhance branch connection handling
This commit is contained in:
@@ -310,6 +310,9 @@ func (v *definitionValidator) validateConditions() {
|
|||||||
if branch.Condition != nil {
|
if branch.Condition != nil {
|
||||||
v.addError(branchField+".condition", "default condition branch must not define a condition")
|
v.addError(branchField+".condition", "default condition branch must not define a condition")
|
||||||
}
|
}
|
||||||
|
if branchIndex != len(config.Branches)-1 {
|
||||||
|
v.addError(branchField, "default condition branch must be last")
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
v.validateCondition(branchField+".condition", strings.TrimSpace(node.ID), branch.Condition)
|
v.validateCondition(branchField+".condition", strings.TrimSpace(node.ID), branch.Condition)
|
||||||
|
|||||||
@@ -317,6 +317,29 @@ func TestValidateDefinitionRejectsInvalidConditionEnumValue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateDefinitionRejectsConditionDefaultBranchBeforeLast(t *testing.T) {
|
||||||
|
def := conditionDefinition()
|
||||||
|
var config dsl.ConditionConfig
|
||||||
|
if err := json.Unmarshal(def.Nodes[1].Config, &config); err != nil {
|
||||||
|
t.Fatalf("unmarshal condition config: %v", err)
|
||||||
|
}
|
||||||
|
config.Branches[0], config.Branches[1] = config.Branches[1], config.Branches[0]
|
||||||
|
raw, err := json.Marshal(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal condition config: %v", err)
|
||||||
|
}
|
||||||
|
def.Nodes[1].Config = raw
|
||||||
|
|
||||||
|
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||||
|
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatalf("expected default branch before last to be invalid")
|
||||||
|
}
|
||||||
|
if !hasValidationMessage(result, "default condition branch must be last") {
|
||||||
|
t.Fatalf("expected default branch order error, got %#v", result.Errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func minimalDefinition() dsl.Definition {
|
func minimalDefinition() dsl.Definition {
|
||||||
return dsl.Definition{
|
return dsl.Definition{
|
||||||
SchemaVersion: 1,
|
SchemaVersion: 1,
|
||||||
|
|||||||
@@ -34,24 +34,17 @@ export type WorkflowBranchSummary = {
|
|||||||
isDefault: boolean
|
isDefault: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkflowBranchTargetOption = {
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NodeConfigPanel({
|
export function NodeConfigPanel({
|
||||||
node,
|
node,
|
||||||
nodeSpec,
|
nodeSpec,
|
||||||
availableVariables,
|
availableVariables,
|
||||||
branchSummaries = [],
|
branchSummaries = [],
|
||||||
branchTargetOptions = [],
|
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
node: Node<WorkflowNodeData> | null
|
node: Node<WorkflowNodeData> | null
|
||||||
nodeSpec?: WorkflowNodeSpec
|
nodeSpec?: WorkflowNodeSpec
|
||||||
availableVariables: WorkflowVariableRef[]
|
availableVariables: WorkflowVariableRef[]
|
||||||
branchSummaries?: WorkflowBranchSummary[]
|
branchSummaries?: WorkflowBranchSummary[]
|
||||||
branchTargetOptions?: WorkflowBranchTargetOption[]
|
|
||||||
onChange: (nodeId: string, data: WorkflowNodeData) => void
|
onChange: (nodeId: string, data: WorkflowNodeData) => void
|
||||||
}) {
|
}) {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
@@ -69,7 +62,6 @@ export function NodeConfigPanel({
|
|||||||
nodeSpec={nodeSpec}
|
nodeSpec={nodeSpec}
|
||||||
availableVariables={availableVariables}
|
availableVariables={availableVariables}
|
||||||
branchSummaries={branchSummaries}
|
branchSummaries={branchSummaries}
|
||||||
branchTargetOptions={branchTargetOptions}
|
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -80,14 +72,12 @@ function NodeConfigForm({
|
|||||||
nodeSpec,
|
nodeSpec,
|
||||||
availableVariables,
|
availableVariables,
|
||||||
branchSummaries,
|
branchSummaries,
|
||||||
branchTargetOptions,
|
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
node: Node<WorkflowNodeData>
|
node: Node<WorkflowNodeData>
|
||||||
nodeSpec?: WorkflowNodeSpec
|
nodeSpec?: WorkflowNodeSpec
|
||||||
availableVariables: WorkflowVariableRef[]
|
availableVariables: WorkflowVariableRef[]
|
||||||
branchSummaries: WorkflowBranchSummary[]
|
branchSummaries: WorkflowBranchSummary[]
|
||||||
branchTargetOptions: WorkflowBranchTargetOption[]
|
|
||||||
onChange: (nodeId: string, data: WorkflowNodeData) => void
|
onChange: (nodeId: string, data: WorkflowNodeData) => void
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(node.data.name ?? "")
|
const [name, setName] = useState(node.data.name ?? "")
|
||||||
@@ -145,7 +135,6 @@ function NodeConfigForm({
|
|||||||
<ConditionNodePanel
|
<ConditionNodePanel
|
||||||
branches={node.data.config?.branches ?? []}
|
branches={node.data.config?.branches ?? []}
|
||||||
branchSummaries={branchSummaries}
|
branchSummaries={branchSummaries}
|
||||||
branchTargetOptions={branchTargetOptions}
|
|
||||||
availableVariables={availableVariables}
|
availableVariables={availableVariables}
|
||||||
outputSchema={outputSchema}
|
outputSchema={outputSchema}
|
||||||
onChange={(branches) => commitChange({ config: { ...(node.data.config ?? {}), branches } })}
|
onChange={(branches) => commitChange({ config: { ...(node.data.config ?? {}), branches } })}
|
||||||
@@ -237,14 +226,12 @@ function NodeConfigForm({
|
|||||||
function ConditionNodePanel({
|
function ConditionNodePanel({
|
||||||
branches,
|
branches,
|
||||||
branchSummaries,
|
branchSummaries,
|
||||||
branchTargetOptions,
|
|
||||||
availableVariables,
|
availableVariables,
|
||||||
outputSchema,
|
outputSchema,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
branches: WorkflowConditionBranch[]
|
branches: WorkflowConditionBranch[]
|
||||||
branchSummaries: WorkflowBranchSummary[]
|
branchSummaries: WorkflowBranchSummary[]
|
||||||
branchTargetOptions: WorkflowBranchTargetOption[]
|
|
||||||
availableVariables: WorkflowVariableRef[]
|
availableVariables: WorkflowVariableRef[]
|
||||||
outputSchema: WorkflowVariableSpec[]
|
outputSchema: WorkflowVariableSpec[]
|
||||||
onChange: (branches: WorkflowConditionBranch[]) => void
|
onChange: (branches: WorkflowConditionBranch[]) => void
|
||||||
@@ -257,25 +244,49 @@ function ConditionNodePanel({
|
|||||||
}
|
}
|
||||||
const addBranch = () => {
|
const addBranch = () => {
|
||||||
const index = branches.length + 1
|
const index = branches.length + 1
|
||||||
|
const nextBranch = {
|
||||||
|
id: `branch_${index}`,
|
||||||
|
name: `分支 ${index}`,
|
||||||
|
targetNodeId: "",
|
||||||
|
condition: { operator: "eq" },
|
||||||
|
}
|
||||||
|
const defaultIndex = branches.findIndex((branch) => branch.default)
|
||||||
|
if (defaultIndex >= 0) {
|
||||||
|
onChange([
|
||||||
|
...branches.slice(0, defaultIndex),
|
||||||
|
nextBranch,
|
||||||
|
...branches.slice(defaultIndex),
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
onChange([
|
onChange([
|
||||||
...branches,
|
...branches,
|
||||||
|
nextBranch,
|
||||||
{
|
{
|
||||||
id: `branch_${index}`,
|
id: "default",
|
||||||
name: `分支 ${index}`,
|
name: "其他情况",
|
||||||
targetNodeId: branchTargetOptions[0]?.value ?? "",
|
targetNodeId: "",
|
||||||
condition: { operator: "eq" },
|
default: true,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
const deleteBranch = (branchId: string) => {
|
const deleteBranch = (branchId: string) => {
|
||||||
onChange(branches.filter((branch) => branch.id !== branchId))
|
onChange(branches.filter((branch) => branch.id !== branchId || branch.default))
|
||||||
}
|
}
|
||||||
const markDefault = (branchId: string) => {
|
const moveBranch = (branchId: string, direction: -1 | 1) => {
|
||||||
onChange(branches.map((branch) => normalizeBranch({
|
const index = branches.findIndex((branch) => branch.id === branchId)
|
||||||
...branch,
|
if (index < 0 || branches[index]?.default) {
|
||||||
default: branch.id === branchId,
|
return
|
||||||
condition: branch.id === branchId ? undefined : branch.condition ?? { operator: "eq" },
|
}
|
||||||
})))
|
const nextIndex = index + direction
|
||||||
|
if (nextIndex < 0 || nextIndex >= branches.length || branches[nextIndex]?.default) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = [...branches]
|
||||||
|
const current = next[index]
|
||||||
|
next[index] = next[nextIndex]
|
||||||
|
next[nextIndex] = current
|
||||||
|
onChange(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -317,14 +328,11 @@ function ConditionNodePanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">目标节点</Label>
|
<Label className="text-xs">目标节点</Label>
|
||||||
<OptionCombobox
|
<div className="rounded-md border border-dashed bg-muted/20 px-2 py-2 text-xs text-muted-foreground">
|
||||||
value={branch.targetNodeId}
|
{summary?.targetNodeId
|
||||||
options={branchTargetOptions}
|
? `已连接到:${summary.targetName}`
|
||||||
placeholder="选择目标节点"
|
: "请从画布中该分支右侧连接点拖线到目标节点"}
|
||||||
searchPlaceholder="搜索目标节点"
|
</div>
|
||||||
emptyText="请先从条件节点连出下游节点"
|
|
||||||
onChange={(value) => commitBranch(branch.id, { targetNodeId: value })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{branch.default ? (
|
{branch.default ? (
|
||||||
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
|
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
|
||||||
@@ -367,14 +375,21 @@ function ConditionNodePanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{!branch.default ? (
|
{!branch.default && index > 0 ? (
|
||||||
<Button type="button" size="sm" variant="outline" onClick={() => markDefault(branch.id)}>
|
<Button type="button" size="sm" variant="outline" onClick={() => moveBranch(branch.id, -1)}>
|
||||||
设为默认
|
上移
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{!branch.default && index < branches.findIndex((item) => item.default) - 1 ? (
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => moveBranch(branch.id, 1)}>
|
||||||
|
下移
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{!branch.default ? (
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => deleteBranch(branch.id)}>
|
||||||
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button type="button" size="sm" variant="outline" onClick={() => deleteBranch(branch.id)}>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="line-clamp-2 text-xs text-muted-foreground">
|
<div className="line-clamp-2 text-xs text-muted-foreground">
|
||||||
{summary?.conditionLabel ?? "尚未完成分支配置"}
|
{summary?.conditionLabel ?? "尚未完成分支配置"}
|
||||||
|
|||||||
@@ -52,12 +52,15 @@ import {
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||||
import {
|
import {
|
||||||
|
applyConditionBranchConnection,
|
||||||
applyAutoInputMappings,
|
applyAutoInputMappings,
|
||||||
calculateWorkflowHelperLines,
|
calculateWorkflowHelperLines,
|
||||||
|
clearConditionBranchConnection,
|
||||||
createWorkflowHistory,
|
createWorkflowHistory,
|
||||||
createWorkflowNodeFromSpec,
|
createWorkflowNodeFromSpec,
|
||||||
fromApiDefinition,
|
fromApiDefinition,
|
||||||
getAvailableVariables,
|
getAvailableVariables,
|
||||||
|
getConditionBranchHandleId,
|
||||||
getNodeSpec,
|
getNodeSpec,
|
||||||
getRequiredInputs,
|
getRequiredInputs,
|
||||||
pushWorkflowHistory,
|
pushWorkflowHistory,
|
||||||
@@ -66,6 +69,7 @@ import {
|
|||||||
undoWorkflowHistory,
|
undoWorkflowHistory,
|
||||||
validateWorkflowDraft,
|
validateWorkflowDraft,
|
||||||
type WorkflowCondition,
|
type WorkflowCondition,
|
||||||
|
type WorkflowDraft,
|
||||||
type WorkflowEditorNode,
|
type WorkflowEditorNode,
|
||||||
type WorkflowHistory,
|
type WorkflowHistory,
|
||||||
type WorkflowHelperLine,
|
type WorkflowHelperLine,
|
||||||
@@ -73,7 +77,6 @@ import {
|
|||||||
type WorkflowVariableSpec,
|
type WorkflowVariableSpec,
|
||||||
} from "./workflow-utils"
|
} from "./workflow-utils"
|
||||||
import { NodeConfigPanel, type WorkflowBranchSummary } from "./node-config-panel"
|
import { NodeConfigPanel, type WorkflowBranchSummary } from "./node-config-panel"
|
||||||
import type { WorkflowBranchTargetOption } from "./node-config-panel"
|
|
||||||
|
|
||||||
type WorkflowNodeData = Record<string, unknown> & {
|
type WorkflowNodeData = Record<string, unknown> & {
|
||||||
nodeType?: string
|
nodeType?: string
|
||||||
@@ -88,6 +91,7 @@ type WorkflowNodeData = Record<string, unknown> & {
|
|||||||
inputCount?: number
|
inputCount?: number
|
||||||
outputCount?: number
|
outputCount?: number
|
||||||
missingInputs?: string[]
|
missingInputs?: string[]
|
||||||
|
branchSummaries?: WorkflowBranchSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkflowFlowNode = Node<WorkflowNodeData>
|
type WorkflowFlowNode = Node<WorkflowNodeData>
|
||||||
@@ -157,10 +161,25 @@ function toFlowEdges(definition: AIWorkflowDefinition): WorkflowFlowEdge[] {
|
|||||||
type: "workflowEdge",
|
type: "workflowEdge",
|
||||||
source: edge.source,
|
source: edge.source,
|
||||||
target: edge.target,
|
target: edge.target,
|
||||||
|
sourceHandle: getConditionBranchHandleForEdge(definition, edge.source, edge.target),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDraft(nodes: WorkflowFlowNode[], edges: WorkflowFlowEdge[]) {
|
function getConditionBranchHandleForEdge(
|
||||||
|
definition: AIWorkflowDefinition,
|
||||||
|
sourceNodeId: string,
|
||||||
|
targetNodeId: string
|
||||||
|
) {
|
||||||
|
const sourceNode = definition.nodes?.find((node) => node.id === sourceNodeId)
|
||||||
|
if (!sourceNode || sourceNode.type !== "condition") {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const config = sourceNode.config as WorkflowNodeConfig | undefined
|
||||||
|
const branch = config?.branches?.find((item) => item.targetNodeId === targetNodeId)
|
||||||
|
return branch ? getConditionBranchHandleId(branch.id) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDraft(nodes: WorkflowFlowNode[], edges: WorkflowFlowEdge[]): WorkflowDraft {
|
||||||
return {
|
return {
|
||||||
nodes: nodes.map((node) => ({
|
nodes: nodes.map((node) => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
@@ -177,6 +196,8 @@ function toDraft(nodes: WorkflowFlowNode[], edges: WorkflowFlowEdge[]) {
|
|||||||
id: edge.id,
|
id: edge.id,
|
||||||
source: edge.source,
|
source: edge.source,
|
||||||
target: edge.target,
|
target: edge.target,
|
||||||
|
sourceHandle: edge.sourceHandle,
|
||||||
|
targetHandle: edge.targetHandle,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,10 +275,6 @@ export function WorkflowEditor({
|
|||||||
() => (propertyPanelNode ? getBranchSummaries(nodes, propertyPanelNode.id, nodeSpecs) : []),
|
() => (propertyPanelNode ? getBranchSummaries(nodes, propertyPanelNode.id, nodeSpecs) : []),
|
||||||
[nodeSpecs, nodes, propertyPanelNode]
|
[nodeSpecs, nodes, propertyPanelNode]
|
||||||
)
|
)
|
||||||
const propertyPanelBranchTargetOptions = useMemo(
|
|
||||||
() => (propertyPanelNode ? getBranchTargetOptions(nodes, edges, propertyPanelNode.id) : []),
|
|
||||||
[edges, nodes, propertyPanelNode]
|
|
||||||
)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDefinitionChange(toApiDefinition(draft) as AIWorkflowDefinition)
|
onDefinitionChange(toApiDefinition(draft) as AIWorkflowDefinition)
|
||||||
}, [draft, onDefinitionChange])
|
}, [draft, onDefinitionChange])
|
||||||
@@ -415,18 +432,28 @@ export function WorkflowEditor({
|
|||||||
pushCurrentSnapshotToHistory()
|
pushCurrentSnapshotToHistory()
|
||||||
const edge = {
|
const edge = {
|
||||||
...connection,
|
...connection,
|
||||||
id: uniqueEdgeId(edges, connection.source, connection.target),
|
id: uniqueEdgeId(edges, connection.source, connection.target, connection.sourceHandle),
|
||||||
type: "workflowEdge",
|
type: "workflowEdge",
|
||||||
} as WorkflowFlowEdge
|
} as WorkflowFlowEdge
|
||||||
setEdges((current) => addEdge(edge, current))
|
const nextEdges = [
|
||||||
|
...edges.filter((item) => !(
|
||||||
|
connection.sourceHandle
|
||||||
|
&& item.source === connection.source
|
||||||
|
&& item.sourceHandle === connection.sourceHandle
|
||||||
|
)),
|
||||||
|
edge,
|
||||||
|
]
|
||||||
|
setEdges((current) => addEdge(edge, current.filter((item) => !(
|
||||||
|
connection.sourceHandle
|
||||||
|
&& item.source === connection.source
|
||||||
|
&& item.sourceHandle === connection.sourceHandle
|
||||||
|
))))
|
||||||
setNodes((currentNodes) => {
|
setNodes((currentNodes) => {
|
||||||
const currentDraft = toDraft(currentNodes, [...edges, edge])
|
const connectedDraft = applyConditionBranchConnection(
|
||||||
const nextDraft = applyAutoInputMappings(
|
toDraft(currentNodes, nextEdges),
|
||||||
currentDraft,
|
edge
|
||||||
connection.source!,
|
|
||||||
connection.target!,
|
|
||||||
nodeSpecs
|
|
||||||
)
|
)
|
||||||
|
const nextDraft = applyAutoInputMappings(connectedDraft, connection.source!, connection.target!, nodeSpecs)
|
||||||
return currentNodes.map((node) => {
|
return currentNodes.map((node) => {
|
||||||
const nextNode = nextDraft.nodes.find((item) => item.id === node.id)
|
const nextNode = nextDraft.nodes.find((item) => item.id === node.id)
|
||||||
if (!nextNode) {
|
if (!nextNode) {
|
||||||
@@ -436,6 +463,7 @@ export function WorkflowEditor({
|
|||||||
...node,
|
...node,
|
||||||
data: {
|
data: {
|
||||||
...node.data,
|
...node.data,
|
||||||
|
config: nextNode.data?.config ?? node.data.config,
|
||||||
inputs: nextNode.data?.inputs ?? node.data.inputs,
|
inputs: nextNode.data?.inputs ?? node.data.inputs,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -498,12 +526,41 @@ export function WorkflowEditor({
|
|||||||
|
|
||||||
const onWorkflowEdgesChange = useCallback(
|
const onWorkflowEdgesChange = useCallback(
|
||||||
(changes: EdgeChange<WorkflowFlowEdge>[]) => {
|
(changes: EdgeChange<WorkflowFlowEdge>[]) => {
|
||||||
if (changes.some((change) => change.type === "remove")) {
|
const removedEdges = changes
|
||||||
|
.filter((change) => change.type === "remove")
|
||||||
|
.map((change) => edges.find((edge) => edge.id === change.id))
|
||||||
|
.filter((edge): edge is WorkflowFlowEdge => Boolean(edge))
|
||||||
|
if (removedEdges.length > 0) {
|
||||||
pushCurrentSnapshotToHistory()
|
pushCurrentSnapshotToHistory()
|
||||||
|
setNodes((currentNodes) => {
|
||||||
|
let draft = toDraft(currentNodes, edges.filter((edge) => !removedEdges.some((removed) => removed.id === edge.id)))
|
||||||
|
for (const removedEdge of removedEdges) {
|
||||||
|
draft = clearConditionBranchConnection(draft, {
|
||||||
|
id: removedEdge.id,
|
||||||
|
source: removedEdge.source,
|
||||||
|
target: removedEdge.target,
|
||||||
|
sourceHandle: removedEdge.sourceHandle,
|
||||||
|
targetHandle: removedEdge.targetHandle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return currentNodes.map((node) => {
|
||||||
|
const nextNode = draft.nodes.find((item) => item.id === node.id)
|
||||||
|
if (!nextNode) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
config: nextNode.data?.config ?? node.data.config,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
onEdgesChange(changes)
|
onEdgesChange(changes)
|
||||||
},
|
},
|
||||||
[onEdgesChange, pushCurrentSnapshotToHistory]
|
[edges, onEdgesChange, pushCurrentSnapshotToHistory, setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onNodeDragStart = useCallback<OnNodeDrag<WorkflowFlowNode>>(() => {
|
const onNodeDragStart = useCallback<OnNodeDrag<WorkflowFlowNode>>(() => {
|
||||||
@@ -938,7 +995,6 @@ export function WorkflowEditor({
|
|||||||
nodeSpec={propertyPanelNodeSpec}
|
nodeSpec={propertyPanelNodeSpec}
|
||||||
availableVariables={propertyPanelAvailableVariables}
|
availableVariables={propertyPanelAvailableVariables}
|
||||||
branchSummaries={propertyPanelBranchSummaries}
|
branchSummaries={propertyPanelBranchSummaries}
|
||||||
branchTargetOptions={propertyPanelBranchTargetOptions}
|
|
||||||
onChange={updateNodeData}
|
onChange={updateNodeData}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -972,12 +1028,13 @@ export function WorkflowEditor({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueEdgeId(edges: WorkflowFlowEdge[], source: string, target: string) {
|
function uniqueEdgeId(edges: WorkflowFlowEdge[], source: string, target: string, sourceHandle?: string | null) {
|
||||||
let nextIndex = edges.length + 1
|
let nextIndex = edges.length + 1
|
||||||
let id = `edge_${source}_${target}_${nextIndex}`
|
const handleSuffix = sourceHandle ? `_${sourceHandle.replace(/[^a-zA-Z0-9_-]/g, "_")}` : ""
|
||||||
|
let id = `edge_${source}${handleSuffix}_${target}_${nextIndex}`
|
||||||
while (edges.some((edge) => edge.id === id)) {
|
while (edges.some((edge) => edge.id === id)) {
|
||||||
nextIndex += 1
|
nextIndex += 1
|
||||||
id = `edge_${source}_${target}_${nextIndex}`
|
id = `edge_${source}${handleSuffix}_${target}_${nextIndex}`
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
@@ -1001,6 +1058,7 @@ function enrichNodesForRender(
|
|||||||
inputCount: spec?.inputSchema?.length ?? 0,
|
inputCount: spec?.inputSchema?.length ?? 0,
|
||||||
outputCount: spec?.outputSchema?.length ?? 0,
|
outputCount: spec?.outputSchema?.length ?? 0,
|
||||||
missingInputs: missingInputs.map((input) => input.name),
|
missingInputs: missingInputs.map((input) => input.name),
|
||||||
|
branchSummaries: node.data.nodeType === "condition" ? getBranchSummaries(nodes, node.id, nodeSpecs) : undefined,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1042,22 +1100,6 @@ function getBranchSummaries(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBranchTargetOptions(
|
|
||||||
nodes: WorkflowFlowNode[],
|
|
||||||
edges: WorkflowFlowEdge[],
|
|
||||||
nodeId: string
|
|
||||||
): WorkflowBranchTargetOption[] {
|
|
||||||
return edges
|
|
||||||
.filter((edge) => edge.source === nodeId)
|
|
||||||
.map((edge) => {
|
|
||||||
const target = nodes.find((node) => node.id === edge.target)
|
|
||||||
return {
|
|
||||||
value: edge.target,
|
|
||||||
label: target?.data.name ?? target?.data.title ?? edge.target,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatConditionLabel(
|
function formatConditionLabel(
|
||||||
condition: WorkflowCondition,
|
condition: WorkflowCondition,
|
||||||
nodes: WorkflowFlowNode[],
|
nodes: WorkflowFlowNode[],
|
||||||
@@ -1226,16 +1268,19 @@ function WorkflowCanvasEdge({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function WorkflowNodeHandle({
|
function WorkflowNodeHandle({
|
||||||
|
id,
|
||||||
type,
|
type,
|
||||||
position,
|
position,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
|
id?: string
|
||||||
type: "source" | "target"
|
type: "source" | "target"
|
||||||
position: Position
|
position: Position
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Handle
|
<Handle
|
||||||
|
id={id}
|
||||||
type={type}
|
type={type}
|
||||||
position={position}
|
position={position}
|
||||||
className={className}
|
className={className}
|
||||||
@@ -1318,45 +1363,70 @@ function WorkflowCanvasNode({ id, data, selected }: NodeProps<WorkflowFlowNode>)
|
|||||||
showHandles ? "pointer-events-auto opacity-100" : "pointer-events-none"
|
showHandles ? "pointer-events-auto opacity-100" : "pointer-events-none"
|
||||||
)
|
)
|
||||||
if (isConditionNode) {
|
if (isConditionNode) {
|
||||||
|
const branches = data.branchSummaries ?? []
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group/node relative flex size-36 items-center justify-center"
|
className={[
|
||||||
|
"group/node relative w-72 rounded-xl border bg-background shadow-[0_12px_34px_rgba(15,23,42,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_42px_rgba(15,23,42,0.12)]",
|
||||||
|
selected ? "border-primary ring-4 ring-primary/10" : "",
|
||||||
|
hasIssue ? "border-destructive/70" : "border-border/70",
|
||||||
|
].join(" ")}
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
"absolute inset-4 rotate-45 rounded-xl border bg-background shadow-[0_10px_30px_rgba(15,23,42,0.08)] transition-all",
|
|
||||||
selected ? "border-primary ring-4 ring-primary/10" : "",
|
|
||||||
hasIssue ? "border-destructive/70" : "border-border/70",
|
|
||||||
].join(" ")}
|
|
||||||
/>
|
|
||||||
<WorkflowNodeHandle
|
<WorkflowNodeHandle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className={cn("!left-0", handleClassName)}
|
className={cn("!left-0", handleClassName)}
|
||||||
/>
|
/>
|
||||||
<div className="relative z-10 flex max-w-24 flex-col items-center text-center">
|
<div className="overflow-hidden rounded-xl">
|
||||||
{hasIssue ? (
|
<div className="flex items-start gap-2 border-b border-border/60 bg-muted/20 px-3 py-2.5">
|
||||||
<AlertCircleIcon className="mb-1 size-4 text-destructive" />
|
<div
|
||||||
) : (
|
className={cn(
|
||||||
<CheckCircle2Icon className="mb-1 size-4 text-emerald-600" />
|
"mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg",
|
||||||
)}
|
hasIssue ? "bg-destructive/10 text-destructive" : "bg-emerald-500/10 text-emerald-700"
|
||||||
<div className="line-clamp-2 text-sm font-medium leading-tight">{data.name ?? data.title}</div>
|
)}
|
||||||
<div className="mt-1 text-[11px] text-muted-foreground">分支</div>
|
>
|
||||||
|
{hasIssue ? (
|
||||||
|
<AlertCircleIcon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2Icon className="size-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium">{data.name ?? data.title}</div>
|
||||||
|
<div className="mt-0.5 truncate text-xs text-muted-foreground">条件分支</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border/60 text-xs">
|
||||||
|
{branches.length > 0 ? branches.map((branch, index) => (
|
||||||
|
<div key={branch.branchId} className="relative flex items-center gap-2 px-3 py-2.5 pr-6">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-sm px-1.5 py-0.5 font-medium",
|
||||||
|
branch.isDefault ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{branch.isDefault ? "ELSE" : index === 0 ? "IF" : "ELIF"}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-medium">{branch.conditionLabel}</div>
|
||||||
|
<div className="mt-0.5 truncate text-muted-foreground">
|
||||||
|
{branch.targetNodeId ? `连接到:${branch.targetName}` : "未连接目标节点"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<WorkflowNodeHandle
|
||||||
|
id={getConditionBranchHandleId(branch.branchId)}
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className={cn("!right-[-8px] !top-1/2 !-translate-y-1/2", handleClassName)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="px-3 py-3 text-muted-foreground">当前还没有分支</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<WorkflowNodeHandle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
className={cn("!right-0", handleClassName)}
|
|
||||||
/>
|
|
||||||
<WorkflowAddAfterButton
|
|
||||||
nodeId={id}
|
|
||||||
visible={showHandles}
|
|
||||||
className="right-4 top-4"
|
|
||||||
nodeSpecs={nodeSpecs}
|
|
||||||
onAddAfter={onAddAfter}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,60 @@ describe("validateWorkflowDraft", () => {
|
|||||||
assert.equal(result.valid, false)
|
assert.equal(result.valid, false)
|
||||||
assert.match(result.errors.join("\n"), /缺少必填输入「replyText」/)
|
assert.match(result.errors.join("\n"), /缺少必填输入「replyText」/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("rejects condition branch target without matching branch handle edge", async () => {
|
||||||
|
const { getConditionBranchHandleId, validateWorkflowDraft } = await loadModule()
|
||||||
|
|
||||||
|
const result = validateWorkflowDraft({
|
||||||
|
nodes: [
|
||||||
|
{ id: "start_1", type: "workflowNode", position: { x: 0, y: 0 }, data: { nodeType: "start" } },
|
||||||
|
{
|
||||||
|
id: "condition_1",
|
||||||
|
type: "workflowNode",
|
||||||
|
position: { x: 200, y: 0 },
|
||||||
|
data: {
|
||||||
|
nodeType: "condition",
|
||||||
|
name: "Route",
|
||||||
|
config: {
|
||||||
|
branches: [
|
||||||
|
{
|
||||||
|
id: "direct",
|
||||||
|
name: "Direct",
|
||||||
|
targetNodeId: "send_1",
|
||||||
|
condition: {
|
||||||
|
left: { nodeId: "start_1", field: "userMessage" },
|
||||||
|
operator: "eq",
|
||||||
|
right: "hello",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
name: "Else",
|
||||||
|
targetNodeId: "send_1",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: "send_1", type: "workflowNode", position: { x: 400, y: 0 }, data: { nodeType: "send_reply" } },
|
||||||
|
{ id: "end_1", type: "workflowNode", position: { x: 600, y: 0 }, data: { nodeType: "end" } },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: "e1", source: "start_1", target: "condition_1" },
|
||||||
|
{
|
||||||
|
id: "e2",
|
||||||
|
source: "condition_1",
|
||||||
|
target: "send_1",
|
||||||
|
sourceHandle: getConditionBranchHandleId("default"),
|
||||||
|
},
|
||||||
|
{ id: "e3", source: "send_1", target: "end_1" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(result.valid, false)
|
||||||
|
assert.match(result.errors.join("\n"), /对应分支连接点/)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("applyAutoInputMappings", () => {
|
describe("applyAutoInputMappings", () => {
|
||||||
@@ -363,6 +417,76 @@ describe("getAvailableVariables", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("toApiDefinition", () => {
|
describe("toApiDefinition", () => {
|
||||||
|
it("updates condition branch target from branch handle connection", async () => {
|
||||||
|
const { applyConditionBranchConnection, getConditionBranchHandleId } = await loadModule()
|
||||||
|
|
||||||
|
const draft = applyConditionBranchConnection(
|
||||||
|
{
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "condition_1",
|
||||||
|
type: "workflowNode",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
nodeType: "condition",
|
||||||
|
config: {
|
||||||
|
branches: [
|
||||||
|
{ id: "direct", name: "Direct", targetNodeId: "", condition: { operator: "eq" } },
|
||||||
|
{ id: "default", name: "Else", targetNodeId: "", default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: "send_1", type: "workflowNode", position: { x: 200, y: 0 }, data: { nodeType: "send_reply" } },
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "condition_1",
|
||||||
|
target: "send_1",
|
||||||
|
sourceHandle: getConditionBranchHandleId("direct"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(draft.nodes[0].data.config.branches[0].targetNodeId, "send_1")
|
||||||
|
assert.equal(draft.nodes[0].data.config.branches[1].targetNodeId, "")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("clears condition branch target when the branch edge is removed", async () => {
|
||||||
|
const { clearConditionBranchConnection, getConditionBranchHandleId } = await loadModule()
|
||||||
|
|
||||||
|
const draft = clearConditionBranchConnection(
|
||||||
|
{
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "condition_1",
|
||||||
|
type: "workflowNode",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
nodeType: "condition",
|
||||||
|
config: {
|
||||||
|
branches: [
|
||||||
|
{ id: "direct", name: "Direct", targetNodeId: "send_1", condition: { operator: "eq" } },
|
||||||
|
{ id: "default", name: "Else", targetNodeId: "fallback_1", default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "edge_condition_send",
|
||||||
|
source: "condition_1",
|
||||||
|
target: "send_1",
|
||||||
|
sourceHandle: getConditionBranchHandleId("direct"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(draft.nodes[0].data.config.branches[0].targetNodeId, "")
|
||||||
|
assert.equal(draft.nodes[0].data.config.branches[1].targetNodeId, "fallback_1")
|
||||||
|
})
|
||||||
|
|
||||||
it("keeps condition branches on the condition node config and exports plain edges", async () => {
|
it("keeps condition branches on the condition node config and exports plain edges", async () => {
|
||||||
const { toApiDefinition } = await loadModule()
|
const { toApiDefinition } = await loadModule()
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export type WorkflowEditorEdge = {
|
|||||||
id: string
|
id: string
|
||||||
source: string
|
source: string
|
||||||
target: string
|
target: string
|
||||||
|
sourceHandle?: string | null
|
||||||
|
targetHandle?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkflowCondition = {
|
export type WorkflowCondition = {
|
||||||
@@ -162,6 +164,19 @@ export type WorkflowHistoryChange<T> = {
|
|||||||
|
|
||||||
const helperLineAlignmentThreshold = 6
|
const helperLineAlignmentThreshold = 6
|
||||||
const defaultWorkflowHistoryLimit = 50
|
const defaultWorkflowHistoryLimit = 50
|
||||||
|
const conditionBranchHandlePrefix = "condition-branch:"
|
||||||
|
|
||||||
|
export function getConditionBranchHandleId(branchId: string): string {
|
||||||
|
return `${conditionBranchHandlePrefix}${branchId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseConditionBranchHandleId(handleId?: string | null): string | null {
|
||||||
|
if (!handleId?.startsWith(conditionBranchHandlePrefix)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const branchId = handleId.slice(conditionBranchHandlePrefix.length)
|
||||||
|
return branchId || null
|
||||||
|
}
|
||||||
|
|
||||||
function cloneHistorySnapshot<T>(snapshot: T): T {
|
function cloneHistorySnapshot<T>(snapshot: T): T {
|
||||||
return JSON.parse(JSON.stringify(snapshot)) as T
|
return JSON.parse(JSON.stringify(snapshot)) as T
|
||||||
@@ -373,6 +388,7 @@ export function validateWorkflowDraft(
|
|||||||
|
|
||||||
const edgeIds = new Set<string>()
|
const edgeIds = new Set<string>()
|
||||||
const outgoingTargets = new Map<string, Set<string>>()
|
const outgoingTargets = new Map<string, Set<string>>()
|
||||||
|
const branchEdges = new Set<string>()
|
||||||
for (const edge of draft.edges) {
|
for (const edge of draft.edges) {
|
||||||
const id = edge.id.trim()
|
const id = edge.id.trim()
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -391,19 +407,22 @@ export function validateWorkflowDraft(
|
|||||||
outgoingTargets.set(edge.source, new Set())
|
outgoingTargets.set(edge.source, new Set())
|
||||||
}
|
}
|
||||||
outgoingTargets.get(edge.source)?.add(edge.target)
|
outgoingTargets.get(edge.source)?.add(edge.target)
|
||||||
|
const branchId = parseConditionBranchHandleId(edge.sourceHandle)
|
||||||
|
if (branchId) {
|
||||||
|
branchEdges.add(`${edge.source}:${branchId}:${edge.target}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const node of draft.nodes) {
|
for (const node of draft.nodes) {
|
||||||
const nodeType = node.data?.nodeType ?? node.type ?? ""
|
const nodeType = node.data?.nodeType ?? node.type ?? ""
|
||||||
const spec = getNodeSpec(nodeSpecs, nodeType)
|
const spec = getNodeSpec(nodeSpecs, nodeType)
|
||||||
if (!spec) {
|
if (spec) {
|
||||||
continue
|
for (const input of getRequiredInputs(spec)) {
|
||||||
}
|
const selector = node.data?.inputs?.[input.name]
|
||||||
for (const input of getRequiredInputs(spec)) {
|
if (!selector?.nodeId || !selector.field) {
|
||||||
const selector = node.data?.inputs?.[input.name]
|
const nodeName = node.data?.name ?? spec.title ?? node.id
|
||||||
if (!selector?.nodeId || !selector.field) {
|
errors.push(`${nodeName} 缺少必填输入「${input.name}」,请选择上游节点的输出变量。`)
|
||||||
const nodeName = node.data?.name ?? spec.title ?? node.id
|
}
|
||||||
errors.push(`${nodeName} 缺少必填输入「${input.name}」,请选择上游节点的输出变量。`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (nodeType === "condition") {
|
if (nodeType === "condition") {
|
||||||
@@ -429,12 +448,17 @@ export function validateWorkflowDraft(
|
|||||||
errors.push(`${node.data?.name ?? node.id} 的分支「${branchName}」目标节点不存在。`)
|
errors.push(`${node.data?.name ?? node.id} 的分支「${branchName}」目标节点不存在。`)
|
||||||
} else if (!targets.has(branch.targetNodeId)) {
|
} else if (!targets.has(branch.targetNodeId)) {
|
||||||
errors.push(`${node.data?.name ?? node.id} 的分支「${branchName}」需要连接到目标节点。`)
|
errors.push(`${node.data?.name ?? node.id} 的分支「${branchName}」需要连接到目标节点。`)
|
||||||
|
} else if (branchEdges.size > 0 && !branchEdges.has(`${node.id}:${branch.id}:${branch.targetNodeId}`)) {
|
||||||
|
errors.push(`${node.data?.name ?? node.id} 的分支「${branchName}」需要从对应分支连接点连到目标节点。`)
|
||||||
}
|
}
|
||||||
if (branch.default) {
|
if (branch.default) {
|
||||||
defaultCount += 1
|
defaultCount += 1
|
||||||
if (branch.condition) {
|
if (branch.condition) {
|
||||||
errors.push(`${node.data?.name ?? node.id} 的默认分支不能配置条件。`)
|
errors.push(`${node.data?.name ?? node.id} 的默认分支不能配置条件。`)
|
||||||
}
|
}
|
||||||
|
if (branches.indexOf(branch) !== branches.length - 1) {
|
||||||
|
errors.push(`${node.data?.name ?? node.id} 的默认分支必须放在最后。`)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!branch.condition?.left?.nodeId || !branch.condition.left.field) {
|
if (!branch.condition?.left?.nodeId || !branch.condition.left.field) {
|
||||||
@@ -480,6 +504,69 @@ export function toApiDefinition(draft: WorkflowDraft): WorkflowDefinition {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyConditionBranchConnection(
|
||||||
|
draft: WorkflowDraft,
|
||||||
|
connection: Pick<WorkflowEditorEdge, "source" | "target" | "sourceHandle">
|
||||||
|
): WorkflowDraft {
|
||||||
|
const branchId = parseConditionBranchHandleId(connection.sourceHandle)
|
||||||
|
if (!branchId || !connection.source || !connection.target) {
|
||||||
|
return draft
|
||||||
|
}
|
||||||
|
return updateConditionBranchTarget(draft, connection.source, branchId, connection.target)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearConditionBranchConnection(
|
||||||
|
draft: WorkflowDraft,
|
||||||
|
edge: WorkflowEditorEdge
|
||||||
|
): WorkflowDraft {
|
||||||
|
const branchId = parseConditionBranchHandleId(edge.sourceHandle)
|
||||||
|
if (branchId) {
|
||||||
|
return updateConditionBranchTarget(draft, edge.source, branchId, "")
|
||||||
|
}
|
||||||
|
const sourceNode = draft.nodes.find((node) => node.id === edge.source)
|
||||||
|
if (!sourceNode || (sourceNode.data?.nodeType ?? sourceNode.type) !== "condition") {
|
||||||
|
return draft
|
||||||
|
}
|
||||||
|
const branch = sourceNode.data?.config?.branches?.find((item) => item.targetNodeId === edge.target)
|
||||||
|
return branch ? updateConditionBranchTarget(draft, edge.source, branch.id, "") : draft
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConditionBranchTarget(
|
||||||
|
draft: WorkflowDraft,
|
||||||
|
conditionNodeId: string,
|
||||||
|
branchId: string,
|
||||||
|
targetNodeId: string
|
||||||
|
): WorkflowDraft {
|
||||||
|
let changed = false
|
||||||
|
const nodes = draft.nodes.map((node) => {
|
||||||
|
if (node.id !== conditionNodeId || (node.data?.nodeType ?? node.type) !== "condition") {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
const branches = node.data?.config?.branches ?? []
|
||||||
|
const nextBranches = branches.map((branch) => {
|
||||||
|
if (branch.id !== branchId || branch.targetNodeId === targetNodeId) {
|
||||||
|
return branch
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
return { ...branch, targetNodeId }
|
||||||
|
})
|
||||||
|
if (!changed) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
config: {
|
||||||
|
...(node.data?.config ?? {}),
|
||||||
|
branches: nextBranches,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return changed ? { ...draft, nodes } : draft
|
||||||
|
}
|
||||||
|
|
||||||
export function fromApiDefinition(definition: WorkflowDefinition): WorkflowDraft {
|
export function fromApiDefinition(definition: WorkflowDefinition): WorkflowDraft {
|
||||||
return {
|
return {
|
||||||
nodes: (definition.nodes ?? []).map((node) => ({
|
nodes: (definition.nodes ?? []).map((node) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user