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}
}
diff --git a/packages/ui/src/theme/emotion/schemaNodeStyles.ts b/packages/ui/src/theme/emotion/schemaNodeStyles.ts
index 727478f5..6fd0ba10 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) => css`
- padding: 12px 12px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- transition: background-color 0.2s;
- position: relative;
- background-color: ${isEven ? theme.colors.accent_1 : 'transparent'};
- border-block: 1.5px solid ${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;
@@ -69,23 +83,25 @@ 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`
${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;
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 (
);
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..15567a2e
--- /dev/null
+++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx
@@ -0,0 +1,126 @@
+/*
+ *
+ * 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[];
+};
+
+type ActiveRelationshipContextValue = {
+ activeEdgeIds?: Set;
+ activeFieldKeys?: Set;
+ activeSchemaNames?: Set;
+ activeSchemaChain?: string[];
+ activateRelationship: (chainStartingIndex: 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(
+ (chainStartingIndex: number) => {
+ const result = traceChain(chainStartingIndex, relationshipMap);
+ 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 undefined;
+ }
+ 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..a1dd4ea7 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, useMemo } 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 = {
@@ -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,42 @@ 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, , onEdgesChange] = useEdgesState(getEdgesFromMap(relationshipMap));
const theme = useThemeContext();
+ const highlightedEdges = useMemo(
+ () => getEdgesWithHighlight(edges, activeEdgeIds, theme.colors.secondary_dark),
+ [edges, activeEdgeIds],
+ );
+
+ const onEdgeClick = useCallback(
+ (_event: React.MouseEvent, edge: Edge) => {
+ const edgeData = edge.data as RelationshipEdgeData | undefined;
+ if (edgeData?.fkIndex !== undefined) {
+ activateRelationship(edgeData.fkIndex);
+ }
+ },
+ [activateRelationship],
+ );
+
+ const onPaneClick = useCallback(() => {
+ deactivateRelationship();
+ }, [deactivateRelationship]);
+
return (
<>
-
+
+
{schema.name}
Schema
@@ -59,11 +63,26 @@ 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);
+
+ const handleFieldClick =
+ isForeignKey ?
+ () => {
+ const fieldKey = `${schema.name}::${field.name}`;
+ const fkIndices = relationshipMap.fieldKeyToFkIndices.get(fieldKey);
+ if (fkIndices?.[0] !== undefined) {
+ activateRelationship(fkIndices[0]);
+ }
+ }
+ : undefined;
return (
-
+
{field.name}
diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts
index bdf95ef1..14f29510 100644
--- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts
+++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts
@@ -21,7 +21,14 @@
import type { Dictionary, Schema } from '@overture-stack/lectern-dictionary';
import { type Edge, type Node, MarkerType } from 'reactflow';
-import { ONE_CARDINALITY_MARKER_ID } from '../../theme/icons/OneCardinalityMarker';
+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
;
@@ -39,6 +46,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 };
+
+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 +95,191 @@ 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 starting edge, following parent links upward
+ * and child links downward to collect all connected edges and field keys.
+ *
+ * @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(
+ chainStartingIndex: number,
+ map: RelationshipMap,
+): { edgeIds: Set; fieldKeys: Set; schemaChain: string[] } {
+ const edgeIds = new Set();
+ const fieldKeys = new Set();
+ const visitedFkIndices = new Set();
+
+ if (chainStartingIndex < 0 || chainStartingIndex >= map.fkRestrictions.length) {
+ return { edgeIds, fieldKeys, schemaChain: [] };
+ }
+
+ // Visit FK: Marks an FK restriction as visited and collects its edge IDs and field keys into the outer accumulators
+ 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);
+
+ 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) => {
+ for (const foreignFieldKey of fk.foreignFieldKeys) {
+ const indices = map.localFieldKeyToFkIndices.get(foreignFieldKey);
+ if (!indices) {
+ continue;
+ }
+ for (const idx of indices) {
+ if (visitFk(idx)) {
+ traceUp(map.fkRestrictions[idx]);
+ }
+ }
+ }
+ };
+
+ // Trace DOWN: from local field keys, find FK restrictions where that field is the foreign side (children pointing here)
+ const traceDown = (fk: FkRestrictionInfo) => {
+ for (const localFieldKey of fk.localFieldKeys) {
+ const indices = map.foreignFieldKeyToFkIndices.get(localFieldKey);
+ if (!indices) {
+ continue;
+ }
+ for (const idx of indices) {
+ if (visitFk(idx)) {
+ traceDown(map.fkRestrictions[idx]);
+ }
+ }
+ }
+ };
+
+ traceUp(chainStartingFk);
+ traceDown(chainStartingFk);
+
+ const schemaNames = new Set();
+ for (const idx of visitedFkIndices) {
+ const fk = map.fkRestrictions[idx];
+ schemaNames.add(fk.localSchema);
+ schemaNames.add(fk.foreignSchema);
+ }
+
+ return { edgeIds, fieldKeys, schemaChain: Array.from(schemaNames) };
+}
+
+/**
+ * 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, activeColor?: string): Edge[] {
+ if (!activeEdgeIds) {
+ return edges.map((edge) => ({
+ ...edge,
+ className: undefined,
+ markerStart: ONE_CARDINALITY_MARKER_ID,
+ markerEnd: DEFAULT_MARKER_CONFIG,
+ }));
+ }
+
+ 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: isActive && activeColor ? { ...DEFAULT_MARKER_CONFIG, color: activeColor } : DEFAULT_MARKER_CONFIG,
+ };
+ });
+}
+
+/**
+ * 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 } satisfies RelationshipEdgeData,
+ markerEnd: DEFAULT_MARKER_CONFIG,
+ markerStart: ONE_CARDINALITY_MARKER_ID,
+ })),
+ );
}
diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/index.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/index.ts
index 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';
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..ac5bd898
--- /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;
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 (
<>
} onClick={() => setIsOpen(true)} disabled={loading || errors.length > 0} isLoading={loading}>
Diagram View
-
- {selectedDictionary && (
-
-
-
- )}
-
+ {relationshipMap && (
+
+ }
+ isOpen={isOpen}
+ setIsOpen={setIsOpen}
+ >
+ {selectedDictionary && (
+
+
+
+ )}
+
+
+ )}
>
);
};
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