From 1836ca9112c2e152ef92b8f82786d1e0e7b1d738 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Tue, 30 Jun 2026 11:19:26 +0200 Subject: [PATCH 1/4] Enhance flow handling by integrating forwards resolution and improving source handle extraction --- .../app/hooks/use-handle-types.spec.ts | 25 ++++ .../frontend/app/hooks/use-handle-types.ts | 23 +--- .../app/routes/studio/canvas/flow.tsx | 39 ++++-- .../canvas/nodetypes/components/handle.tsx | 38 +++++- .../studio/canvas/nodetypes/frank-node.tsx | 13 +- .../routes/studio/xml-to-json-parser.spec.ts | 75 +++++++++++ .../app/routes/studio/xml-to-json-parser.ts | 56 +++++--- .../frontend/app/utils/frankdoc-utils.spec.ts | 127 ++++++++++++++++++ src/main/frontend/app/utils/frankdoc-utils.ts | 43 ++++++ 9 files changed, 380 insertions(+), 59 deletions(-) create mode 100644 src/main/frontend/app/hooks/use-handle-types.spec.ts create mode 100644 src/main/frontend/app/routes/studio/xml-to-json-parser.spec.ts create mode 100644 src/main/frontend/app/utils/frankdoc-utils.spec.ts create mode 100644 src/main/frontend/app/utils/frankdoc-utils.ts diff --git a/src/main/frontend/app/hooks/use-handle-types.spec.ts b/src/main/frontend/app/hooks/use-handle-types.spec.ts new file mode 100644 index 00000000..e7d2d3d4 --- /dev/null +++ b/src/main/frontend/app/hooks/use-handle-types.spec.ts @@ -0,0 +1,25 @@ +import { getHandleTypes } from './use-handle-types' + +describe('getHandleTypes', () => { + it('returns empty array when typesAllowed is undefined', () => { + expect(getHandleTypes()).toEqual([]) + }) + + it('returns empty array when typesAllowed is empty', () => { + expect(getHandleTypes({})).toEqual([]) + }) + + it('returns all forwards as-is', () => { + const result = getHandleTypes({ success: {}, exception: {}, failure: {} }) + expect(result).toContain('success') + expect(result).toContain('exception') + expect(result).toContain('failure') + }) + + it('maps wildcard * to custom', () => { + const result = getHandleTypes({ '*': {}, exception: {} }) + expect(result).toContain('custom') + expect(result).toContain('exception') + expect(result).not.toContain('*') + }) +}) diff --git a/src/main/frontend/app/hooks/use-handle-types.ts b/src/main/frontend/app/hooks/use-handle-types.ts index 6713efce..4fce4ee9 100644 --- a/src/main/frontend/app/hooks/use-handle-types.ts +++ b/src/main/frontend/app/hooks/use-handle-types.ts @@ -1,23 +1,12 @@ import { useMemo } from 'react' import type { ElementProperty } from '@frankframework/doc-library-core' -export function useHandleTypes(typesAllowed?: Record) { - return useMemo(() => { - // Always include the 'success' handle, using a Set to avoid duplicates - const handles = new Set(['success']) - - if (!typesAllowed) return [...handles] - - if ('*' in typesAllowed) { - handles.add('custom') - } +export function getHandleTypes(typesAllowed?: Record): string[] { + if (!typesAllowed) return [] - for (const type of Object.keys(typesAllowed)) { - if (type !== '*') { - handles.add(type) - } - } + return Object.keys(typesAllowed).flatMap((type) => (type === '*' ? ['custom'] : [type])) +} - return [...handles] - }, [typesAllowed]) +export function useHandleTypes(typesAllowed?: Record) { + return useMemo(() => getHandleTypes(typesAllowed), [typesAllowed]) } diff --git a/src/main/frontend/app/routes/studio/canvas/flow.tsx b/src/main/frontend/app/routes/studio/canvas/flow.tsx index becde7dc..bbbe896c 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -35,12 +35,17 @@ import { logApiError } from '~/utils/logger' import { NodeContextMenuContext, useNodeContextMenu } from './node-context-menu-context' import StickyNoteComponent, { type StickyNote } from '~/routes/studio/canvas/nodetypes/sticky-note' import useTabStore, { type TabData } from '~/stores/tab-store' -import { convertAdapterXmlToJson, getAdapterFromConfiguration } from '~/routes/studio/xml-to-json-parser' +import { + convertAdapterXmlToJson, + getAdapterFromConfiguration, + type ResolveForwards, +} from '~/routes/studio/xml-to-json-parser' import { exportFlowToXml, replaceAdapterInXml } from '~/routes/studio/flow-to-xml-parser' import useNodeContextStore from '~/stores/node-context-store' import CreateNodeModal from '~/components/flow/create-node-modal' import { useFFDoc } from '@frankframework/doc-library-react' import type { ElementDetails } from '@frankframework/doc-library-core' +import { getDefaultSourceHandles, resolveForwardsWithInheritance } from '~/utils/frankdoc-utils' import { useProjectStore } from '~/stores/project-store' import { clearConfigurationFileCache, @@ -195,7 +200,7 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { setSelectedGroupId: store.setSelectedGroupId, })), ) - const { elements } = useFFDoc() + const { elements, ffDoc } = useFFDoc() const elementsRef = useRef(elements) const showNodeContextMenuRef = useRef(showNodeContextMenu) const navigate = useNavigate() @@ -992,6 +997,18 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { [elements], ) + const resolveForwards = useCallback( + (subtype: string) => { + const element = lookupFrankElement(subtype) + if (!element || !ffDoc) return element?.forwards + return resolveForwardsWithInheritance(element.forwards, ffDoc.elements) + }, + [lookupFrankElement, ffDoc], + ) + + const importForwardsResolverRef = useRef(undefined) + importForwardsResolverRef.current = ffDoc ? resolveForwards : undefined + const deselectOtherNodes = useCallback( (nodeId: string) => { const flowNodes = reactFlow.getNodes() @@ -1241,6 +1258,11 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { const width = nodeType === 'exitNode' ? FlowConfig.EXIT_DEFAULT_WIDTH : FlowConfig.NODE_DEFAULT_WIDTH const height = nodeType === 'exitNode' ? FlowConfig.EXIT_DEFAULT_HEIGHT : FlowConfig.NODE_MIN_HEIGHT + const sourceHandles = + nodeType === 'frankNode' && ffDoc + ? getDefaultSourceHandles(resolveForwards(elementName)) + : [{ type: 'success', index: 1 }] + const newNode: FrankNodeType = { id: newId.toString(), position: { @@ -1251,7 +1273,7 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { subtype: elementName, type: elementType, name: ``, - sourceHandles: [{ type: 'success', index: 1 }], + sourceHandles, children: [], }, type: nodeType, @@ -1487,7 +1509,7 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { tab.adapterPosition, ) if (!adapter) return - const adapterJson = await convertAdapterXmlToJson(adapter) + const adapterJson = await convertAdapterXmlToJson(adapter, importForwardsResolverRef.current) flowStore.setEdges(adapterJson.edges) flowStore.setNodes(adapterJson.nodes) @@ -1733,10 +1755,7 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { position={pendingCompactConnection.position} onClose={() => setPendingCompactConnection(null)} onSelect={handleCompactHandleSelect} - typesAllowed={ - (elements as Record | null)?.[pendingCompactConnection.sourceNodeSubtype] - ?.forwards - } + typesAllowed={resolveForwards(pendingCompactConnection.sourceNodeSubtype)} /> )} @@ -1746,9 +1765,7 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { position={pendingEdgeDrop.position} onClose={() => setPendingEdgeDrop(null)} onSelect={handleEdgeDropHandleSelect} - typesAllowed={ - (elements as Record | null)?.[pendingEdgeDrop.sourceNodeSubtype]?.forwards - } + typesAllowed={resolveForwards(pendingEdgeDrop.sourceNodeSubtype)} /> )} diff --git a/src/main/frontend/app/routes/studio/canvas/nodetypes/components/handle.tsx b/src/main/frontend/app/routes/studio/canvas/nodetypes/components/handle.tsx index b8979921..ff3389f7 100644 --- a/src/main/frontend/app/routes/studio/canvas/nodetypes/components/handle.tsx +++ b/src/main/frontend/app/routes/studio/canvas/nodetypes/components/handle.tsx @@ -13,25 +13,49 @@ interface HandleProperties { typesAllowed?: Record } -const HANDLE_TYPE_COLOURS: Record = { +const SEMANTIC_COLOURS: Record = { success: '#68D250', failure: '#E84E4E', exception: '#424242', timeout: '#F2A900', error: '#ff7605ff', - default: '#1B97D1', +} + +const FORWARD_COLOURS: Record = { + notfound: '#1B97D1', + empty: '#26A69A', + // eslint-disable-next-line unicorn/no-thenable + then: '#7E57C2', + else: '#EC407A', + lessthan: '#5C6BC0', + greaterthan: '#00ACC1', + equals: '#AB47BC', + stop: '#D81B60', + continue: '#42A5F5', + notinrole: '#8D6E63', + custom: '#9575CD', +} + +const FALLBACK_PALETTE = Object.values(FORWARD_COLOURS) + +function colourFromName(type: string): string { + let hash = 0 + for (let index = 0; index < type.length; index++) { + hash = (hash * 31 + (type.codePointAt(index) ?? 0)) % FALLBACK_PALETTE.length + } + return FALLBACK_PALETTE[hash] } export function translateHandleTypeToColour(type: string): string { const normalized = type.toLowerCase() - for (const [suffix, colour] of Object.entries(HANDLE_TYPE_COLOURS)) { - if (normalized.endsWith(suffix)) { - return colour - } + if (normalized in FORWARD_COLOURS) return FORWARD_COLOURS[normalized] + + for (const [suffix, colour] of Object.entries(SEMANTIC_COLOURS)) { + if (normalized.endsWith(suffix)) return colour } - return HANDLE_TYPE_COLOURS.default + return colourFromName(normalized) } export function CustomHandle(properties: Readonly) { diff --git a/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx b/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx index e5b45cb0..05fe8f81 100644 --- a/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx +++ b/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx @@ -27,6 +27,7 @@ import type { ElementDetails } from '@frankframework/doc-library-core' import { DeprecatedPopover } from './components/deprecated-popover' import { showWarningToast } from '~/components/toast' import { useHandleTypes } from '~/hooks/use-handle-types' +import { resolveForwardsWithInheritance } from '~/utils/frankdoc-utils' import AddSubcomponentModal from '~/components/flow/add-subcomponent-modal' import { useFrankConfigXsd } from '~/providers/frankconfig-xsd-provider' import { @@ -63,7 +64,7 @@ export default function FrankNode(properties: NodeProps) { const [dragOver, setDragOver] = useState(false) const [canDropDraggedElement, setCanDropDraggedElement] = useState(false) const showNodeContextMenu = useNodeContextMenu() - const { elements } = useFFDoc() + const { elements, ffDoc } = useFFDoc() const { xsdDoc } = useFrankConfigXsd() const { setNodeId, @@ -85,8 +86,14 @@ export default function FrankNode(properties: NodeProps) { if (!elements) return null const recordElements = elements as Record - return Object.values(recordElements).find((element) => element.name === properties.data.subtype) ?? null - }, [elements, properties.data.subtype]) + const element = Object.values(recordElements).find((element) => element.name === properties.data.subtype) ?? null + if (!element || !ffDoc) return element + + return { + ...element, + forwards: resolveForwardsWithInheritance(element.forwards, ffDoc.elements), + } + }, [elements, ffDoc, properties.data.subtype]) const isDeprecated = frankElement?.deprecated const [showDeprecated, setShowDeprecated] = useState(false) diff --git a/src/main/frontend/app/routes/studio/xml-to-json-parser.spec.ts b/src/main/frontend/app/routes/studio/xml-to-json-parser.spec.ts new file mode 100644 index 00000000..7bee5147 --- /dev/null +++ b/src/main/frontend/app/routes/studio/xml-to-json-parser.spec.ts @@ -0,0 +1,75 @@ +import { extractSourceHandles, type ResolveForwards } from './xml-to-json-parser' +import type { ElementProperty } from '@frankframework/doc-library-core' + +function elementFromXml(xml: string): Element { + const document_ = new DOMParser().parseFromString(xml, 'text/xml') + return document_.documentElement +} + +const FORWARDS: Record> = { + EchoPipe: { success: {}, exception: {} }, + IfPipe: { exception: {}, '*': {}, else: {} }, + SwitchPipe: { exception: {}, notFound: {}, empty: {}, '*': {} }, +} + +const resolveForwards: ResolveForwards = (subtype) => FORWARDS[subtype] + +describe('extractSourceHandles', () => { + it('creates a handle per for any element type', () => { + const element = elementFromXml( + '', + ) + expect(extractSourceHandles(element, resolveForwards)).toEqual([ + { type: 'exception', index: 1 }, + { type: 'then', index: 2 }, + { type: 'else', index: 3 }, + ]) + }) + + it('does NOT add an implicit success handle to a routing pipe (IfPipe)', () => { + const element = elementFromXml( + '', + ) + const handles = extractSourceHandles(element, resolveForwards) + expect(handles.some((handle) => handle.type === 'success')).toBe(false) + }) + + it('adds the implicit success fall-through handle for a FixedForwardPipe subclass with only an exception forward', () => { + const element = elementFromXml('') + expect(extractSourceHandles(element, resolveForwards)).toEqual([ + { type: 'exception', index: 1 }, + { type: 'success', index: 2 }, + ]) + }) + + it('keeps an explicit success forward without duplicating it', () => { + const element = elementFromXml('') + expect(extractSourceHandles(element, resolveForwards)).toEqual([{ type: 'success', index: 1 }]) + }) + + it('uses the FrankDoc default handle when no forwards are declared (EchoPipe -> success)', () => { + const element = elementFromXml('') + expect(extractSourceHandles(element, resolveForwards)).toEqual([{ type: 'success', index: 1 }]) + }) + + it('uses the FrankDoc default handle when no forwards are declared (SwitchPipe -> first routing forward)', () => { + const element = elementFromXml('') + expect(extractSourceHandles(element, resolveForwards)).toEqual([{ type: 'notFound', index: 1 }]) + }) + + it('falls back to a single success handle when no FrankDoc resolver is provided', () => { + const element = elementFromXml('') + expect(extractSourceHandles(element)).toEqual([{ type: 'success', index: 1 }]) + }) + + it('keeps adding the implicit success handle without a resolver (legacy behaviour)', () => { + const element = elementFromXml( + '', + ) + expect(extractSourceHandles(element)).toEqual([ + { type: 'then', index: 1 }, + { type: 'else', index: 2 }, + { type: 'success', index: 3 }, + ]) + }) +}) diff --git a/src/main/frontend/app/routes/studio/xml-to-json-parser.ts b/src/main/frontend/app/routes/studio/xml-to-json-parser.ts index f15dc780..f8b2c0c8 100644 --- a/src/main/frontend/app/routes/studio/xml-to-json-parser.ts +++ b/src/main/frontend/app/routes/studio/xml-to-json-parser.ts @@ -5,6 +5,8 @@ import type { FrankNodeType } from '~/routes/studio/canvas/nodetypes/frank-node' import { getElementTypeFromName } from '~/routes/studio/node-translator-module' import { fetchConfigurationFileCached } from '~/services/configuration-file-service' import { translateElementFromOldToNewFormat } from '~/utils/flow-utils' +import { getDefaultSourceHandles } from '~/utils/frankdoc-utils' +import type { ElementProperty } from '@frankframework/doc-library-core' import { FlowConfig } from './canvas/flow.config' import type { GroupNode } from './canvas/nodetypes/group-node' import { isFrankNode } from '~/stores/flow-store' @@ -102,15 +104,22 @@ export async function getAdapterListenerType( return null } -export async function convertAdapterXmlToJson(adapter: Element) { +export type ResolveForwards = (subtype: string) => Record | undefined + +function supportsSuccessForward(subtype: string, resolveForwards?: ResolveForwards): boolean { + const forwards = resolveForwards?.(subtype) + return forwards ? 'success' in forwards : true +} + +export async function convertAdapterXmlToJson(adapter: Element, resolveForwards?: ResolveForwards) { const idCounter: IdCounter = { current: 0 } - const { nodes: flowNodes, elementToId } = convertAdapterToFlowNodes(adapter, idCounter) + const { nodes: flowNodes, elementToId } = convertAdapterToFlowNodes(adapter, idCounter, resolveForwards) const stickyNotes = extractStickyNotesFromAdapter(adapter, idCounter, flowNodes) const groupNodes = extractGroupNodesFromAdapter(adapter, flowNodes, idCounter) assignParentRelationships(flowNodes, groupNodes) const allNodes: FlowNode[] = [...groupNodes, ...flowNodes, ...stickyNotes] - return { nodes: allNodes, edges: extractEdgesFromAdapter(adapter, flowNodes, elementToId) } + return { nodes: allNodes, edges: extractEdgesFromAdapter(adapter, flowNodes, elementToId, resolveForwards) } } function buildNodeNameToIdMap(nodes: FlowNode[]): Map { @@ -174,7 +183,12 @@ function buildNameToNodeMap(nodes: FlowNode[]): Map { * @param elementToId A mapping of XML Elements to their corresponding node IDs, used for edge creation * @returns An array of FrankEdge objects representing all generated edges */ -function extractEdgesFromAdapter(adapter: Element, nodes: FlowNode[], elementToId: Map): FrankEdge[] { +function extractEdgesFromAdapter( + adapter: Element, + nodes: FlowNode[], + elementToId: Map, + resolveForwards?: ResolveForwards, +): FrankEdge[] { const pipelineElement = [...adapter.children].find((el) => el.tagName.toLowerCase() === 'pipeline') || null if (!pipelineElement) return [] @@ -207,9 +221,10 @@ function extractEdgesFromAdapter(adapter: Element, nodes: FlowNode[], elementToI explicitTargetsBySourceId, sourcesWithSuccessExitForward, sourcesWithSuccessPipeForward, + resolveForwards, ) - addImplicitSuccessExitEdge(nodes, edges, forwardIndexBySourceId) + addImplicitSuccessExitEdge(nodes, edges, forwardIndexBySourceId, resolveForwards) return edges } @@ -347,6 +362,7 @@ function addSequentialFallbackEdges( explicitTargetsBySourceId: Map>, sourcesWithSuccessExitForward: Set, sourcesWithSuccessPipeForward: Set, + resolveForwards?: ResolveForwards, ) { for (let i = 0; i < nodes.length - 1; i++) { const current = nodes[i] @@ -354,6 +370,7 @@ function addSequentialFallbackEdges( if (current.type === 'exitNode') continue // skip receivers (they already get edges to first pipeline pipe) if (isFrankNode(current) && current.data.type === 'receiver') continue + if (isFrankNode(current) && !supportsSuccessForward(current.data.subtype, resolveForwards)) continue // find next NON-exit node const next = nodes.slice(i + 1).find((n) => n.type !== 'exitNode') @@ -385,6 +402,7 @@ function addImplicitSuccessExitEdge( nodes: FlowNode[], edges: FrankEdge[], forwardIndexBySourceId: Map, + resolveForwards?: ResolveForwards, ) { const successExit = findSuccessExit(nodes) if (!successExit) return @@ -397,6 +415,8 @@ function addImplicitSuccessExitEdge( if (!lastPipelineNode) return + if (isFrankNode(lastPipelineNode) && !supportsSuccessForward(lastPipelineNode.data.subtype, resolveForwards)) return + const handleIndex = forwardIndexBySourceId.get(lastPipelineNode.id) ?? 1 forwardIndexBySourceId.set(lastPipelineNode.id, handleIndex + 1) @@ -438,15 +458,16 @@ function collectPipelineElements(adapter: Element): Element[] { return elements } -function extractSourceHandles(element: Element): SourceHandle[] { +export function extractSourceHandles(element: Element, resolveForwards?: ResolveForwards): SourceHandle[] { + const { subtype } = translateElementFromOldToNewFormat(element) + const forwards = resolveForwards?.(subtype) + let forwardElements = [...element.querySelectorAll('Forward')] - // Check if forwards are lower case instead if (forwardElements.length === 0) { forwardElements = [...element.querySelectorAll('forward')] - // No forwards? Create a single implicit success handle if (forwardElements.length === 0) { - return [{ type: 'success', index: 1 }] + return forwards ? getDefaultSourceHandles(forwards) : [{ type: 'success', index: 1 }] } } @@ -459,18 +480,10 @@ function extractSourceHandles(element: Element): SourceHandle[] { } }) - // Check if any forward represents SUCCESS - const hasSuccessForward = forwardElements.some((forward) => { - const name = forward.getAttribute('name')?.toUpperCase() - return name === 'SUCCESS' - }) + const hasSuccessForward = forwardElements.some((forward) => forward.getAttribute('name')?.toUpperCase() === 'SUCCESS') - // If not, add implicit fallback handle - if (!hasSuccessForward) { - handles.push({ - type: 'success', - index: handles.length + 1, - }) + if (!hasSuccessForward && supportsSuccessForward(subtype, resolveForwards)) { + handles.push({ type: 'success', index: handles.length + 1 }) } return handles @@ -505,6 +518,7 @@ function processExitElements(element: Element, exitNodes: ExitNode[]) { function convertAdapterToFlowNodes( adapter: Element, idCounter: IdCounter, + resolveForwards?: ResolveForwards, ): { nodes: FlowNode[]; elementToId: Map } { const nodes: FlowNode[] = [] const exitNodes: ExitNode[] = [] @@ -540,7 +554,7 @@ function convertAdapterToFlowNodes( continue } - const sourceHandles = extractSourceHandles(element) + const sourceHandles = extractSourceHandles(element, resolveForwards) const frankNode: FrankNodeType = convertElementToNode(element, idCounter, sourceHandles) elementToId.set(element, frankNode.id) nodes.push(frankNode) diff --git a/src/main/frontend/app/utils/frankdoc-utils.spec.ts b/src/main/frontend/app/utils/frankdoc-utils.spec.ts new file mode 100644 index 00000000..b5a38883 --- /dev/null +++ b/src/main/frontend/app/utils/frankdoc-utils.spec.ts @@ -0,0 +1,127 @@ +// @vitest-environment node +import { getDefaultSourceHandles, resolveForwardsWithInheritance } from './frankdoc-utils' +import type { ElementProperty, FFDocJson } from '@frankframework/doc-library-core' + +const FIXED_FORWARD_PIPE_CLASS = 'org.frankframework.pipes.FixedForwardPipe' + +function makeRawElements(extras: Record = {}): FFDocJson['elements'] { + return { + [FIXED_FORWARD_PIPE_CLASS]: { + name: 'FixedForwardPipe', + abstract: true, + forwards: { success: { description: 'successful processing' } }, + }, + ...extras, + } as FFDocJson['elements'] +} + +describe('resolveForwardsWithInheritance', () => { + it('adds success from FixedForwardPipe for a regular pipe with only exception (e.g. EchoPipe)', () => { + const result = resolveForwardsWithInheritance({ exception: {} }, makeRawElements()) + expect(result).toHaveProperty('success') + expect(result).toHaveProperty('exception') + }) + + it('adds success when element has no forwards at all', () => { + const result = resolveForwardsWithInheritance({}, makeRawElements()) + expect(result).toHaveProperty('success') + }) + + it('adds success when forwards is undefined', () => { + const result = resolveForwardsWithInheritance(undefined, makeRawElements()) + expect(result).toHaveProperty('success') + }) + + it('does NOT add success for SwitchPipe-style pipes (has empty forward)', () => { + const result = resolveForwardsWithInheritance( + { exception: {}, notFound: {}, empty: {}, '*': {} }, + makeRawElements(), + ) + expect(result).not.toHaveProperty('success') + }) + + it('does NOT add success for IfPipe-style pipes (has then/else forwards)', () => { + // eslint-disable-next-line unicorn/no-thenable -- 'then' is a Frank forward name here, not a thenable + const result = resolveForwardsWithInheritance({ exception: {}, then: {}, else: {}, '*': {} }, makeRawElements()) + expect(result).not.toHaveProperty('success') + }) + + it('does NOT add success for CompareStringPipe (has lessthan/greaterthan/equals)', () => { + const result = resolveForwardsWithInheritance( + { exception: {}, lessthan: {}, greaterthan: {}, equals: {} }, + makeRawElements(), + ) + expect(result).not.toHaveProperty('success') + }) + + it('does NOT add success for ForPipe (has stop/continue)', () => { + const result = resolveForwardsWithInheritance({ exception: {}, stop: {}, continue: {} }, makeRawElements()) + expect(result).not.toHaveProperty('success') + }) + + it('adds success for SenderPipe-style pipes (error forwards but no routing outcomes)', () => { + const result = resolveForwardsWithInheritance( + { exception: {}, timeout: {}, illegalResult: {}, '*': {} }, + makeRawElements(), + ) + expect(result).toHaveProperty('success') + expect(result).toHaveProperty('timeout') + }) + + it('does not duplicate success when element already defines it', () => { + const result = resolveForwardsWithInheritance({ exception: {}, success: {} }, makeRawElements()) + expect(Object.keys(result).filter((k) => k === 'success')).toHaveLength(1) + }) + + it('returns forwards unchanged when FixedForwardPipe is not in rawElements', () => { + const result = resolveForwardsWithInheritance({ exception: {} }, {}) + expect(result).toEqual({ exception: {} }) + expect(result).not.toHaveProperty('success') + }) +}) + +describe('getDefaultSourceHandles', () => { + it('defaults to the success handle when the element supports the success forward', () => { + expect(getDefaultSourceHandles({ success: {}, exception: {} })).toEqual([{ type: 'success', index: 1 }]) + }) + + it('skips the exception error forward when picking the default handle', () => { + expect(getDefaultSourceHandles({ exception: {}, success: {} })).toEqual([{ type: 'success', index: 1 }]) + }) + + it('defaults to the first routing forward for switch pipes (no success forward)', () => { + expect(getDefaultSourceHandles({ exception: {}, notFound: {}, empty: {}, '*': {} })).toEqual([ + { type: 'notFound', index: 1 }, + ]) + }) + + it('maps a wildcard-only forward to a custom default handle', () => { + expect(getDefaultSourceHandles({ exception: {}, '*': {} })).toEqual([{ type: 'custom', index: 1 }]) + }) + + it('falls back to exception when it is the only forward', () => { + expect(getDefaultSourceHandles({ exception: {} })).toEqual([{ type: 'exception', index: 1 }]) + }) + + it('returns no handles when forwards is empty', () => { + expect(getDefaultSourceHandles({})).toEqual([]) + }) + + it('returns no handles when forwards is missing', () => { + const missing: Record | undefined = undefined + expect(getDefaultSourceHandles(missing)).toEqual([]) + }) + + it('is consistent with resolveForwardsWithInheritance: a regular pipe defaults to success', () => { + const resolved = resolveForwardsWithInheritance({ exception: {} }, makeRawElements()) + expect(getDefaultSourceHandles(resolved)).toEqual([{ type: 'success', index: 1 }]) + }) + + it('is consistent with resolveForwardsWithInheritance: a SwitchPipe defaults to its first forward', () => { + const resolved = resolveForwardsWithInheritance( + { exception: {}, notFound: {}, empty: {}, '*': {} }, + makeRawElements(), + ) + expect(getDefaultSourceHandles(resolved)).toEqual([{ type: 'notFound', index: 1 }]) + }) +}) diff --git a/src/main/frontend/app/utils/frankdoc-utils.ts b/src/main/frontend/app/utils/frankdoc-utils.ts new file mode 100644 index 00000000..de4c8342 --- /dev/null +++ b/src/main/frontend/app/utils/frankdoc-utils.ts @@ -0,0 +1,43 @@ +import type { ElementProperty, FFDocJson } from '@frankframework/doc-library-core' +import { getHandleTypes } from '~/hooks/use-handle-types' + +const FIXED_FORWARD_PIPE_CLASS = 'org.frankframework.pipes.FixedForwardPipe' + +const ROUTING_OUTCOME_FORWARDS = new Set([ + 'then', + 'else', + 'lessthan', + 'greaterthan', + 'equals', + 'stop', + 'continue', + 'empty', +]) + +export interface SourceHandle { + type: string + index: number +} + +export function resolveForwardsWithInheritance( + effectiveForwards: Record | undefined, + rawElements: FFDocJson['elements'], +): Record { + const forwards = effectiveForwards ?? {} + + const fixedForwardPipe = rawElements[FIXED_FORWARD_PIPE_CLASS] + if (!fixedForwardPipe?.forwards) return forwards + + const isRoutingPipe = Object.keys(forwards).some((forward) => ROUTING_OUTCOME_FORWARDS.has(forward)) + if (isRoutingPipe) return forwards + + return { ...fixedForwardPipe.forwards, ...forwards } +} + +export function getDefaultSourceHandles(resolvedForwards: Record | undefined): SourceHandle[] { + const handleTypes = getHandleTypes(resolvedForwards) + if (handleTypes.length === 0) return [] + + const defaultType = handleTypes.find((type) => type !== 'exception') ?? handleTypes[0] + return [{ type: defaultType, index: 1 }] +} From 254ca9595a818412c2982a54812a0c5c86365f08 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Tue, 30 Jun 2026 11:41:18 +0200 Subject: [PATCH 2/4] Refactor forwards resolution logic and simplify source handle extraction --- .../app/routes/studio/canvas/flow.tsx | 19 ++--- .../canvas/nodetypes/components/handle.tsx | 27 ++----- .../studio/canvas/nodetypes/frank-node.tsx | 8 +- .../frontend/app/utils/frankdoc-utils.spec.ts | 79 ++++++------------- src/main/frontend/app/utils/frankdoc-utils.ts | 18 ++--- 5 files changed, 46 insertions(+), 105 deletions(-) diff --git a/src/main/frontend/app/routes/studio/canvas/flow.tsx b/src/main/frontend/app/routes/studio/canvas/flow.tsx index bbbe896c..c43f92de 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -200,7 +200,7 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { setSelectedGroupId: store.setSelectedGroupId, })), ) - const { elements, ffDoc } = useFFDoc() + const { elements } = useFFDoc() const elementsRef = useRef(elements) const showNodeContextMenuRef = useRef(showNodeContextMenu) const navigate = useNavigate() @@ -998,16 +998,12 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { ) const resolveForwards = useCallback( - (subtype: string) => { - const element = lookupFrankElement(subtype) - if (!element || !ffDoc) return element?.forwards - return resolveForwardsWithInheritance(element.forwards, ffDoc.elements) - }, - [lookupFrankElement, ffDoc], + (subtype: string) => resolveForwardsWithInheritance(lookupFrankElement(subtype)?.forwards), + [lookupFrankElement], ) - const importForwardsResolverRef = useRef(undefined) - importForwardsResolverRef.current = ffDoc ? resolveForwards : undefined + const importForwardsResolverRef = useRef(resolveForwards) + importForwardsResolverRef.current = resolveForwards const deselectOtherNodes = useCallback( (nodeId: string) => { @@ -1258,10 +1254,7 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { const width = nodeType === 'exitNode' ? FlowConfig.EXIT_DEFAULT_WIDTH : FlowConfig.NODE_DEFAULT_WIDTH const height = nodeType === 'exitNode' ? FlowConfig.EXIT_DEFAULT_HEIGHT : FlowConfig.NODE_MIN_HEIGHT - const sourceHandles = - nodeType === 'frankNode' && ffDoc - ? getDefaultSourceHandles(resolveForwards(elementName)) - : [{ type: 'success', index: 1 }] + const sourceHandles = getDefaultSourceHandles(resolveForwards(elementName)) const newNode: FrankNodeType = { id: newId.toString(), diff --git a/src/main/frontend/app/routes/studio/canvas/nodetypes/components/handle.tsx b/src/main/frontend/app/routes/studio/canvas/nodetypes/components/handle.tsx index ff3389f7..c44e4615 100644 --- a/src/main/frontend/app/routes/studio/canvas/nodetypes/components/handle.tsx +++ b/src/main/frontend/app/routes/studio/canvas/nodetypes/components/handle.tsx @@ -21,36 +21,21 @@ const SEMANTIC_COLOURS: Record = { error: '#ff7605ff', } -const FORWARD_COLOURS: Record = { - notfound: '#1B97D1', - empty: '#26A69A', - // eslint-disable-next-line unicorn/no-thenable - then: '#7E57C2', - else: '#EC407A', - lessthan: '#5C6BC0', - greaterthan: '#00ACC1', - equals: '#AB47BC', - stop: '#D81B60', - continue: '#42A5F5', - notinrole: '#8D6E63', - custom: '#9575CD', -} - -const FALLBACK_PALETTE = Object.values(FORWARD_COLOURS) +const GREEN_BAND_START = 95 +const GREEN_BAND_SIZE = 70 function colourFromName(type: string): string { let hash = 0 - for (let index = 0; index < type.length; index++) { - hash = (hash * 31 + (type.codePointAt(index) ?? 0)) % FALLBACK_PALETTE.length + for (const character of type) { + hash = (hash * 31 + (character.codePointAt(0) ?? 0)) % (360 - GREEN_BAND_SIZE) } - return FALLBACK_PALETTE[hash] + const hue = hash < GREEN_BAND_START ? hash : hash + GREEN_BAND_SIZE + return `hsl(${hue}, 65%, 52%)` } export function translateHandleTypeToColour(type: string): string { const normalized = type.toLowerCase() - if (normalized in FORWARD_COLOURS) return FORWARD_COLOURS[normalized] - for (const [suffix, colour] of Object.entries(SEMANTIC_COLOURS)) { if (normalized.endsWith(suffix)) return colour } diff --git a/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx b/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx index 05fe8f81..54c8049a 100644 --- a/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx +++ b/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx @@ -64,7 +64,7 @@ export default function FrankNode(properties: NodeProps) { const [dragOver, setDragOver] = useState(false) const [canDropDraggedElement, setCanDropDraggedElement] = useState(false) const showNodeContextMenu = useNodeContextMenu() - const { elements, ffDoc } = useFFDoc() + const { elements } = useFFDoc() const { xsdDoc } = useFrankConfigXsd() const { setNodeId, @@ -87,13 +87,13 @@ export default function FrankNode(properties: NodeProps) { const recordElements = elements as Record const element = Object.values(recordElements).find((element) => element.name === properties.data.subtype) ?? null - if (!element || !ffDoc) return element + if (!element) return element return { ...element, - forwards: resolveForwardsWithInheritance(element.forwards, ffDoc.elements), + forwards: resolveForwardsWithInheritance(element.forwards), } - }, [elements, ffDoc, properties.data.subtype]) + }, [elements, properties.data.subtype]) const isDeprecated = frankElement?.deprecated const [showDeprecated, setShowDeprecated] = useState(false) diff --git a/src/main/frontend/app/utils/frankdoc-utils.spec.ts b/src/main/frontend/app/utils/frankdoc-utils.spec.ts index b5a38883..9dafc771 100644 --- a/src/main/frontend/app/utils/frankdoc-utils.spec.ts +++ b/src/main/frontend/app/utils/frankdoc-utils.spec.ts @@ -1,82 +1,53 @@ // @vitest-environment node import { getDefaultSourceHandles, resolveForwardsWithInheritance } from './frankdoc-utils' -import type { ElementProperty, FFDocJson } from '@frankframework/doc-library-core' - -const FIXED_FORWARD_PIPE_CLASS = 'org.frankframework.pipes.FixedForwardPipe' - -function makeRawElements(extras: Record = {}): FFDocJson['elements'] { - return { - [FIXED_FORWARD_PIPE_CLASS]: { - name: 'FixedForwardPipe', - abstract: true, - forwards: { success: { description: 'successful processing' } }, - }, - ...extras, - } as FFDocJson['elements'] -} +import type { ElementProperty } from '@frankframework/doc-library-core' describe('resolveForwardsWithInheritance', () => { - it('adds success from FixedForwardPipe for a regular pipe with only exception (e.g. EchoPipe)', () => { - const result = resolveForwardsWithInheritance({ exception: {} }, makeRawElements()) + it('adds success for a regular pipe with only an exception forward (e.g. EchoPipe)', () => { + const result = resolveForwardsWithInheritance({ exception: {} }) expect(result).toHaveProperty('success') expect(result).toHaveProperty('exception') }) - it('adds success when element has no forwards at all', () => { - const result = resolveForwardsWithInheritance({}, makeRawElements()) - expect(result).toHaveProperty('success') + it('adds success when there are no forwards', () => { + expect(resolveForwardsWithInheritance({})).toHaveProperty('success') }) - it('adds success when forwards is undefined', () => { - const result = resolveForwardsWithInheritance(undefined, makeRawElements()) + it('adds success when forwards is missing', () => { + const missing: Record | undefined = undefined + expect(resolveForwardsWithInheritance(missing)).toHaveProperty('success') + }) + + it('adds success for SenderPipe-style pipes (error forwards but no routing outcomes)', () => { + const result = resolveForwardsWithInheritance({ exception: {}, timeout: {}, illegalResult: {}, '*': {} }) expect(result).toHaveProperty('success') + expect(result).toHaveProperty('timeout') }) it('does NOT add success for SwitchPipe-style pipes (has empty forward)', () => { - const result = resolveForwardsWithInheritance( - { exception: {}, notFound: {}, empty: {}, '*': {} }, - makeRawElements(), - ) + const result = resolveForwardsWithInheritance({ exception: {}, notFound: {}, empty: {}, '*': {} }) expect(result).not.toHaveProperty('success') }) it('does NOT add success for IfPipe-style pipes (has then/else forwards)', () => { // eslint-disable-next-line unicorn/no-thenable -- 'then' is a Frank forward name here, not a thenable - const result = resolveForwardsWithInheritance({ exception: {}, then: {}, else: {}, '*': {} }, makeRawElements()) + const result = resolveForwardsWithInheritance({ exception: {}, then: {}, else: {}, '*': {} }) expect(result).not.toHaveProperty('success') }) it('does NOT add success for CompareStringPipe (has lessthan/greaterthan/equals)', () => { - const result = resolveForwardsWithInheritance( - { exception: {}, lessthan: {}, greaterthan: {}, equals: {} }, - makeRawElements(), - ) + const result = resolveForwardsWithInheritance({ exception: {}, lessthan: {}, greaterthan: {}, equals: {} }) expect(result).not.toHaveProperty('success') }) it('does NOT add success for ForPipe (has stop/continue)', () => { - const result = resolveForwardsWithInheritance({ exception: {}, stop: {}, continue: {} }, makeRawElements()) + const result = resolveForwardsWithInheritance({ exception: {}, stop: {}, continue: {} }) expect(result).not.toHaveProperty('success') }) - it('adds success for SenderPipe-style pipes (error forwards but no routing outcomes)', () => { - const result = resolveForwardsWithInheritance( - { exception: {}, timeout: {}, illegalResult: {}, '*': {} }, - makeRawElements(), - ) - expect(result).toHaveProperty('success') - expect(result).toHaveProperty('timeout') - }) - - it('does not duplicate success when element already defines it', () => { - const result = resolveForwardsWithInheritance({ exception: {}, success: {} }, makeRawElements()) - expect(Object.keys(result).filter((k) => k === 'success')).toHaveLength(1) - }) - - it('returns forwards unchanged when FixedForwardPipe is not in rawElements', () => { - const result = resolveForwardsWithInheritance({ exception: {} }, {}) - expect(result).toEqual({ exception: {} }) - expect(result).not.toHaveProperty('success') + it('does not duplicate success when the element already defines it', () => { + const result = resolveForwardsWithInheritance({ exception: {}, success: {} }) + expect(Object.keys(result).filter((forward) => forward === 'success')).toHaveLength(1) }) }) @@ -113,15 +84,13 @@ describe('getDefaultSourceHandles', () => { }) it('is consistent with resolveForwardsWithInheritance: a regular pipe defaults to success', () => { - const resolved = resolveForwardsWithInheritance({ exception: {} }, makeRawElements()) - expect(getDefaultSourceHandles(resolved)).toEqual([{ type: 'success', index: 1 }]) + expect(getDefaultSourceHandles(resolveForwardsWithInheritance({ exception: {} }))).toEqual([ + { type: 'success', index: 1 }, + ]) }) it('is consistent with resolveForwardsWithInheritance: a SwitchPipe defaults to its first forward', () => { - const resolved = resolveForwardsWithInheritance( - { exception: {}, notFound: {}, empty: {}, '*': {} }, - makeRawElements(), - ) + const resolved = resolveForwardsWithInheritance({ exception: {}, notFound: {}, empty: {}, '*': {} }) expect(getDefaultSourceHandles(resolved)).toEqual([{ type: 'notFound', index: 1 }]) }) }) diff --git a/src/main/frontend/app/utils/frankdoc-utils.ts b/src/main/frontend/app/utils/frankdoc-utils.ts index de4c8342..f8dcbac3 100644 --- a/src/main/frontend/app/utils/frankdoc-utils.ts +++ b/src/main/frontend/app/utils/frankdoc-utils.ts @@ -1,8 +1,6 @@ -import type { ElementProperty, FFDocJson } from '@frankframework/doc-library-core' +import type { ElementProperty } from '@frankframework/doc-library-core' import { getHandleTypes } from '~/hooks/use-handle-types' -const FIXED_FORWARD_PIPE_CLASS = 'org.frankframework.pipes.FixedForwardPipe' - const ROUTING_OUTCOME_FORWARDS = new Set([ 'then', 'else', @@ -20,18 +18,14 @@ export interface SourceHandle { } export function resolveForwardsWithInheritance( - effectiveForwards: Record | undefined, - rawElements: FFDocJson['elements'], + forwards: Record | undefined, ): Record { - const forwards = effectiveForwards ?? {} - - const fixedForwardPipe = rawElements[FIXED_FORWARD_PIPE_CLASS] - if (!fixedForwardPipe?.forwards) return forwards + const resolved = forwards ?? {} - const isRoutingPipe = Object.keys(forwards).some((forward) => ROUTING_OUTCOME_FORWARDS.has(forward)) - if (isRoutingPipe) return forwards + const isRoutingPipe = Object.keys(resolved).some((forward) => ROUTING_OUTCOME_FORWARDS.has(forward)) + if (isRoutingPipe) return resolved - return { ...fixedForwardPipe.forwards, ...forwards } + return { success: {}, ...resolved } } export function getDefaultSourceHandles(resolvedForwards: Record | undefined): SourceHandle[] { From 13ae2a4f34dd89ef2718c12d8c05344a2c51e754 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Tue, 30 Jun 2026 11:47:15 +0200 Subject: [PATCH 3/4] Refactor forwards resolution to simplify handling and improve clarity --- .../app/routes/studio/canvas/flow.tsx | 2 +- .../studio/canvas/nodetypes/frank-node.tsx | 2 +- .../frontend/app/utils/frankdoc-utils.spec.ts | 53 +++++++++---------- src/main/frontend/app/utils/frankdoc-utils.ts | 25 ++++----- 4 files changed, 36 insertions(+), 46 deletions(-) diff --git a/src/main/frontend/app/routes/studio/canvas/flow.tsx b/src/main/frontend/app/routes/studio/canvas/flow.tsx index c43f92de..b3adb837 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -998,7 +998,7 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { ) const resolveForwards = useCallback( - (subtype: string) => resolveForwardsWithInheritance(lookupFrankElement(subtype)?.forwards), + (subtype: string) => resolveForwardsWithInheritance(lookupFrankElement(subtype)), [lookupFrankElement], ) diff --git a/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx b/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx index 54c8049a..4348d4d3 100644 --- a/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx +++ b/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx @@ -91,7 +91,7 @@ export default function FrankNode(properties: NodeProps) { return { ...element, - forwards: resolveForwardsWithInheritance(element.forwards), + forwards: resolveForwardsWithInheritance(element), } }, [elements, properties.data.subtype]) diff --git a/src/main/frontend/app/utils/frankdoc-utils.spec.ts b/src/main/frontend/app/utils/frankdoc-utils.spec.ts index 9dafc771..d37463f2 100644 --- a/src/main/frontend/app/utils/frankdoc-utils.spec.ts +++ b/src/main/frontend/app/utils/frankdoc-utils.spec.ts @@ -3,50 +3,44 @@ import { getDefaultSourceHandles, resolveForwardsWithInheritance } from './frank import type { ElementProperty } from '@frankframework/doc-library-core' describe('resolveForwardsWithInheritance', () => { - it('adds success for a regular pipe with only an exception forward (e.g. EchoPipe)', () => { - const result = resolveForwardsWithInheritance({ exception: {} }) + it('adds success for a non-router pipe with only an exception forward (e.g. EchoPipe)', () => { + const result = resolveForwardsWithInheritance({ forwards: { exception: {} } }) expect(result).toHaveProperty('success') expect(result).toHaveProperty('exception') }) - it('adds success when there are no forwards', () => { + it('adds success for a non-router pipe without any forwards', () => { expect(resolveForwardsWithInheritance({})).toHaveProperty('success') }) - it('adds success when forwards is missing', () => { - const missing: Record | undefined = undefined - expect(resolveForwardsWithInheritance(missing)).toHaveProperty('success') + it('adds success when the element is missing', () => { + expect(resolveForwardsWithInheritance()).toHaveProperty('success') }) - it('adds success for SenderPipe-style pipes (error forwards but no routing outcomes)', () => { - const result = resolveForwardsWithInheritance({ exception: {}, timeout: {}, illegalResult: {}, '*': {} }) + it('adds success for an endpoint pipe with error forwards (e.g. SenderPipe)', () => { + const result = resolveForwardsWithInheritance({ + forwards: { exception: {}, timeout: {}, '*': {} }, + labels: { EIP: 'Endpoint' }, + }) expect(result).toHaveProperty('success') expect(result).toHaveProperty('timeout') }) - it('does NOT add success for SwitchPipe-style pipes (has empty forward)', () => { - const result = resolveForwardsWithInheritance({ exception: {}, notFound: {}, empty: {}, '*': {} }) - expect(result).not.toHaveProperty('success') - }) - - it('does NOT add success for IfPipe-style pipes (has then/else forwards)', () => { - // eslint-disable-next-line unicorn/no-thenable -- 'then' is a Frank forward name here, not a thenable - const result = resolveForwardsWithInheritance({ exception: {}, then: {}, else: {}, '*': {} }) - expect(result).not.toHaveProperty('success') - }) - - it('does NOT add success for CompareStringPipe (has lessthan/greaterthan/equals)', () => { - const result = resolveForwardsWithInheritance({ exception: {}, lessthan: {}, greaterthan: {}, equals: {} }) + it('does NOT add success for a router pipe (EIP=Router, e.g. SwitchPipe)', () => { + const result = resolveForwardsWithInheritance({ + forwards: { exception: {}, notFound: {}, empty: {}, '*': {} }, + labels: { EIP: 'Router' }, + }) expect(result).not.toHaveProperty('success') }) - it('does NOT add success for ForPipe (has stop/continue)', () => { - const result = resolveForwardsWithInheritance({ exception: {}, stop: {}, continue: {} }) + it('does NOT add success for a wildcard-only router (e.g. CounterSwitchPipe)', () => { + const result = resolveForwardsWithInheritance({ forwards: { exception: {}, '*': {} }, labels: { EIP: 'Router' } }) expect(result).not.toHaveProperty('success') }) it('does not duplicate success when the element already defines it', () => { - const result = resolveForwardsWithInheritance({ exception: {}, success: {} }) + const result = resolveForwardsWithInheritance({ forwards: { exception: {}, success: {} } }) expect(Object.keys(result).filter((forward) => forward === 'success')).toHaveLength(1) }) }) @@ -83,14 +77,17 @@ describe('getDefaultSourceHandles', () => { expect(getDefaultSourceHandles(missing)).toEqual([]) }) - it('is consistent with resolveForwardsWithInheritance: a regular pipe defaults to success', () => { - expect(getDefaultSourceHandles(resolveForwardsWithInheritance({ exception: {} }))).toEqual([ + it('is consistent with resolveForwardsWithInheritance: a non-router pipe defaults to success', () => { + expect(getDefaultSourceHandles(resolveForwardsWithInheritance({ forwards: { exception: {} } }))).toEqual([ { type: 'success', index: 1 }, ]) }) - it('is consistent with resolveForwardsWithInheritance: a SwitchPipe defaults to its first forward', () => { - const resolved = resolveForwardsWithInheritance({ exception: {}, notFound: {}, empty: {}, '*': {} }) + it('is consistent with resolveForwardsWithInheritance: a router pipe defaults to its first forward', () => { + const resolved = resolveForwardsWithInheritance({ + forwards: { exception: {}, notFound: {}, empty: {}, '*': {} }, + labels: { EIP: 'Router' }, + }) expect(getDefaultSourceHandles(resolved)).toEqual([{ type: 'notFound', index: 1 }]) }) }) diff --git a/src/main/frontend/app/utils/frankdoc-utils.ts b/src/main/frontend/app/utils/frankdoc-utils.ts index f8dcbac3..38f437b1 100644 --- a/src/main/frontend/app/utils/frankdoc-utils.ts +++ b/src/main/frontend/app/utils/frankdoc-utils.ts @@ -1,31 +1,24 @@ import type { ElementProperty } from '@frankframework/doc-library-core' import { getHandleTypes } from '~/hooks/use-handle-types' -const ROUTING_OUTCOME_FORWARDS = new Set([ - 'then', - 'else', - 'lessthan', - 'greaterthan', - 'equals', - 'stop', - 'continue', - 'empty', -]) - export interface SourceHandle { type: string index: number } +interface ForwardSource { + forwards?: Record + labels?: Record +} + export function resolveForwardsWithInheritance( - forwards: Record | undefined, + element: ForwardSource | null | undefined, ): Record { - const resolved = forwards ?? {} + const forwards = element?.forwards ?? {} - const isRoutingPipe = Object.keys(resolved).some((forward) => ROUTING_OUTCOME_FORWARDS.has(forward)) - if (isRoutingPipe) return resolved + if (element?.labels?.EIP === 'Router') return forwards - return { success: {}, ...resolved } + return { success: {}, ...forwards } } export function getDefaultSourceHandles(resolvedForwards: Record | undefined): SourceHandle[] { From 6c21035a8a1d7fe7b2eb85a60a374d981e7e38f6 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Tue, 30 Jun 2026 11:49:54 +0200 Subject: [PATCH 4/4] Remove Vitest environment declaration from frankdoc-utils.spec.ts --- src/main/frontend/app/utils/frankdoc-utils.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/frontend/app/utils/frankdoc-utils.spec.ts b/src/main/frontend/app/utils/frankdoc-utils.spec.ts index d37463f2..29306489 100644 --- a/src/main/frontend/app/utils/frankdoc-utils.spec.ts +++ b/src/main/frontend/app/utils/frankdoc-utils.spec.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { getDefaultSourceHandles, resolveForwardsWithInheritance } from './frankdoc-utils' import type { ElementProperty } from '@frankframework/doc-library-core'