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 4775aada..82a8c69a 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() @@ -995,6 +1000,14 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { [elements], ) + const resolveForwards = useCallback( + (subtype: string) => resolveForwardsWithInheritance(lookupFrankElement(subtype), ffDoc?.elements, ffDoc?.enums), + [lookupFrankElement, ffDoc], + ) + + const importForwardsResolverRef = useRef(resolveForwards) + importForwardsResolverRef.current = resolveForwards + const deselectOtherNodes = useCallback( (nodeId: string) => { const flowNodes = reactFlow.getNodes() @@ -1244,6 +1257,8 @@ 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 = getDefaultSourceHandles(resolveForwards(elementName)) + const newNode: FrankNodeType = { id: newId.toString(), position: { @@ -1254,7 +1269,7 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { subtype: elementName, type: elementType, name: ``, - sourceHandles: [{ type: 'success', index: 1 }], + sourceHandles, children: [], }, type: nodeType, @@ -1491,7 +1506,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) @@ -1737,10 +1752,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)} /> )} @@ -1750,9 +1762,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-menu.tsx b/src/main/frontend/app/routes/studio/canvas/nodetypes/components/handle-menu.tsx index 763d32b7..f494a003 100644 --- a/src/main/frontend/app/routes/studio/canvas/nodetypes/components/handle-menu.tsx +++ b/src/main/frontend/app/routes/studio/canvas/nodetypes/components/handle-menu.tsx @@ -57,7 +57,7 @@ export default function HandleMenu({ return createPortal(
} -const HANDLE_TYPE_COLOURS: Record = { +const SEMANTIC_COLOURS: Record = { success: '#68D250', failure: '#E84E4E', exception: '#424242', timeout: '#F2A900', error: '#ff7605ff', - default: '#1B97D1', +} + +const GREEN_BAND_START = 95 +const GREEN_BAND_SIZE = 70 + +const HASH_MULTIPLIER = 31 + +function colourFromName(type: string): string { + let hash = 0 + for (const character of type) { + hash = (hash * HASH_MULTIPLIER + (character.codePointAt(0) ?? 0)) % (360 - GREEN_BAND_SIZE) + } + 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() - for (const [suffix, colour] of Object.entries(HANDLE_TYPE_COLOURS)) { - if (normalized.endsWith(suffix)) { - return colour - } + 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..b907673c 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) return element + + return { + ...element, + forwards: resolveForwardsWithInheritance(element, ffDoc?.elements, ffDoc?.enums), + } + }, [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 bd217ded..d3964c5f 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,23 @@ 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) + if (!forwards || Object.keys(forwards).length === 0) return true + return 'success' in forwards +} + +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 +184,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 +222,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 +363,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 +371,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 +403,7 @@ function addImplicitSuccessExitEdge( nodes: FlowNode[], edges: FrankEdge[], forwardIndexBySourceId: Map, + resolveForwards?: ResolveForwards, ) { const successExit = findSuccessExit(nodes) if (!successExit) return @@ -397,6 +416,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 +459,17 @@ 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 }] + const hasForwards = forwards && Object.keys(forwards).length > 0 + return hasForwards ? getDefaultSourceHandles(forwards) : [{ type: 'success', index: 1 }] } } @@ -459,18 +482,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 +520,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 +556,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..a528431f --- /dev/null +++ b/src/main/frontend/app/utils/frankdoc-utils.spec.ts @@ -0,0 +1,132 @@ +import { getDefaultSourceHandles, resolveForwardsWithInheritance } from './frankdoc-utils' +import type { ElementClass, ElementProperty } from '@frankframework/doc-library-core' + +const FIXED_FORWARD_PIPE_FQN = 'org.frankframework.pipes.FixedForwardPipe' +const ABSTRACT_PIPE_FQN = 'org.frankframework.pipes.AbstractPipe' + +const TEST_ELEMENTS: Record = { + [ABSTRACT_PIPE_FQN]: { + name: 'AbstractPipe', + abstract: true, + forwards: { exception: { description: 'error occurred' } }, + }, + [FIXED_FORWARD_PIPE_FQN]: { + name: 'FixedForwardPipe', + abstract: true, + parent: ABSTRACT_PIPE_FQN, + forwards: { success: { description: 'successful processing' } }, + }, +} + +function echoPipe(overrides: Partial = {}): ElementClass { + return { name: 'EchoPipe', parent: FIXED_FORWARD_PIPE_FQN, ...overrides } +} + +describe('resolveForwardsWithInheritance', () => { + it('inherits success for a non-router pipe via the parent chain (e.g. EchoPipe)', () => { + const result = resolveForwardsWithInheritance(echoPipe({ forwards: { exception: {} } }), TEST_ELEMENTS) + expect(result).toHaveProperty('success') + expect(result).toHaveProperty('exception') + }) + + it('inherits success for a non-router pipe with no own forwards', () => { + const result = resolveForwardsWithInheritance(echoPipe(), TEST_ELEMENTS) + expect(result).toHaveProperty('success') + }) + + it('returns empty forwards when the element is missing', () => { + expect(resolveForwardsWithInheritance()).toEqual({}) + }) + + it('returns only own forwards when no elements map is provided (no inheritance)', () => { + const result = resolveForwardsWithInheritance(echoPipe({ forwards: { exception: {} } })) + expect(result).not.toHaveProperty('success') + expect(result).toHaveProperty('exception') + }) + + it('inherits success for an endpoint pipe with error forwards (e.g. SenderPipe)', () => { + const result = resolveForwardsWithInheritance( + echoPipe({ forwards: { exception: {}, timeout: {}, '*': {} }, labels: { EIP: 'Endpoint' } }), + TEST_ELEMENTS, + ) + expect(result).toHaveProperty('success') + expect(result).toHaveProperty('timeout') + }) + + it('does NOT include success for a router pipe (EIP=Router, e.g. SwitchPipe)', () => { + const result = resolveForwardsWithInheritance( + echoPipe({ forwards: { exception: {}, notFound: {}, empty: {}, '*': {} }, labels: { EIP: 'Router' } }), + TEST_ELEMENTS, + ) + expect(result).not.toHaveProperty('success') + }) + + it('does NOT include success for a wildcard-only router (e.g. CounterSwitchPipe)', () => { + const result = resolveForwardsWithInheritance( + echoPipe({ forwards: { exception: {}, '*': {} }, labels: { EIP: 'Router' } }), + TEST_ELEMENTS, + ) + expect(result).not.toHaveProperty('success') + }) + + it('does not duplicate success when the element already defines it as an own forward', () => { + const result = resolveForwardsWithInheritance(echoPipe({ forwards: { exception: {}, success: {} } }), TEST_ELEMENTS) + expect(Object.keys(result).filter((forward) => forward === 'success')).toHaveLength(1) + }) + + it('own forwards take precedence over inherited forwards', () => { + const ownDesc = 'custom description' + const result = resolveForwardsWithInheritance( + echoPipe({ forwards: { success: { description: ownDesc } } }), + TEST_ELEMENTS, + ) + expect(result.success?.description).toBe(ownDesc) + }) +}) + +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 non-router pipe defaults to success', () => { + expect( + getDefaultSourceHandles(resolveForwardsWithInheritance(echoPipe({ forwards: { exception: {} } }), TEST_ELEMENTS)), + ).toEqual([{ type: 'success', index: 1 }]) + }) + + it('is consistent with resolveForwardsWithInheritance: a router pipe defaults to its first forward', () => { + const resolved = resolveForwardsWithInheritance( + echoPipe({ forwards: { exception: {}, notFound: {}, empty: {}, '*': {} }, labels: { EIP: 'Router' } }), + TEST_ELEMENTS, + ) + 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..cbfa9485 --- /dev/null +++ b/src/main/frontend/app/utils/frankdoc-utils.ts @@ -0,0 +1,47 @@ +import { getInheritedProperties } from '@frankframework/doc-library-core' +import type { ElementClass, ElementProperty, EnumValue } from '@frankframework/doc-library-core' +import { getHandleTypes } from '~/hooks/use-handle-types' + +export type SourceHandle = { + type: string + index: number +} + +type ForwardSource = { + forwards?: Record + labels?: Record + parent?: string +} + +export function resolveForwardsWithInheritance( + element: ForwardSource | null | undefined, + allElements?: Record, + enums?: Record>, +): Record { + if (!element) return {} + + const ownForwards = element.forwards ?? {} + + const inherited = + allElements && element.parent + ? getInheritedProperties(element as ElementClass, allElements, enums ?? {}).forwards + : {} + + const merged = { ...inherited, ...ownForwards } + + const isRouter = element.labels?.EIP === 'Router' + if (isRouter) { + const { success: _omit, ...withoutSuccess } = merged + return withoutSuccess + } + + return merged +} + +export function getDefaultSourceHandles(resolvedForwards?: Record): SourceHandle[] { + const handleTypes = getHandleTypes(resolvedForwards) + if (handleTypes.length === 0) return [] + + const defaultType = handleTypes.find((type) => type !== 'exception') ?? handleTypes[0] + return [{ type: defaultType, index: 1 }] +} diff --git a/src/main/frontend/vitest.config.ts b/src/main/frontend/vitest.config.ts index ce3bde40..2b0f4a8d 100644 --- a/src/main/frontend/vitest.config.ts +++ b/src/main/frontend/vitest.config.ts @@ -1,9 +1,19 @@ +import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import tsconfigPaths from 'vite-tsconfig-paths' +const docLibrary = (FFPackage: string, entry: string) => + fileURLToPath(new URL(`node_modules/@frankframework/${FFPackage}/${entry}`, import.meta.url)) + export default defineConfig({ plugins: [react(), tsconfigPaths()], + resolve: { + alias: { + '@frankframework/doc-library-core': docLibrary('doc-library-core', 'dist/doc-library-core.js'), + '@frankframework/doc-library-react': docLibrary('doc-library-react', 'doc-library-react.js'), + }, + }, test: { globals: true, environment: 'jsdom', diff --git a/src/main/java/org/frankframework/flow/frankdoc/FrankDocService.java b/src/main/java/org/frankframework/flow/frankdoc/FrankDocService.java index 90ad52b9..ee5a3579 100644 --- a/src/main/java/org/frankframework/flow/frankdoc/FrankDocService.java +++ b/src/main/java/org/frankframework/flow/frankdoc/FrankDocService.java @@ -1,5 +1,13 @@ package org.frankframework.flow.frankdoc; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; import lombok.extern.log4j.Log4j2; import org.frankframework.flow.exception.ApiException; import org.springframework.http.HttpStatus; @@ -13,22 +21,72 @@ public class FrankDocService { private static final String FRANKDOC_JSON_URL = "https://reference.frankframework.org/js/frankdoc.json"; private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; - public FrankDocService(RestTemplate restTemplate) { + public FrankDocService(RestTemplate restTemplate, ObjectMapper objectMapper) { this.restTemplate = restTemplate; + this.objectMapper = objectMapper; } /** - * Fetches the FrankDoc JSON from the external URL. + * Fetches the FrankDoc JSON and repairs broken parent chains before returning it. * - * @return The FrankDoc JSON as a String. + * @return Repaired FrankDoc JSON. */ public String getFrankDocJson() { try { log.info("Fetching FrankDoc JSON from {}", FRANKDOC_JSON_URL); - return restTemplate.getForObject(FRANKDOC_JSON_URL, String.class); + String raw = restTemplate.getForObject(FRANKDOC_JSON_URL, String.class); + return repairParentChains(raw); } catch (RestClientException _) { throw new ApiException("Failed to fetch FrankDoc JSON", HttpStatus.NOT_FOUND); } } + + private String repairParentChains(String frankDocJson) { + try { + JsonNode root = objectMapper.readTree(frankDocJson); + JsonNode elements = root.path("elements"); + + Map> childrenByParent = groupChildrenByParent(elements); + childrenByParent.values().forEach(siblings -> + findSuccessDeclaringAbstract(elements, siblings) + .ifPresent(successBase -> repointSiblingsToBase(elements, siblings, successBase)) + ); + + return objectMapper.writeValueAsString(root); + } catch (Exception e) { + log.warn("Failed to repair FrankDoc parent chains, returning raw JSON", e); + return frankDocJson; + } + } + + private static Map> groupChildrenByParent(JsonNode elements) { + Iterable> fields = elements::fields; + return StreamSupport.stream(fields.spliterator(), false) + .filter(entry -> entry.getValue().hasNonNull("parent")) + .collect(Collectors.groupingBy( + entry -> entry.getValue().get("parent").asText(), + Collectors.mapping(Map.Entry::getKey, Collectors.toList()))); + } + + private static Optional findSuccessDeclaringAbstract(JsonNode elements, List siblings) { + return siblings.stream() + .filter(fullyQualifiedName -> isAbstract(elements.path(fullyQualifiedName)) && declaresSuccessForward(elements.path(fullyQualifiedName))) + .findFirst(); + } + + private static void repointSiblingsToBase(JsonNode elements, List siblings, String successBase) { + siblings.stream() + .filter(fullyQualifiedName -> !fullyQualifiedName.equals(successBase)) + .forEach(fullyQualifiedName -> ((ObjectNode) elements.path(fullyQualifiedName)).put("parent", successBase)); + } + + private static boolean isAbstract(JsonNode element) { + return element.path("abstract").asBoolean(false); + } + + private static boolean declaresSuccessForward(JsonNode element) { + return element.path("forwards").has("success"); + } } diff --git a/src/test/java/org/frankframework/flow/frankdoc/FrankDocServiceTest.java b/src/test/java/org/frankframework/flow/frankdoc/FrankDocServiceTest.java index d560c2ef..e2f8d9ec 100644 --- a/src/test/java/org/frankframework/flow/frankdoc/FrankDocServiceTest.java +++ b/src/test/java/org/frankframework/flow/frankdoc/FrankDocServiceTest.java @@ -3,6 +3,8 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import org.frankframework.flow.exception.ApiException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -18,17 +20,29 @@ class FrankDocServiceTest { @Mock private RestTemplate restTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + private FrankDocService frankDocService; private static final String FRANKDOC_URL = "https://reference.frankframework.org/js/frankdoc.json"; @BeforeEach void setUp() { - frankDocService = new FrankDocService(restTemplate); + frankDocService = new FrankDocService(restTemplate, objectMapper); + } + + private JsonNode fetchAndParse(String rawJson) throws Exception { + when(restTemplate.getForObject(FRANKDOC_URL, String.class)).thenReturn(rawJson); + String result = frankDocService.getFrankDocJson(); + return objectMapper.readTree(result); + } + + private static String parentOf(JsonNode root, String elementFullyQualifiedName) { + return root.path("elements").path(elementFullyQualifiedName).path("parent").asText(null); } @Test - void getFrankDocJsonReturnsJsonContent() { + void getFrankDocJsonReturnsContentWhenThereIsNothingToRepair() { String expectedJson = "{\"version\":\"1.0\",\"types\":{}}"; when(restTemplate.getForObject(FRANKDOC_URL, String.class)).thenReturn(expectedJson); @@ -46,4 +60,112 @@ void getFrankDocJsonThrowsWhenRestTemplateFails() { verify(restTemplate).getForObject(FRANKDOC_URL, String.class); } + + @Test + void repointsSiblingsToTheAbstractSuccessDeclaringSibling() throws Exception { + String json = """ + { + "elements": { + "org.frankframework.core.IPipe": {}, + "org.frankframework.pipes.AbstractPipe": { + "parent": "org.frankframework.core.IPipe", + "abstract": true, + "forwards": { "success": "the pipe that is executed next" } + }, + "org.frankframework.pipes.EchoPipe": { + "parent": "org.frankframework.core.IPipe" + }, + "org.frankframework.pipes.XmlSwitch": { + "parent": "org.frankframework.core.IPipe" + } + } + } + """; + + JsonNode root = fetchAndParse(json); + + assertEquals("org.frankframework.pipes.AbstractPipe", parentOf(root, "org.frankframework.pipes.EchoPipe")); + assertEquals("org.frankframework.pipes.AbstractPipe", parentOf(root, "org.frankframework.pipes.XmlSwitch")); + assertEquals("org.frankframework.core.IPipe", parentOf(root, "org.frankframework.pipes.AbstractPipe")); + } + + @Test + void leavesParentsUntouchedWhenNoSiblingIsBothAbstractAndDeclaresSuccess() throws Exception { + String json = """ + { + "elements": { + "P": {}, + "AbstractWithoutSuccess": { "parent": "P", "abstract": true }, + "ConcreteWithSuccess": { "parent": "P", "forwards": { "success": "next" } } + } + } + """; + + JsonNode root = fetchAndParse(json); + + assertEquals("P", parentOf(root, "AbstractWithoutSuccess")); + assertEquals("P", parentOf(root, "ConcreteWithSuccess")); + } + + @Test + void repairsEachParentGroupIndependently() throws Exception { + String json = """ + { + "elements": { + "IPipe": {}, + "ISender": {}, + "AbstractPipe": { + "parent": "IPipe", + "abstract": true, + "forwards": { "success": "next" } + }, + "EchoPipe": { "parent": "IPipe" }, + "AbstractSender": { + "parent": "ISender", + "abstract": true, + "forwards": { "success": "next" } + }, + "HttpSender": { "parent": "ISender" } + } + } + """; + + JsonNode root = fetchAndParse(json); + + assertEquals("AbstractPipe", parentOf(root, "EchoPipe")); + assertEquals("AbstractSender", parentOf(root, "HttpSender")); + assertEquals("IPipe", parentOf(root, "AbstractPipe")); + assertEquals("ISender", parentOf(root, "AbstractSender")); + } + + @Test + void ignoresElementsWithoutAParent() throws Exception { + String json = """ + { + "elements": { + "Root": { "abstract": true, "forwards": { "success": "next" } }, + "AbstractPipe": { + "parent": "Root", + "abstract": true, + "forwards": { "success": "next" } + } + } + } + """; + + JsonNode root = fetchAndParse(json); + + assertNull(parentOf(root, "Root")); + assertEquals("Root", parentOf(root, "AbstractPipe")); + } + + @Test + void returnsRawJsonWhenContentCannotBeParsed() { + String malformed = "{ this is not valid json"; + when(restTemplate.getForObject(FRANKDOC_URL, String.class)).thenReturn(malformed); + + String result = frankDocService.getFrankDocJson(); + + assertEquals(malformed, result); + } }