chore: snapshot workflow migration progress
This commit is contained in:
@@ -1,10 +1,21 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { addEdge, useEdgesState, useNodesState, type Connection, type Edge, type Node, type ReactFlowInstance } from '@xyflow/react'
|
||||
import {
|
||||
addEdge,
|
||||
applyNodeChanges,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeChange,
|
||||
type ReactFlowInstance,
|
||||
} from '@xyflow/react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
dispatchWorkflowDraft,
|
||||
getWorkflowOrderLineContexts,
|
||||
getNodeDefinitions,
|
||||
getWorkflowRunComparison,
|
||||
getWorkflowRuns,
|
||||
@@ -13,17 +24,25 @@ import {
|
||||
type WorkflowDefinition,
|
||||
type WorkflowExecutionMode,
|
||||
type WorkflowNodeDefinition,
|
||||
type WorkflowOrderLineContextGroup as WorkflowOrderLineContextGroupApi,
|
||||
type WorkflowOrderLineContextOption as WorkflowOrderLineContextOptionApi,
|
||||
type WorkflowParams,
|
||||
type WorkflowPreflightResponse,
|
||||
} from '../../api/workflows'
|
||||
import {
|
||||
applyAutoLayout,
|
||||
buildWorkflowCanvasNodeData,
|
||||
buildCurrentWorkflowConfig,
|
||||
deriveWorkflowAuthoringFamily,
|
||||
findOpenNodePosition,
|
||||
graphNeedsAutoLayout,
|
||||
inferNodeLabel,
|
||||
inferNodeType,
|
||||
inferStepFromNodeType,
|
||||
normalizeWorkflowParams,
|
||||
resolveParamsForStepChange,
|
||||
resolveNodeCollisions,
|
||||
shouldAutoLayoutAfterInsert,
|
||||
type WorkflowCanvasNodeData,
|
||||
validateWorkflowDraft,
|
||||
workflowToGraph,
|
||||
@@ -35,6 +54,11 @@ import {
|
||||
GRAPH_FAMILY_LABELS,
|
||||
isDefinitionAllowedForGraphFamily,
|
||||
} from './workflowNodeLibrary'
|
||||
import { createWorkflowModuleBundleInsertion, type WorkflowModuleBundleId } from './workflowModuleBundles'
|
||||
import {
|
||||
createWorkflowReferenceBundleInsertion,
|
||||
type WorkflowReferenceBundleId,
|
||||
} from './workflowReferenceBundles'
|
||||
import type { WorkflowUtilityTab } from './WorkflowCanvasUtilitySidebar'
|
||||
|
||||
export type NodeMenuAnchor = {
|
||||
@@ -43,20 +67,30 @@ export type NodeMenuAnchor = {
|
||||
flowPosition: { x: number; y: number }
|
||||
}
|
||||
|
||||
function buildNodeData(
|
||||
step: string,
|
||||
params: WorkflowParams = {},
|
||||
definition?: WorkflowNodeDefinition,
|
||||
overrides?: Partial<WorkflowCanvasNodeData>,
|
||||
): WorkflowCanvasNodeData {
|
||||
return {
|
||||
label: overrides?.label ?? definition?.label ?? inferNodeLabel(step),
|
||||
params: normalizeWorkflowParams(params),
|
||||
step,
|
||||
description: overrides?.description ?? definition?.description,
|
||||
icon: overrides?.icon ?? definition?.icon,
|
||||
category: overrides?.category ?? definition?.category,
|
||||
}
|
||||
export type WorkflowOrderLineContextOption = {
|
||||
value: string
|
||||
label: string
|
||||
meta: string
|
||||
}
|
||||
|
||||
export type WorkflowOrderLineContextGroup = {
|
||||
orderId: string
|
||||
orderLabel: string
|
||||
options: WorkflowOrderLineContextOption[]
|
||||
}
|
||||
|
||||
function normalizeOrderLineContextGroups(
|
||||
groups: WorkflowOrderLineContextGroupApi[],
|
||||
): WorkflowOrderLineContextGroup[] {
|
||||
return groups.map(group => ({
|
||||
orderId: group.order_id,
|
||||
orderLabel: group.order_label,
|
||||
options: group.options.map((option: WorkflowOrderLineContextOptionApi) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
meta: option.meta,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
type UseWorkflowCanvasControllerArgs = {
|
||||
@@ -73,14 +107,17 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
})
|
||||
const nodeDefinitions = nodeDefinitionsData?.definitions ?? []
|
||||
const nodeDefinitionsByStep = Object.fromEntries(nodeDefinitions.map(definition => [definition.step, definition]))
|
||||
const definitionsLoaded = nodeDefinitions.length > 0
|
||||
|
||||
const { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config, nodeDefinitionsByStep)
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initNodes)
|
||||
const [nodes, setNodes] = useNodesState(initNodes)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
const [selectedEdgeIds, setSelectedEdgeIds] = useState<string[]>([])
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
|
||||
const [dispatchContextId, setDispatchContextId] = useState('')
|
||||
const [preflightResult, setPreflightResult] = useState<WorkflowPreflightResponse | null>(null)
|
||||
const [lastSuccessfulPreflightFingerprint, setLastSuccessfulPreflightFingerprint] = useState<string | null>(null)
|
||||
const [executionMode, setExecutionMode] = useState<WorkflowExecutionMode>(workflow.config.ui?.execution_mode ?? 'legacy')
|
||||
const [nodeMenuAnchor, setNodeMenuAnchor] = useState<NodeMenuAnchor | null>(null)
|
||||
const [activeUtilityTab, setActiveUtilityTab] = useState<WorkflowUtilityTab>('library')
|
||||
@@ -88,18 +125,66 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
const [reactFlowInstance, setReactFlowInstance] = useState<ReactFlowInstance<Node, Edge> | null>(null)
|
||||
|
||||
const validation = validateWorkflowDraft(nodes, edges, nodeDefinitionsByStep, nodeDefinitions.length > 0)
|
||||
const selectedEdgeIds = useMemo(
|
||||
() => edges.filter(edge => Boolean((edge as Edge & { selected?: boolean }).selected)).map(edge => edge.id),
|
||||
[edges],
|
||||
const authoringFamily = useMemo(
|
||||
() => deriveWorkflowAuthoringFamily(workflow, nodes, nodeDefinitionsByStep, definitionsLoaded),
|
||||
[definitionsLoaded, nodeDefinitionsByStep, nodes, workflow],
|
||||
)
|
||||
const currentWorkflowConfig = useMemo(
|
||||
() => buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode, authoringFamily),
|
||||
[authoringFamily, edges, executionMode, nodes, workflow],
|
||||
)
|
||||
const graphFamily = useMemo(
|
||||
() =>
|
||||
inferWorkflowFamily(
|
||||
buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode),
|
||||
nodeDefinitionsByStep,
|
||||
),
|
||||
[edges, executionMode, nodeDefinitionsByStep, nodes, workflow],
|
||||
() => inferWorkflowFamily(currentWorkflowConfig, nodeDefinitionsByStep),
|
||||
[currentWorkflowConfig, nodeDefinitionsByStep],
|
||||
)
|
||||
const isOrderLineGraph = graphFamily === 'order_line'
|
||||
|
||||
const { data: workflowOrderLineContexts = [], isFetching: isOrderLineContextsLoading } = useQuery({
|
||||
queryKey: ['workflow-order-line-contexts'],
|
||||
queryFn: () => getWorkflowOrderLineContexts(50),
|
||||
enabled: isOrderLineGraph,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const orderLineContextGroups = useMemo<WorkflowOrderLineContextGroup[]>(
|
||||
() => normalizeOrderLineContextGroups(workflowOrderLineContexts).filter(group => group.options.length > 0),
|
||||
[workflowOrderLineContexts],
|
||||
)
|
||||
const selectedOrderLineContext = useMemo(
|
||||
() =>
|
||||
orderLineContextGroups
|
||||
.flatMap(group => group.options)
|
||||
.find(option => option.value === dispatchContextId) ?? null,
|
||||
[dispatchContextId, orderLineContextGroups],
|
||||
)
|
||||
const dispatchContextLabel = useMemo(() => {
|
||||
if (isOrderLineGraph) return 'Order Line'
|
||||
if (graphFamily === 'cad_file') return 'CAD File'
|
||||
return 'Context'
|
||||
}, [graphFamily, isOrderLineGraph])
|
||||
const dispatchContextSummary = useMemo(() => {
|
||||
if (isOrderLineGraph) return selectedOrderLineContext?.label ?? null
|
||||
const trimmed = dispatchContextId.trim()
|
||||
if (graphFamily === 'cad_file' && trimmed.length > 0) return 'CAD File'
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}, [dispatchContextId, graphFamily, isOrderLineGraph, selectedOrderLineContext])
|
||||
const dispatchContextMeta = useMemo(() => {
|
||||
if (isOrderLineGraph) return selectedOrderLineContext?.meta ?? null
|
||||
if (graphFamily !== 'cad_file') return null
|
||||
const trimmed = dispatchContextId.trim()
|
||||
if (!trimmed) return null
|
||||
if (preflightResult?.context_id === trimmed && preflightResult.resolved_cad_file_id) {
|
||||
return `${preflightResult.resolved_cad_file_id} · validated`
|
||||
}
|
||||
return trimmed
|
||||
}, [dispatchContextId, graphFamily, isOrderLineGraph, preflightResult, selectedOrderLineContext])
|
||||
const currentDispatchFingerprint = useMemo(
|
||||
() => JSON.stringify({ contextId: dispatchContextId.trim(), config: currentWorkflowConfig }),
|
||||
[currentWorkflowConfig, dispatchContextId],
|
||||
)
|
||||
const hasFreshSuccessfulPreflight =
|
||||
preflightResult?.graph_dispatch_allowed === true &&
|
||||
lastSuccessfulPreflightFingerprint === currentDispatchFingerprint
|
||||
|
||||
const { data: workflowRuns = [] } = useQuery({
|
||||
queryKey: ['workflow-runs', workflow.id],
|
||||
@@ -140,32 +225,47 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
context_id: contextId,
|
||||
config,
|
||||
}),
|
||||
onSuccess: result => {
|
||||
onSuccess: (result, variables) => {
|
||||
setPreflightResult(result)
|
||||
if (result.graph_dispatch_allowed) {
|
||||
setLastSuccessfulPreflightFingerprint(JSON.stringify({ contextId: variables.contextId, config: variables.config }))
|
||||
toast.success(result.summary)
|
||||
} else {
|
||||
setLastSuccessfulPreflightFingerprint(null)
|
||||
toast.error(result.summary)
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setPreflightResult(null)
|
||||
setLastSuccessfulPreflightFingerprint(null)
|
||||
toast.error(error?.response?.data?.detail || 'Failed to preflight workflow')
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const graph = workflowToGraph(workflow.config, nodeDefinitionsByStep)
|
||||
setNodes(graph.nodes)
|
||||
const nextNodes = graphNeedsAutoLayout(graph.nodes) ? applyAutoLayout(graph.nodes, graph.edges) : graph.nodes
|
||||
setNodes(nextNodes)
|
||||
setEdges(graph.edges)
|
||||
setSelectedNodeId(null)
|
||||
setSelectedEdgeIds([])
|
||||
setSelectedRunId(null)
|
||||
setNodeMenuAnchor(null)
|
||||
setPreflightResult(null)
|
||||
setLastSuccessfulPreflightFingerprint(null)
|
||||
setExecutionMode(workflow.config.ui?.execution_mode ?? 'legacy')
|
||||
setActiveUtilityTab('library')
|
||||
}, [nodeDefinitionsData, setEdges, setNodes, workflow.config])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOrderLineGraph) return
|
||||
if (dispatchContextId.trim()) return
|
||||
const firstOption = orderLineContextGroups[0]?.options[0]
|
||||
if (firstOption) {
|
||||
setDispatchContextId(firstOption.value)
|
||||
}
|
||||
}, [dispatchContextId, isOrderLineGraph, orderLineContextGroups])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRunId && workflowRuns.length > 0) {
|
||||
setSelectedRunId(workflowRuns[0].id)
|
||||
@@ -177,18 +277,110 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
}, [selectedRunId, workflowRuns])
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => setEdges(currentEdges => addEdge(connection, currentEdges)),
|
||||
[setEdges],
|
||||
(connection: Connection) => {
|
||||
if (!connection.source || !connection.target) return
|
||||
|
||||
const sourceNode = nodes.find(node => node.id === connection.source)
|
||||
const targetNode = nodes.find(node => node.id === connection.target)
|
||||
const sourceData = sourceNode?.data as WorkflowCanvasNodeData | undefined
|
||||
const targetData = targetNode?.data as WorkflowCanvasNodeData | undefined
|
||||
const sourcePorts = sourceData?.outputPorts ?? []
|
||||
const targetPorts = targetData?.inputPorts ?? []
|
||||
|
||||
if (sourcePorts.length === 0) {
|
||||
toast.error('Selected source node does not expose any downstream outputs.')
|
||||
return
|
||||
}
|
||||
|
||||
if (targetPorts.length === 0) {
|
||||
toast.error('Selected target node does not declare any upstream inputs.')
|
||||
return
|
||||
}
|
||||
|
||||
const requestedTargetPort = connection.targetHandle
|
||||
? targetPorts.find(port => port.id === connection.targetHandle)
|
||||
: undefined
|
||||
const requestedSourcePort = connection.sourceHandle
|
||||
? sourcePorts.find(port => port.id === connection.sourceHandle)
|
||||
: undefined
|
||||
|
||||
const matchingTargetPort =
|
||||
requestedTargetPort &&
|
||||
sourcePorts.some(sourcePort =>
|
||||
requestedTargetPort.roles.some(role => sourcePort.roles.includes(role)),
|
||||
)
|
||||
? requestedTargetPort
|
||||
: targetPorts.find(port =>
|
||||
sourcePorts.some(sourcePort => port.roles.some(role => sourcePort.roles.includes(role))),
|
||||
)
|
||||
|
||||
if (!matchingTargetPort) {
|
||||
toast.error('These nodes do not share a compatible input/output contract.')
|
||||
return
|
||||
}
|
||||
|
||||
const matchingSourcePort =
|
||||
requestedSourcePort &&
|
||||
matchingTargetPort.roles.some(role => requestedSourcePort.roles.includes(role))
|
||||
? requestedSourcePort
|
||||
: sourcePorts.find(port => matchingTargetPort.roles.some(role => port.roles.includes(role)))
|
||||
|
||||
if (!matchingSourcePort) {
|
||||
toast.error('The selected source handle does not satisfy the target input contract.')
|
||||
return
|
||||
}
|
||||
|
||||
setEdges(currentEdges => {
|
||||
const duplicateEdge = currentEdges.some(
|
||||
edge => edge.source === connection.source && edge.target === connection.target,
|
||||
)
|
||||
if (duplicateEdge) {
|
||||
toast.error('A connection between these nodes already exists.')
|
||||
return currentEdges
|
||||
}
|
||||
|
||||
return addEdge(
|
||||
{
|
||||
...connection,
|
||||
sourceHandle: matchingSourcePort.id,
|
||||
targetHandle: matchingTargetPort.id,
|
||||
},
|
||||
currentEdges,
|
||||
)
|
||||
})
|
||||
},
|
||||
[nodes, setEdges],
|
||||
)
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
setNodes(currentNodes => {
|
||||
const nextNodes = applyNodeChanges(changes, currentNodes)
|
||||
const settledNodeIds = changes
|
||||
.filter((change): change is Extract<NodeChange, { type: 'position' }> => change.type === 'position')
|
||||
.filter(change => change.dragging !== true)
|
||||
.map(change => change.id)
|
||||
|
||||
if (settledNodeIds.length === 0) {
|
||||
return nextNodes
|
||||
}
|
||||
|
||||
return resolveNodeCollisions(nextNodes, settledNodeIds)
|
||||
})
|
||||
},
|
||||
[setNodes],
|
||||
)
|
||||
|
||||
const onNodeClick = useCallback((_: ReactMouseEvent, node: Node) => {
|
||||
setNodeMenuAnchor(null)
|
||||
setSelectedEdgeIds([])
|
||||
setSelectedNodeId(node.id)
|
||||
setActiveUtilityTab('inspector')
|
||||
}, [])
|
||||
|
||||
const onEdgeClick = useCallback((_: ReactMouseEvent, edge: Edge) => {
|
||||
setNodeMenuAnchor(null)
|
||||
setSelectedEdgeIds([edge.id])
|
||||
setSelectedNodeId(null)
|
||||
setEdges(currentEdges =>
|
||||
currentEdges.map(currentEdge => ({
|
||||
@@ -200,6 +392,7 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
setNodeMenuAnchor(null)
|
||||
setSelectedEdgeIds([])
|
||||
setSelectedNodeId(null)
|
||||
setEdges(currentEdges =>
|
||||
currentEdges.map(edge => ({
|
||||
@@ -226,8 +419,8 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
const handlePipelineStepChange = useCallback(
|
||||
(stepName: string) => {
|
||||
const definition = nodeDefinitionsByStep[stepName]
|
||||
if (definition && !isDefinitionAllowedForGraphFamily(definition, graphFamily)) {
|
||||
toast.error(`${definition.label} does not belong to the ${GRAPH_FAMILY_LABELS[graphFamily]} family.`)
|
||||
if (definition && !isDefinitionAllowedForGraphFamily(definition, authoringFamily)) {
|
||||
toast.error(`${definition.label} does not belong to the ${GRAPH_FAMILY_LABELS[authoringFamily]} authoring family.`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -235,12 +428,12 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
currentNodes.map(node => {
|
||||
if (node.id !== selectedNodeId) return node
|
||||
|
||||
const currentData = (node.data as WorkflowCanvasNodeData | undefined) ?? buildNodeData(stepName)
|
||||
const currentData = (node.data as WorkflowCanvasNodeData | undefined) ?? buildWorkflowCanvasNodeData(stepName)
|
||||
return {
|
||||
...node,
|
||||
type: definition?.node_type ?? inferNodeType(stepName),
|
||||
data: {
|
||||
...buildNodeData(
|
||||
...buildWorkflowCanvasNodeData(
|
||||
stepName || inferStepFromNodeType(node.type),
|
||||
resolveParamsForStepChange(definition, currentData.params),
|
||||
definition,
|
||||
@@ -251,7 +444,7 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
}),
|
||||
)
|
||||
},
|
||||
[graphFamily, nodeDefinitionsByStep, selectedNodeId, setNodes],
|
||||
[authoringFamily, nodeDefinitionsByStep, selectedNodeId, setNodes],
|
||||
)
|
||||
|
||||
const openNodeMenu = useCallback(
|
||||
@@ -287,27 +480,91 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
const insertNode = useCallback(
|
||||
(step: string, preferredPosition?: { x: number; y: number }) => {
|
||||
const definition = nodeDefinitionsByStep[step]
|
||||
if (definition && !isDefinitionAllowedForGraphFamily(definition, graphFamily)) {
|
||||
toast.error(`${definition.label} cannot be added to a ${GRAPH_FAMILY_LABELS[graphFamily]} workflow.`)
|
||||
if (definition && !isDefinitionAllowedForGraphFamily(definition, authoringFamily)) {
|
||||
toast.error(`${definition.label} cannot be added to a ${GRAPH_FAMILY_LABELS[authoringFamily]} workflow.`)
|
||||
return
|
||||
}
|
||||
|
||||
const type = definition?.node_type ?? inferNodeType(step)
|
||||
const fallbackX = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.x)) + 220 : 120
|
||||
const fallbackY = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.y)) + 40 : 120
|
||||
const fallbackX = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.x)) + 312 : 120
|
||||
const fallbackY = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.y)) + 140 : 120
|
||||
const position = findOpenNodePosition(nodes, preferredPosition ?? { x: fallbackX, y: fallbackY })
|
||||
const newNode: Node = {
|
||||
id: `${step}_${Date.now()}`,
|
||||
type,
|
||||
position: preferredPosition ?? { x: fallbackX, y: fallbackY },
|
||||
data: buildNodeData(step, definition?.defaults ?? {}, definition),
|
||||
position,
|
||||
data: buildWorkflowCanvasNodeData(step, definition?.defaults ?? {}, definition),
|
||||
}
|
||||
const nextNodes = [...nodes, newNode]
|
||||
const shouldAutoLayout = shouldAutoLayoutAfterInsert(nextNodes, newNode, preferredPosition ?? null)
|
||||
const laidOutNodes = shouldAutoLayout ? applyAutoLayout(nextNodes, edges) : nextNodes
|
||||
|
||||
setNodes(currentNodes => [...currentNodes, newNode])
|
||||
setNodes(laidOutNodes)
|
||||
setSelectedNodeId(newNode.id)
|
||||
setNodeMenuAnchor(null)
|
||||
setActiveUtilityTab('inspector')
|
||||
|
||||
if (shouldAutoLayout) {
|
||||
window.requestAnimationFrame(() => {
|
||||
reactFlowInstance?.fitView({ padding: 0.2, duration: 220 })
|
||||
})
|
||||
}
|
||||
},
|
||||
[graphFamily, nodeDefinitionsByStep, nodes, setNodes],
|
||||
[authoringFamily, edges, nodeDefinitionsByStep, nodes, reactFlowInstance, setNodes],
|
||||
)
|
||||
|
||||
const insertModuleBundle = useCallback(
|
||||
(bundleId: WorkflowModuleBundleId, preferredPosition?: { x: number; y: number }) => {
|
||||
const insertion = createWorkflowModuleBundleInsertion({
|
||||
bundleId,
|
||||
graphFamily: authoringFamily,
|
||||
nodeDefinitionsByStep,
|
||||
existingNodes: nodes,
|
||||
preferredPosition,
|
||||
})
|
||||
|
||||
if (!insertion.ok) {
|
||||
toast.error(insertion.reason)
|
||||
return
|
||||
}
|
||||
|
||||
const combinedNodes = [...nodes, ...insertion.nodes]
|
||||
const combinedEdges = [...edges, ...insertion.edges]
|
||||
setNodes(graphNeedsAutoLayout(combinedNodes) ? applyAutoLayout(combinedNodes, combinedEdges) : combinedNodes)
|
||||
setEdges(combinedEdges)
|
||||
setSelectedNodeId(insertion.nodes[0]?.id ?? null)
|
||||
setNodeMenuAnchor(null)
|
||||
setActiveUtilityTab('inspector')
|
||||
toast.success(`${insertion.bundle.label} inserted`)
|
||||
},
|
||||
[authoringFamily, edges, nodeDefinitionsByStep, nodes, setEdges, setNodes],
|
||||
)
|
||||
|
||||
const insertReferenceBundle = useCallback(
|
||||
(bundleId: WorkflowReferenceBundleId, preferredPosition?: { x: number; y: number }) => {
|
||||
const insertion = createWorkflowReferenceBundleInsertion({
|
||||
bundleId,
|
||||
graphFamily: authoringFamily,
|
||||
nodeDefinitionsByStep,
|
||||
existingNodes: nodes,
|
||||
preferredPosition,
|
||||
})
|
||||
|
||||
if (!insertion.ok) {
|
||||
toast.error(insertion.reason)
|
||||
return
|
||||
}
|
||||
|
||||
const combinedNodes = [...nodes, ...insertion.nodes]
|
||||
const combinedEdges = [...edges, ...insertion.edges]
|
||||
setNodes(graphNeedsAutoLayout(combinedNodes) ? applyAutoLayout(combinedNodes, combinedEdges) : combinedNodes)
|
||||
setEdges(combinedEdges)
|
||||
setSelectedNodeId(insertion.nodes[0]?.id ?? null)
|
||||
setNodeMenuAnchor(null)
|
||||
setActiveUtilityTab('inspector')
|
||||
toast.success(`${insertion.bundle.label} inserted`)
|
||||
},
|
||||
[authoringFamily, edges, nodeDefinitionsByStep, nodes, setEdges, setNodes],
|
||||
)
|
||||
|
||||
const handleOpenToolbarNodeMenu = useCallback(() => {
|
||||
@@ -327,6 +584,7 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
const deleteEdgesById = useCallback((edgeIds: string[]) => {
|
||||
if (edgeIds.length === 0) return
|
||||
setEdges(currentEdges => currentEdges.filter(edge => !edgeIds.includes(edge.id)))
|
||||
setSelectedEdgeIds(currentIds => currentIds.filter(edgeId => !edgeIds.includes(edgeId)))
|
||||
setSelectedNodeId(null)
|
||||
setNodeMenuAnchor(null)
|
||||
toast.success(edgeIds.length === 1 ? 'Connection deleted' : `${edgeIds.length} connections deleted`)
|
||||
@@ -348,6 +606,27 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
deleteEdgesById([edge.id])
|
||||
}, [deleteEdgesById])
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
|
||||
if (selectedNodes.length > 0) {
|
||||
setSelectedNodeId(selectedNodes[0].id)
|
||||
setSelectedEdgeIds(selectedEdges.map(edge => edge.id))
|
||||
setActiveUtilityTab('inspector')
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedEdges.length > 0) {
|
||||
setSelectedNodeId(null)
|
||||
setSelectedEdgeIds(selectedEdges.map(edge => edge.id))
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedNodeId(null)
|
||||
setSelectedEdgeIds([])
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
@@ -383,8 +662,8 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
toast.error('Resolve workflow validation errors before saving.')
|
||||
return
|
||||
}
|
||||
onSave(buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode))
|
||||
}, [edges, executionMode, nodes, onSave, validation.errors.length, workflow])
|
||||
onSave(currentWorkflowConfig)
|
||||
}, [currentWorkflowConfig, onSave, validation.errors.length])
|
||||
|
||||
const handleDispatch = useCallback(() => {
|
||||
if (!dispatchContextId.trim()) {
|
||||
@@ -395,11 +674,15 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
toast.error('Resolve workflow validation errors before dispatching.')
|
||||
return
|
||||
}
|
||||
if (!hasFreshSuccessfulPreflight) {
|
||||
toast.error('Run a fresh Dry Run for the current graph and context before dispatching.')
|
||||
return
|
||||
}
|
||||
dispatchMutation.mutate({
|
||||
contextId: dispatchContextId.trim(),
|
||||
config: buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode),
|
||||
config: currentWorkflowConfig,
|
||||
})
|
||||
}, [dispatchContextId, dispatchMutation, edges, executionMode, nodes, validation.errors.length, workflow])
|
||||
}, [currentWorkflowConfig, dispatchContextId, dispatchMutation, hasFreshSuccessfulPreflight, validation.errors.length])
|
||||
|
||||
const handlePreflight = useCallback(() => {
|
||||
if (!dispatchContextId.trim()) {
|
||||
@@ -412,14 +695,21 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
}
|
||||
preflightMutation.mutate({
|
||||
contextId: dispatchContextId.trim(),
|
||||
config: buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode),
|
||||
config: currentWorkflowConfig,
|
||||
})
|
||||
}, [dispatchContextId, edges, executionMode, nodes, preflightMutation, validation.errors.length, workflow])
|
||||
}, [currentWorkflowConfig, dispatchContextId, preflightMutation, validation.errors.length])
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() => nodes.find(node => node.id === selectedNodeId),
|
||||
[nodes, selectedNodeId],
|
||||
)
|
||||
const preflightState = useMemo<'ready' | 'required' | 'stale' | 'blocked'>(() => {
|
||||
if (!dispatchContextId.trim()) return 'required'
|
||||
if (preflightResult && !preflightResult.graph_dispatch_allowed) return 'blocked'
|
||||
if (hasFreshSuccessfulPreflight) return 'ready'
|
||||
if (preflightResult) return 'stale'
|
||||
return 'required'
|
||||
}, [dispatchContextId, hasFreshSuccessfulPreflight, preflightResult])
|
||||
|
||||
return {
|
||||
reactFlowWrapper,
|
||||
@@ -439,7 +729,15 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
preflightMutation,
|
||||
dispatchContextId,
|
||||
setDispatchContextId,
|
||||
isOrderLineGraph,
|
||||
isOrderLineContextsLoading,
|
||||
orderLineContextGroups,
|
||||
dispatchContextLabel,
|
||||
dispatchContextSummary,
|
||||
dispatchContextMeta,
|
||||
preflightResult,
|
||||
preflightState,
|
||||
hasFreshSuccessfulPreflight,
|
||||
executionMode,
|
||||
setExecutionMode,
|
||||
nodeMenuAnchor,
|
||||
@@ -447,6 +745,7 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
activeUtilityTab,
|
||||
setActiveUtilityTab,
|
||||
validation,
|
||||
authoringFamily,
|
||||
graphFamily,
|
||||
onConnect,
|
||||
onNodeClick,
|
||||
@@ -457,11 +756,14 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
handlePaneContextMenu,
|
||||
handleNodeContextMenu,
|
||||
insertNode,
|
||||
insertModuleBundle,
|
||||
insertReferenceBundle,
|
||||
handleOpenToolbarNodeMenu,
|
||||
handleAutoLayout,
|
||||
handleDeleteSelectedEdges,
|
||||
onEdgeContextMenu,
|
||||
onEdgeDoubleClick,
|
||||
handleSelectionChange,
|
||||
handleSave,
|
||||
handleDispatch,
|
||||
handlePreflight,
|
||||
|
||||
Reference in New Issue
Block a user