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 ( @@ -43,6 +45,18 @@ const OneCardinalityMarker = ({ color = '#374151' }: OneCardinalityMarkerProps) > + + + ); 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 ( <> - - {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