diff --git a/src/main/frontend/app/hooks/use-handle-types.ts b/src/main/frontend/app/hooks/use-handle-types.ts index 6713efce..47ad1c55 100644 --- a/src/main/frontend/app/hooks/use-handle-types.ts +++ b/src/main/frontend/app/hooks/use-handle-types.ts @@ -3,19 +3,12 @@ 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']) + const handles = new Set() if (!typesAllowed) return [...handles] - if ('*' in typesAllowed) { - handles.add('custom') - } - for (const type of Object.keys(typesAllowed)) { - if (type !== '*') { - handles.add(type) - } + handles.add(type === '*' ? 'custom' : type) } return [...handles] diff --git a/src/main/frontend/app/routes/studio/canvas/flow.tsx b/src/main/frontend/app/routes/studio/canvas/flow.tsx index 739fd6ed..49e7cc4f 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -1332,7 +1332,7 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { subtype: elementName, type: elementType, name: ``, - sourceHandles: [{ type: 'success', index: 1 }], + sourceHandles: [], children: [], }, type: nodeType, 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..089c04e0 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,14 +57,14 @@ export default function HandleMenu({ return createPortal(
-
+
{title}
    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 44a6c4af..7240d54f 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 @@ -43,6 +43,7 @@ export function CustomHandle(properties: Readonly) { const [menuPosition, setMenuPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) const handleClick = (event: React.MouseEvent) => { + if (typesAllowedListIsEmpty()) return const { clientX, clientY } = event setMenuPosition({ x: clientX, @@ -51,6 +52,13 @@ export function CustomHandle(properties: Readonly) { setIsMenuOpen(!isMenuOpen) // Toggle menu visibility } + function typesAllowedListIsEmpty() { + return ( + properties.typesAllowed === undefined || + (properties.typesAllowed && Object.keys(properties.typesAllowed).length <= 0) + ) + } + const handleMenuClick = (newType: string) => { properties.onChangeType(newType) // Change the handle type setIsMenuOpen(false) // Close the menu after selection 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 e9ed6e6d..c2487385 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 @@ -5,6 +5,8 @@ import { type NodeProps, NodeResizeControl, Position, + type ResizeDragEvent, + type ResizeParams, useReactFlow, useStore, useUpdateNodeInternals, @@ -23,20 +25,21 @@ import { useFFDoc } from '@frankframework/doc-library-react' import HandleMenu from './components/handle-menu' import { NodeHeader } from './components/node-header' import { NodeChildrenContainer } from './components/node-children-container' -import { ChildNodeComponent, type ChildNode } from './child-node' +import { type ChildNode, ChildNodeComponent } from './child-node' import { findChildRecursive } from '~/stores/child-utilities' import type { ElementDetails } from '@frankframework/doc-library-core' +import { getInheritedProperties } from '@frankframework/doc-library-core' import { DeprecatedPopover } from './components/deprecated-popover' import { showWarningToast } from '~/components/toast' import { useHandleTypes } from '~/hooks/use-handle-types' import AddSubcomponentModal from '~/components/flow/add-subcomponent-modal' import { useFrankConfigXsd } from '~/providers/frankconfig-xsd-provider' import { - type Requirement, getAllowedChildElementsForElement, getElementRequirements, getMissingRequirements, isRequirementFulfilled, + type Requirement, } from '~/utils/xsd-utils' import MissingRequirements from './components/missing-requirements' import ZoomedOutNode from './zoomed-out-node' @@ -77,7 +80,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, @@ -94,20 +97,42 @@ export default function FrankNode(properties: NodeProps) { const zoom = useStore((state) => state.transform[2]) const isCompact = zoom < FlowConfig.ZOOM_THRESHOLD const [isOverflowing, setIsOverflowing] = useState(false) + const sourceHandles = properties.data.sourceHandles + const addHandle = useFlowStore.getState().addHandle const frankElement = useMemo(() => { - if (!elements) return null - const recordElements = elements as Record + if (!elements || !ffDoc || properties.data.subtype === 'Receiver') return + + const element = elements[properties.data.subtype] + if (!element) return + + const inherited = getInheritedProperties(element, ffDoc.elements, ffDoc.enums) + // TODO: Remove when https://github.com/frankframework/frank-doc/issues/466 is fixed. + const fixedForwardPipeForwards = ffDoc.elements['org.frankframework.pipes.FixedForwardPipe']?.forwards + const successForward = element.labels['EIP'] !== 'Router' && fixedForwardPipeForwards + element.forwards = { ...element.forwards, ...inherited.forwards, ...successForward } - return Object.values(recordElements).find((element) => element.name === properties.data.subtype) ?? null - }, [elements, properties.data.subtype]) + return element + }, [elements, ffDoc, properties.data.subtype]) + + useEffect(() => { + if (!frankElement?.forwards) return + + // console.log(frankElement.name, Object.keys(frankElement.forwards), sourceHandles.map((handle) => handle.type)) + + if ( + Object.keys(frankElement.forwards).includes('success') && + sourceHandles.every((handle) => handle.type !== 'success') + ) { + addHandle(properties.id, { type: 'success', index: sourceHandles.length + 1 }) + } + }, [addHandle, frankElement?.forwards, properties.id, sourceHandles]) const isDeprecated = frankElement?.deprecated const [showDeprecated, setShowDeprecated] = useState(false) const [anchorRect, setAnchorRect] = useState(null) const [isSubcomponentModalOpen, setIsSubcomponentModalOpen] = useState(false) const dangerTriangleReference = useRef(null) - const availableHandleTypes = useHandleTypes(frankElement?.forwards) const hoveredNodeId = useNodeContextStore((state) => state.hoveredNodeId) const showAllForwards = useNodeContextStore((state) => state.showAllForwards) @@ -161,6 +186,17 @@ export default function FrankNode(properties: NodeProps) { const [isHandleMenuOpen, setIsHandleMenuOpen] = useState(false) const [handleMenuPosition, setHandleMenuPosition] = useState({ x: 0, y: 0 }) const [isManuallyResized, setIsManuallyResized] = useState(properties.data.manuallyResized) + const { handleMenuTypesAllowed, handleMenuTypesAllowedHasOptions } = useMemo(() => { + if (!frankElement?.forwards) return {} + const filteredHandles = Object.entries(frankElement.forwards).filter(([type]) => + sourceHandles.every((handle) => handle.type !== type), + ) + + return { + handleMenuTypesAllowed: Object.fromEntries(filteredHandles), + handleMenuTypesAllowedHasOptions: filteredHandles.length > 0, + } + }, [frankElement?.forwards, sourceHandles]) const [dimensions, setDimensions] = useState({ width: properties.width ?? minNodeWidth, @@ -168,21 +204,8 @@ export default function FrankNode(properties: NodeProps) { }) const firstHandlePosition = useMemo(() => { - return (dimensions.height - (properties.data.sourceHandles.length - 1) * handleSpacing) / 2 - }, [dimensions.height, properties.data.sourceHandles.length]) - - const allForwardTypesUsed = useMemo(() => { - if (availableHandleTypes.length === 0) return true - - // If custom is allowed, "+" should always remain visible - if (availableHandleTypes.includes('custom')) { - return false - } - - const existingTypesCount = properties.data.sourceHandles.length - - return existingTypesCount >= availableHandleTypes.length - }, [availableHandleTypes, properties.data.sourceHandles]) + return (dimensions.height - (sourceHandles.length - 1) * handleSpacing) / 2 + }, [dimensions.height, sourceHandles.length]) useEffect(() => { if (dragOver && containerReference.current) { @@ -224,7 +247,7 @@ export default function FrankNode(properties: NodeProps) { return previous }) } - }, [properties.data.children, properties.data.sourceHandles.length, dragOver]) + }, [properties.data.children, sourceHandles.length, dragOver]) useEffect(() => { const container = containerReference.current @@ -236,36 +259,18 @@ export default function FrankNode(properties: NodeProps) { return () => observer.disconnect() }, []) - const addHandle = useFlowStore.getState().addHandle const addChild = useFlowStore((state) => state.addChild) - const hasHandleOfType = useCallback( - (type: string) => { - // Custom handles are never considered duplicates - if (type === 'custom') return false - - return properties.data.sourceHandles.some((handle) => handle.type === type) - }, - [properties.data.sourceHandles], - ) - const handleMenuClick = useCallback( (handleType: string) => { - // Prevent adding duplicate handle types - if (hasHandleOfType(handleType)) { - showWarningToast(`Handle of type "${handleType}" is already present!`) - console.warn(`Handle of type "${handleType}" is already present!`) - return - } - addHandle(properties.id, { type: handleType, - index: properties.data.sourceHandles.length + 1, + index: sourceHandles.length + 1, }) updateNodeInternals(properties.id) // Update the edge setIsHandleMenuOpen(false) // Close the menu after selection }, - [hasHandleOfType, addHandle, properties.id, properties.data.sourceHandles.length, updateNodeInternals], + [addHandle, properties.id, sourceHandles.length, updateNodeInternals], ) const toggleHandleMenu = (event: React.MouseEvent) => { @@ -314,11 +319,9 @@ export default function FrankNode(properties: NodeProps) { reactFlow.setNodes((nodes) => nodes.map((node) => ({ ...node, selected: false }))) } - const changeHandleType = (handleIndex: number, newType: string) => { + const changeHandleType = (currentHandle: { type: string; index: number }, newType: string) => { // Prevent changing to a duplicate handle type - const existing = properties.data.sourceHandles.some( - (handle) => handle.type === newType && handle.index !== handleIndex, - ) + const existing = sourceHandles.some((handle) => handle.type === newType && handle.index !== currentHandle.index) if (existing) { showWarningToast(`Handle of type "${newType}" is already present!`) @@ -326,7 +329,12 @@ export default function FrankNode(properties: NodeProps) { return } - useFlowStore.getState().updateHandle(properties.id, handleIndex, { type: newType, index: handleIndex }) + if (currentHandle.type === 'success') { + addHandle(properties.id, { type: 'success', index: sourceHandles.length + 1 }) + } + useFlowStore + .getState() + .updateHandle(properties.id, currentHandle.index, { type: newType, index: currentHandle.index }) // Timeout to prevent bug from edgelabel not properly updating setTimeout(() => { updateNodeInternals(properties.id) @@ -392,14 +400,16 @@ export default function FrankNode(properties: NodeProps) { addChild(properties.id, child) }, [ - properties.id, - addChild, + setNodeId, + setAttributes, setIsNewNode, setEditingSubtype, showNodeContextMenu, setIsEditing, setParentId, + properties.id, setChildParentId, + addChild, ], ) @@ -450,7 +460,7 @@ export default function FrankNode(properties: NodeProps) { colorVariable={colorVariable} selected={properties.selected} showTargetHandle={properties.data.subtype !== 'Receiver'} - sourceHandles={properties.data.sourceHandles} + sourceHandles={sourceHandles} /> ) } @@ -460,7 +470,7 @@ export default function FrankNode(properties: NodeProps) { { + onResize={(_event: ResizeDragEvent, data: ResizeParams) => { setIsManuallyResized(true) setDimensions({ width: data.width, height: data.height }) }} @@ -592,42 +602,61 @@ export default function FrankNode(properties: NodeProps) {
{/* Receivers can only have outgoing connections, so we hide the input handle for them */} - {properties.data.subtype !== 'Receiver' && ( - + {properties.data.subtype === 'Receiver' ? ( + <> + ) : ( + /* + * TODO: https://github.com/frankframework/flow/issues/613 + * + */ + <> + + {sourceHandles.map((handle) => ( + changeHandleType(handle, newType)} + absolutePosition={{ x: properties.positionAbsoluteX, y: properties.positionAbsoluteY }} + typesAllowed={handleMenuTypesAllowed} + dimmed={dimmedHandleIndices.has(handle.index)} + /> + ))} + )} - {properties.data.sourceHandles.map((handle) => ( - changeHandleType(handle.index, newType)} - absolutePosition={{ x: properties.positionAbsoluteX, y: properties.positionAbsoluteY }} - typesAllowed={frankElement?.forwards} - dimmed={dimmedHandleIndices.has(handle.index)} - /> - ))} {/* Only show the add handle button if there are available handle types that are not yet used on this node */} - {!allForwardTypesUsed && ( + {handleMenuTypesAllowedHasOptions && (
{ toggleHandleMenu(event) }} className="nodrag absolute h-4 w-4 cursor-pointer justify-center rounded-full border bg-gray-400 text-center text-[8px] font-bold text-white" style={{ - top: `${firstHandlePosition + properties.data.sourceHandles.length * handleSpacing + 12.5}px`, + top: `${firstHandlePosition + sourceHandles.length * handleSpacing + 12.5}px`, right: '-23px', }} > @@ -640,7 +669,7 @@ export default function FrankNode(properties: NodeProps) { position={handleMenuPosition} onClose={() => setIsHandleMenuOpen(false)} onSelect={handleMenuClick} - typesAllowed={frankElement?.forwards} + typesAllowed={handleMenuTypesAllowed} /> )} diff --git a/src/main/frontend/app/routes/studio/canvas/nodetypes/group-node.tsx b/src/main/frontend/app/routes/studio/canvas/nodetypes/group-node.tsx index b024d4ec..a6abd331 100644 --- a/src/main/frontend/app/routes/studio/canvas/nodetypes/group-node.tsx +++ b/src/main/frontend/app/routes/studio/canvas/nodetypes/group-node.tsx @@ -1,4 +1,4 @@ -import { type Node, type NodeProps, NodeResizeControl } from '@xyflow/react' +import { type Node, type NodeProps, NodeResizeControl, type ResizeDragEvent, type ResizeParams } from '@xyflow/react' import { useState } from 'react' import { ResizeIcon } from '~/routes/studio/canvas/nodetypes/frank-node' @@ -32,10 +32,10 @@ export default function GroupNodeComponent({ data, selected }: NodeProps { + onResize={(_event: ResizeDragEvent, data: ResizeParams) => { setDimensions({ - width: resizeData.width, - height: resizeData.height, + width: data.width, + height: data.height, }) }} style={{ background: 'transparent', border: 'none' }} diff --git a/src/main/frontend/app/routes/studio/canvas/nodetypes/start-node.tsx b/src/main/frontend/app/routes/studio/canvas/nodetypes/start-node.tsx deleted file mode 100644 index 08340b96..00000000 --- a/src/main/frontend/app/routes/studio/canvas/nodetypes/start-node.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Handle, type Node, Position } from '@xyflow/react' - -export type StartNode = Node> -export default function StartNodeComponent() { - return ( - <> -
- - - ) -} 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 e6acf071..5e7eaef4 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 @@ -444,10 +444,6 @@ function extractSourceHandles(element: Element): SourceHandle[] { // 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 handles: SourceHandle[] = forwardElements.map((forward, index) => { @@ -459,20 +455,6 @@ 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' - }) - - // If not, add implicit fallback handle - if (!hasSuccessForward) { - handles.push({ - type: 'success', - index: handles.length + 1, - }) - } - return handles } diff --git a/src/main/frontend/app/stores/flow-store.ts b/src/main/frontend/app/stores/flow-store.ts index 13eaab73..28d22dd3 100644 --- a/src/main/frontend/app/stores/flow-store.ts +++ b/src/main/frontend/app/stores/flow-store.ts @@ -533,6 +533,10 @@ const useFlowStore = create()( set({ nodes: get().nodes.map((node) => { if (node.id === nodeId && isFrankNode(node)) { + // Prevent duplicate handle types (e.g. double "success" on initial load) + if (node.data.sourceHandles.some((existing) => existing.type === handle.type)) { + return node + } return { ...node, data: {