From d96879343ca875e435b07f0d8257ebfe3f12578c Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Tue, 3 Feb 2026 13:43:48 -0500 Subject: [PATCH 01/44] fix: update year to 2026 --- packages/ui/src/common/Modal.tsx | 2 +- packages/ui/src/viewer-table/Toolbar/Toolbar.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/common/Modal.tsx b/packages/ui/src/common/Modal.tsx index a19bc3c7..3c8519c9 100644 --- a/packages/ui/src/common/Modal.tsx +++ b/packages/ui/src/common/Modal.tsx @@ -1,6 +1,6 @@ /* * - * Copyright (c) 2025 The Ontario Institute for Cancer Research. All rights reserved + * 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 diff --git a/packages/ui/src/viewer-table/Toolbar/Toolbar.tsx b/packages/ui/src/viewer-table/Toolbar/Toolbar.tsx index 1b19ae99..cc2de232 100644 --- a/packages/ui/src/viewer-table/Toolbar/Toolbar.tsx +++ b/packages/ui/src/viewer-table/Toolbar/Toolbar.tsx @@ -1,6 +1,6 @@ /* * - * Copyright (c) 2025 The Ontario Institute for Cancer Research. All rights reserved + * 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 From 3709c14f77e8a4cdab24198d714f3a75fcea1024 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Tue, 3 Feb 2026 13:44:07 -0500 Subject: [PATCH 02/44] feat: add subtitle prop to modal component --- packages/ui/src/common/Modal.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/common/Modal.tsx b/packages/ui/src/common/Modal.tsx index 3c8519c9..a4149958 100644 --- a/packages/ui/src/common/Modal.tsx +++ b/packages/ui/src/common/Modal.tsx @@ -36,6 +36,7 @@ export type ModalProps = { onAfterOpen?: () => void; children?: ReactNode; title: string; + subtitle?: string; }; // Using react-modal's built-in styling system instead of emotion css for modal configuration @@ -85,8 +86,16 @@ const titleStyle = (theme: Theme) => css` ${theme.typography.subtitleBold}; color: ${theme.colors.accent}; `; + +const subtitleStyle = (theme: Theme) => css` + ${theme.typography.data}; + color: ${theme.colors.black}; + margin-top: 8px; + margin-bottom: 0; +`; + Modal.setAppElement('body'); -const ModalComponent = ({ children, setIsOpen, isOpen, onAfterOpen, title }: ModalProps) => { +const ModalComponent = ({ children, setIsOpen, isOpen, onAfterOpen, title, subtitle }: ModalProps) => { const theme: Theme = useThemeContext(); return ( <> @@ -106,7 +115,10 @@ const ModalComponent = ({ children, setIsOpen, isOpen, onAfterOpen, title }: Mod bodyOpenClassName="modal-open" >
- {title} +
+ {title} + {subtitle &&

{subtitle}

} +
{children}
From cc905ada9f210784dda099a79507c07c8ee876d5 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Tue, 3 Feb 2026 13:44:26 -0500 Subject: [PATCH 03/44] feat: create diagram view button --- .../Toolbar/DiagramViewButton.tsx | 50 +++++++++++++++++++ .../Toolbar/DiagramViewButton.stories.tsx | 45 +++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx create mode 100644 packages/ui/stories/viewer-table/Toolbar/DiagramViewButton.stories.tsx diff --git a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx new file mode 100644 index 00000000..cd300408 --- /dev/null +++ b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx @@ -0,0 +1,50 @@ +/* + * + * 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 { useState } from 'react'; + +import Button from '../../common/Button'; +import Modal from '../../common/Modal'; +import { useDictionaryDataContext } from '../../dictionary-controller/DictionaryDataContext'; +import { useThemeContext } from '../../theme/index'; + +const DiagramViewButton = () => { + const [isOpen, setIsOpen] = useState(false); + const theme = useThemeContext(); + const { Eye } = theme.icons; + const { loading, errors } = useDictionaryDataContext(); + + return ( + <> + + + + ); +}; + +export default DiagramViewButton; diff --git a/packages/ui/stories/viewer-table/Toolbar/DiagramViewButton.stories.tsx b/packages/ui/stories/viewer-table/Toolbar/DiagramViewButton.stories.tsx new file mode 100644 index 00000000..32a81d08 --- /dev/null +++ b/packages/ui/stories/viewer-table/Toolbar/DiagramViewButton.stories.tsx @@ -0,0 +1,45 @@ +/* + * + * 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 { Meta, StoryObj } from '@storybook/react'; +import { userEvent, within } from '@storybook/test'; + +import DiagramViewButton from '../../../src/viewer-table/Toolbar/DiagramViewButton'; + +import { multipleDictionaryData, withDictionaryContext, withForeverLoading } from '../../dictionaryDecorator'; +import themeDecorator from '../../themeDecorator'; + +const meta = { + component: DiagramViewButton, + title: 'Viewer - Table/Toolbar/DiagramViewButton', + tags: ['autodocs'], + decorators: [themeDecorator(), withDictionaryContext(multipleDictionaryData)], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + decorators: [themeDecorator(), withForeverLoading()], +}; \ No newline at end of file From 1b0a3d54e33a56edfb76f4fa782126a6e36e3d1d Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Tue, 3 Feb 2026 13:44:47 -0500 Subject: [PATCH 04/44] feat: add dictionary view button to toolbar --- packages/ui/src/viewer-table/Toolbar/Toolbar.tsx | 2 ++ packages/ui/src/viewer-table/Toolbar/index.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/ui/src/viewer-table/Toolbar/Toolbar.tsx b/packages/ui/src/viewer-table/Toolbar/Toolbar.tsx index cc2de232..5b40d696 100644 --- a/packages/ui/src/viewer-table/Toolbar/Toolbar.tsx +++ b/packages/ui/src/viewer-table/Toolbar/Toolbar.tsx @@ -29,6 +29,7 @@ import { ToolbarSkeleton } from '../Loading'; import AttributeFilterDropdown from './AttributeFilterDropdown'; import CollapseAllButton from './CollapseAllButton'; +import DiagramViewButton from './DiagramViewButton'; import DictionaryDownloadButton from './DictionaryDownloadButton'; import ExpandAllButton from './ExpandAllButton'; import TableOfContentsDropdown from './TableOfContentsDropdown'; @@ -84,6 +85,7 @@ const Toolbar = ({ onSelect, setIsCollapsed, isCollapsed }: ToolbarProps) => { : setIsCollapsed(true)} />}
+
diff --git a/packages/ui/src/viewer-table/Toolbar/index.ts b/packages/ui/src/viewer-table/Toolbar/index.ts index 7bac6020..9028ed3b 100644 --- a/packages/ui/src/viewer-table/Toolbar/index.ts +++ b/packages/ui/src/viewer-table/Toolbar/index.ts @@ -1,5 +1,6 @@ export { default as AttributeFilterDropdown } from './AttributeFilterDropdown.js'; export { default as CollapseAllButton, type CollapseAllButtonProps } from './CollapseAllButton.js'; +export { default as DiagramViewButton } from './DiagramViewButton.js'; export { default as DictionaryDownloadButton, type DictionaryDownloadButtonProps } from './DictionaryDownloadButton.js'; export { default as ExpandAllButton, type ExpandAllButtonProps } from './ExpandAllButton.js'; export { default as TableOfContentsDropdown, type TableOfContentsDropdownProps } from './TableOfContentsDropdown.js'; From bd02d6b4f3811fd9650c0e6cd57482bd8ccdc0d3 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Tue, 3 Feb 2026 13:49:27 -0500 Subject: [PATCH 05/44] fix: format fles --- packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx | 2 +- .../stories/viewer-table/Toolbar/DiagramViewButton.stories.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx index cd300408..97f2100d 100644 --- a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx +++ b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx @@ -35,7 +35,7 @@ const DiagramViewButton = () => { return ( <> Date: Tue, 10 Feb 2026 09:21:19 -0500 Subject: [PATCH 06/44] feat: implement entity relationship diagram --- .../EntityRelationshipDiagram.tsx | 71 +++++++ .../EntityRelationshipDiagram/SchemaNode.tsx | 199 ++++++++++++++++++ .../EntityRelationshipDiagram/diagramUtils.ts | 92 ++++++++ .../EntityRelationshipDiagram/index.ts | 22 ++ 4 files changed, 384 insertions(+) create mode 100644 packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx create mode 100644 packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx create mode 100644 packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts create mode 100644 packages/ui/src/viewer-table/EntityRelationshipDiagram/index.ts diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx new file mode 100644 index 00000000..f43c1cd7 --- /dev/null +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx @@ -0,0 +1,71 @@ +/* + * + * 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. + * + */ + +/** @jsxImportSource @emotion/react */ +import type { Dictionary } from '@overture-stack/lectern-dictionary'; +import ReactFlow, { + Background, + BackgroundVariant, + Controls, + useEdgesState, + useNodesState, + type NodeTypes, +} from 'reactflow'; +import 'reactflow/dist/style.css'; +import OneCardinalityMarker from '../../theme/icons/OneCardinalityMarker'; +import { getEdgesForDictionary, getNodesForDictionary, type SchemaNodeLayout } from './diagramUtils'; +import { SchemaNode } from './SchemaNode'; + +const nodeTypes: NodeTypes = { + schema: SchemaNode, +}; + +type EntityRelationshipDiagramProps = { + dictionary: Dictionary; + layout?: Partial; +}; + +export function EntityRelationshipDiagram({ dictionary, layout }: EntityRelationshipDiagramProps) { + const [nodes, , onNodesChange] = useNodesState(getNodesForDictionary(dictionary, layout)); + const [edges, , onEdgesChange] = useEdgesState(getEdgesForDictionary(dictionary)); + + return ( + <> + + + + + + + ); +} diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx new file mode 100644 index 00000000..ddec5ac3 --- /dev/null +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx @@ -0,0 +1,199 @@ +/* + * + * 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. + * + */ + +/** @jsxImportSource @emotion/react */ +import { css } from '@emotion/react'; +import type { Schema } from '@overture-stack/lectern-dictionary'; +import { Handle, Position } from 'reactflow'; +import 'reactflow/dist/style.css'; +import Key from '../../theme/icons/Key'; +import { type Theme, useThemeContext } from '../../theme'; +import { createFieldHandleId } from './diagramUtils'; + +const fieldRowStyles = css` + padding: 12px 12px; + display: flex; + align-items: center; + justify-content: space-between; + transition: background-color 0.2s; + position: relative; +`; + +const fieldContentStyles = css` + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +`; + +const fieldNameStyles = (theme: Theme) => css` + ${theme.typography.subtitleSecondary} + font-size: 14px; + color: #1f2937; + line-height: 1.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +`; + +const dataTypeBadgeStyles = (theme: Theme) => css` + ${theme.typography.regular} + font-size: 12px; + color: #374151; + flex-shrink: 0; + + &:first-letter { + text-transform: uppercase; + } +`; + +const nodeContainerStyles = css` + background: white; + border: 1px solid black; + border-radius: 8px; + box-shadow: + 0 10px 20px -3px rgba(0, 0, 0, 0.30), + 0 4px 10px -2px rgba(0, 0, 0, 0.35); + min-width: 280px; + max-width: 350px; + overflow: hidden; +`; + +const nodeHeaderStyles = (theme: Theme) => css` + ${theme.typography.subtitleSecondary} + background: ${theme.colors.accent}; + color: white; + padding: 16px 24px; + text-align: left; + border-bottom: 1px solid black; + letter-spacing: 0.05em; + margin: 0; + gap: 4px; + display: flex; + flex-direction: column; + align-items: left; +`; + +const nodeTitleTextStyle = css` + font-size: 20px; + ::first-letter { + text-transform: uppercase; + } +`; + +const nodeSubtitleTextStyle = css` + font-size: 16px; +`; + +const fieldsListStyles = css` + background: #f8fafc; + max-height: 350px; + overflow-y: auto; + & > div:nth-child(even) { + background-color: #e5edf3; + border-block: 1.5px solid #d4dce2; + } +`; + +const fieldNameContainerStyles = css` + display: flex; + align-items: center; + gap: 4px; +`; + +const baseHandleStyles = css` + position: absolute; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: none; + width: 8px; + height: 8px; +`; + +const sourceHandleStyles = css` + ${baseHandleStyles} + right: -10px; +`; + +const targetHandleStyles = css` + ${baseHandleStyles} + left: -10px; +`; + +export function SchemaNode(props: { data: Schema }) { + const { data: schema } = props; + const theme = useThemeContext(); + + return ( +
+
+ {schema.name} + 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 valueType = field.isArray ? `${field.valueType}[]` : field.valueType; + + return ( +
+
+ {field.name} +
+ +
+ {(isUniqueKey || isForeignKey) && } + {valueType} +
+ + {isUniqueKey && ( + + )} + + {isForeignKey && ( + + )} +
+ ); + })} +
+
+ ); +} diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts new file mode 100644 index 00000000..59845872 --- /dev/null +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -0,0 +1,92 @@ +/* + * + * 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 { Dictionary, Schema } from '@overture-stack/lectern-dictionary'; +import type { Edge, Node } from 'reactflow'; +import { MarkerType } from 'reactflow'; +import { ONE_CARDINALITY_MARKER_ID } from '../../theme/icons/OneCardinalityMarker'; + +export type SchemaFlowNode = Node; + +export type SchemaNodeLayout = { + maxColumns: number; + columnWidth: number; + rowHeight: number; +}; + +function buildSchemaNode(schema: Schema): Omit { + return { + id: schema.name, + type: 'schema', + data: { ...schema }, + }; +} + +export function getNodesForDictionary(dictionary: Dictionary, layout?: Partial): Node[] { + const maxColumns = layout?.maxColumns ?? 4; + const columnWidth = layout?.columnWidth ?? 500; + const rowHeight = layout?.rowHeight ?? 500; + + return dictionary.schemas.map((schema, index) => { + const partialNode = buildSchemaNode(schema); + + const row = Math.floor(index / maxColumns); + const col = index % maxColumns; + + const position: Node['position'] = { + x: col * columnWidth, + y: row * rowHeight, + }; + + return { ...partialNode, position }; + }); +} + +export const createFieldHandleId = (schemaName: string, fieldName: string, type: 'source' | 'target'): string => + `${schemaName}-${fieldName}-${type}`; + +export function getEdgesForDictionary(dictionary: Dictionary): Edge[] { + return dictionary.schemas.flatMap((schema) => { + if (!schema.restrictions?.foreignKey) return []; + + return schema.restrictions.foreignKey.flatMap((foreignKey) => { + return foreignKey.mappings.map((mapping) => ({ + id: `${schema.name}-${mapping.local}-to-${foreignKey.schema}-${mapping.foreign}`, + source: foreignKey.schema, + sourceHandle: createFieldHandleId(foreignKey.schema, mapping.foreign, 'source'), + target: schema.name, + targetHandle: createFieldHandleId(schema.name, mapping.local, 'target'), + type: 'smoothstep', + style: { stroke: '#374151', strokeWidth: 2 }, + pathOptions: { + offset: -20, + }, + markerEnd: { + type: MarkerType.Arrow, + width: 20, + height: 20, + color: '#374151', + }, + 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 new file mode 100644 index 00000000..f13bdc44 --- /dev/null +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/index.ts @@ -0,0 +1,22 @@ +/* + * + * 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. + * + */ + +export { EntityRelationshipDiagram } from './EntityRelationshipDiagram'; From 2d6185673a253f30c489de68bd41e60e19ee924e Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Tue, 10 Feb 2026 09:21:52 -0500 Subject: [PATCH 07/44] feat: add icons for diagram --- packages/ui/src/theme/icons/Key.tsx | 49 ++++++++++++++++++ .../src/theme/icons/OneCardinalityMarker.tsx | 51 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 packages/ui/src/theme/icons/Key.tsx create mode 100644 packages/ui/src/theme/icons/OneCardinalityMarker.tsx diff --git a/packages/ui/src/theme/icons/Key.tsx b/packages/ui/src/theme/icons/Key.tsx new file mode 100644 index 00000000..6489ac16 --- /dev/null +++ b/packages/ui/src/theme/icons/Key.tsx @@ -0,0 +1,49 @@ +/* + * + * 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. + * + */ + +/** @jsxImportSource @emotion/react */ + +import { css } from '@emotion/react'; + +import IconProps from './IconProps'; + +const Key = ({ fill, width, height, style }: IconProps) => { + return ( + + + + ); +}; + +export default Key; diff --git a/packages/ui/src/theme/icons/OneCardinalityMarker.tsx b/packages/ui/src/theme/icons/OneCardinalityMarker.tsx new file mode 100644 index 00000000..c8fb74a1 --- /dev/null +++ b/packages/ui/src/theme/icons/OneCardinalityMarker.tsx @@ -0,0 +1,51 @@ +/* + * + * 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. + * + */ + +/** @jsxImportSource @emotion/react */ + +export const ONE_CARDINALITY_MARKER_ID = 'one-cardinality-marker'; + +type OneCardinalityMarkerProps = { + color?: string; +}; + +const OneCardinalityMarker = ({ color = '#374151' }: OneCardinalityMarkerProps) => { + return ( + + + + + + + + ); +}; + +export default OneCardinalityMarker; From f8404049fedf44cb2cf13a89f9349ce0b9af565f Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Tue, 10 Feb 2026 09:22:12 -0500 Subject: [PATCH 08/44] feat: add entity relationship diagram to diagram view button --- .../src/viewer-table/Toolbar/DiagramViewButton.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx index 97f2100d..83ecab68 100644 --- a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx +++ b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx @@ -19,18 +19,21 @@ * */ +import type { Dictionary } from '@overture-stack/lectern-dictionary'; import { useState } from 'react'; import Button from '../../common/Button'; import Modal from '../../common/Modal'; -import { useDictionaryDataContext } from '../../dictionary-controller/DictionaryDataContext'; +import { useDictionaryDataContext, useDictionaryStateContext } from '../../dictionary-controller/DictionaryDataContext'; import { useThemeContext } from '../../theme/index'; +import { EntityRelationshipDiagram } from '../EntityRelationshipDiagram'; const DiagramViewButton = () => { const [isOpen, setIsOpen] = useState(false); const theme = useThemeContext(); const { Eye } = theme.icons; const { loading, errors } = useDictionaryDataContext(); + const { selectedDictionary } = useDictionaryStateContext(); return ( <> @@ -42,7 +45,13 @@ const DiagramViewButton = () => { subtitle="Select any key field or edge to highlight a relation." isOpen={isOpen} setIsOpen={setIsOpen} - /> + > + {selectedDictionary && ( +
+ +
+ )} +
); }; From 1479f36ff7196ddc34e12fb10f43d9ac80921939 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Tue, 10 Feb 2026 09:22:23 -0500 Subject: [PATCH 09/44] feat: add entity relationship diagram story --- .../EntityRelationshipDiagram.stories.tsx | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 packages/ui/stories/viewer-table/EntityRelationshipDiagram.stories.tsx diff --git a/packages/ui/stories/viewer-table/EntityRelationshipDiagram.stories.tsx b/packages/ui/stories/viewer-table/EntityRelationshipDiagram.stories.tsx new file mode 100644 index 00000000..a1e5676d --- /dev/null +++ b/packages/ui/stories/viewer-table/EntityRelationshipDiagram.stories.tsx @@ -0,0 +1,52 @@ +/* + * + * 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 { Meta, StoryObj } from '@storybook/react'; +import type { Dictionary } from '@overture-stack/lectern-dictionary'; + +import { EntityRelationshipDiagram } from '../../src/viewer-table/EntityRelationshipDiagram'; +import DictionarySample from '../fixtures/pcgl.json'; +import SimpleERDiagram from '../fixtures/simpleERDiagram.json'; +import themeDecorator from '../themeDecorator'; +import React from 'react'; + +const meta = { + component: EntityRelationshipDiagram, + title: 'Viewer - Table/Entity Relationship Diagram', + decorators: [themeDecorator()], + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + dictionary: DictionarySample as Dictionary, + }, + render: (args) => ( +
+ +
+ ), +}; \ No newline at end of file From f66e39f327a29795dc5f530b543d989deb5b7277 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Tue, 10 Feb 2026 09:51:58 -0500 Subject: [PATCH 10/44] fix: removed unused imports --- .../stories/viewer-table/Toolbar/DiagramViewButton.stories.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui/stories/viewer-table/Toolbar/DiagramViewButton.stories.tsx b/packages/ui/stories/viewer-table/Toolbar/DiagramViewButton.stories.tsx index 145be087..a6d8b9b8 100644 --- a/packages/ui/stories/viewer-table/Toolbar/DiagramViewButton.stories.tsx +++ b/packages/ui/stories/viewer-table/Toolbar/DiagramViewButton.stories.tsx @@ -20,7 +20,6 @@ */ import type { Meta, StoryObj } from '@storybook/react'; -import { userEvent, within } from '@storybook/test'; import DiagramViewButton from '../../../src/viewer-table/Toolbar/DiagramViewButton'; From d69ef677777e971fccf9272384edaa1b03ec0981 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Tue, 10 Feb 2026 09:52:23 -0500 Subject: [PATCH 11/44] fix: use empty dictionary instead of full dictionary --- .../viewer-table/Toolbar/DiagramViewButton.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/stories/viewer-table/Toolbar/DiagramViewButton.stories.tsx b/packages/ui/stories/viewer-table/Toolbar/DiagramViewButton.stories.tsx index a6d8b9b8..6879d9f7 100644 --- a/packages/ui/stories/viewer-table/Toolbar/DiagramViewButton.stories.tsx +++ b/packages/ui/stories/viewer-table/Toolbar/DiagramViewButton.stories.tsx @@ -23,14 +23,14 @@ import type { Meta, StoryObj } from '@storybook/react'; import DiagramViewButton from '../../../src/viewer-table/Toolbar/DiagramViewButton'; -import { multipleDictionaryData, withDictionaryContext, withForeverLoading } from '../../dictionaryDecorator'; +import { emptyDictionaryData, withDictionaryContext, withForeverLoading } from '../../dictionaryDecorator'; import themeDecorator from '../../themeDecorator'; const meta = { component: DiagramViewButton, title: 'Viewer - Table/Toolbar/DiagramViewButton', tags: ['autodocs'], - decorators: [themeDecorator(), withDictionaryContext(multipleDictionaryData)], + decorators: [themeDecorator(), withDictionaryContext(emptyDictionaryData)], } satisfies Meta; export default meta; From 1448234300b6da12c92919ef2061ee234206b0f1 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 11 Feb 2026 14:07:30 -0500 Subject: [PATCH 12/44] feat: add separate file for schema node css --- .../ui/src/theme/emotion/schemaNodeStyles.ts | 136 ++++++++++++++++++ .../EntityRelationshipDiagram/SchemaNode.tsx | 130 ++--------------- 2 files changed, 151 insertions(+), 115 deletions(-) create mode 100644 packages/ui/src/theme/emotion/schemaNodeStyles.ts diff --git a/packages/ui/src/theme/emotion/schemaNodeStyles.ts b/packages/ui/src/theme/emotion/schemaNodeStyles.ts new file mode 100644 index 00000000..302aebf0 --- /dev/null +++ b/packages/ui/src/theme/emotion/schemaNodeStyles.ts @@ -0,0 +1,136 @@ +/* + * + * 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 { css } from '@emotion/react'; +import type { Theme } from '../'; + +export const fieldRowStyles = css` + padding: 12px 12px; + display: flex; + align-items: center; + justify-content: space-between; + transition: background-color 0.2s; + position: relative; +`; + +export const fieldContentStyles = css` + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +`; + +export const fieldNameStyles = (theme: Theme) => css` + ${theme.typography.subtitleSecondary} + font-size: 14px; + color: #1f2937; + line-height: 1.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +`; + +export const dataTypeBadgeStyles = (theme: Theme) => css` + ${theme.typography.regular} + font-size: 12px; + color: #374151; + flex-shrink: 0; + + &:first-letter { + text-transform: uppercase; + } +`; + +export const nodeContainerStyles = css` + background: white; + border: 1px solid black; + border-radius: 8px; + box-shadow: + 0 10px 20px -3px rgba(0, 0, 0, 0.30), + 0 4px 10px -2px rgba(0, 0, 0, 0.35); + min-width: 280px; + max-width: 350px; + overflow: hidden; +`; + +export const nodeHeaderStyles = (theme: Theme) => css` + ${theme.typography.subtitleSecondary} + background: ${theme.colors.accent}; + color: white; + padding: 16px 24px; + text-align: left; + border-bottom: 1px solid black; + letter-spacing: 0.05em; + margin: 0; + gap: 4px; + display: flex; + flex-direction: column; + align-items: left; +`; + +export const nodeTitleTextStyle = css` + font-size: 20px; + ::first-letter { + text-transform: uppercase; + } +`; + +export const nodeSubtitleTextStyle = css` + font-size: 16px; +`; + +export const fieldsListStyles = css` + background: #f8fafc; + max-height: 350px; + overflow-y: auto; + & > div:nth-child(even) { + background-color: #e5edf3; + border-block: 1.5px solid #d4dce2; + } +`; + +export const fieldNameContainerStyles = css` + display: flex; + align-items: center; + gap: 4px; +`; + +const baseHandleStyles = css` + position: absolute; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: none; + width: 8px; + height: 8px; +`; + +export const sourceHandleStyles = css` + ${baseHandleStyles} + right: -10px; +`; + +export const targetHandleStyles = css` + ${baseHandleStyles} + left: -10px; +`; diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx index ddec5ac3..036609f2 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx @@ -20,127 +20,27 @@ */ /** @jsxImportSource @emotion/react */ -import { css } from '@emotion/react'; import type { Schema } from '@overture-stack/lectern-dictionary'; import { Handle, Position } from 'reactflow'; import 'reactflow/dist/style.css'; import Key from '../../theme/icons/Key'; -import { type Theme, useThemeContext } from '../../theme'; +import { useThemeContext } from '../../theme'; +import { + fieldRowStyles, + fieldContentStyles, + fieldNameStyles, + dataTypeBadgeStyles, + nodeContainerStyles, + nodeHeaderStyles, + nodeTitleTextStyle, + nodeSubtitleTextStyle, + fieldsListStyles, + fieldNameContainerStyles, + sourceHandleStyles, + targetHandleStyles, +} from '../../theme/emotion/schemaNodeStyles'; import { createFieldHandleId } from './diagramUtils'; -const fieldRowStyles = css` - padding: 12px 12px; - display: flex; - align-items: center; - justify-content: space-between; - transition: background-color 0.2s; - position: relative; -`; - -const fieldContentStyles = css` - display: flex; - align-items: center; - gap: 8px; - flex: 1; - min-width: 0; -`; - -const fieldNameStyles = (theme: Theme) => css` - ${theme.typography.subtitleSecondary} - font-size: 14px; - color: #1f2937; - line-height: 1.5; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex: 1; -`; - -const dataTypeBadgeStyles = (theme: Theme) => css` - ${theme.typography.regular} - font-size: 12px; - color: #374151; - flex-shrink: 0; - - &:first-letter { - text-transform: uppercase; - } -`; - -const nodeContainerStyles = css` - background: white; - border: 1px solid black; - border-radius: 8px; - box-shadow: - 0 10px 20px -3px rgba(0, 0, 0, 0.30), - 0 4px 10px -2px rgba(0, 0, 0, 0.35); - min-width: 280px; - max-width: 350px; - overflow: hidden; -`; - -const nodeHeaderStyles = (theme: Theme) => css` - ${theme.typography.subtitleSecondary} - background: ${theme.colors.accent}; - color: white; - padding: 16px 24px; - text-align: left; - border-bottom: 1px solid black; - letter-spacing: 0.05em; - margin: 0; - gap: 4px; - display: flex; - flex-direction: column; - align-items: left; -`; - -const nodeTitleTextStyle = css` - font-size: 20px; - ::first-letter { - text-transform: uppercase; - } -`; - -const nodeSubtitleTextStyle = css` - font-size: 16px; -`; - -const fieldsListStyles = css` - background: #f8fafc; - max-height: 350px; - overflow-y: auto; - & > div:nth-child(even) { - background-color: #e5edf3; - border-block: 1.5px solid #d4dce2; - } -`; - -const fieldNameContainerStyles = css` - display: flex; - align-items: center; - gap: 4px; -`; - -const baseHandleStyles = css` - position: absolute; - top: 50%; - transform: translateY(-50%); - background: transparent; - border: none; - width: 8px; - height: 8px; -`; - -const sourceHandleStyles = css` - ${baseHandleStyles} - right: -10px; -`; - -const targetHandleStyles = css` - ${baseHandleStyles} - left: -10px; -`; - export function SchemaNode(props: { data: Schema }) { const { data: schema } = props; const theme = useThemeContext(); From e2ec2ed52dc104c5a07320001c6b5199379b4a81 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 11 Feb 2026 14:08:05 -0500 Subject: [PATCH 13/44] fix: import types and components in one line --- .../src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index 59845872..3d6fd0ef 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -20,8 +20,7 @@ */ import type { Dictionary, Schema } from '@overture-stack/lectern-dictionary'; -import type { Edge, Node } from 'reactflow'; -import { MarkerType } from 'reactflow'; +import { type Edge, type Node, MarkerType } from 'reactflow'; import { ONE_CARDINALITY_MARKER_ID } from '../../theme/icons/OneCardinalityMarker'; export type SchemaFlowNode = Node; From 6eba979267f1d5773a7965f06d5ac3c9e641b44b Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 11 Feb 2026 14:08:14 -0500 Subject: [PATCH 14/44] fix: remove spread --- .../src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index 3d6fd0ef..6b0b2d65 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -35,7 +35,7 @@ function buildSchemaNode(schema: Schema): Omit { return { id: schema.name, type: 'schema', - data: { ...schema }, + data: schema, }; } From 7700a6316770d8c0cc7bcd141a186b60bb8aa028 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 11 Feb 2026 14:10:01 -0500 Subject: [PATCH 15/44] fix: remove type casting --- packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx index 2ea77628..6b9f1449 100644 --- a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx +++ b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx @@ -21,7 +21,6 @@ import type { Dictionary } from '@overture-stack/lectern-dictionary'; import { useState } from 'react'; - import Button from '../../common/Button'; import Modal from '../../common/Modal'; import { useDictionaryDataContext, useDictionaryStateContext } from '../../dictionary-controller/DictionaryDataContext'; @@ -48,7 +47,7 @@ const DiagramViewButton = () => { > {selectedDictionary && (
- +
)}
From e083d7319820703695c14cef044c321faee7e8fa Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 11 Feb 2026 14:10:23 -0500 Subject: [PATCH 16/44] fix: reduce height of modal to remove vertical scrolling on container --- packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx index 6b9f1449..2ee914db 100644 --- a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx +++ b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx @@ -46,7 +46,7 @@ const DiagramViewButton = () => { setIsOpen={setIsOpen} > {selectedDictionary && ( -
+
)} From 04e03140b7ab1dc1bf1b9dcfb9c85b8509b90e99 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 11 Feb 2026 14:10:54 -0500 Subject: [PATCH 17/44] fix: remove unused type import --- packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx index 2ee914db..dc5c1bc7 100644 --- a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx +++ b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx @@ -19,7 +19,6 @@ * */ -import type { Dictionary } from '@overture-stack/lectern-dictionary'; import { useState } from 'react'; import Button from '../../common/Button'; import Modal from '../../common/Modal'; From ffb47673c349860050092e26f9e882066bcf9361 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Thu, 12 Feb 2026 11:17:49 -0500 Subject: [PATCH 18/44] refactor: remove max-height from nodes --- packages/ui/src/theme/emotion/schemaNodeStyles.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui/src/theme/emotion/schemaNodeStyles.ts b/packages/ui/src/theme/emotion/schemaNodeStyles.ts index 302aebf0..e8830f61 100644 --- a/packages/ui/src/theme/emotion/schemaNodeStyles.ts +++ b/packages/ui/src/theme/emotion/schemaNodeStyles.ts @@ -101,7 +101,6 @@ export const nodeSubtitleTextStyle = css` export const fieldsListStyles = css` background: #f8fafc; - max-height: 350px; overflow-y: auto; & > div:nth-child(even) { background-color: #e5edf3; From 3a9efd9d6b81d71ad9b14d3077dbc8e125049fa6 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Thu, 12 Feb 2026 11:20:07 -0500 Subject: [PATCH 19/44] docs: add docs for functions and props --- .../EntityRelationshipDiagram.tsx | 9 +++++++++ .../EntityRelationshipDiagram/diagramUtils.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx index f43c1cd7..99657009 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx @@ -43,6 +43,15 @@ type EntityRelationshipDiagramProps = { layout?: Partial; }; +/** + * Entity Relationship Diagram visualizing schemas and their foreign key relationships. + * + * @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) + */ export function EntityRelationshipDiagram({ dictionary, layout }: EntityRelationshipDiagramProps) { const [nodes, , onNodesChange] = useNodesState(getNodesForDictionary(dictionary, layout)); const [edges, , onEdgesChange] = useEdgesState(getEdgesForDictionary(dictionary)); diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index 6b0b2d65..2a79af6a 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -39,6 +39,13 @@ function buildSchemaNode(schema: Schema): Omit { }; } +/** + * 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 + */ export function getNodesForDictionary(dictionary: Dictionary, layout?: Partial): Node[] { const maxColumns = layout?.maxColumns ?? 4; const columnWidth = layout?.columnWidth ?? 500; @@ -62,6 +69,12 @@ export function getNodesForDictionary(dictionary: Dictionary, layout?: Partial `${schemaName}-${fieldName}-${type}`; +/** + * Converts a dictionary's foreign key relationships into ReactFlow edges connecting schema nodes. + * + * @param {Dictionary} dictionary — The Lectern dictionary containing schemas with foreign key restrictions + * @returns {Edge[]} Array of ReactFlow edges representing foreign key relationships + */ export function getEdgesForDictionary(dictionary: Dictionary): Edge[] { return dictionary.schemas.flatMap((schema) => { if (!schema.restrictions?.foreignKey) return []; From cb0e9d427816753e5c63081fd73ade04c8eb932f Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Thu, 12 Feb 2026 13:53:59 -0500 Subject: [PATCH 20/44] feat: add row hover states --- packages/ui/src/theme/emotion/schemaNodeStyles.ts | 14 +++++++++----- .../EntityRelationshipDiagram/SchemaNode.tsx | 4 ++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/theme/emotion/schemaNodeStyles.ts b/packages/ui/src/theme/emotion/schemaNodeStyles.ts index e8830f61..1891a410 100644 --- a/packages/ui/src/theme/emotion/schemaNodeStyles.ts +++ b/packages/ui/src/theme/emotion/schemaNodeStyles.ts @@ -22,13 +22,21 @@ import { css } from '@emotion/react'; import type { Theme } from '../'; -export const fieldRowStyles = css` +export const fieldRowStyles = (theme: Theme, isForeignKey: boolean, isEven: boolean) => css` padding: 12px 12px; display: flex; align-items: center; justify-content: space-between; transition: background-color 0.2s; position: relative; + background-color: ${isEven ? '#e5edf3' : 'transparent'}; + border-block: 1.5px solid ${isEven ? '#d4dce2' : 'transparent'}; + ${isForeignKey ? 'cursor: pointer;' : ''} + + &:hover { + background-color: ${isForeignKey ? theme.colors.secondary_1 : theme.colors.grey_3}; + border-block: 1.5px solid ${isForeignKey ? theme.colors.secondary_dark : theme.colors.grey_4}; + } `; export const fieldContentStyles = css` @@ -102,10 +110,6 @@ export const nodeSubtitleTextStyle = css` export const fieldsListStyles = css` background: #f8fafc; overflow-y: auto; - & > div:nth-child(even) { - background-color: #e5edf3; - border-block: 1.5px solid #d4dce2; - } `; export const fieldNameContainerStyles = css` diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx index 036609f2..208e3ae7 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx @@ -59,11 +59,11 @@ export function SchemaNode(props: { data: Schema }) { schema.restrictions?.foreignKey?.some((fk) => fk.mappings.some((mapping) => mapping.local === field.name), ) || false; - + const isEvenRow = index % 2 === 1; const valueType = field.isArray ? `${field.valueType}[]` : field.valueType; return ( -
+
{field.name}
From 82b93f835cd105aca4c3b7900c91006647fe1666 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Thu, 12 Feb 2026 13:54:25 -0500 Subject: [PATCH 21/44] feat: add edge hover states --- .../EntityRelationshipDiagram.tsx | 50 +++++++++++++------ .../EntityRelationshipDiagram/diagramUtils.ts | 1 - 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx index 99657009..529eb63f 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx @@ -20,6 +20,8 @@ */ /** @jsxImportSource @emotion/react */ +import { css } from '@emotion/react'; +import { type Theme, useThemeContext } from '../../theme'; import type { Dictionary } from '@overture-stack/lectern-dictionary'; import ReactFlow, { Background, @@ -52,29 +54,45 @@ type EntityRelationshipDiagramProps = { * 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) */ +const edgeHoverStyles = (theme: Theme) => css` + .react-flow__edge { + cursor: pointer; + } + .react-flow__edge-path { + stroke: ${theme.colors.black}; + stroke-width: 2; + } + .react-flow__edge:hover .react-flow__edge-path { + stroke: ${theme.colors.secondary_dark}; + } +`; + export function EntityRelationshipDiagram({ dictionary, layout }: EntityRelationshipDiagramProps) { const [nodes, , onNodesChange] = useNodesState(getNodesForDictionary(dictionary, layout)); const [edges, , onEdgesChange] = useEdgesState(getEdgesForDictionary(dictionary)); + const theme = useThemeContext(); return ( <> - - - - +
+ + + + +
); } diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index 2a79af6a..bdf95ef1 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -87,7 +87,6 @@ export function getEdgesForDictionary(dictionary: Dictionary): Edge[] { target: schema.name, targetHandle: createFieldHandleId(schema.name, mapping.local, 'target'), type: 'smoothstep', - style: { stroke: '#374151', strokeWidth: 2 }, pathOptions: { offset: -20, }, From a75c21eb3f3b1734bf1672b4a355bbeaf86c7e04 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 18 Feb 2026 16:42:23 -0500 Subject: [PATCH 22/44] feat: allow modal to take in reactnode as subtitle --- packages/ui/src/common/Modal.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/common/Modal.tsx b/packages/ui/src/common/Modal.tsx index a4149958..998121f8 100644 --- a/packages/ui/src/common/Modal.tsx +++ b/packages/ui/src/common/Modal.tsx @@ -36,7 +36,7 @@ export type ModalProps = { onAfterOpen?: () => void; children?: ReactNode; title: string; - subtitle?: string; + subtitle?: ReactNode; }; // Using react-modal's built-in styling system instead of emotion css for modal configuration @@ -73,6 +73,8 @@ const headerStyle = (theme: Theme) => css` border-bottom: 1px solid ${theme.colors.border_subtle}; background: ${theme.colors.white}; box-shadow: 0px 1px 6px 0px #00000026; + position: relative; + z-index: 1; `; const bodyStyle = css` @@ -117,7 +119,7 @@ const ModalComponent = ({ children, setIsOpen, isOpen, onAfterOpen, title, subti
{title} - {subtitle &&

{subtitle}

} + {subtitle &&
{subtitle}
}
From 5c814cf1bdd29e83a444016d90b98218fb9a754c Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 18 Feb 2026 16:43:38 -0500 Subject: [PATCH 23/44] feat: add highlight states to schema node styles --- packages/ui/src/theme/emotion/schemaNodeStyles.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/theme/emotion/schemaNodeStyles.ts b/packages/ui/src/theme/emotion/schemaNodeStyles.ts index 1891a410..e3bc69b3 100644 --- a/packages/ui/src/theme/emotion/schemaNodeStyles.ts +++ b/packages/ui/src/theme/emotion/schemaNodeStyles.ts @@ -22,15 +22,15 @@ import { css } from '@emotion/react'; import type { Theme } from '../'; -export const fieldRowStyles = (theme: Theme, isForeignKey: boolean, isEven: boolean) => css` +export const fieldRowStyles = (theme: Theme, isForeignKey: boolean, isEven: boolean, isHighlighted: boolean = false) => css` padding: 12px 12px; display: flex; align-items: center; justify-content: space-between; transition: background-color 0.2s; position: relative; - background-color: ${isEven ? '#e5edf3' : 'transparent'}; - border-block: 1.5px solid ${isEven ? '#d4dce2' : 'transparent'}; + background-color: ${isHighlighted ? theme.colors.secondary_1 : isEven ? '#e5edf3' : 'transparent'}; + border-block: 1.5px solid ${isHighlighted ? theme.colors.secondary_dark : isEven ? '#d4dce2' : 'transparent'}; ${isForeignKey ? 'cursor: pointer;' : ''} &:hover { @@ -69,16 +69,18 @@ export const dataTypeBadgeStyles = (theme: Theme) => css` } `; -export const nodeContainerStyles = css` +export const nodeContainerStyles = (isInactive: boolean = false) => css` background: white; border: 1px solid black; border-radius: 8px; box-shadow: - 0 10px 20px -3px rgba(0, 0, 0, 0.30), + 0 10px 20px -3px rgba(0, 0, 0, 0.3), 0 4px 10px -2px rgba(0, 0, 0, 0.35); min-width: 280px; max-width: 350px; overflow: hidden; + transition: opacity 0.2s; + ${isInactive ? 'opacity: 0.4;' : ''} `; export const nodeHeaderStyles = (theme: Theme) => css` From e53cac7c539bd5b4bde8f887837f4130bc522887 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 18 Feb 2026 16:45:02 -0500 Subject: [PATCH 24/44] feat: add relationship provider to create relationship to determine highlighted edges --- .../ActiveRelationshipContext.tsx | 120 ++++++++ .../EntityRelationshipDiagram.tsx | 58 +++- .../EntityRelationshipDiagram/diagramUtils.ts | 288 ++++++++++++++++-- .../Toolbar/DiagramViewButton.tsx | 42 ++- 4 files changed, 459 insertions(+), 49 deletions(-) create mode 100644 packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx new file mode 100644 index 00000000..b3b293b7 --- /dev/null +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx @@ -0,0 +1,120 @@ +/* + * + * 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, useMemo, useState, type ReactNode } from 'react'; +import { traceChain, type RelationshipMap } from './diagramUtils'; + +type ActiveRelationshipState = { + edgeIds: Set; + fieldKeys: Set; + schemaChain: string[]; +} | null; + +type ActiveRelationshipContextValue = { + activeEdgeIds: Set | null; + activeFieldKeys: Set | null; + activeSchemaNames: Set | null; + activeSchemaChain: string[] | null; + activateRelationship: (fkIndex: number, mappingIndex?: number) => void; + deactivateRelationship: () => void; + relationshipMap: RelationshipMap; + isFieldInActiveRelationship: (schemaName: string, fieldName: string) => boolean; +}; + +const ActiveRelationshipContext = createContext(null); + +type ActiveRelationshipProviderProps = { + relationshipMap: RelationshipMap; + children: ReactNode; +}; + +/** + * Provides active relationship state and actions to the ERD component tree. + * Wraps children with context that tracks which FK chain is currently highlighted, + * and exposes methods to activate/deactivate highlighting via traceChain. + * + * @param {RelationshipMap} relationshipMap — The FK adjacency graph used for chain tracing + * @param {ReactNode} children — Child components that can consume the active relationship context + */ +export function ActiveRelationshipProvider({ relationshipMap, children }: ActiveRelationshipProviderProps) { + const [activeState, setActiveState] = useState(null); + + const activateRelationship = useCallback( + (fkIndex: number, mappingIndex?: number) => { + const result = traceChain(fkIndex, relationshipMap, mappingIndex); + setActiveState(result); + }, + [relationshipMap], + ); + + const deactivateRelationship = useCallback(() => { + setActiveState(null); + }, []); + + const isFieldInActiveRelationship = useCallback( + (schemaName: string, fieldName: string): boolean => { + if (!activeState) return false; + return activeState.fieldKeys.has(`${schemaName}::${fieldName}`); + }, + [activeState], + ); + + const activeSchemaNames = useMemo(() => { + if (!activeState) return null; + const names = new Set(); + for (const key of activeState.fieldKeys) { + const schemaName = key.split('::')[0]; + if (schemaName) names.add(schemaName); + } + return names; + }, [activeState]); + + return ( + + {children} + + ); +} + +/** + * Consumes the active relationship context. Must be called from a component + * that is a descendant of ActiveRelationshipProvider. + * + * @returns {ActiveRelationshipContextValue} The current active relationship state and actions + */ +export function useActiveRelationship(): ActiveRelationshipContextValue { + const context = useContext(ActiveRelationshipContext); + if (!context) { + throw new Error('useActiveRelationship must be used within an ActiveRelationshipProvider'); + } + return context; +} diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx index bedaab90..e951f812 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx @@ -23,17 +23,26 @@ import { css } from '@emotion/react'; import { type Theme, useThemeContext } from '../../theme'; import type { Dictionary } from '@overture-stack/lectern-dictionary'; +import { useCallback, useEffect } from 'react'; import ReactFlow, { Background, BackgroundVariant, Controls, useEdgesState, useNodesState, + type Edge, type NodeTypes, } from 'reactflow'; import 'reactflow/dist/style.css'; import OneCardinalityMarker from '../../theme/icons/OneCardinalityMarker'; -import { getEdgesForDictionary, getNodesForDictionary, type SchemaNodeLayout } from './diagramUtils'; +import { + getEdgesFromMap, + getEdgesWithHighlight, + getNodesForDictionary, + type RelationshipEdgeData, + type SchemaNodeLayout, +} from './diagramUtils'; +import { useActiveRelationship } from './ActiveRelationshipContext'; import { SchemaNode } from './SchemaNode'; const nodeTypes: NodeTypes = { @@ -45,7 +54,7 @@ type EntityRelationshipDiagramProps = { layout?: Partial; }; -const edgeHoverStyles = (theme: Theme) => css` +const edgeStyles = (theme: Theme) => css` .react-flow__edge { cursor: pointer; } @@ -56,10 +65,26 @@ const edgeHoverStyles = (theme: Theme) => css` .react-flow__edge:hover .react-flow__edge-path { stroke: ${theme.colors.secondary_dark}; } + + .react-flow__edge.edge-active .react-flow__edge-path { + stroke: ${theme.colors.secondary_dark}; + stroke-width: 3; + } + + .react-flow__edge.edge-inactive .react-flow__edge-path { + stroke: ${theme.colors.grey_5}; + stroke-width: 1.5; + opacity: 0.9; + } + + .react-flow__edge.edge-inactive .react-flow__edge-path:hover { + stroke: ${theme.colors.grey_4}; + } `; /** * 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. @@ -67,20 +92,41 @@ const edgeHoverStyles = (theme: Theme) => css` * 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) */ -export function EntityRelationshipDiagram({ dictionary, layout }: EntityRelationshipDiagramProps) { +export function EntityRelationshipDiagramContent({ dictionary, layout }: EntityRelationshipDiagramProps) { const [nodes, , onNodesChange] = useNodesState(getNodesForDictionary(dictionary, layout)); - const [edges, , onEdgesChange] = useEdgesState(getEdgesForDictionary(dictionary)); + const { activeEdgeIds, activateRelationship, deactivateRelationship, relationshipMap } = useActiveRelationship(); + const [edges, setEdges, onEdgesChange] = useEdgesState(getEdgesFromMap(relationshipMap)); const theme = useThemeContext(); + useEffect(() => { + setEdges((currentEdges) => getEdgesWithHighlight(currentEdges, activeEdgeIds, theme.colors.secondary_dark)); + }, [activeEdgeIds, setEdges]); + + const onEdgeClick = useCallback( + (_event: React.MouseEvent, edge: Edge) => { + const edgeData = edge.data as RelationshipEdgeData | undefined; + if (edgeData?.fkIndex !== undefined) { + activateRelationship(edgeData.fkIndex, edgeData.mappingIndex); + } + }, + [activateRelationship], + ); + + const onPaneClick = useCallback(() => { + deactivateRelationship(); + }, [deactivateRelationship]); + return ( <> - -
+ +
; @@ -39,6 +39,28 @@ function buildSchemaNode(schema: Schema): Omit { }; } +export const createFieldHandleId = (schemaName: string, fieldName: string, type: 'source' | 'target'): string => + `${schemaName}-${fieldName}-${type}`; + +type FkRestrictionInfo = { + localSchema: string; + foreignSchema: string; + mappings: { localField: string; foreignField: string }[]; + edgeIds: string[]; + fieldKeys: string[]; + localFieldKeys: string[]; + foreignFieldKeys: string[]; +}; + +export type RelationshipEdgeData = { fkIndex: number; mappingIndex: number }; + +export type RelationshipMap = { + fkRestrictions: FkRestrictionInfo[]; + localFieldKeyToFkIndices: Map; + foreignFieldKeyToFkIndices: Map; + fieldKeyToFkIndices: Map; +}; + /** * Converts a dictionary's schemas into positioned ReactFlow nodes arranged in a grid layout. * @@ -66,38 +88,246 @@ export function getNodesForDictionary(dictionary: Dictionary, layout?: Partial - `${schemaName}-${fieldName}-${type}`; - /** - * Converts a dictionary's foreign key relationships into ReactFlow edges connecting schema nodes. + * Builds an FK adjacency graph from the dictionary's foreign key restrictions. + * Each FK restriction is indexed, and adjacency maps allow tracing chains + * up (child→parent) and down (parent→child). * * @param {Dictionary} dictionary — The Lectern dictionary containing schemas with foreign key restrictions - * @returns {Edge[]} Array of ReactFlow edges representing foreign key relationships + * @returns {RelationshipMap} FK adjacency graph for chain tracing */ -export function getEdgesForDictionary(dictionary: Dictionary): Edge[] { - return dictionary.schemas.flatMap((schema) => { - if (!schema.restrictions?.foreignKey) return []; - - return schema.restrictions.foreignKey.flatMap((foreignKey) => { - return foreignKey.mappings.map((mapping) => ({ - id: `${schema.name}-${mapping.local}-to-${foreignKey.schema}-${mapping.foreign}`, - source: foreignKey.schema, - sourceHandle: createFieldHandleId(foreignKey.schema, mapping.foreign, 'source'), - target: schema.name, - targetHandle: createFieldHandleId(schema.name, mapping.local, 'target'), - type: 'smoothstep', - pathOptions: { - offset: -20, - }, - markerEnd: { - type: MarkerType.Arrow, - width: 20, - height: 20, - color: '#374151', - }, - markerStart: ONE_CARDINALITY_MARKER_ID, - })); +export function buildRelationshipMap(dictionary: Dictionary): RelationshipMap { + const fkRestrictions: FkRestrictionInfo[] = []; + const localFieldKeyToFkIndices = new Map(); + const foreignFieldKeyToFkIndices = new Map(); + const fieldKeyToFkIndices = new Map(); + + const addToList = (map: Map, key: string, index: number) => { + const existing = map.get(key) ?? []; + existing.push(index); + map.set(key, existing); + }; + + dictionary.schemas.forEach((schema) => { + if (!schema.restrictions?.foreignKey) return; + + schema.restrictions.foreignKey.forEach((foreignKey) => { + const fkIndex = fkRestrictions.length; + const mappings: { localField: string; foreignField: string }[] = []; + const edgeIds: string[] = []; + const fieldKeys: string[] = []; + const localFieldKeys: string[] = []; + const foreignFieldKeys: string[] = []; + + foreignKey.mappings.forEach((mapping) => { + const edgeId = `${schema.name}-${mapping.local}-to-${foreignKey.schema}-${mapping.foreign}`; + mappings.push({ localField: mapping.local, foreignField: mapping.foreign }); + edgeIds.push(edgeId); + + const localKey = `${schema.name}::${mapping.local}`; + const foreignKey_ = `${foreignKey.schema}::${mapping.foreign}`; + fieldKeys.push(localKey, foreignKey_); + localFieldKeys.push(localKey); + foreignFieldKeys.push(foreignKey_); + addToList(fieldKeyToFkIndices, localKey, fkIndex); + addToList(fieldKeyToFkIndices, foreignKey_, fkIndex); + addToList(localFieldKeyToFkIndices, localKey, fkIndex); + addToList(foreignFieldKeyToFkIndices, foreignKey_, fkIndex); + }); + + fkRestrictions.push({ + localSchema: schema.name, + foreignSchema: foreignKey.schema, + mappings, + edgeIds, + fieldKeys, + localFieldKeys, + foreignFieldKeys, + }); }); }); + + return { fkRestrictions, localFieldKeyToFkIndices, foreignFieldKeyToFkIndices, fieldKeyToFkIndices }; +} + +/** + * Traces the full FK chain from a clicked edge or field, following parent links upward + * and child links downward to collect all connected edges and field keys. + * + * When `mappingIndex` is provided (edge click), only the specific mapping's edge and + * field pair are collected, and chains are traced through individual fields. + * When `mappingIndex` is omitted (field click), all mappings of the FK are collected. + * + * @param {number} fkIndex — The index into fkRestrictions for the clicked FK + * @param {RelationshipMap} map — The FK adjacency graph + * @param {number} [mappingIndex] — Optional index of the specific mapping within the FK + * @returns {{ edgeIds: Set, fieldKeys: Set, schemaChain: string[] }} All edges and fields in the chain + */ +export function traceChain( + fkIndex: number, + map: RelationshipMap, + mappingIndex?: number, +): { edgeIds: Set; fieldKeys: Set; schemaChain: string[] } { + const edgeIds = new Set(); + const fieldKeys = new Set(); + const visitedEdges = new Set(); + + if (fkIndex < 0 || fkIndex >= map.fkRestrictions.length) return { edgeIds, fieldKeys, schemaChain: [] }; + + const collectMapping = (fkIdx: number, mapIdx: number) => { + const fk = map.fkRestrictions[fkIdx]; + const edgeId = fk.edgeIds[mapIdx]; + if (!edgeId || visitedEdges.has(edgeId)) return; + visitedEdges.add(edgeId); + edgeIds.add(edgeId); + + const mapping = fk.mappings[mapIdx]; + const localKey = `${fk.localSchema}::${mapping.localField}`; + const foreignKey = `${fk.foreignSchema}::${mapping.foreignField}`; + fieldKeys.add(localKey); + fieldKeys.add(foreignKey); + }; + + const collectAllMappings = (fkIdx: number) => { + const fk = map.fkRestrictions[fkIdx]; + for (let i = 0; i < fk.mappings.length; i++) { + collectMapping(fkIdx, i); + } + }; + + // Trace a single field key in a direction, finding which specific mapping in a connected FK uses that field + const traceFieldUp = (foreignFieldKey: string) => { + const indices = map.localFieldKeyToFkIndices.get(foreignFieldKey); + if (!indices) return; + for (const idx of indices) { + const fk = map.fkRestrictions[idx]; + for (let i = 0; i < fk.mappings.length; i++) { + const localKey = `${fk.localSchema}::${fk.mappings[i].localField}`; + if (localKey === foreignFieldKey) { + const edgeId = fk.edgeIds[i]; + if (!visitedEdges.has(edgeId)) { + collectMapping(idx, i); + const fKey = `${fk.foreignSchema}::${fk.mappings[i].foreignField}`; + traceFieldUp(fKey); + } + } + } + } + }; + + const traceFieldDown = (localFieldKey: string) => { + const indices = map.foreignFieldKeyToFkIndices.get(localFieldKey); + if (!indices) return; + for (const idx of indices) { + const fk = map.fkRestrictions[idx]; + for (let i = 0; i < fk.mappings.length; i++) { + const foreignKey = `${fk.foreignSchema}::${fk.mappings[i].foreignField}`; + if (foreignKey === localFieldKey) { + const edgeId = fk.edgeIds[i]; + if (!visitedEdges.has(edgeId)) { + collectMapping(idx, i); + const lKey = `${fk.localSchema}::${fk.mappings[i].localField}`; + traceFieldDown(lKey); + } + } + } + } + }; + + const clickedFk = map.fkRestrictions[fkIndex]; + + if (mappingIndex !== undefined) { + // Edge click: collect only the specific mapping, trace through individual fields + collectMapping(fkIndex, mappingIndex); + const mapping = clickedFk.mappings[mappingIndex]; + const foreignKey = `${clickedFk.foreignSchema}::${mapping.foreignField}`; + const localKey = `${clickedFk.localSchema}::${mapping.localField}`; + traceFieldUp(foreignKey); + traceFieldDown(localKey); + } else { + // Field click: collect all mappings of the FK, trace through all fields + collectAllMappings(fkIndex); + for (const foreignFieldKey of clickedFk.foreignFieldKeys) { + traceFieldUp(foreignFieldKey); + } + for (const localFieldKey of clickedFk.localFieldKeys) { + traceFieldDown(localFieldKey); + } + } + + const visitedFkIndices = new Set(); + for (const edgeId of visitedEdges) { + for (let i = 0; i < map.fkRestrictions.length; i++) { + if (map.fkRestrictions[i].edgeIds.includes(edgeId)) { + visitedFkIndices.add(i); + } + } + } + const schemaChain = buildSchemaChain(visitedFkIndices, map); + + return { edgeIds, fieldKeys, schemaChain }; +} + +function buildSchemaChain(visitedFkIndices: Set, map: RelationshipMap): string[] { + const schemas = new Set(); + for (const idx of visitedFkIndices) { + const fk = map.fkRestrictions[idx]; + schemas.add(fk.foreignSchema); + schemas.add(fk.localSchema); + } + return Array.from(schemas); +} + +/** + * Returns a new edges array with className set based on the active edge set. + * Active edges get 'edge-active', non-active edges get 'edge-inactive', + * and when no relationship is active all edges have no className. + */ +export function getEdgesWithHighlight(edges: Edge[], activeEdgeIds: Set | null, activeColor?: string): Edge[] { + if (!activeEdgeIds) { + return edges.map((edge) => ({ + ...edge, + className: undefined, + markerStart: ONE_CARDINALITY_MARKER_ID, + markerEnd: { type: MarkerType.Arrow, width: 20, height: 20, color: '#374151' }, + })); + } + + return edges.map((edge) => { + const isActive = activeEdgeIds.has(edge.id); + return { + ...edge, + className: isActive ? 'edge-active' : 'edge-inactive', + markerStart: isActive ? ONE_CARDINALITY_MARKER_ACTIVE_ID : ONE_CARDINALITY_MARKER_ID, + markerEnd: { + type: MarkerType.Arrow, + width: 20, + height: 20, + color: isActive ? (activeColor ?? '#374151') : '#374151', + }, + }; + }); +} + +/** + * Derives ReactFlow edges from the relationship map, attaching fkIndex to each edge's data + * + * @param {RelationshipMap} map — The FK adjacency graph built by buildRelationshipMap + * @returns {Edge[]} Array of ReactFlow edges representing foreign key relationships + */ +export function getEdgesFromMap(map: RelationshipMap): Edge[] { + return map.fkRestrictions.flatMap((fk, fkIndex) => + fk.mappings.map((mapping, i) => ({ + id: fk.edgeIds[i], + source: fk.foreignSchema, + sourceHandle: createFieldHandleId(fk.foreignSchema, mapping.foreignField, 'source'), + target: fk.localSchema, + targetHandle: createFieldHandleId(fk.localSchema, mapping.localField, 'target'), + type: 'smoothstep', + pathOptions: { offset: -20 }, + data: { fkIndex, mappingIndex: i } satisfies RelationshipEdgeData, + markerEnd: { type: MarkerType.Arrow, width: 20, height: 20, color: '#374151' }, + markerStart: ONE_CARDINALITY_MARKER_ID, + })), + ); } diff --git a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx index dc5c1bc7..982b97e2 100644 --- a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx +++ b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx @@ -19,12 +19,17 @@ * */ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import Button from '../../common/Button'; import Modal from '../../common/Modal'; import { useDictionaryDataContext, useDictionaryStateContext } from '../../dictionary-controller/DictionaryDataContext'; import { useThemeContext } from '../../theme/index'; -import { EntityRelationshipDiagram } from '../EntityRelationshipDiagram'; +import { + ActiveRelationshipProvider, + buildRelationshipMap, + EntityRelationshipDiagramContent, +} from '../EntityRelationshipDiagram'; +import DiagramSubtitle from './DiagramSubtitle'; const DiagramViewButton = () => { const [isOpen, setIsOpen] = useState(false); @@ -33,23 +38,32 @@ const DiagramViewButton = () => { const { loading, errors } = useDictionaryDataContext(); const { selectedDictionary } = useDictionaryStateContext(); + const relationshipMap = useMemo( + () => (selectedDictionary ? buildRelationshipMap(selectedDictionary) : null), + [selectedDictionary], + ); + return ( <> - - {selectedDictionary && ( -
- -
- )} -
+ {relationshipMap && ( + + } + isOpen={isOpen} + setIsOpen={setIsOpen} + > + {selectedDictionary && ( +
+ +
+ )} +
+
+ )} ); }; From 2941455693f64404a80b31c8d7b6f77e07631324 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 18 Feb 2026 16:45:30 -0500 Subject: [PATCH 25/44] feat: add diagram subtitle component to render scheam chain --- .../viewer-table/Toolbar/DiagramSubtitle.tsx | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx diff --git a/packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx b/packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx new file mode 100644 index 00000000..02f347b7 --- /dev/null +++ b/packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx @@ -0,0 +1,97 @@ +/* + * + * 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. + * + */ + +/** @jsxImportSource @emotion/react */ + +import { css } from '@emotion/react'; +import { useActiveRelationship } from '../EntityRelationshipDiagram'; +import { useThemeContext, type Theme } from '../../theme/index'; + +const pillStyle = (theme: Theme) => css` + display: inline-block; + border: 1px solid black; + border-radius: 12px; + padding: 2px 10px; + ${theme.typography.data}; + color: black; +`; + +const arrowStyle = css` + margin: 0 6px; +`; + +const clearButtonStyle = (theme: Theme) => css` + background: none; + border: none; + cursor: pointer; + color: black; + ${theme.typography.data}; + text-decoration: underline; + padding: 0; + margin-top: 4px; +`; + +const chainRowStyle = css` + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 2px; +`; + +const chainItemStyle = css` + display: inline-flex; + align-items: center; +`; + +const chainLabelStyle = css` + font-weight: bold; + padding-right: 5px; +`; + +const DiagramSubtitle = () => { + const { activeSchemaChain, deactivateRelationship } = useActiveRelationship(); + const theme = useThemeContext(); + + if (!activeSchemaChain) { + return Select any key field or edge to highlight a relation.; + } + + return ( +
+
+ Highlighting schema relation: + {activeSchemaChain.map((schema, index) => ( + + {index > 0 && {'\u2192'}} + {schema} + + ))} +
+
+ +
+
+ ); +}; + +export default DiagramSubtitle; From 897ca27eafaca5a9c42db82be97fd77ce8dca84e Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 18 Feb 2026 16:46:10 -0500 Subject: [PATCH 26/44] feat: update schema node to activate / deactive edges on click --- .../EntityRelationshipDiagram/SchemaNode.tsx | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx index 208e3ae7..d6c5325a 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx @@ -40,13 +40,17 @@ import { targetHandleStyles, } from '../../theme/emotion/schemaNodeStyles'; import { createFieldHandleId } from './diagramUtils'; +import { useActiveRelationship } from './ActiveRelationshipContext'; export function SchemaNode(props: { data: Schema }) { const { data: schema } = props; const theme = useThemeContext(); + const { activateRelationship, isFieldInActiveRelationship, activeSchemaNames, relationshipMap } = + useActiveRelationship(); + const isInactive = activeSchemaNames !== null && !activeSchemaNames.has(schema.name); return ( -
+
{schema.name} Schema @@ -61,9 +65,28 @@ export function SchemaNode(props: { data: Schema }) { ) || false; const isEvenRow = index % 2 === 1; const valueType = field.isArray ? `${field.valueType}[]` : field.valueType; + const isHighlighted = isFieldInActiveRelationship(schema.name, field.name); + + const handleFieldClick = + isForeignKey ? + () => { + const fieldKey = `${schema.name}::${field.name}`; + const fkIndices = relationshipMap.fieldKeyToFkIndices.get(fieldKey); + if (fkIndices?.[0] !== undefined) { + const fkIndex = fkIndices[0]; + const fk = relationshipMap.fkRestrictions[fkIndex]; + const mappingIdx = fk.mappings.findIndex((m) => m.localField === field.name); + activateRelationship(fkIndex, mappingIdx !== -1 ? mappingIdx : undefined); + } + } + : undefined; return ( -
+
{field.name}
From f9c88f37309f88bfd3217da1c5d4e7be7b4240fe Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 18 Feb 2026 16:46:37 -0500 Subject: [PATCH 27/44] feat: add active and inactive marker --- .../ui/src/theme/icons/OneCardinalityMarker.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/theme/icons/OneCardinalityMarker.tsx b/packages/ui/src/theme/icons/OneCardinalityMarker.tsx index c8fb74a1..603bcf58 100644 --- a/packages/ui/src/theme/icons/OneCardinalityMarker.tsx +++ b/packages/ui/src/theme/icons/OneCardinalityMarker.tsx @@ -22,12 +22,14 @@ /** @jsxImportSource @emotion/react */ export const ONE_CARDINALITY_MARKER_ID = 'one-cardinality-marker'; +export const ONE_CARDINALITY_MARKER_ACTIVE_ID = 'one-cardinality-marker-active'; type OneCardinalityMarkerProps = { color?: string; + activeColor: string; }; -const OneCardinalityMarker = ({ color = '#374151' }: OneCardinalityMarkerProps) => { +const OneCardinalityMarker = ({ color = '#374151', activeColor }: OneCardinalityMarkerProps) => { return ( @@ -43,6 +45,18 @@ const OneCardinalityMarker = ({ color = '#374151' }: OneCardinalityMarkerProps) > + + + ); From f3d58d151b6d91251c630140a2ccd8b43bfb449a Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 18 Feb 2026 16:46:55 -0500 Subject: [PATCH 28/44] refactor: update index with new utils --- .../ui/src/viewer-table/EntityRelationshipDiagram/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/index.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/index.ts index f13bdc44..195dd06a 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/index.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/index.ts @@ -19,4 +19,6 @@ * */ -export { EntityRelationshipDiagram } from './EntityRelationshipDiagram'; +export { EntityRelationshipDiagramContent } from './EntityRelationshipDiagram'; +export { ActiveRelationshipProvider, useActiveRelationship } from './ActiveRelationshipContext'; +export { buildRelationshipMap } from './diagramUtils'; From 3ea658cc41358f2665a88d514108b1b6ee41dff7 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Wed, 18 Feb 2026 16:48:03 -0500 Subject: [PATCH 29/44] feat: add dictionaries for testing --- packages/ui/stories/fixtures/compoundKey.json | 37 ++++ packages/ui/stories/fixtures/cyclical.json | 61 ++++++ packages/ui/stories/fixtures/fanOut.json | 72 +++++++ .../stories/fixtures/invalid_uniquekey.json | 32 +++ .../ui/stories/fixtures/mixedRelations.json | 83 ++++++++ packages/ui/stories/fixtures/multiFk.json | 52 +++++ .../fixtures/simpleClinicalERDiagram.json | 193 ++++++++++++++++++ .../ui/stories/fixtures/singleSchema.json | 20 ++ .../ui/stories/fixtures/threeSchemaChain.json | 54 +++++ .../stories/fixtures/twoIsolatedSchemas.json | 37 ++++ .../ui/stories/fixtures/twoSchemaLinear.json | 37 ++++ .../EntityRelationshipDiagram.stories.tsx | 157 +++++++++++++- 12 files changed, 829 insertions(+), 6 deletions(-) create mode 100644 packages/ui/stories/fixtures/compoundKey.json create mode 100644 packages/ui/stories/fixtures/cyclical.json create mode 100644 packages/ui/stories/fixtures/fanOut.json create mode 100644 packages/ui/stories/fixtures/invalid_uniquekey.json create mode 100644 packages/ui/stories/fixtures/mixedRelations.json create mode 100644 packages/ui/stories/fixtures/multiFk.json create mode 100644 packages/ui/stories/fixtures/simpleClinicalERDiagram.json create mode 100644 packages/ui/stories/fixtures/singleSchema.json create mode 100644 packages/ui/stories/fixtures/threeSchemaChain.json create mode 100644 packages/ui/stories/fixtures/twoIsolatedSchemas.json create mode 100644 packages/ui/stories/fixtures/twoSchemaLinear.json diff --git a/packages/ui/stories/fixtures/compoundKey.json b/packages/ui/stories/fixtures/compoundKey.json new file mode 100644 index 00000000..da2e3abb --- /dev/null +++ b/packages/ui/stories/fixtures/compoundKey.json @@ -0,0 +1,37 @@ +{ + "name": "compound_key_dictionary", + "version": "1.0", + "description": "Tests a schema with a compound uniqueKey across two fields", + "schemas": [ + { + "name": "participant", + "description": "Study participants", + "fields": [ + { "name": "participant_id", "valueType": "string", "unique": true }, + { "name": "age", "valueType": "integer" } + ], + "restrictions": { + "uniqueKey": ["participant_id"] + } + }, + { + "name": "visit", + "description": "Participant visits. Uniqueness requires both participant and visit number", + "fields": [ + { "name": "participant_id", "valueType": "string" }, + { "name": "visit_number", "valueType": "integer" }, + { "name": "visit_date", "valueType": "string" }, + { "name": "visit_type", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["participant_id", "visit_number"], + "foreignKey": [ + { + "schema": "participant", + "mappings": [{ "local": "participant_id", "foreign": "participant_id" }] + } + ] + } + } + ] +} diff --git a/packages/ui/stories/fixtures/cyclical.json b/packages/ui/stories/fixtures/cyclical.json new file mode 100644 index 00000000..cb446033 --- /dev/null +++ b/packages/ui/stories/fixtures/cyclical.json @@ -0,0 +1,61 @@ +{ + "name": "cyclical_dictionary", + "version": "1.0", + "description": "Three schemas forming a cyclical FK reference: node_a -> node_b -> node_c -> node_a", + "schemas": [ + { + "name": "node_a", + "description": "References node_c, completing the cycle", + "fields": [ + { "name": "a_id", "valueType": "string", "unique": true }, + { "name": "c_id", "valueType": "string" }, + { "name": "label", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["a_id"], + "foreignKey": [ + { + "schema": "node_c", + "mappings": [{ "local": "c_id", "foreign": "c_id" }] + } + ] + } + }, + { + "name": "node_b", + "description": "References node_a", + "fields": [ + { "name": "b_id", "valueType": "string", "unique": true }, + { "name": "a_id", "valueType": "string" }, + { "name": "label", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["b_id"], + "foreignKey": [ + { + "schema": "node_a", + "mappings": [{ "local": "a_id", "foreign": "a_id" }] + } + ] + } + }, + { + "name": "node_c", + "description": "References node_b", + "fields": [ + { "name": "c_id", "valueType": "string", "unique": true }, + { "name": "b_id", "valueType": "string" }, + { "name": "label", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["c_id"], + "foreignKey": [ + { + "schema": "node_b", + "mappings": [{ "local": "b_id", "foreign": "b_id" }] + } + ] + } + } + ] +} diff --git a/packages/ui/stories/fixtures/fanOut.json b/packages/ui/stories/fixtures/fanOut.json new file mode 100644 index 00000000..a88ca348 --- /dev/null +++ b/packages/ui/stories/fixtures/fanOut.json @@ -0,0 +1,72 @@ +{ + "name": "fan_out_dictionary", + "version": "1.0", + "description": "One parent schema referenced by multiple independent child schemas", + "schemas": [ + { + "name": "participant", + "description": "Root participant record referenced by all other schemas", + "fields": [ + { "name": "participant_id", "valueType": "string", "unique": true }, + { "name": "age", "valueType": "integer" } + ], + "restrictions": { + "uniqueKey": ["participant_id"] + } + }, + { + "name": "diagnosis", + "description": "Diagnoses — FK to participant", + "fields": [ + { "name": "diagnosis_id", "valueType": "string", "unique": true }, + { "name": "participant_id", "valueType": "string" }, + { "name": "disease_code", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["diagnosis_id"], + "foreignKey": [ + { + "schema": "participant", + "mappings": [{ "local": "participant_id", "foreign": "participant_id" }] + } + ] + } + }, + { + "name": "demographic", + "description": "Demographic data — FK to participant", + "fields": [ + { "name": "demographic_id", "valueType": "string", "unique": true }, + { "name": "participant_id", "valueType": "string" }, + { "name": "ethnicity", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["demographic_id"], + "foreignKey": [ + { + "schema": "participant", + "mappings": [{ "local": "participant_id", "foreign": "participant_id" }] + } + ] + } + }, + { + "name": "follow_up", + "description": "Follow-up contacts — FK to participant", + "fields": [ + { "name": "follow_up_id", "valueType": "string", "unique": true }, + { "name": "participant_id", "valueType": "string" }, + { "name": "follow_up_date", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["follow_up_id"], + "foreignKey": [ + { + "schema": "participant", + "mappings": [{ "local": "participant_id", "foreign": "participant_id" }] + } + ] + } + } + ] +} diff --git a/packages/ui/stories/fixtures/invalid_uniquekey.json b/packages/ui/stories/fixtures/invalid_uniquekey.json new file mode 100644 index 00000000..3a94fa51 --- /dev/null +++ b/packages/ui/stories/fixtures/invalid_uniquekey.json @@ -0,0 +1,32 @@ +{ + "name": "invalid_uniquekey_reference", + "version": "1.0.0", + "description": "FK references a field that exists on the target schema but is not declared as a uniqueKey", + "schemas": [ + { + "name": "participant", + "description": "Participant schema where participant_id is NOT declared as a uniqueKey", + "fields": [ + { "name": "participant_id", "valueType": "string" }, + { "name": "age", "valueType": "integer" } + ] + }, + { + "name": "sample", + "description": "Sample with FK pointing to participant_id which has no uniqueKey declaration", + "fields": [ + { "name": "sample_id", "valueType": "string", "unique": true }, + { "name": "participant_id", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["sample_id"], + "foreignKey": [ + { + "schema": "participant", + "mappings": [{ "local": "participant_id", "foreign": "participant_id" }] + } + ] + } + } + ] +} diff --git a/packages/ui/stories/fixtures/mixedRelations.json b/packages/ui/stories/fixtures/mixedRelations.json new file mode 100644 index 00000000..b2e8094b --- /dev/null +++ b/packages/ui/stories/fixtures/mixedRelations.json @@ -0,0 +1,83 @@ +{ + "name": "mixed_relations_dictionary", + "version": "1.0", + "description": "Five schemas with a mix of chain, fan-in, and isolated relationships", + "schemas": [ + { + "name": "participant", + "description": "Study participants", + "fields": [ + { "name": "participant_id", "valueType": "string", "unique": true }, + { "name": "age", "valueType": "integer" }, + { "name": "sex", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["participant_id"] + } + }, + { + "name": "specimen", + "description": "Biological specimens from participants", + "fields": [ + { "name": "specimen_id", "valueType": "string", "unique": true }, + { "name": "participant_id", "valueType": "string" }, + { "name": "tissue_type", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["specimen_id"], + "foreignKey": [ + { + "schema": "participant", + "mappings": [{ "local": "participant_id", "foreign": "participant_id" }] + } + ] + } + }, + { + "name": "experiment", + "description": "Sequencing experiments", + "fields": [ + { "name": "experiment_id", "valueType": "string", "unique": true }, + { "name": "experiment_type", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["experiment_id"] + } + }, + { + "name": "sample", + "description": "Samples linking a specimen to an experiment", + "fields": [ + { "name": "sample_id", "valueType": "string", "unique": true }, + { "name": "specimen_id", "valueType": "string" }, + { "name": "experiment_id", "valueType": "string" }, + { "name": "sample_type", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["sample_id"], + "foreignKey": [ + { + "schema": "specimen", + "mappings": [{ "local": "specimen_id", "foreign": "specimen_id" }] + }, + { + "schema": "experiment", + "mappings": [{ "local": "experiment_id", "foreign": "experiment_id" }] + } + ] + } + }, + { + "name": "audit_log", + "description": "Standalone audit records — no FK relationships", + "fields": [ + { "name": "log_id", "valueType": "string", "unique": true }, + { "name": "action", "valueType": "string" }, + { "name": "timestamp", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["log_id"] + } + } + ] +} diff --git a/packages/ui/stories/fixtures/multiFk.json b/packages/ui/stories/fixtures/multiFk.json new file mode 100644 index 00000000..3ca6c2d6 --- /dev/null +++ b/packages/ui/stories/fixtures/multiFk.json @@ -0,0 +1,52 @@ +{ + "name": "multi_fk", + "version": "1.0", + "description": "One schema with FKs pointing to two separate parent schemas", + "schemas": [ + { + "name": "participant", + "description": "Study participants", + "fields": [ + { "name": "participant_id", "valueType": "string", "unique": true }, + { "name": "age", "valueType": "integer" } + ], + "restrictions": { + "uniqueKey": ["participant_id"] + } + }, + { + "name": "study", + "description": "Research studies", + "fields": [ + { "name": "study_id", "valueType": "string", "unique": true }, + { "name": "study_name", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["study_id"] + } + }, + { + "name": "enrollment", + "description": "Participant enrollment in a study — links both parent schemas", + "fields": [ + { "name": "enrollment_id", "valueType": "string", "unique": true }, + { "name": "participant_id", "valueType": "string" }, + { "name": "study_id", "valueType": "string" }, + { "name": "enrollment_date", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["enrollment_id"], + "foreignKey": [ + { + "schema": "participant", + "mappings": [{ "local": "participant_id", "foreign": "participant_id" }] + }, + { + "schema": "study", + "mappings": [{ "local": "study_id", "foreign": "study_id" }] + } + ] + } + } + ] +} diff --git a/packages/ui/stories/fixtures/simpleClinicalERDiagram.json b/packages/ui/stories/fixtures/simpleClinicalERDiagram.json new file mode 100644 index 00000000..0a097a0e --- /dev/null +++ b/packages/ui/stories/fixtures/simpleClinicalERDiagram.json @@ -0,0 +1,193 @@ +{ + "name": "Simple ER Diagram Example", + "version": "1.0", + "description": "A simplified clinical data model demonstrating entity relationships", + "schemas": [ + { + "name": "Participant", + "description": "Study participants", + "fields": [ + { + "name": "participant_id", + "description": "Unique identifier for the participant", + "valueType": "string", + "unique": true, + "restrictions": { "required": true } + }, + { + "name": "duo_modifier", + "description": "Data Use Ontology modifier", + "valueType": "string" + }, + { + "name": "treatment_id", + "description": "Reference to treatment", + "valueType": "string", + "unique": true + } + ], + "restrictions": { + "uniqueKey": ["participant_id"] + } + }, + { + "name": "Diagnosis", + "description": "Medical diagnoses for participants", + "fields": [ + { + "name": "diagnosis_id", + "description": "Unique identifier for the diagnosis", + "valueType": "string", + "unique": true, + "restrictions": { "required": true } + }, + { + "name": "participant_id", + "description": "Reference to participant", + "valueType": "string", + "unique": true, + "restrictions": { "required": true } + }, + { + "name": "primary_site", + "description": "Primary site of disease", + "valueType": "string" + }, + { + "name": "age_at_diagnosis", + "description": "Age at time of diagnosis", + "valueType": "integer" + }, + { + "name": "cancer_type", + "description": "Type of cancer diagnosed", + "valueType": "string" + }, + { + "name": "staging system", + "description": "Cancer staging system used", + "valueType": "string" + } + ], + "restrictions": { + "uniqueKey": ["diagnosis_id"], + "foreignKey": [ + { + "schema": "Participant", + "mappings": [ + { + "local": "participant_id", + "foreign": "participant_id" + } + ] + } + ] + } + }, + { + "name": "Sequencing", + "description": "Sequencing data related to treatments", + "fields": [ + { + "name": "participant_id", + "description": "Reference to participant", + "valueType": "string", + "unique": true, + "restrictions": { "required": true } + }, + { + "name": "treatment_id", + "description": "Unique treatment identifier", + "valueType": "string", + "unique": true, + "restrictions": { "required": true } + }, + { + "name": "treatment_type", + "description": "Type of treatment administered", + "valueType": "string" + }, + { + "name": "treatment_start", + "description": "Start day of treatment", + "valueType": "integer" + }, + { + "name": "treatment_duration", + "description": "Duration of treatment in days", + "valueType": "integer" + }, + { + "name": "treatment_response", + "description": "Response to treatment", + "valueType": "string" + } + ], + "restrictions": { + "uniqueKey": ["treatment_id"], + "foreignKey": [ + { + "schema": "Participant", + "mappings": [ + { + "local": "participant_id", + "foreign": "participant_id" + } + ] + }, + { + "schema": "Participant", + "mappings": [ + { + "local": "treatment_id" , + "foreign": "treatment_id" + } + ] + } + ] + } + }, + { + "name": "Followup", + "description": "Follow-up records for treatments", + "fields": [ + { + "name": "treatment_id", + "description": "Reference to treatment", + "valueType": "string", + "unique": true, + "restrictions": { "required": true } + }, + { + "name": "followup_id", + "description": "Unique follow-up identifier", + "valueType": "string" + }, + { + "name": "followup_interval", + "description": "Days since treatment started", + "valueType": "integer" + }, + { + "name": "disease status", + "description": "Current disease status", + "valueType": "string" + } + ], + "restrictions": { + "uniqueKey": ["treatment_id"], + "foreignKey": [ + { + "schema": "Sequencing", + "mappings": [ + { + "local": "treatment_id", + "foreign": "treatment_id" + } + ] + } + ] + } + } + ] +} diff --git a/packages/ui/stories/fixtures/singleSchema.json b/packages/ui/stories/fixtures/singleSchema.json new file mode 100644 index 00000000..c1c9bf4b --- /dev/null +++ b/packages/ui/stories/fixtures/singleSchema.json @@ -0,0 +1,20 @@ +{ + "name": "single_schema_dictionary", + "version": "1.0", + "description": "A dictionary with one schema and no relationships", + "schemas": [ + { + "name": "sample", + "description": "Basic sample metadata", + "fields": [ + { "name": "sample_id", "valueType": "string", "unique": true }, + { "name": "sample_type", "valueType": "string" }, + { "name": "collection_date", "valueType": "string" }, + { "name": "volume_ml", "valueType": "number" } + ], + "restrictions": { + "uniqueKey": ["sample_id"] + } + } + ] +} diff --git a/packages/ui/stories/fixtures/threeSchemaChain.json b/packages/ui/stories/fixtures/threeSchemaChain.json new file mode 100644 index 00000000..f7429122 --- /dev/null +++ b/packages/ui/stories/fixtures/threeSchemaChain.json @@ -0,0 +1,54 @@ +{ + "name": "three_schema_chain", + "version": "1.0", + "description": "Three schemas forming a linear FK chain", + "schemas": [ + { + "name": "participant", + "description": "Study participants", + "fields": [ + { "name": "participant_id", "valueType": "string", "unique": true }, + { "name": "age", "valueType": "integer" } + ], + "restrictions": { + "uniqueKey": ["participant_id"] + } + }, + { + "name": "diagnosis", + "description": "Diagnoses linked to participants", + "fields": [ + { "name": "diagnosis_id", "valueType": "string", "unique": true }, + { "name": "participant_id", "valueType": "string" }, + { "name": "disease_code", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["diagnosis_id"], + "foreignKey": [ + { + "schema": "participant", + "mappings": [{ "local": "participant_id", "foreign": "participant_id" }] + } + ] + } + }, + { + "name": "treatment", + "description": "Treatments linked to diagnoses", + "fields": [ + { "name": "treatment_id", "valueType": "string", "unique": true }, + { "name": "diagnosis_id", "valueType": "string" }, + { "name": "treatment_type", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["treatment_id"], + "foreignKey": [ + { + "schema": "diagnosis", + "mappings": [{ "local": "diagnosis_id", "foreign": "diagnosis_id" }] + } + ] + } + } + ] +} diff --git a/packages/ui/stories/fixtures/twoIsolatedSchemas.json b/packages/ui/stories/fixtures/twoIsolatedSchemas.json new file mode 100644 index 00000000..96630d64 --- /dev/null +++ b/packages/ui/stories/fixtures/twoIsolatedSchemas.json @@ -0,0 +1,37 @@ +{ + "name": "two_schema_linear", + "version": "1.0", + "description": "Two schemas with a simple one-to-many FK relationship", + "schemas": [ + { + "name": "participant", + "description": "Study participants", + "fields": [ + { "name": "participant_id", "valueType": "string", "unique": true }, + { "name": "age", "valueType": "integer" }, + { "name": "sex", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["participant_id"] + } + }, + { + "name": "sample", + "description": "Samples collected from participants", + "fields": [ + { "name": "sample_id", "valueType": "string", "unique": true }, + { "name": "participant_id", "valueType": "string" }, + { "name": "sample_type", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["sample_id"], + "foreignKey": [ + { + "schema": "participant", + "mappings": [{ "local": "participant_id", "foreign": "participant_id" }] + } + ] + } + } + ] +} diff --git a/packages/ui/stories/fixtures/twoSchemaLinear.json b/packages/ui/stories/fixtures/twoSchemaLinear.json new file mode 100644 index 00000000..96630d64 --- /dev/null +++ b/packages/ui/stories/fixtures/twoSchemaLinear.json @@ -0,0 +1,37 @@ +{ + "name": "two_schema_linear", + "version": "1.0", + "description": "Two schemas with a simple one-to-many FK relationship", + "schemas": [ + { + "name": "participant", + "description": "Study participants", + "fields": [ + { "name": "participant_id", "valueType": "string", "unique": true }, + { "name": "age", "valueType": "integer" }, + { "name": "sex", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["participant_id"] + } + }, + { + "name": "sample", + "description": "Samples collected from participants", + "fields": [ + { "name": "sample_id", "valueType": "string", "unique": true }, + { "name": "participant_id", "valueType": "string" }, + { "name": "sample_type", "valueType": "string" } + ], + "restrictions": { + "uniqueKey": ["sample_id"], + "foreignKey": [ + { + "schema": "participant", + "mappings": [{ "local": "participant_id", "foreign": "participant_id" }] + } + ] + } + } + ] +} diff --git a/packages/ui/stories/viewer-table/EntityRelationshipDiagram.stories.tsx b/packages/ui/stories/viewer-table/EntityRelationshipDiagram.stories.tsx index a1e5676d..e50c04f9 100644 --- a/packages/ui/stories/viewer-table/EntityRelationshipDiagram.stories.tsx +++ b/packages/ui/stories/viewer-table/EntityRelationshipDiagram.stories.tsx @@ -21,32 +21,177 @@ import type { Meta, StoryObj } from '@storybook/react'; import type { Dictionary } from '@overture-stack/lectern-dictionary'; +import { useMemo } from 'react'; -import { EntityRelationshipDiagram } from '../../src/viewer-table/EntityRelationshipDiagram'; +import React from 'react'; +import { + ActiveRelationshipProvider, + buildRelationshipMap, + EntityRelationshipDiagramContent, +} from '../../src/viewer-table/EntityRelationshipDiagram'; import DictionarySample from '../fixtures/pcgl.json'; -import SimpleERDiagram from '../fixtures/simpleERDiagram.json'; +import SimpleClinicalERDiagram from '../fixtures/simpleClinicalERDiagram.json'; +import SingleSchemaFixture from '../fixtures/singleSchema.json'; +import TwoIsolatedSchemasFixture from '../fixtures/twoIsolatedSchemas.json'; +import TwoSchemaLinearFixture from '../fixtures/twoSchemaLinear.json'; +import ThreeSchemaChainFixture from '../fixtures/threeSchemaChain.json'; +import MultiFkFixture from '../fixtures/multiFk.json'; +import FanOutFixture from '../fixtures/fanOut.json'; +import MixedRelationsFixture from '../fixtures/mixedRelations.json'; +import CompoundKeyFixture from '../fixtures/compoundKey.json'; +import CyclicalFixture from '../fixtures/cyclical.json'; +import InvalidUniqueKeyFixture from '../fixtures/invalid_uniquekey.json'; import themeDecorator from '../themeDecorator'; -import React from 'react'; const meta = { - component: EntityRelationshipDiagram, + component: EntityRelationshipDiagramContent, title: 'Viewer - Table/Entity Relationship Diagram', decorators: [themeDecorator()], parameters: { layout: 'fullscreen', }, -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; +const StoryWrapper = ({ dictionary }: { dictionary: Dictionary }) => { + const relationshipMap = useMemo(() => buildRelationshipMap(dictionary), [dictionary]); + return ( + + + + ); +}; + export const Default: Story = { args: { dictionary: DictionarySample as Dictionary, }, render: (args) => (
- + +
+ ), +}; + +export const SimpleClinicalExample: Story = { + args: { + dictionary: SimpleClinicalERDiagram as Dictionary, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const SingleSchema: Story = { + args: { + dictionary: SingleSchemaFixture as Dictionary, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const TwoIsolatedSchemas: Story = { + args: { + dictionary: TwoIsolatedSchemasFixture as Dictionary, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const TwoSchemaLinear: Story = { + args: { + dictionary: TwoSchemaLinearFixture as Dictionary, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const ThreeSchemaChain: Story = { + args: { + dictionary: ThreeSchemaChainFixture as Dictionary, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const MultiFk: Story = { + args: { + dictionary: MultiFkFixture as Dictionary, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const FanOut: Story = { + args: { + dictionary: FanOutFixture as Dictionary, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const MixedRelations: Story = { + args: { + dictionary: MixedRelationsFixture as Dictionary, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const CompoundKey: Story = { + args: { + dictionary: CompoundKeyFixture as Dictionary, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const Cyclical: Story = { + args: { + dictionary: CyclicalFixture as Dictionary, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const NonUniqueForeignKey: Story = { + args: { + dictionary: InvalidUniqueKeyFixture as Dictionary, + }, + render: (args) => ( +
+
), }; \ No newline at end of file From 8bafc252d15f538a4a10666682968e65d9224576 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Thu, 19 Feb 2026 10:43:00 -0500 Subject: [PATCH 30/44] test --- .../src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index 5ede842c..810dbb7b 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -246,6 +246,7 @@ export function getEdgesWithHighlight(edges: Edge[], activeEdgeIds: Set type: MarkerType.Arrow, width: 20, height: 20, + color: isActive ? (activeColor ?? '#374151') : '#374151', }, }; From 834959306bafd18387a57355e2eaf99cf986a75f Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Thu, 19 Feb 2026 10:44:19 -0500 Subject: [PATCH 31/44] revert test --- .../src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index 810dbb7b..5ede842c 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -246,7 +246,6 @@ export function getEdgesWithHighlight(edges: Edge[], activeEdgeIds: Set type: MarkerType.Arrow, width: 20, height: 20, - color: isActive ? (activeColor ?? '#374151') : '#374151', }, }; From 52da977d4cd927380061b071dbc88e7d21fdace4 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Fri, 20 Feb 2026 11:52:40 -0500 Subject: [PATCH 32/44] fix: replace div element with fragment --- packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx b/packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx index 02f347b7..ac5bd898 100644 --- a/packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx +++ b/packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx @@ -75,7 +75,7 @@ const DiagramSubtitle = () => { } return ( -
+ <>
Highlighting schema relation: {activeSchemaChain.map((schema, index) => ( @@ -90,7 +90,7 @@ const DiagramSubtitle = () => { Clear Highlight
-
+ ); }; From 355fe54d682508cb4096472a047f6d8a9e522ba8 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Fri, 20 Feb 2026 11:55:05 -0500 Subject: [PATCH 33/44] refactor: use css selectors with hover text variable --- .../ui/src/theme/emotion/schemaNodeStyles.ts | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/theme/emotion/schemaNodeStyles.ts b/packages/ui/src/theme/emotion/schemaNodeStyles.ts index 5acac5ad..539f34d5 100644 --- a/packages/ui/src/theme/emotion/schemaNodeStyles.ts +++ b/packages/ui/src/theme/emotion/schemaNodeStyles.ts @@ -22,22 +22,36 @@ import { css } from '@emotion/react'; import type { Theme } from '../'; -export const fieldRowStyles = (theme: Theme, isForeignKey: boolean, isEven: boolean, isHighlighted: boolean = false) => css` - padding: 12px 12px; - display: flex; - align-items: center; - justify-content: space-between; - transition: background-color 0.2s; - position: relative; - background-color: ${isHighlighted ? theme.colors.secondary_1 : isEven ? theme.colors.accent_1 : 'transparent'}; - border-block: 1.5px solid ${isHighlighted ? theme.colors.secondary_dark : isEven ? theme.colors.accent_2 : 'transparent'}; - ${isForeignKey ? 'cursor: pointer;' : ''} - - &:hover { +export const fieldRowStyles = (theme: Theme, isForeignKey: boolean, isHighlighted: boolean = false) => { + const hoverText = css` background-color: ${isForeignKey ? theme.colors.secondary_1 : theme.colors.grey_3}; border-block: 1.5px solid ${isForeignKey ? theme.colors.secondary_dark : theme.colors.grey_4}; - } -`; + `; + + return css` + padding: 12px 12px; + display: flex; + align-items: center; + justify-content: space-between; + transition: background-color 0.2s; + position: relative; + border-block: 1.5px solid ${isHighlighted ? theme.colors.secondary_dark : 'transparent'}; + ${isForeignKey ? 'cursor: pointer;' : ''} + + &:hover { + ${hoverText} + } + + &:nth-child(even) { + background-color: ${theme.colors.accent_1}; + border-block: 1.5px solid ${theme.colors.accent_2}; + } + + &:nth-child(even):hover { + ${hoverText} + } + `; +}; export const fieldContentStyles = css` display: flex; From d67e0aa8bf4e722c462abdfadcffc7e00c270e04 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Fri, 20 Feb 2026 11:55:59 -0500 Subject: [PATCH 34/44] fix: align schema title and rows text --- packages/ui/src/theme/emotion/schemaNodeStyles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/theme/emotion/schemaNodeStyles.ts b/packages/ui/src/theme/emotion/schemaNodeStyles.ts index 539f34d5..6fd0ba10 100644 --- a/packages/ui/src/theme/emotion/schemaNodeStyles.ts +++ b/packages/ui/src/theme/emotion/schemaNodeStyles.ts @@ -101,7 +101,7 @@ export const nodeHeaderStyles = (theme: Theme) => css` ${theme.typography.subtitleSecondary} background: ${theme.colors.accent}; color: white; - padding: 16px 24px; + padding: 16px 12px; text-align: left; border-bottom: 1px solid black; letter-spacing: 0.05em; From 020ba8be10175d039963f5c92643dae762503f70 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Fri, 20 Feb 2026 11:57:47 -0500 Subject: [PATCH 35/44] fix: remove union with null --- .../EntityRelationshipDiagram/ActiveRelationshipContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx index 5b06e4c2..4941b6be 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx @@ -26,7 +26,7 @@ type ActiveRelationshipState = { edgeIds: Set; fieldKeys: Set; schemaChain: string[]; -} | null; +}; type ActiveRelationshipContextValue = { activeEdgeIds: Set | null; From d5bdcc5118d9adef424fff74a8420c970c39a20b Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Fri, 20 Feb 2026 11:58:58 -0500 Subject: [PATCH 36/44] fix: use optional fields instead of union with null --- .../ActiveRelationshipContext.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx index 4941b6be..16a9f3bb 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx @@ -29,11 +29,11 @@ type ActiveRelationshipState = { }; type ActiveRelationshipContextValue = { - activeEdgeIds: Set | null; - activeFieldKeys: Set | null; - activeSchemaNames: Set | null; - activeSchemaChain: string[] | null; - activateRelationship: (fkIndex: number) => void; + activeEdgeIds?: Set; + activeFieldKeys?: Set; + activeSchemaNames?: Set; + activeSchemaChain?: string[]; + activateRelationship: (chainStartingIndex: number) => void; deactivateRelationship: () => void; relationshipMap: RelationshipMap; isFieldInActiveRelationship: (schemaName: string, fieldName: string) => boolean; @@ -55,7 +55,7 @@ type ActiveRelationshipProviderProps = { * @param {ReactNode} children — Child components that can consume the active relationship context */ export function ActiveRelationshipProvider({ relationshipMap, children }: ActiveRelationshipProviderProps) { - const [activeState, setActiveState] = useState(null); + const [activeState, setActiveState] = useState(null); const activateRelationship = useCallback( (fkIndex: number) => { @@ -90,10 +90,10 @@ export function ActiveRelationshipProvider({ relationshipMap, children }: Active return ( Date: Fri, 20 Feb 2026 12:03:16 -0500 Subject: [PATCH 37/44] fix: replace one line if statements --- .../ActiveRelationshipContext.tsx | 12 ++++++++--- .../EntityRelationshipDiagram/diagramUtils.ts | 20 +++++++++++++------ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx index 16a9f3bb..23b0c06a 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx @@ -71,18 +71,24 @@ export function ActiveRelationshipProvider({ relationshipMap, children }: Active const isFieldInActiveRelationship = useCallback( (schemaName: string, fieldName: string): boolean => { - if (!activeState) return false; + if (!activeState) { + return false; + } return activeState.fieldKeys.has(`${schemaName}::${fieldName}`); }, [activeState], ); const activeSchemaNames = useMemo(() => { - if (!activeState) return null; + if (!activeState) { + return undefined; + } const names = new Set(); for (const key of activeState.fieldKeys) { const schemaName = key.split('::')[0]; - if (schemaName) names.add(schemaName); + if (schemaName) { + names.add(schemaName); + } } return names; }, [activeState]); diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index 5ede842c..ab8d199d 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -109,8 +109,9 @@ export function buildRelationshipMap(dictionary: Dictionary): RelationshipMap { }; dictionary.schemas.forEach((schema) => { - if (!schema.restrictions?.foreignKey) return; - + if (!schema.restrictions?.foreignKey) { + return; + } schema.restrictions.foreignKey.forEach((foreignKey) => { const fkIndex = fkRestrictions.length; const mappings: { localField: string; foreignField: string }[] = []; @@ -166,9 +167,12 @@ export function traceChain( const fieldKeys = new Set(); const visitedFkIndices = new Set(); - if (fkIndex < 0 || fkIndex >= map.fkRestrictions.length) return { edgeIds, fieldKeys, schemaChain: [] }; + if (chainStartingIndex < 0 || chainStartingIndex >= map.fkRestrictions.length) { + return { edgeIds, fieldKeys, schemaChain: [] }; + } - const collectFk = (index: number) => { + // Visit FK: Marks an FK restriction as visited and collects its edge IDs and field keys into the outer accumulators + const visitFk = (index: number) => { if (visitedFkIndices.has(index)) return; visitedFkIndices.add(index); const fk = map.fkRestrictions[index]; @@ -184,7 +188,9 @@ export function traceChain( const traceUp = (fk: FkRestrictionInfo) => { for (const foreignFieldKey of fk.foreignFieldKeys) { const indices = map.localFieldKeyToFkIndices.get(foreignFieldKey); - if (!indices) continue; + if (!indices) { + continue; + } for (const idx of indices) { if (!visitedFkIndices.has(idx)) { collectFk(idx); @@ -198,7 +204,9 @@ export function traceChain( const traceDown = (fk: FkRestrictionInfo) => { for (const localFieldKey of fk.localFieldKeys) { const indices = map.foreignFieldKeyToFkIndices.get(localFieldKey); - if (!indices) continue; + if (!indices) { + continue; + } for (const idx of indices) { if (!visitedFkIndices.has(idx)) { collectFk(idx); From cfdd84a66259c686bb2fcc313fe2199216a51ecc Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Fri, 20 Feb 2026 12:04:11 -0500 Subject: [PATCH 38/44] refactor: rename variable names for clarity --- .../ActiveRelationshipContext.tsx | 4 ++-- .../EntityRelationshipDiagram/diagramUtils.ts | 22 ++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx index 23b0c06a..15567a2e 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx @@ -58,8 +58,8 @@ export function ActiveRelationshipProvider({ relationshipMap, children }: Active const [activeState, setActiveState] = useState(null); const activateRelationship = useCallback( - (fkIndex: number) => { - const result = traceChain(fkIndex, relationshipMap); + (chainStartingIndex: number) => { + const result = traceChain(chainStartingIndex, relationshipMap); setActiveState(result); }, [relationshipMap], diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index ab8d199d..a69cbbbb 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -152,15 +152,15 @@ export function buildRelationshipMap(dictionary: Dictionary): RelationshipMap { } /** - * Traces the full FK chain from a clicked edge, following parent links upward + * Traces the full FK chain from a starting edge, following parent links upward * and child links downward to collect all connected edges and field keys. * - * @param {number} fkIndex — The index into fkRestrictions for the clicked FK + * @param {number} chainStartingIndex — The index into fkRestrictions for the FK that initiates the chain traversal * @param {RelationshipMap} map — The FK adjacency graph * @returns {{ edgeIds: Set, fieldKeys: Set, schemaChain: string[] }} All edges, fields, and schema names in the chain */ export function traceChain( - fkIndex: number, + chainStartingIndex: number, map: RelationshipMap, ): { edgeIds: Set; fieldKeys: Set; schemaChain: string[] } { const edgeIds = new Set(); @@ -173,16 +173,18 @@ export function traceChain( // Visit FK: Marks an FK restriction as visited and collects its edge IDs and field keys into the outer accumulators const visitFk = (index: number) => { - if (visitedFkIndices.has(index)) return; + if (visitedFkIndices.has(index)) { + return; + } visitedFkIndices.add(index); const fk = map.fkRestrictions[index]; fk.edgeIds.forEach((id) => edgeIds.add(id)); fk.fieldKeys.forEach((key) => fieldKeys.add(key)); }; - collectFk(fkIndex); + visitFk(chainStartingIndex); - const clickedFk = map.fkRestrictions[fkIndex]; + const chainStartingFk = map.fkRestrictions[chainStartingIndex]; // Trace UP: from foreign field keys, find FK restrictions where that field is the local side (parent's own FKs) const traceUp = (fk: FkRestrictionInfo) => { @@ -193,7 +195,7 @@ export function traceChain( } for (const idx of indices) { if (!visitedFkIndices.has(idx)) { - collectFk(idx); + visitFk(idx); traceUp(map.fkRestrictions[idx]); } } @@ -209,15 +211,15 @@ export function traceChain( } for (const idx of indices) { if (!visitedFkIndices.has(idx)) { - collectFk(idx); + visitFk(idx); traceDown(map.fkRestrictions[idx]); } } } }; - traceUp(clickedFk); - traceDown(clickedFk); + traceUp(chainStartingFk); + traceDown(chainStartingFk); const schemaNames = new Set(); for (const idx of visitedFkIndices) { From 6029fdb9a7e5406e92addb36519661cd5775929e Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Fri, 20 Feb 2026 12:04:27 -0500 Subject: [PATCH 39/44] fix: simplify conditional logic --- .../src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index a69cbbbb..777f39ba 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -256,7 +256,7 @@ export function getEdgesWithHighlight(edges: Edge[], activeEdgeIds: Set type: MarkerType.Arrow, width: 20, height: 20, - color: isActive ? (activeColor ?? '#374151') : '#374151', + color: isActive && activeColor ? activeColor : '#374151', }, }; }); From 3a6160ac0fa33c880d704b04bf9ec6d95a313c04 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Fri, 20 Feb 2026 12:05:25 -0500 Subject: [PATCH 40/44] fix: change from null to undefined for now optional fields --- .../src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx | 2 +- .../src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx index 6c38281f..3aec97dd 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx @@ -47,7 +47,7 @@ export function SchemaNode(props: { data: Schema }) { const theme = useThemeContext(); const { activateRelationship, isFieldInActiveRelationship, activeSchemaNames, relationshipMap } = useActiveRelationship(); - const isInactive = activeSchemaNames !== null && !activeSchemaNames.has(schema.name); + const isInactive = activeSchemaNames !== undefined && !activeSchemaNames.has(schema.name); return (
diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index 777f39ba..a0b7d13b 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -236,7 +236,7 @@ export function traceChain( * Active edges get 'edge-active', non-active edges get 'edge-inactive', * and when no relationship is active all edges have no className. */ -export function getEdgesWithHighlight(edges: Edge[], activeEdgeIds: Set | null, activeColor?: string): Edge[] { +export function getEdgesWithHighlight(edges: Edge[], activeEdgeIds?: Set, activeColor?: string): Edge[] { if (!activeEdgeIds) { return edges.map((edge) => ({ ...edge, From 84f1b7a7dbc98281c8af4535922c2e940e0ff0f8 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Fri, 20 Feb 2026 12:06:01 -0500 Subject: [PATCH 41/44] refactor: remove isEvenRow property from rows --- .../src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx index 3aec97dd..27e185a9 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx @@ -63,7 +63,6 @@ export function SchemaNode(props: { data: Schema }) { schema.restrictions?.foreignKey?.some((fk) => fk.mappings.some((mapping) => mapping.local === field.name), ) || false; - const isEvenRow = index % 2 === 1; const valueType = field.isArray ? `${field.valueType}[]` : field.valueType; const isHighlighted = isFieldInActiveRelationship(schema.name, field.name); @@ -81,7 +80,7 @@ export function SchemaNode(props: { data: Schema }) { return (
From 113110086d9a2dd17af514e051be1aa326e99eb5 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Tue, 24 Feb 2026 20:03:32 -0500 Subject: [PATCH 42/44] refactor: move styling for marker outside of function --- .../EntityRelationshipDiagram/diagramUtils.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index a0b7d13b..c22f2652 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -23,6 +23,13 @@ import type { Dictionary, Schema } from '@overture-stack/lectern-dictionary'; import { type Edge, type Node, MarkerType } from 'reactflow'; import { ONE_CARDINALITY_MARKER_ID, ONE_CARDINALITY_MARKER_ACTIVE_ID } from '../../theme/icons/OneCardinalityMarker'; +const DEFAULT_MARKER_CONFIG = { + type: MarkerType.Arrow, + width: 20, + height: 20, + color: '#374151', +}; + export type SchemaFlowNode = Node; export type SchemaNodeLayout = { @@ -242,7 +249,7 @@ export function getEdgesWithHighlight(edges: Edge[], activeEdgeIds?: Set ...edge, className: undefined, markerStart: ONE_CARDINALITY_MARKER_ID, - markerEnd: { type: MarkerType.Arrow, width: 20, height: 20, color: '#374151' }, + markerEnd: DEFAULT_MARKER_CONFIG, })); } @@ -252,12 +259,7 @@ export function getEdgesWithHighlight(edges: Edge[], activeEdgeIds?: Set ...edge, className: isActive ? 'edge-active' : 'edge-inactive', markerStart: isActive ? ONE_CARDINALITY_MARKER_ACTIVE_ID : ONE_CARDINALITY_MARKER_ID, - markerEnd: { - type: MarkerType.Arrow, - width: 20, - height: 20, - color: isActive && activeColor ? activeColor : '#374151', - }, + markerEnd: isActive && activeColor ? { ...DEFAULT_MARKER_CONFIG, color: activeColor } : DEFAULT_MARKER_CONFIG, }; }); } @@ -279,7 +281,7 @@ export function getEdgesFromMap(map: RelationshipMap): Edge[] { type: 'smoothstep', pathOptions: { offset: -20 }, data: { fkIndex } satisfies RelationshipEdgeData, - markerEnd: { type: MarkerType.Arrow, width: 20, height: 20, color: '#374151' }, + markerEnd: DEFAULT_MARKER_CONFIG, markerStart: ONE_CARDINALITY_MARKER_ID, })), ); From 46de40e7e22418965800c84d0b603ac88420100c Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Tue, 24 Feb 2026 20:04:30 -0500 Subject: [PATCH 43/44] refactor: remove unnecessary duplicate check on sets --- .../EntityRelationshipDiagram/diagramUtils.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index c22f2652..14f29510 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -179,14 +179,13 @@ export function traceChain( } // Visit FK: Marks an FK restriction as visited and collects its edge IDs and field keys into the outer accumulators - const visitFk = (index: number) => { - if (visitedFkIndices.has(index)) { - return; - } + const visitFk = (index: number): boolean => { + if (visitedFkIndices.has(index)) return false; visitedFkIndices.add(index); const fk = map.fkRestrictions[index]; fk.edgeIds.forEach((id) => edgeIds.add(id)); fk.fieldKeys.forEach((key) => fieldKeys.add(key)); + return true; }; visitFk(chainStartingIndex); @@ -201,8 +200,7 @@ export function traceChain( continue; } for (const idx of indices) { - if (!visitedFkIndices.has(idx)) { - visitFk(idx); + if (visitFk(idx)) { traceUp(map.fkRestrictions[idx]); } } @@ -217,8 +215,7 @@ export function traceChain( continue; } for (const idx of indices) { - if (!visitedFkIndices.has(idx)) { - visitFk(idx); + if (visitFk(idx)) { traceDown(map.fkRestrictions[idx]); } } From 27a6cb7eef21764d66f5eddd19f7b9e28ee8e865 Mon Sep 17 00:00:00 2001 From: Ethan Luc Date: Tue, 24 Feb 2026 20:06:34 -0500 Subject: [PATCH 44/44] refactor: use useMemo to prevent extra re-renders --- .../EntityRelationshipDiagram.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx index 80b6f59d..a1dd4ea7 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, useEffect } from 'react'; +import { useCallback, useMemo } from 'react'; import ReactFlow, { Background, BackgroundVariant, @@ -95,12 +95,13 @@ const edgeHoverStyles = (theme: Theme) => css` export function EntityRelationshipDiagramContent({ dictionary, layout }: EntityRelationshipDiagramProps) { const [nodes, , onNodesChange] = useNodesState(getNodesForDictionary(dictionary, layout)); const { activeEdgeIds, activateRelationship, deactivateRelationship, relationshipMap } = useActiveRelationship(); - const [edges, setEdges, onEdgesChange] = useEdgesState(getEdgesFromMap(relationshipMap)); + const [edges, , onEdgesChange] = useEdgesState(getEdgesFromMap(relationshipMap)); const theme = useThemeContext(); - useEffect(() => { - setEdges((currentEdges) => getEdgesWithHighlight(currentEdges, activeEdgeIds, theme.colors.secondary_dark)); - }, [activeEdgeIds, setEdges]); + const highlightedEdges = useMemo( + () => getEdgesWithHighlight(edges, activeEdgeIds, theme.colors.secondary_dark), + [edges, activeEdgeIds], + ); const onEdgeClick = useCallback( (_event: React.MouseEvent, edge: Edge) => { @@ -122,7 +123,7 @@ export function EntityRelationshipDiagramContent({ dictionary, layout }: EntityR