From d729130bb9b4995d4008ad6c0fde2cc702742495 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Thu, 26 Feb 2026 09:18:23 -0500 Subject: [PATCH 01/30] feat: add d3-dag library --- packages/ui/package.json | 1 + pnpm-lock.yaml | 82 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 51d4ead4..d7f61d07 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -30,6 +30,7 @@ "@overture-stack/lectern-dictionary": "workspace:*", "@overture-stack/lectern-validation": "workspace:*", "@tanstack/react-table": "^8.21.3", + "d3-dag": "^1.1.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-loading-skeleton": "^3.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b43a8ab..fb848f97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,6 +278,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + d3-dag: + specifier: ^1.1.0 + version: 1.1.0 react: specifier: ^19.1.0 version: 19.1.0 @@ -2090,10 +2093,17 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + d3-color@3.1.0: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} + d3-dag@1.1.0: + resolution: {integrity: sha512-N8IxsIHcUaIxLrV3cElTC47kVJGFiY3blqSuJubQhyhYBJs0syfFPTnRSj2Cq0LBxxi4mzJmcqCvHIv9sPdILQ==} + d3-dispatch@3.0.1: resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} engines: {node: '>=12'} @@ -2294,6 +2304,9 @@ packages: electron-to-chromium@1.5.162: resolution: {integrity: sha512-hQA+Zb5QQwoSaXJWEAGEw1zhk//O7qDzib05Z4qTqZfNju/FAkrm5ZInp0JbTp4Z18A6bilopdZWEYrFSsfllA==} + elkjs@0.9.3: + resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==} + emoji-regex@7.0.3: resolution: {integrity: sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==} @@ -2590,6 +2603,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-own-enumerable-keys@1.0.0: + resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} + engines: {node: '>=14.16'} + get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} @@ -2623,24 +2640,25 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.1.4: resolution: {integrity: sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@7.1.6: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -2785,6 +2803,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + interpret@1.4.0: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} @@ -2860,6 +2882,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-obj@3.0.0: + resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==} + engines: {node: '>=12'} + is-plain-obj@2.1.0: resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} engines: {node: '>=8'} @@ -2874,6 +2900,10 @@ packages: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} + is-regexp@3.1.0: + resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} + engines: {node: '>=12'} + is-stream@1.1.0: resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} engines: {node: '>=0.10.0'} @@ -2948,6 +2978,9 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + javascript-lp-solver@0.4.24: + resolution: {integrity: sha512-5edoDKnMrt/u3M6GnZKDDIPxOyFOg+WrwDv8mjNiMC2DePhy2H9/FFQgf4ggywaXT1utvkxusJcjQUER72cZmA==} + jest-diff@29.7.0: resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3706,6 +3739,10 @@ packages: resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==} engines: {node: '>=0.6'} + quadprog@1.6.1: + resolution: {integrity: sha512-fN5Jkcjlln/b3pJkseDKREf89JkKIyu6cKIVXisgL6ocKPQ0yTp9n6NZUAq3otEPPw78WZMG9K0o9WsfKyMWJw==} + engines: {node: '>=8.x'} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -4157,6 +4194,10 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-object@5.0.0: + resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} + engines: {node: '>=14.16'} + strip-ansi@4.0.0: resolution: {integrity: sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==} engines: {node: '>=4'} @@ -4212,7 +4253,7 @@ packages: superagent@8.1.2: resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} engines: {node: '>=6.4.0 <13 || >=14'} - deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net + deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} @@ -6657,8 +6698,19 @@ snapshots: csstype@3.1.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + d3-color@3.1.0: {} + d3-dag@1.1.0: + dependencies: + d3-array: 3.2.4 + javascript-lp-solver: 0.4.24 + quadprog: 1.6.1 + stringify-object: 5.0.0 + d3-dispatch@3.0.1: {} d3-drag@3.0.0: @@ -6827,6 +6879,8 @@ snapshots: electron-to-chromium@1.5.162: {} + elkjs@0.9.3: {} + emoji-regex@7.0.3: {} emoji-regex@8.0.0: {} @@ -7171,6 +7225,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-own-enumerable-keys@1.0.0: {} + get-package-type@0.1.0: {} get-port@4.2.0: {} @@ -7372,6 +7428,8 @@ snapshots: inherits@2.0.4: {} + internmap@2.0.3: {} + interpret@1.4.0: {} ip-address@9.0.5: @@ -7429,6 +7487,8 @@ snapshots: is-number@7.0.0: {} + is-obj@3.0.0: {} + is-plain-obj@2.1.0: {} is-promise@2.2.2: {} @@ -7442,6 +7502,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + is-regexp@3.1.0: {} + is-stream@1.1.0: {} is-stream@2.0.1: {} @@ -7525,6 +7587,8 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + javascript-lp-solver@0.4.24: {} + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -8328,6 +8392,8 @@ snapshots: qs@6.5.3: {} + quadprog@1.6.1: {} + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -8873,6 +8939,12 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-object@5.0.0: + dependencies: + get-own-enumerable-keys: 1.0.0 + is-obj: 3.0.0 + is-regexp: 3.1.0 + strip-ansi@4.0.0: dependencies: ansi-regex: 3.0.1 From a113b30a7351d5cb1a859cef015030e47c2b8ad5 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Thu, 26 Feb 2026 09:18:52 -0500 Subject: [PATCH 02/30] feat: implement layout algorithm using d3-dag's sugiyama algorithm --- .../EntityRelationshipDiagram/diagramUtils.ts | 90 ++++++++++++++----- 1 file changed, 67 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index 14f29510..324682fe 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -21,6 +21,7 @@ import type { Dictionary, Schema } from '@overture-stack/lectern-dictionary'; import { type Edge, type Node, MarkerType } from 'reactflow'; +import { graphStratify, sugiyama, type GraphNode } from 'd3-dag'; import { ONE_CARDINALITY_MARKER_ID, ONE_CARDINALITY_MARKER_ACTIVE_ID } from '../../theme/icons/OneCardinalityMarker'; const DEFAULT_MARKER_CONFIG = { @@ -30,13 +31,15 @@ const DEFAULT_MARKER_CONFIG = { color: '#374151', }; +const NODE_WIDTH = 350; +const HEADER_HEIGHT = 60; +const FIELD_ROW_HEIGHT = 45; +const GAP_X = 100; +const GAP_Y = 100; + export type SchemaFlowNode = Node; -export type SchemaNodeLayout = { - maxColumns: number; - columnWidth: number; - rowHeight: number; -}; +type StratifyDatum = { id: string; parentIds: string[] }; function buildSchemaNode(schema: Schema): Omit { return { @@ -68,31 +71,72 @@ export type RelationshipMap = { fieldKeyToFkIndices: Map; }; +function estimateNodeHeight(schema: Schema): number { + return HEADER_HEIGHT + schema.fields.length * FIELD_ROW_HEIGHT; +} + /** - * Converts a dictionary's schemas into positioned ReactFlow nodes arranged in a grid layout. - * - * @param {Dictionary} dictionary — The Lectern dictionary containing schemas to visualize - * @param {Partial} layout — Optional overrides for grid layout configuration - * @returns {Node[]} Array of positioned ReactFlow nodes + * Computes node positions using d3-dag's Sugiyama layout algorithm. */ -export function getNodesForDictionary(dictionary: Dictionary, layout?: Partial): Node[] { - const maxColumns = layout?.maxColumns ?? 4; - const columnWidth = layout?.columnWidth ?? 500; - const rowHeight = layout?.rowHeight ?? 500; +export function getLayoutedElements(nodes: Node[], edges: Edge[]): Node[] { + if (nodes.length === 0) { + return []; + } - return dictionary.schemas.map((schema, index) => { - const partialNode = buildSchemaNode(schema); + const parentMap = new Map>(); + for (const edge of edges) { + if (!parentMap.has(edge.target)) { + parentMap.set(edge.target, new Set()); + } + parentMap.get(edge.target)!.add(edge.source); + } - const row = Math.floor(index / maxColumns); - const col = index % maxColumns; + const stratifyData: StratifyDatum[] = nodes.map((node) => ({ + id: node.id, + parentIds: Array.from(parentMap.get(node.id) ?? []), + })); - const position: Node['position'] = { - x: col * columnWidth, - y: row * rowHeight, - }; + const dag = graphStratify()(stratifyData); + const schemaByName = new Map(); + + for (const node of nodes) { + schemaByName.set(node.id, node.data as Schema); + } - return { ...partialNode, position }; + const layout = sugiyama().nodeSize((dagNode: GraphNode): [number, number] => { + const schema = schemaByName.get(dagNode.data.id); + const height = schema ? estimateNodeHeight(schema) : HEADER_HEIGHT; + return [NODE_WIDTH + GAP_X, height + GAP_Y]; }); + + layout(dag); + + const positionMap = new Map(); + for (const dagNode of dag.nodes()) { + positionMap.set(dagNode.data.id, { x: dagNode.x, y: dagNode.y }); + } + + return nodes.map((node) => ({ + ...node, + position: positionMap.get(node.id) ?? { x: 0, y: 0 }, + })); +} + +/** + * Builds unpositioned nodes from the dictionary and computes layout using + * d3-dag's Sugiyama algorithm. + * + * @param {Dictionary} dictionary — The Lectern dictionary containing schemas to visualize + * @param {Edge[]} edges — ReactFlow edges representing foreign key relationships + * @returns {{ nodes: Node[]; edges: Edge[] }} Positioned nodes and the original edges + */ +export function getLayoutedDiagram(dictionary: Dictionary, edges: Edge[]): { nodes: Node[]; edges: Edge[] } { + const unpositionedNodes: Node[] = dictionary.schemas.map((schema) => ({ + ...buildSchemaNode(schema), + position: { x: 0, y: 0 }, + })); + const layoutedNodes = getLayoutedElements(unpositionedNodes, edges); + return { nodes: layoutedNodes, edges }; } /** From 8defe1458402ad1beae1f535d9c13253e4d55a15 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Thu, 26 Feb 2026 09:20:01 -0500 Subject: [PATCH 03/30] feat: render nodes and edges from the generated layout --- .../EntityRelationshipDiagram.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx index a1dd4ea7..0af56583 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx @@ -85,17 +85,14 @@ const edgeHoverStyles = (theme: Theme) => css` /** * Entity Relationship Diagram visualizing schemas and their foreign key relationships. * Must be rendered inside an `ActiveRelationshipProvider`. - * - * @param {Dictionary} dictionary — The Lectern dictionary whose schemas and relationships to visualize - * @param {Partial} layout — Optional overrides for the grid layout of schema nodes. - * maxColumns controls the number of nodes per row before wrapping (default 4), - * columnWidth sets horizontal spacing in pixels between column left edges (default 500), - * and rowHeight sets vertical spacing in pixels between row top edges (default 500) + * Uses d3-dag's Sugiyama algorithm to compute a hierarchical layout from FK relationships. */ -export function EntityRelationshipDiagramContent({ dictionary, layout }: EntityRelationshipDiagramProps) { - const [nodes, , onNodesChange] = useNodesState(getNodesForDictionary(dictionary, layout)); +export function EntityRelationshipDiagramContent({ dictionary }: EntityRelationshipDiagramProps) { const { activeEdgeIds, activateRelationship, deactivateRelationship, relationshipMap } = useActiveRelationship(); - const [edges, , onEdgesChange] = useEdgesState(getEdgesFromMap(relationshipMap)); + const allEdges = getEdgesFromMap(relationshipMap); + const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedDiagram(dictionary, allEdges); + const [nodes, , onNodesChange] = useNodesState(layoutedNodes); + const [edges, , onEdgesChange] = useEdgesState(layoutedEdges); const theme = useThemeContext(); const highlightedEdges = useMemo( From 4a3e5ec6e9ec0958486524af3d9be9f3271b8553 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Thu, 26 Feb 2026 09:21:09 -0500 Subject: [PATCH 04/30] feat: import new utils --- .../EntityRelationshipDiagram.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx index 0af56583..687d4cbe 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx @@ -35,13 +35,7 @@ import ReactFlow, { } from 'reactflow'; import 'reactflow/dist/style.css'; import OneCardinalityMarker from '../../theme/icons/OneCardinalityMarker'; -import { - getEdgesFromMap, - getEdgesWithHighlight, - getNodesForDictionary, - type RelationshipEdgeData, - type SchemaNodeLayout, -} from './diagramUtils'; +import { getEdgesFromMap, getEdgesWithHighlight, getLayoutedDiagram, type RelationshipEdgeData } from './diagramUtils'; import { useActiveRelationship } from './ActiveRelationshipContext'; import { SchemaNode } from './SchemaNode'; @@ -51,7 +45,6 @@ const nodeTypes: NodeTypes = { type EntityRelationshipDiagramProps = { dictionary: Dictionary; - layout?: Partial; }; const edgeHoverStyles = (theme: Theme) => css` From 8ef038c915325d3aa9c844cd7640530fe88ed798 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Thu, 26 Feb 2026 09:21:44 -0500 Subject: [PATCH 05/30] fix: adjust viewport configuration to support larger diagrams --- .../EntityRelationshipDiagram/EntityRelationshipDiagram.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx index 687d4cbe..6b8f3be9 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx @@ -120,10 +120,10 @@ export function EntityRelationshipDiagramContent({ dictionary }: EntityRelations onPaneClick={onPaneClick} nodeTypes={nodeTypes} fitView - fitViewOptions={{ padding: 20, maxZoom: 1.5, minZoom: 0.5 }} + fitViewOptions={{ padding: 0.2, maxZoom: 1 }} style={{ width: '100%', height: '100%' }} defaultViewport={{ x: 0, y: 0, zoom: 1.0 }} - minZoom={0.1} + minZoom={0.05} maxZoom={3} > From 7ee353acdd28720706d84f3e9cea7b80c1536ef0 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Thu, 26 Feb 2026 09:59:09 -0500 Subject: [PATCH 06/30] fix: re-add conditonal background and border logic --- packages/ui/src/theme/emotion/schemaNodeStyles.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/theme/emotion/schemaNodeStyles.ts b/packages/ui/src/theme/emotion/schemaNodeStyles.ts index 6fd0ba10..40133083 100644 --- a/packages/ui/src/theme/emotion/schemaNodeStyles.ts +++ b/packages/ui/src/theme/emotion/schemaNodeStyles.ts @@ -35,6 +35,7 @@ export const fieldRowStyles = (theme: Theme, isForeignKey: boolean, isHighlighte justify-content: space-between; transition: background-color 0.2s; position: relative; + background-color: ${isHighlighted ? theme.colors.secondary_1 : 'transparent'}; border-block: 1.5px solid ${isHighlighted ? theme.colors.secondary_dark : 'transparent'}; ${isForeignKey ? 'cursor: pointer;' : ''} @@ -43,8 +44,8 @@ export const fieldRowStyles = (theme: Theme, isForeignKey: boolean, isHighlighte } &:nth-child(even) { - background-color: ${theme.colors.accent_1}; - border-block: 1.5px solid ${theme.colors.accent_2}; + background-color: ${isHighlighted ? theme.colors.secondary_1 : theme.colors.accent_1}; + border-block: 1.5px solid ${isHighlighted ? theme.colors.secondary_dark : theme.colors.accent_2}; } &:nth-child(even):hover { From dddb512c3b3664b4814b8d9150495f6a8be34875 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Tue, 3 Mar 2026 15:00:58 -0500 Subject: [PATCH 07/30] refactor: remove non-null assertion operator --- .../EntityRelationshipDiagram/diagramUtils.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index 324682fe..62e0060a 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -78,17 +78,21 @@ function estimateNodeHeight(schema: Schema): number { /** * Computes node positions using d3-dag's Sugiyama layout algorithm. */ -export function getLayoutedElements(nodes: Node[], edges: Edge[]): Node[] { +export function getLayoutedElements(nodes: SchemaFlowNode[], edges: Edge[]): Node[] { if (nodes.length === 0) { return []; } const parentMap = new Map>(); for (const edge of edges) { - if (!parentMap.has(edge.target)) { - parentMap.set(edge.target, new Set()); + let parentSet = parentMap.get(edge.target); + + if (!parentSet) { + parentSet = new Set(); + parentMap.set(edge.target, parentSet); } - parentMap.get(edge.target)!.add(edge.source); + + parentSet.add(edge.source); } const stratifyData: StratifyDatum[] = nodes.map((node) => ({ From 5e49787b1793c26b956c71692001bdad54c04afa Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Tue, 3 Mar 2026 15:01:27 -0500 Subject: [PATCH 08/30] refactor: remove type casting --- .../viewer-table/EntityRelationshipDiagram/diagramUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index 62e0060a..486f18ad 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -104,7 +104,7 @@ export function getLayoutedElements(nodes: SchemaFlowNode[], edges: Edge[]): Nod const schemaByName = new Map(); for (const node of nodes) { - schemaByName.set(node.id, node.data as Schema); + schemaByName.set(node.id, node.data); } const layout = sugiyama().nodeSize((dagNode: GraphNode): [number, number] => { @@ -135,7 +135,7 @@ export function getLayoutedElements(nodes: SchemaFlowNode[], edges: Edge[]): Nod * @returns {{ nodes: Node[]; edges: Edge[] }} Positioned nodes and the original edges */ export function getLayoutedDiagram(dictionary: Dictionary, edges: Edge[]): { nodes: Node[]; edges: Edge[] } { - const unpositionedNodes: Node[] = dictionary.schemas.map((schema) => ({ + const unpositionedNodes: SchemaFlowNode[] = dictionary.schemas.map((schema) => ({ ...buildSchemaNode(schema), position: { x: 0, y: 0 }, })); From b15a9ecf2599e44fafe7e3219ea9a51989ee1578 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 4 Mar 2026 09:48:27 -0500 Subject: [PATCH 09/30] refactor: centralize utility functions for use in other files --- packages/ui/src/utils/isFieldForeignKey.ts | 28 +++++++++++++++++++ packages/ui/src/utils/isFieldUniqueKey.ts | 26 +++++++++++++++++ .../EntityRelationshipDiagram/SchemaNode.tsx | 9 +++--- 3 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/utils/isFieldForeignKey.ts create mode 100644 packages/ui/src/utils/isFieldUniqueKey.ts diff --git a/packages/ui/src/utils/isFieldForeignKey.ts b/packages/ui/src/utils/isFieldForeignKey.ts new file mode 100644 index 00000000..2fbc0d11 --- /dev/null +++ b/packages/ui/src/utils/isFieldForeignKey.ts @@ -0,0 +1,28 @@ +/* + * + * Copyright (c) 2026 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + */ + +import type { Schema, SchemaField } from '@overture-stack/lectern-dictionary'; + +export function isFieldForeignKey(schema: Schema, field: SchemaField): boolean { + return ( + schema.restrictions?.foreignKey?.some((fk) => fk.mappings.some((mapping) => mapping.local === field.name)) || false + ); +} diff --git a/packages/ui/src/utils/isFieldUniqueKey.ts b/packages/ui/src/utils/isFieldUniqueKey.ts new file mode 100644 index 00000000..25db27b6 --- /dev/null +++ b/packages/ui/src/utils/isFieldUniqueKey.ts @@ -0,0 +1,26 @@ +/* + * + * Copyright (c) 2026 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + */ + +import type { Schema, SchemaField } from '@overture-stack/lectern-dictionary'; + +export function isFieldUniqueKey(schema: Schema, field: SchemaField): boolean { + return schema.restrictions?.uniqueKey?.includes(field.name) || field.unique === true || false; +} diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx index 27e185a9..25fc67b0 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx @@ -25,6 +25,8 @@ import { Handle, Position } from 'reactflow'; import 'reactflow/dist/style.css'; import Key from '../../theme/icons/Key'; import { useThemeContext } from '../../theme'; +import { isFieldForeignKey } from '../../utils/isFieldForeignKey'; +import { isFieldUniqueKey } from '../../utils/isFieldUniqueKey'; import { fieldRowStyles, fieldContentStyles, @@ -58,11 +60,8 @@ export function SchemaNode(props: { data: Schema }) {
{schema.fields.map((field, index) => { - const isUniqueKey = schema.restrictions?.uniqueKey?.includes(field.name) || field.unique === true; - const isForeignKey = - schema.restrictions?.foreignKey?.some((fk) => - fk.mappings.some((mapping) => mapping.local === field.name), - ) || false; + const isUniqueKey = isFieldUniqueKey(schema, field); + const isForeignKey = isFieldForeignKey(schema, field); const valueType = field.isArray ? `${field.valueType}[]` : field.valueType; const isHighlighted = isFieldInActiveRelationship(schema.name, field.name); From ae312edce399a68187982a4b330489cadd388a2d Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 4 Mar 2026 09:50:37 -0500 Subject: [PATCH 10/30] refactor: update file with centralized logic for rendering content --- .../SchemaTable/Columns/Attribute.tsx | 78 ++++++++++++++++--- .../ui/src/viewer-table/OpenModalButton.tsx | 5 -- 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/viewer-table/DataTable/SchemaTable/Columns/Attribute.tsx b/packages/ui/src/viewer-table/DataTable/SchemaTable/Columns/Attribute.tsx index b86d88d9..0ac81912 100644 --- a/packages/ui/src/viewer-table/DataTable/SchemaTable/Columns/Attribute.tsx +++ b/packages/ui/src/viewer-table/DataTable/SchemaTable/Columns/Attribute.tsx @@ -20,15 +20,20 @@ /** @jsxImportSource @emotion/react */ import { css } from '@emotion/react'; -import type { SchemaField } from '@overture-stack/lectern-dictionary'; +import type { Schema, SchemaField } from '@overture-stack/lectern-dictionary'; import { SchemaFieldRestrictions } from '@overture-stack/lectern-dictionary'; import { useState } from 'react'; import { type Theme, useThemeContext } from '../../../../theme/index'; +import Key from '../../../../theme/icons/Key'; +import Eye from '../../../../theme/icons/Eye'; +import { isFieldForeignKey } from '../../../../utils/isFieldForeignKey'; import { isFieldRequired } from '../../../../utils/isFieldRequired'; +import { isFieldUniqueKey } from '../../../../utils/isFieldUniqueKey'; import { ConditionalLogicModal } from '../../../ConditionalLogicModal/ConditionalLogicModal'; import { NoMarginParagraph } from '../../../../theme/emotion'; import OpenModalButton from '../../../OpenModalButton'; +import { useDiagramViewContext } from '../../../DiagramViewContext'; export type Attributes = 'Required' | 'Optional' | 'Required When'; @@ -41,10 +46,38 @@ const containerStyle = (theme: Theme) => css` ${theme.typography.paragraphSmallBold} `; -const buttonTextContainer = css` +const diagramLinkStyle = (theme: Theme) => css` + ${theme.typography.paragraphSmallBold} + padding: 0; + background: none; + border: none; + color: ${theme.colors.black}; + text-decoration: underline; + cursor: pointer; + &:hover { + color: ${theme.colors.secondary}; + } + +`; + +const hoverGroupStyle = (theme: Theme) => css` display: flex; flex-direction: column; - gap: 0; + align-items: center; + gap: 10px; + &:has(button:hover) svg { + stroke: ${theme.colors.secondary}; + } + &:has(button:hover) button { + color: ${theme.colors.secondary}; + } +`; + +const iconGroupStyle = css` + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; `; /** @@ -55,21 +88,30 @@ const buttonTextContainer = css` export const renderAttributesColumn = ( schemaFieldRestrictions: SchemaFieldRestrictions, currentSchemaField?: SchemaField, + schema?: Schema, ) => { const theme: Theme = useThemeContext(); - const [isOpen, setIsOpen] = useState(false); + const { openFocusedDiagram } = useDiagramViewContext(); + const [isOpen, setIsOpen] = useState(false); const showConditional = !!(schemaFieldRestrictions && 'if' in schemaFieldRestrictions); + const isUniqueKey = !!(schema && currentSchemaField) && isFieldUniqueKey(schema, currentSchemaField); + const isForeignKey = !!(schema && currentSchemaField) && isFieldForeignKey(schema, currentSchemaField); + const isRequired = currentSchemaField && isFieldRequired(currentSchemaField); return (
{showConditional ? <> - setIsOpen(true)}> -
+
+
+ + {(isUniqueKey || isForeignKey) && } +
+ setIsOpen(true)}>

Required

When

-
- + +
{currentSchemaField && ( )} - :
{currentSchemaField && isFieldRequired(currentSchemaField) ? 'Required' : 'Optional'}
} + : isForeignKey ? +
+ + +
+ : <> + {isUniqueKey && } +
{isRequired ? 'Required' : 'Optional'}
+ + }
); }; diff --git a/packages/ui/src/viewer-table/OpenModalButton.tsx b/packages/ui/src/viewer-table/OpenModalButton.tsx index 25f42a0a..5733e773 100644 --- a/packages/ui/src/viewer-table/OpenModalButton.tsx +++ b/packages/ui/src/viewer-table/OpenModalButton.tsx @@ -22,7 +22,6 @@ import { css } from '@emotion/react'; import { type ReactNode, type SyntheticEvent } from 'react'; import { type Theme, useThemeContext } from '../theme/index'; -import Eye from '../theme/icons/Eye'; export type OpenModalButtonProps = { onClick?: (e: SyntheticEvent) => any | ((e: SyntheticEvent) => Promise); @@ -42,9 +41,6 @@ const buttonStyle = (theme: Theme) => css` color: ${theme.colors.black}; text-decoration: underline; cursor: pointer; - &:hover { - color: ${theme.colors.secondary}; - } `; const OpenModalButton = ({ onClick, children }: OpenModalButtonProps) => { @@ -52,7 +48,6 @@ const OpenModalButton = ({ onClick, children }: OpenModalButtonProps) => { return ( ); From 75186766dce0017e1f473ac740c5cd0ac54ae09d Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 4 Mar 2026 14:12:14 -0500 Subject: [PATCH 11/30] feat: move diagram view button to dictionary table viewer component --- .../src/viewer-table/DiagramViewContext.tsx | 67 +++++++++++++++++ .../viewer-table/DictionaryTableViewer.tsx | 73 +++++++++++++++++-- .../Toolbar/DiagramViewButton.tsx | 43 ++--------- 3 files changed, 138 insertions(+), 45 deletions(-) create mode 100644 packages/ui/src/viewer-table/DiagramViewContext.tsx diff --git a/packages/ui/src/viewer-table/DiagramViewContext.tsx b/packages/ui/src/viewer-table/DiagramViewContext.tsx new file mode 100644 index 00000000..e8e547fb --- /dev/null +++ b/packages/ui/src/viewer-table/DiagramViewContext.tsx @@ -0,0 +1,67 @@ +/* + * + * Copyright (c) 2026 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + */ + +import { createContext, useCallback, useContext, useState } from 'react'; +import type { ReactNode } from 'react'; + +type DiagramViewContextType = { + isOpen: boolean; + focusField: { schemaName: string; fieldName: string } | undefined; + openDiagram: () => void; + openFocusedDiagram: (field: { schemaName: string; fieldName: string }) => void; + closeDiagram: () => void; +}; + +export const DiagramViewContext = createContext({ + isOpen: false, + focusField: undefined, + openDiagram: () => {}, + openFocusedDiagram: () => {}, + closeDiagram: () => {}, +}); + +export const useDiagramViewContext = () => useContext(DiagramViewContext); + +export function DiagramViewProvider({ children }: { children: ReactNode }) { + const [isOpen, setIsOpen] = useState(false); + const [focusField, setFocusField] = useState<{ schemaName: string; fieldName: string } | undefined>(undefined); + + const openDiagram = useCallback(() => { + setFocusField(undefined); + setIsOpen(true); + }, []); + + const openFocusedDiagram = useCallback((field: { schemaName: string; fieldName: string }) => { + setFocusField(field); + setIsOpen(true); + }, []); + + const closeDiagram = useCallback(() => { + setIsOpen(false); + setFocusField(undefined); + }, []); + + return ( + + {children} + + ); +} diff --git a/packages/ui/src/viewer-table/DictionaryTableViewer.tsx b/packages/ui/src/viewer-table/DictionaryTableViewer.tsx index d6f9814e..7966c5ac 100644 --- a/packages/ui/src/viewer-table/DictionaryTableViewer.tsx +++ b/packages/ui/src/viewer-table/DictionaryTableViewer.tsx @@ -23,17 +23,26 @@ import { css } from '@emotion/react'; import type { Dictionary, Schema, SchemaFieldRestrictions } from '@overture-stack/lectern-dictionary'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import Accordion from '../common/Accordion/index'; +import Modal from '../common/Modal'; import { ErrorModal } from '../common/Error/ErrorModal'; import { useDictionaryDataContext, useDictionaryStateContext } from '../dictionary-controller/DictionaryDataContext'; import { type Theme, useThemeContext } from '../theme/index'; import { isFieldRequired } from '../utils/isFieldRequired'; +import { DiagramViewProvider, useDiagramViewContext } from './DiagramViewContext'; +import { + ActiveRelationshipProvider, + buildRelationshipMap, + RelationshipDiagramContent, + useActiveRelationship, +} from './EntityRelationshipDiagram'; import SchemaTable from './DataTable/SchemaTable/index'; import DictionaryHeader from './DictionaryHeader/DictionaryHeader'; import DictionaryViewerLoadingPage from './DictionaryViewer/DictionaryViewerLoadingPage'; +import DiagramSubtitle from './Toolbar/DiagramSubtitle'; import Toolbar from './Toolbar/index'; type ParsedHashTarget = { @@ -110,10 +119,40 @@ const ErrorDisplay = ({ errors, onContactClick }: { errors: string[]; onContactC ); }; +const DiagramModal = () => { + const { isOpen, focusField, closeDiagram } = useDiagramViewContext(); + const { selectedDictionary } = useDictionaryStateContext(); + const { deactivateRelationship } = useActiveRelationship(); + + // Clear the relationship when the modal is closed + useEffect(() => { + if (!isOpen) { + deactivateRelationship(); + } + }, [isOpen, deactivateRelationship]); + + return ( + } + isOpen={isOpen} + setIsOpen={(open) => { + if (!open) closeDiagram(); + }} + > + {selectedDictionary && ( +
+ +
+ )} +
+ ); +}; + // TODO: produce a simplified version that accepts a dictionary and produces this same view, // so that there's no requirement for a Lectern server, etc. and without a Toolbar, or a simpler one. -export const DictionaryTableViewer = () => { +const DictionaryTableViewerContent = () => { const theme = useThemeContext(); const { loading, errors } = useDictionaryDataContext(); const { filters, selectedDictionary } = useDictionaryStateContext(); @@ -122,6 +161,11 @@ export const DictionaryTableViewer = () => { const [selectedSchemaIndex, setSelectedSchemaIndex] = useState(undefined); const [highlightedField, setHighlightedField] = useState<{ schemaName: string; fieldName: string } | null>(null); + const relationshipMap = useMemo( + () => (selectedDictionary ? buildRelationshipMap(selectedDictionary) : null), + [selectedDictionary], + ); + const handleHash = useCallback(() => { const target = parseHash(window.location.hash, selectedDictionary?.schemas); if (!target) return; @@ -208,14 +252,27 @@ export const DictionaryTableViewer = () => { } return ( -
-
- + <> +
+
+ +
+ +
- - -
+ {relationshipMap && !loading && errors.length === 0 && ( + + + + )} + ); }; +export const DictionaryTableViewer = () => ( + + + +); + export default DictionaryTableViewer; diff --git a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx index 982b97e2..25abce68 100644 --- a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx +++ b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx @@ -19,52 +19,21 @@ * */ -import { useMemo, useState } from 'react'; import Button from '../../common/Button'; -import Modal from '../../common/Modal'; -import { useDictionaryDataContext, useDictionaryStateContext } from '../../dictionary-controller/DictionaryDataContext'; +import { useDictionaryDataContext } from '../../dictionary-controller/DictionaryDataContext'; import { useThemeContext } from '../../theme/index'; -import { - ActiveRelationshipProvider, - buildRelationshipMap, - EntityRelationshipDiagramContent, -} from '../EntityRelationshipDiagram'; -import DiagramSubtitle from './DiagramSubtitle'; +import { useDiagramViewContext } from '../DiagramViewContext'; const DiagramViewButton = () => { - const [isOpen, setIsOpen] = useState(false); const theme = useThemeContext(); const { Eye } = theme.icons; const { loading, errors } = useDictionaryDataContext(); - const { selectedDictionary } = useDictionaryStateContext(); - - const relationshipMap = useMemo( - () => (selectedDictionary ? buildRelationshipMap(selectedDictionary) : null), - [selectedDictionary], - ); + const { openDiagram } = useDiagramViewContext(); return ( - <> - - {relationshipMap && ( - - } - isOpen={isOpen} - setIsOpen={setIsOpen} - > - {selectedDictionary && ( -
- -
- )} -
-
- )} - + ); }; From 8e6201e131eb69987bfe92f83ce1f7ccb026ed97 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 4 Mar 2026 14:13:00 -0500 Subject: [PATCH 12/30] feat: add diagram view link to allowed values --- .../RenderAllowedValues.tsx | 39 ++++++++++++++++++- .../DataTable/SchemaTable/SchemaTableInit.tsx | 4 +- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/viewer-table/DataTable/SchemaTable/Columns/AllowedValuesColumn/RenderAllowedValues.tsx b/packages/ui/src/viewer-table/DataTable/SchemaTable/Columns/AllowedValuesColumn/RenderAllowedValues.tsx index 12eb70c8..65418aef 100644 --- a/packages/ui/src/viewer-table/DataTable/SchemaTable/Columns/AllowedValuesColumn/RenderAllowedValues.tsx +++ b/packages/ui/src/viewer-table/DataTable/SchemaTable/Columns/AllowedValuesColumn/RenderAllowedValues.tsx @@ -20,11 +20,13 @@ /** @jsxImportSource @emotion/react */ import { css } from '@emotion/react'; -import { SchemaField, SchemaFieldRestrictions, SchemaRestrictions } from '@overture-stack/lectern-dictionary'; +import { Schema, SchemaField, SchemaFieldRestrictions, SchemaRestrictions } from '@overture-stack/lectern-dictionary'; import { type ReactNode } from 'react'; import ReadMoreText from '../../../../../common/ReadMoreText'; import { type Theme, useThemeContext } from '../../../../../theme/index'; +import { isFieldForeignKey } from '../../../../../utils/isFieldForeignKey'; +import { useDiagramViewContext } from '../../../../DiagramViewContext'; import { computeAllowedValuesColumn, type RestrictionItem } from './ComputeAllowedValues'; @@ -47,6 +49,22 @@ const codeListContentStyle = css` gap: 2px; `; +const viewInDiagramButtonStyle = (theme: Theme) => css` + ${theme.typography.paragraphSmallBold} + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0; + background: none; + border: none; + text-decoration: underline; + cursor: pointer; + margin-top: 4px; + &:hover { + color: ${theme.colors.secondary}; + } +`; + const renderRestrictionItem = (value: RestrictionItem, key: string): ReactNode => { const { prefix, content } = value; return ( @@ -71,9 +89,12 @@ export const renderAllowedValuesColumn = ( fieldLevelRestrictions: SchemaFieldRestrictions, schemaLevelRestrictions: SchemaRestrictions, currentSchemaField: SchemaField, + schema?: Schema, ) => { const items = computeAllowedValuesColumn(fieldLevelRestrictions, schemaLevelRestrictions, currentSchemaField); const theme: Theme = useThemeContext(); + const { openFocusedDiagram } = useDiagramViewContext(); + const isForeignKey = schema ? isFieldForeignKey(schema, currentSchemaField) : false; if (!items || Object.keys(items).length === 0) { return ( @@ -83,6 +104,14 @@ export const renderAllowedValuesColumn = ( `} > No restrictions provided for this field. + {isForeignKey && ( + + )} ); } @@ -98,6 +127,14 @@ export const renderAllowedValuesColumn = ( : null ); })} + {isForeignKey && ( + + )} ); }; diff --git a/packages/ui/src/viewer-table/DataTable/SchemaTable/SchemaTableInit.tsx b/packages/ui/src/viewer-table/DataTable/SchemaTable/SchemaTableInit.tsx index fd855c2d..1dddf319 100644 --- a/packages/ui/src/viewer-table/DataTable/SchemaTable/SchemaTableInit.tsx +++ b/packages/ui/src/viewer-table/DataTable/SchemaTable/SchemaTableInit.tsx @@ -50,7 +50,7 @@ export const getSchemaBaseColumns = (schema: Schema) => [ cell: (attribute: CellContext) => { const schemaField: SchemaField = attribute.row.original; const fieldLevelRestrictions: SchemaFieldRestrictions = schemaField.restrictions; - return renderAttributesColumn(fieldLevelRestrictions, schemaField); + return renderAttributesColumn(fieldLevelRestrictions, schemaField, schema); }, }), @@ -70,7 +70,7 @@ export const getSchemaBaseColumns = (schema: Schema) => [ const fieldLevelRestrictions = schemaField.restrictions; const schemaLevelRestrictions = schema.restrictions; - return renderAllowedValuesColumn(fieldLevelRestrictions, schemaLevelRestrictions, schemaField); + return renderAllowedValuesColumn(fieldLevelRestrictions, schemaLevelRestrictions, schemaField, schema); }, }), ]; From 892f7f1fdea2b91c8cf2ae392b01396493714813 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 4 Mar 2026 14:18:51 -0500 Subject: [PATCH 13/30] feat: add separate grid algorithm for isFocused state --- .../EntityRelationshipDiagram.tsx | 83 ++++++++++++++++--- .../EntityRelationshipDiagram/diagramUtils.ts | 75 ++++++++++++----- .../EntityRelationshipDiagram/index.ts | 2 +- 3 files changed, 124 insertions(+), 36 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx index 6b8f3be9..16c123e6 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx @@ -23,7 +23,7 @@ import { css } from '@emotion/react'; import { type Theme, useThemeContext } from '../../theme'; import type { Dictionary } from '@overture-stack/lectern-dictionary'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import ReactFlow, { Background, BackgroundVariant, @@ -31,11 +31,18 @@ import ReactFlow, { useEdgesState, useNodesState, type Edge, + type Node, type NodeTypes, } from 'reactflow'; import 'reactflow/dist/style.css'; import OneCardinalityMarker from '../../theme/icons/OneCardinalityMarker'; -import { getEdgesFromMap, getEdgesWithHighlight, getLayoutedDiagram, type RelationshipEdgeData } from './diagramUtils'; +import { + getEdgesFromMap, + getEdgesWithHighlight, + getLayoutedDiagram, + traceChain, + type RelationshipEdgeData, +} from './diagramUtils'; import { useActiveRelationship } from './ActiveRelationshipContext'; import { SchemaNode } from './SchemaNode'; @@ -43,8 +50,9 @@ const nodeTypes: NodeTypes = { schema: SchemaNode, }; -type EntityRelationshipDiagramProps = { +type RelationshipDiagramContentProps = { dictionary: Dictionary; + focusField?: { schemaName: string; fieldName: string }; }; const edgeHoverStyles = (theme: Theme) => css` @@ -76,21 +84,68 @@ const edgeHoverStyles = (theme: Theme) => css` `; /** - * Entity Relationship Diagram visualizing schemas and their foreign key relationships. + * Unified relationship diagram component. When `focusField` is provided, traces the FK + * chain for that field and renders a focused grid layout. Otherwise renders the full ERD + * using the Sugiyama algorithm. + * * Must be rendered inside an `ActiveRelationshipProvider`. - * Uses d3-dag's Sugiyama algorithm to compute a hierarchical layout from FK relationships. */ -export function EntityRelationshipDiagramContent({ dictionary }: EntityRelationshipDiagramProps) { - const { activeEdgeIds, activateRelationship, deactivateRelationship, relationshipMap } = useActiveRelationship(); +export function RelationshipDiagramContent({ dictionary, focusField }: RelationshipDiagramContentProps) { + const { relationshipMap, activateRelationship } = useActiveRelationship(); const allEdges = getEdgesFromMap(relationshipMap); - const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedDiagram(dictionary, allEdges); - const [nodes, , onNodesChange] = useNodesState(layoutedNodes); - const [edges, , onEdgesChange] = useEdgesState(layoutedEdges); + + useEffect(() => { + if (!focusField) return; + const fieldKey = `${focusField.schemaName}::${focusField.fieldName}`; + const fkIndices = relationshipMap.fieldKeyToFkIndices.get(fieldKey); + if (fkIndices?.[0] !== undefined) { + activateRelationship(fkIndices[0]); + } + }, [focusField, activateRelationship, relationshipMap]); + + if (focusField) { + const fieldKey = `${focusField.schemaName}::${focusField.fieldName}`; + const fkIndices = relationshipMap.fieldKeyToFkIndices.get(fieldKey); + if (!fkIndices?.length) { + return ( +
+ No relationship found for this field. +
+ ); + } + + const { schemaChain, edgeIds } = traceChain(fkIndices[0], relationshipMap); + const filteredSchemas = dictionary.schemas.filter((s) => schemaChain.includes(s.name)); + const filteredEdges = allEdges.filter((e) => edgeIds.has(e.id)); + const { nodes, edges } = getLayoutedDiagram(filteredSchemas, filteredEdges, 'grid'); + return ; + } + + const { nodes, edges } = getLayoutedDiagram(dictionary.schemas, allEdges); + return ; +} + +/** + * Shared ReactFlow shell. Must be rendered inside an `ActiveRelationshipProvider`. + * Handles edge/pane clicks for FK chain highlighting. + */ +function RelationshipDiagramFlow({ + nodes: initialNodes, + edges: initialEdges, + isFocused, +}: { + nodes: Node[]; + edges: Edge[]; + isFocused?: boolean; +}) { + const { activeEdgeIds, activateRelationship, deactivateRelationship } = useActiveRelationship(); + const [nodes, , onNodesChange] = useNodesState(initialNodes); + const [edges, , onEdgesChange] = useEdgesState(initialEdges); const theme = useThemeContext(); const highlightedEdges = useMemo( () => getEdgesWithHighlight(edges, activeEdgeIds, theme.colors.secondary_dark), - [edges, activeEdgeIds], + [edges, activeEdgeIds, theme.colors.secondary_dark], ); const onEdgeClick = useCallback( @@ -104,8 +159,10 @@ export function EntityRelationshipDiagramContent({ dictionary }: EntityRelations ); const onPaneClick = useCallback(() => { - deactivateRelationship(); - }, [deactivateRelationship]); + if (!isFocused) { + deactivateRelationship(); + } + }, [isFocused, deactivateRelationship]); return ( <> diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index 486f18ad..691a2826 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -21,7 +21,7 @@ import type { Dictionary, Schema } from '@overture-stack/lectern-dictionary'; import { type Edge, type Node, MarkerType } from 'reactflow'; -import { graphStratify, sugiyama, type GraphNode } from 'd3-dag'; +import { graphStratify, grid, laneGreedy, sugiyama, type GraphNode } from 'd3-dag'; import { ONE_CARDINALITY_MARKER_ID, ONE_CARDINALITY_MARKER_ACTIVE_ID } from '../../theme/icons/OneCardinalityMarker'; const DEFAULT_MARKER_CONFIG = { @@ -34,7 +34,7 @@ const DEFAULT_MARKER_CONFIG = { const NODE_WIDTH = 350; const HEADER_HEIGHT = 60; const FIELD_ROW_HEIGHT = 45; -const GAP_X = 100; +const GAP_X = 250; const GAP_Y = 100; export type SchemaFlowNode = Node; @@ -75,10 +75,21 @@ function estimateNodeHeight(schema: Schema): number { return HEADER_HEIGHT + schema.fields.length * FIELD_ROW_HEIGHT; } +type DiagramLayout = 'sugiyama' | 'grid'; + /** - * Computes node positions using d3-dag's Sugiyama layout algorithm. + * Builds positioned nodes from schemas and edges using d3-dag's Sugiyama or grid layout algorithm. + * + * @param {Schema[]} schemas — The schemas to visualize + * @param {Edge[]} edges — ReactFlow edges representing foreign key relationships + * @param {DiagramLayout} algorithm — Layout algorithm: 'sugiyama' (default, full ERD) or 'grid' (FK chain view) + * @returns {{ nodes: Node[]; edges: Edge[] }} Positioned nodes and the original edges */ -export function getLayoutedElements(nodes: SchemaFlowNode[], edges: Edge[]): Node[] { +export function getLayoutedElements( + nodes: SchemaFlowNode[], + edges: Edge[], + algorithm: DiagramLayout = 'sugiyama', +): Node[] { if (nodes.length === 0) { return []; } @@ -102,22 +113,36 @@ export function getLayoutedElements(nodes: SchemaFlowNode[], edges: Edge[]): Nod const dag = graphStratify()(stratifyData); const schemaByName = new Map(); - for (const node of nodes) { schemaByName.set(node.id, node.data); } - const layout = sugiyama().nodeSize((dagNode: GraphNode): [number, number] => { - const schema = schemaByName.get(dagNode.data.id); - const height = schema ? estimateNodeHeight(schema) : HEADER_HEIGHT; - return [NODE_WIDTH + GAP_X, height + GAP_Y]; - }); + const positionMap = new Map(); - layout(dag); + if (algorithm === 'grid') { + const layout = grid() + .nodeSize((dagNode: GraphNode): [number, number] => { + const schema = schemaByName.get(dagNode.data.id); + const height = schema ? estimateNodeHeight(schema) : HEADER_HEIGHT; + return [height + GAP_Y, NODE_WIDTH + GAP_X]; + }) + .lane(laneGreedy()); + layout(dag); + + for (const dagNode of dag.nodes()) { + positionMap.set(dagNode.data.id, { x: dagNode.y, y: dagNode.x }); + } + } else { + const layout = sugiyama().nodeSize((dagNode: GraphNode): [number, number] => { + const schema = schemaByName.get(dagNode.data.id); + const height = schema ? estimateNodeHeight(schema) : HEADER_HEIGHT; + return [NODE_WIDTH + GAP_X, height + GAP_Y]; + }); + layout(dag); - const positionMap = new Map(); - for (const dagNode of dag.nodes()) { - positionMap.set(dagNode.data.id, { x: dagNode.x, y: dagNode.y }); + for (const dagNode of dag.nodes()) { + positionMap.set(dagNode.data.id, { x: dagNode.x, y: dagNode.y }); + } } return nodes.map((node) => ({ @@ -127,20 +152,27 @@ export function getLayoutedElements(nodes: SchemaFlowNode[], edges: Edge[]): Nod } /** - * Builds unpositioned nodes from the dictionary and computes layout using - * d3-dag's Sugiyama algorithm. + * Builds positioned nodes from schemas and edges using d3-dag's Sugiyama or grid layout algorithm. * - * @param {Dictionary} dictionary — The Lectern dictionary containing schemas to visualize + * @param {Schema[]} schemas — The schemas to visualize * @param {Edge[]} edges — ReactFlow edges representing foreign key relationships + * @param {DiagramLayout} algorithm — Layout algorithm: 'sugiyama' (default, full ERD) or 'grid' (FK chain view) * @returns {{ nodes: Node[]; edges: Edge[] }} Positioned nodes and the original edges */ -export function getLayoutedDiagram(dictionary: Dictionary, edges: Edge[]): { nodes: Node[]; edges: Edge[] } { - const unpositionedNodes: SchemaFlowNode[] = dictionary.schemas.map((schema) => ({ +export function getLayoutedDiagram( + schemas: Schema[], + edges: Edge[], + algorithm: DiagramLayout = 'sugiyama', +): { nodes: Node[]; edges: Edge[] } { + if (schemas.length === 0) { + return { nodes: [], edges: [] }; + } + const unpositionedNodes: SchemaFlowNode[] = schemas.map((schema) => ({ ...buildSchemaNode(schema), position: { x: 0, y: 0 }, })); - const layoutedNodes = getLayoutedElements(unpositionedNodes, edges); - return { nodes: layoutedNodes, edges }; + const nodes = getLayoutedElements(unpositionedNodes, edges, algorithm); + return { nodes, edges }; } /** @@ -327,7 +359,6 @@ export function getEdgesFromMap(map: RelationshipMap): Edge[] { pathOptions: { offset: -20 }, data: { fkIndex } satisfies RelationshipEdgeData, markerEnd: DEFAULT_MARKER_CONFIG, - markerStart: ONE_CARDINALITY_MARKER_ID, })), ); } diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/index.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/index.ts index 195dd06a..500fa77d 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/index.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/index.ts @@ -19,6 +19,6 @@ * */ -export { EntityRelationshipDiagramContent } from './EntityRelationshipDiagram'; +export { RelationshipDiagramContent } from './EntityRelationshipDiagram'; export { ActiveRelationshipProvider, useActiveRelationship } from './ActiveRelationshipContext'; export { buildRelationshipMap } from './diagramUtils'; From 95dbb7f0b2d4ec9d804c49169d064aab1b2c95a0 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 4 Mar 2026 14:19:32 -0500 Subject: [PATCH 14/30] feat: update diagram subtitle to include logic for isFocused state --- .../viewer-table/Toolbar/DiagramSubtitle.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx b/packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx index ac5bd898..29202d57 100644 --- a/packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx +++ b/packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx @@ -66,18 +66,18 @@ const chainLabelStyle = css` padding-right: 5px; `; -const DiagramSubtitle = () => { +const DiagramSubtitle = ({ isFocused }: { isFocused?: boolean }) => { const { activeSchemaChain, deactivateRelationship } = useActiveRelationship(); const theme = useThemeContext(); if (!activeSchemaChain) { - return Select any key field or edge to highlight a relation.; + return Select any key field or edge to highlight a relation.; } return ( <>
- Highlighting schema relation: + {isFocused ? 'Schema relation:' : 'Highlighting schema relation:'} {activeSchemaChain.map((schema, index) => ( {index > 0 && {'\u2192'}} @@ -85,11 +85,13 @@ const DiagramSubtitle = () => { ))}
-
- -
+ {!isFocused && ( +
+ +
+ )} ); }; From 5896b5533bb372adf21dace3fe2399c405893565 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 4 Mar 2026 14:19:53 -0500 Subject: [PATCH 15/30] fix: update entity diagram relationship diagram story --- .../viewer-table/EntityRelationshipDiagram.stories.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/stories/viewer-table/EntityRelationshipDiagram.stories.tsx b/packages/ui/stories/viewer-table/EntityRelationshipDiagram.stories.tsx index e50c04f9..56348a6f 100644 --- a/packages/ui/stories/viewer-table/EntityRelationshipDiagram.stories.tsx +++ b/packages/ui/stories/viewer-table/EntityRelationshipDiagram.stories.tsx @@ -27,7 +27,7 @@ import React from 'react'; import { ActiveRelationshipProvider, buildRelationshipMap, - EntityRelationshipDiagramContent, + RelationshipDiagramContent, } from '../../src/viewer-table/EntityRelationshipDiagram'; import DictionarySample from '../fixtures/pcgl.json'; import SimpleClinicalERDiagram from '../fixtures/simpleClinicalERDiagram.json'; @@ -44,13 +44,13 @@ import InvalidUniqueKeyFixture from '../fixtures/invalid_uniquekey.json'; import themeDecorator from '../themeDecorator'; const meta = { - component: EntityRelationshipDiagramContent, + component: RelationshipDiagramContent, title: 'Viewer - Table/Entity Relationship Diagram', decorators: [themeDecorator()], parameters: { layout: 'fullscreen', }, -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; @@ -59,7 +59,7 @@ const StoryWrapper = ({ dictionary }: { dictionary: Dictionary }) => { const relationshipMap = useMemo(() => buildRelationshipMap(dictionary), [dictionary]); return ( - + ); }; From f9195d8a7dae33e915e17f96f3cc994e6f44d72c Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 11 Mar 2026 09:17:18 -0400 Subject: [PATCH 16/30] style: reduce button and dropdown padding --- packages/ui/src/common/Button.tsx | 17 ++++++++++++++--- packages/ui/src/common/Dropdown/Dropdown.tsx | 4 ++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/common/Button.tsx b/packages/ui/src/common/Button.tsx index b96c869e..71878e93 100644 --- a/packages/ui/src/common/Button.tsx +++ b/packages/ui/src/common/Button.tsx @@ -35,6 +35,7 @@ export interface ButtonProps { className?: string; isLoading?: boolean; icon?: ReactNode; + size?: number; width?: string; iconOnly?: boolean; tooltipText?: string; @@ -48,7 +49,7 @@ const getButtonContainerStyles = (theme: any, width?: string, styleOverride?: Se gap: 11px; width: ${width || 'auto'}; min-width: fit-content; - padding: 6px 12px; + padding: 2px 12px; background-color: ${theme.colors.background_light}; color: ${theme.colors.accent_dark}; border: 2px solid ${theme.colors.border_button}; @@ -72,7 +73,15 @@ const getButtonContainerStyles = (theme: any, width?: string, styleOverride?: Se ${styleOverride} `; -const getContentStyles = (theme: Theme, shouldShowLoading: boolean) => css` +const getContentStyles = ({ + theme, + size, + shouldShowLoading, +}: { + theme: Theme; + size: number; + shouldShowLoading: boolean; +}) => css` display: flex; align-items: center; gap: 8px; @@ -81,6 +90,7 @@ const getContentStyles = (theme: Theme, shouldShowLoading: boolean) => css` white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-size: ${size}px; visibility: ${shouldShowLoading ? 'hidden' : 'visible'}; `; @@ -140,6 +150,7 @@ const Button = forwardRef( isLoading: controlledLoading, icon, width, + size = 16, iconOnly = false, styleOverride, tooltipText, @@ -171,7 +182,7 @@ const Button = forwardRef( )} {icon && !shouldShowLoading && {icon}} {/* If iconOnly is true, we don't show the children */} - {!iconOnly && {children}} + {!iconOnly && {children}} diff --git a/packages/ui/src/common/Dropdown/Dropdown.tsx b/packages/ui/src/common/Dropdown/Dropdown.tsx index a0de2c4e..19188d13 100644 --- a/packages/ui/src/common/Dropdown/Dropdown.tsx +++ b/packages/ui/src/common/Dropdown/Dropdown.tsx @@ -52,7 +52,7 @@ const dropdownButtonStyle = ({ gap: 11px; width: ${width || 'auto'}; min-width: fit-content; - padding: 6px 12px; + padding: 2px 12px; background-color: ${theme.colors.background_light}; color: ${theme.colors.accent_dark}; border: 2px solid ${theme.colors.border_button}; @@ -126,7 +126,7 @@ export type DropDownProps = { * @returns {JSX.Element} Dropdown component */ -const Dropdown = ({ menuItems = [], title, leftIcon, disabled = false, size = 20, styles }: DropDownProps) => { +const Dropdown = ({ menuItems = [], title, leftIcon, disabled = false, size = 16, styles }: DropDownProps) => { const [open, setOpen] = useState(false); const dropdownRef = useRef(null); const theme: Theme = useThemeContext(); From 43af033875e2462b8a12fa05bb6aaef51e482cae Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 11 Mar 2026 09:17:50 -0400 Subject: [PATCH 17/30] style: update accordion card --- .../ui/src/common/Accordion/AccordionItem.tsx | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/common/Accordion/AccordionItem.tsx b/packages/ui/src/common/Accordion/AccordionItem.tsx index 619271ff..11975853 100644 --- a/packages/ui/src/common/Accordion/AccordionItem.tsx +++ b/packages/ui/src/common/Accordion/AccordionItem.tsx @@ -42,7 +42,6 @@ export type AccordionItemProps = { const accordionItemStyle = (theme: Theme) => css` list-style: none; width: 100%; - border-radius: 8px; margin-bottom: 1px; overflow: hidden; background-color: ${theme.colors.white}; @@ -58,7 +57,7 @@ const accordionItemStyle = (theme: Theme) => css` const accordionItemTitleStyle = css` display: flex; align-items: flex-start; - padding: 24px 30px; + padding: 24px 30px 32px; transition: all 0.2s ease; width: 100%; box-sizing: border-box; @@ -82,7 +81,6 @@ const accordionItemButtonStyle = css` const contentColumnStyle = css` display: flex; flex-direction: column; - gap: 8px; `; const titleStyle = (theme: Theme) => css` @@ -91,7 +89,6 @@ const titleStyle = (theme: Theme) => css` overflow-wrap: break-word; word-wrap: break-word; cursor: pointer; - padding-bottom: 7px; `; const chevronStyle = (isOpen: boolean) => css` @@ -103,28 +100,27 @@ const titleRowStyle = (theme: Theme) => css` display: flex; gap: 4px; align-items: center; - margin-bottom: 10px; + &:hover [data-anchor-button] { opacity: 1; } &:hover [data-title-link] { color: ${theme.colors.secondary}; - text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 4px; } `; -const hashIconStyle = (theme: Theme) => css` +const hashIconStyle = () => css` + display: inline-flex; + align-items: center; + justify-content: center; opacity: 0; padding-block: 0px; padding-inline: 0px; background: transparent; border: none; cursor: pointer; - svg { - border-bottom: 2px solid ${theme.colors.secondary}; - } `; const descriptionWrapperStyle = (theme: Theme) => css` @@ -140,12 +136,11 @@ const accordionCollapseStyle = (isOpen: boolean) => css` `; const accordionItemContentStyle = css` - padding: 0px 30px 30px 30px; + padding: 0px 0px 30px; + border: none; `; const contentInnerContainerStyle = (theme: Theme) => css` - border-left: 2px solid ${theme.colors.grey_3}; - padding-left: 30px; ${theme.typography?.data}; `; @@ -185,7 +180,7 @@ const AccordionItem = ({ index, accordionData, openState }: AccordionItemProps)
@@ -201,7 +196,7 @@ const AccordionItem = ({ index, accordionData, openState }: AccordionItemProps)