From cf23d8eb8c6fa16c904bf1728d29c5d690c8888c Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Mon, 10 Aug 2026 17:36:56 +0200 Subject: [PATCH 1/2] fix handle position, put orphan tables top left --- .../Data/SemanticTables/Flow/LinkRelation.tsx | 60 +++++++- .../Data/SemanticTables/Flow/TableDetails.tsx | 78 +++++----- .../Data/SemanticTables/Flow/TableFlow.tsx | 134 +++++++++++++----- .../app-builder/src/components/ReactFlow.tsx | 1 + 4 files changed, 198 insertions(+), 75 deletions(-) diff --git a/packages/app-builder/src/components/Data/SemanticTables/Flow/LinkRelation.tsx b/packages/app-builder/src/components/Data/SemanticTables/Flow/LinkRelation.tsx index af4a8df763..d512c75f43 100644 --- a/packages/app-builder/src/components/Data/SemanticTables/Flow/LinkRelation.tsx +++ b/packages/app-builder/src/components/Data/SemanticTables/Flow/LinkRelation.tsx @@ -29,22 +29,78 @@ export const defaultDataModelEdgeOptions: DefaultEdgeOptions = { }, }; +export type DataModelHandleSide = 'l' | 'r'; + +export function relatedHandleId(fieldName: string, side: DataModelHandleSide) { + return `related:${fieldName}:${side}`; +} + +export function belongsToHandleId(side: DataModelHandleSide) { + return `belongs_to:header:${side}`; +} + export function getLinkToSingleDataEdgeId(linkToSingleData: LinkToSingleData) { const { original } = linkToSingleData; return original.childTableId + original.name; } +/** Default LR wiring (child right → parent left); geometry may retarget sides later. */ export function getLinkToSingleDataEdge(linkToSingleData: LinkToSingleData) { const { original } = linkToSingleData; const isRelated = original.relationType === 'related'; return { source: original.childTableName, - sourceHandle: isRelated ? `related:${original.childFieldName}` : 'belongs_to:header', + sourceHandle: isRelated ? relatedHandleId(original.childFieldName, 'r') : belongsToHandleId('r'), target: original.parentTableName, - targetHandle: isRelated ? `related:${original.parentFieldName}` : 'belongs_to:header', + targetHandle: isRelated ? relatedHandleId(original.parentFieldName, 'l') : belongsToHandleId('l'), }; } +/** Pick left/right handles from node centers (Δx ≥ 0 → source right / target left). */ +export function retargetDataModelHandles< + N extends { + id: string; + position: { x: number; y: number }; + measured?: { width?: number | null; height?: number | null } | null; + width?: number | null; + height?: number | null; + }, + E extends { + source: string; + target: string; + sourceHandle?: string | null; + targetHandle?: string | null; + data?: LinkToSingleData | null; + }, +>(nodes: N[], edges: E[]): E[] { + const centers = new Map(); + for (const node of nodes) { + const width = node.measured?.width ?? node.width ?? 0; + const height = node.measured?.height ?? node.height ?? 0; + centers.set(node.id, { x: node.position.x + width / 2, y: node.position.y + height / 2 }); + } + + return edges.map((edge) => { + const from = centers.get(edge.source); + const to = centers.get(edge.target); + const original = edge.data?.original; + if (!from || !to || !original) return edge; + + const sourceSide: DataModelHandleSide = to.x - from.x >= 0 ? 'r' : 'l'; + const targetSide: DataModelHandleSide = to.x - from.x >= 0 ? 'l' : 'r'; + const isRelated = original.relationType === 'related'; + const sourceHandle = isRelated + ? relatedHandleId(original.childFieldName, sourceSide) + : belongsToHandleId(sourceSide); + const targetHandle = isRelated + ? relatedHandleId(original.parentFieldName, targetSide) + : belongsToHandleId(targetSide); + + if (edge.sourceHandle === sourceHandle && edge.targetHandle === targetHandle) return edge; + return { ...edge, sourceHandle, targetHandle }; + }); +} + export function LinkRelation({ id, sourceX, diff --git a/packages/app-builder/src/components/Data/SemanticTables/Flow/TableDetails.tsx b/packages/app-builder/src/components/Data/SemanticTables/Flow/TableDetails.tsx index 08fb788329..d1c8989c13 100644 --- a/packages/app-builder/src/components/Data/SemanticTables/Flow/TableDetails.tsx +++ b/packages/app-builder/src/components/Data/SemanticTables/Flow/TableDetails.tsx @@ -16,9 +16,47 @@ import { adaptUpdateTableValue } from '../EditTable/updateTable-adapter'; import { DatatypeIcon } from '../Shared/DatatypeOption'; import { ChangeRecord, LinkValue, SemanticTableFormValues } from '../Shared/semanticData-types'; import { UploadTableDrawer } from '../UploadData/UploadTableDrawer'; +import { belongsToHandleId, relatedHandleId } from './LinkRelation'; import { TableRecordPreviewDrawer } from './TableRecordPreviewDrawer'; import '@xyflow/react/dist/style.css'; +const transparentHandleStyle = { background: 'transparent', border: 'none' } as const; + +function SideHandles({ leftId, rightId }: { leftId: string; rightId: string }) { + return ( + <> + + + + + + ); +} + export interface TableDetailsProps { tableModel: TableModel; relationFieldNames: string[]; @@ -93,27 +131,12 @@ export function TableDetails({ data }: NodeProps) { )} > {hasHandles && ( - + )}
{field.alias || field.name} {extended && field.dataType !== 'String' && }
- {hasHandles && ( - - )} ); }; @@ -168,17 +191,7 @@ export function TableDetails({ data }: NodeProps) { <>
- +
{data.tableModel.alias || data.tableModel.name} @@ -242,17 +255,6 @@ export function TableDetails({ data }: NodeProps) { -
{isNumberOfFieldsOpen ? ( diff --git a/packages/app-builder/src/components/Data/SemanticTables/Flow/TableFlow.tsx b/packages/app-builder/src/components/Data/SemanticTables/Flow/TableFlow.tsx index 8886fe7579..3d0057be49 100644 --- a/packages/app-builder/src/components/Data/SemanticTables/Flow/TableFlow.tsx +++ b/packages/app-builder/src/components/Data/SemanticTables/Flow/TableFlow.tsx @@ -1,4 +1,3 @@ -import { AutoLayoutControlButton } from '@app-builder/components/ReactFlow'; import { SchemaMenuMenuItem, SchemaMenuMenuPopover, SchemaMenuRoot } from '@app-builder/components/Schema/SchemaMenu'; import { Spinner } from '@app-builder/components/Spinner'; import { useTheme } from '@app-builder/contexts/ThemeContext'; @@ -31,9 +30,13 @@ import { getLinkToSingleDataEdgeId, LinkRelation, type LinkToSingleData, + retargetDataModelHandles, } from './LinkRelation'; import { TableDetails, TableDetailsProps } from './TableDetails'; +const ORPHAN_COLUMN_GAP = 100; +const ORPHAN_STACK_GAP = 100; + type CommonData = D & { type: T; state: 'initialized' | 'laid_out' | 'visible'; @@ -156,10 +159,13 @@ function DataModelFlowImpl({ dataModel, children }: TableFlowProps) { R.map((linkToSingleData) => { const edgeId = getLinkToSingleDataEdgeId(linkToSingleData); const existingEdge = currentEdges.find((ed) => ed.id === edgeId); + const endpoints = getLinkToSingleDataEdge(linkToSingleData); if (existingEdge) { if (existingEdge.data === undefined) return existingEdge; return { ...existingEdge, + sourceHandle: endpoints.sourceHandle, + targetHandle: endpoints.targetHandle, data: { ...existingEdge.data, ...linkToSingleData, @@ -169,7 +175,7 @@ function DataModelFlowImpl({ dataModel, children }: TableFlowProps) { return { id: edgeId, type: 'link_to_single_edge', - ...getLinkToSingleDataEdge(linkToSingleData), + ...endpoints, data: { ...linkToSingleData, type: 'link_to_single_edge', @@ -184,6 +190,16 @@ function DataModelFlowImpl({ dataModel, children }: TableFlowProps) { const { fitView, getEdges, getNodes } = useDataModelReactFlow(); + const onNodeDragStop = useCallback(() => { + setEdges((eds) => retargetDataModelHandles(getNodes(), eds)); + }, [getNodes]); + + const onAutoLayout = useCallback(() => { + const layout = layoutElements(getNodes(), getEdges()); + setNodes(layout.nodes); + setEdges(layout.edges); + }, [getEdges, getNodes]); + useIsomorphicLayoutEffect(() => { if (!nodesInitialized) return; if (!nodes.some((nd) => nd.data.state === 'initialized') && !edges.some((ed) => ed.data?.state === 'initialized')) @@ -337,12 +353,13 @@ function DataModelFlowImpl({ dataModel, children }: TableFlowProps) { minZoom={0.3} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} + onNodeDragStop={onNodeDragStop} defaultEdgeOptions={defaultDataModelEdgeOptions} connectionLineStyle={defaultDataModelEdgeOptions.style} colorMode={theme.theme} > - + {children} @@ -350,7 +367,7 @@ function DataModelFlowImpl({ dataModel, children }: TableFlowProps) { ); } -function CustomControls() { +function CustomControls({ onAutoLayout }: { onAutoLayout: () => void }) { const { getNodes, fitView } = useDataModelReactFlow(); return ( @@ -373,51 +390,98 @@ function CustomControls() { - + ); } -function layoutElements(nodes: Array>, edges: Array>) { - const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); - g.setGraph({ - rankdir: 'LR', - nodesep: 100, - ranksep: 100, +function withPosition(nd: Node, position: { x: number; y: number }): Node { + if (position.x === nd.position.x && position.y === nd.position.y) return nd; + return { ...nd, position } satisfies Node; +} + +function stackOrphans( + orphans: Array>, + origin: { x: number; y: number }, +): Array> { + let y = origin.y; + return orphans.map((nd) => { + const { height } = nodeMeasured(nd); + const positioned = withPosition(nd, { x: origin.x, y }); + y += (height ?? 0) + ORPHAN_STACK_GAP; + return positioned; }); +} - edges.forEach((edge) => g.setEdge(edge.source, edge.target)); - nodes.forEach((node) => { - const { width, height } = nodeMeasured(node); - return g.setNode(node.id, { - width, - height, +function layoutElements(nodes: Array>, edges: Array>) { + const linkedIds = new Set(); + for (const edge of edges) { + linkedIds.add(edge.source); + linkedIds.add(edge.target); + } + + const orphans = nodes.filter((nd) => !linkedIds.has(nd.id)).sort((a, b) => a.id.localeCompare(b.id)); + const linked = nodes.filter((nd) => linkedIds.has(nd.id)); + + // Orphan rail always occupies the top-left of the layout coordinate space. + const maxOrphanWidth = orphans.reduce((max, nd) => Math.max(max, nodeMeasured(nd).width ?? 0), 0); + const laidOrphans = stackOrphans(orphans, { x: 0, y: 0 }); + const linkedOriginX = orphans.length > 0 ? maxOrphanWidth + ORPHAN_COLUMN_GAP : 0; + + let laidLinked: Array> = []; + + if (linked.length > 0) { + const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); + g.setGraph({ + rankdir: 'LR', + nodesep: 100, + ranksep: 100, }); - }); - Dagre.layout(g, { - weight: 1000, - minlen: 3, - }); + edges.forEach((edge) => g.setEdge(edge.source, edge.target)); + linked.forEach((node) => { + const { width, height } = nodeMeasured(node); + g.setNode(node.id, { width, height }); + }); - return { - nodes: nodes.map((nd) => { + Dagre.layout(g, { + weight: 1000, + minlen: 3, + }); + + const dagreLinked = linked.map((nd) => { const { x, y } = g.node(nd.id); const { width, height } = nodeMeasured(nd); - const position = { + return withPosition(nd, { x: x - (width ?? 0) / 2, y: y - (height ?? 0) / 2, - }; + }); + }); - if (position.x === nd.position.x && position.y === nd.position.y) { - return nd; - } + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + for (const nd of dagreLinked) { + minX = Math.min(minX, nd.position.x); + minY = Math.min(minY, nd.position.y); + } + + const shiftX = linkedOriginX - minX; + const shiftY = -minY; + laidLinked = dagreLinked.map((nd) => + withPosition(nd, { + x: nd.position.x + shiftX, + y: nd.position.y + shiftY, + }), + ); + } - return { - ...nd, - position, - } satisfies Node; - }), - edges: edges, + const byId = new Map([...laidLinked, ...laidOrphans].map((nd) => [nd.id, nd])); + const laidOutNodes = nodes.map((nd) => byId.get(nd.id) ?? nd); + + return { + nodes: laidOutNodes, + edges: retargetDataModelHandles(laidOutNodes, edges), }; } diff --git a/packages/app-builder/src/components/ReactFlow.tsx b/packages/app-builder/src/components/ReactFlow.tsx index 8e3cefce73..0bca9615cc 100644 --- a/packages/app-builder/src/components/ReactFlow.tsx +++ b/packages/app-builder/src/components/ReactFlow.tsx @@ -27,6 +27,7 @@ export function useLayoutElements( }) { const { fitView, getEdges, getNodes, setEdges, setNodes } = useReactFlow(); const layoutElementsRef = useRef(layoutElements); + layoutElementsRef.current = layoutElements; return useCallback( (options: { fitView?: boolean }) => { const { nodes, edges } = layoutElementsRef.current(getNodes(), getEdges()); From 940f99071a440709f5a7ffe4f9e9932582a9b5f5 Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Tue, 11 Aug 2026 10:24:33 +0200 Subject: [PATCH 2/2] fix coderaabbit review --- .../Data/SemanticTables/Flow/TableFlow.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/app-builder/src/components/Data/SemanticTables/Flow/TableFlow.tsx b/packages/app-builder/src/components/Data/SemanticTables/Flow/TableFlow.tsx index 3d0057be49..9cf5fd5494 100644 --- a/packages/app-builder/src/components/Data/SemanticTables/Flow/TableFlow.tsx +++ b/packages/app-builder/src/components/Data/SemanticTables/Flow/TableFlow.tsx @@ -116,6 +116,8 @@ function DataModelFlowImpl({ dataModel, children }: TableFlowProps) { setEdges((eds) => applyEdgeChanges(allowedChanges, eds)); }, []); + const { fitView, getEdges, getNodes } = useDataModelReactFlow(); + useEffect(() => { setIsInitialLayoutSettled(false); hasScheduledInitialStabilizationRef.current = false; @@ -150,8 +152,8 @@ function DataModelFlowImpl({ dataModel, children }: TableFlowProps) { }), ), ); - setEdges((currentEdges) => - R.pipe( + setEdges((currentEdges) => { + const nextEdges = R.pipe( dataModel, R.flatMap((tableModel) => tableModel.linksToSingle), R.filter((link) => link.parentTableId !== link.childTableId), @@ -164,8 +166,6 @@ function DataModelFlowImpl({ dataModel, children }: TableFlowProps) { if (existingEdge.data === undefined) return existingEdge; return { ...existingEdge, - sourceHandle: endpoints.sourceHandle, - targetHandle: endpoints.targetHandle, data: { ...existingEdge.data, ...linkToSingleData, @@ -184,11 +184,13 @@ function DataModelFlowImpl({ dataModel, children }: TableFlowProps) { hidden: true, } satisfies Edge; }), - ), - ); - }, [dataModel]); + ); - const { fitView, getEdges, getNodes } = useDataModelReactFlow(); + // Reused edges keep the sides geometry gave them; recompute from current node positions + // instead of falling back to the default LR endpoints. + return retargetDataModelHandles(getNodes(), nextEdges); + }); + }, [dataModel, getNodes]); const onNodeDragStop = useCallback(() => { setEdges((eds) => retargetDataModelHandles(getNodes(), eds));