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 e8830f61..e3bc69b3 100644 --- a/packages/ui/src/theme/emotion/schemaNodeStyles.ts +++ b/packages/ui/src/theme/emotion/schemaNodeStyles.ts @@ -22,13 +22,21 @@ import { css } from '@emotion/react'; import type { Theme } from '../'; -export const fieldRowStyles = css` +export const fieldRowStyles = (theme: Theme, isForeignKey: boolean, isEven: boolean, isHighlighted: boolean = false) => css` padding: 12px 12px; display: flex; align-items: center; justify-content: space-between; transition: background-color 0.2s; position: relative; + background-color: ${isHighlighted ? theme.colors.secondary_1 : isEven ? '#e5edf3' : 'transparent'}; + border-block: 1.5px solid ${isHighlighted ? theme.colors.secondary_dark : isEven ? '#d4dce2' : 'transparent'}; + ${isForeignKey ? 'cursor: pointer;' : ''} + + &:hover { + background-color: ${isForeignKey ? theme.colors.secondary_1 : theme.colors.grey_3}; + border-block: 1.5px solid ${isForeignKey ? theme.colors.secondary_dark : theme.colors.grey_4}; + } `; export const fieldContentStyles = css` @@ -61,16 +69,18 @@ export const dataTypeBadgeStyles = (theme: Theme) => css` } `; -export const nodeContainerStyles = css` +export const nodeContainerStyles = (isInactive: boolean = false) => css` background: white; border: 1px solid black; border-radius: 8px; box-shadow: - 0 10px 20px -3px rgba(0, 0, 0, 0.30), + 0 10px 20px -3px rgba(0, 0, 0, 0.3), 0 4px 10px -2px rgba(0, 0, 0, 0.35); min-width: 280px; max-width: 350px; overflow: hidden; + transition: opacity 0.2s; + ${isInactive ? 'opacity: 0.4;' : ''} `; export const nodeHeaderStyles = (theme: Theme) => css` @@ -102,10 +112,6 @@ export const nodeSubtitleTextStyle = css` export const fieldsListStyles = css` background: #f8fafc; overflow-y: auto; - & > div:nth-child(even) { - background-color: #e5edf3; - border-block: 1.5px solid #d4dce2; - } `; export const fieldNameContainerStyles = css` diff --git a/packages/ui/src/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..b3b293b7 --- /dev/null +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx @@ -0,0 +1,120 @@ +/* + * + * Copyright (c) 2026 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + */ + +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'; +import { traceChain, type RelationshipMap } from './diagramUtils'; + +type ActiveRelationshipState = { + edgeIds: Set; + fieldKeys: Set; + schemaChain: string[]; +} | null; + +type ActiveRelationshipContextValue = { + activeEdgeIds: Set | null; + activeFieldKeys: Set | null; + activeSchemaNames: Set | null; + activeSchemaChain: string[] | null; + activateRelationship: (fkIndex: number, mappingIndex?: number) => void; + deactivateRelationship: () => void; + relationshipMap: RelationshipMap; + isFieldInActiveRelationship: (schemaName: string, fieldName: string) => boolean; +}; + +const ActiveRelationshipContext = createContext(null); + +type ActiveRelationshipProviderProps = { + relationshipMap: RelationshipMap; + children: ReactNode; +}; + +/** + * Provides active relationship state and actions to the ERD component tree. + * Wraps children with context that tracks which FK chain is currently highlighted, + * and exposes methods to activate/deactivate highlighting via traceChain. + * + * @param {RelationshipMap} relationshipMap — The FK adjacency graph used for chain tracing + * @param {ReactNode} children — Child components that can consume the active relationship context + */ +export function ActiveRelationshipProvider({ relationshipMap, children }: ActiveRelationshipProviderProps) { + const [activeState, setActiveState] = useState(null); + + const activateRelationship = useCallback( + (fkIndex: number, mappingIndex?: number) => { + const result = traceChain(fkIndex, relationshipMap, mappingIndex); + setActiveState(result); + }, + [relationshipMap], + ); + + const deactivateRelationship = useCallback(() => { + setActiveState(null); + }, []); + + const isFieldInActiveRelationship = useCallback( + (schemaName: string, fieldName: string): boolean => { + if (!activeState) return false; + return activeState.fieldKeys.has(`${schemaName}::${fieldName}`); + }, + [activeState], + ); + + const activeSchemaNames = useMemo(() => { + if (!activeState) return null; + const names = new Set(); + for (const key of activeState.fieldKeys) { + const schemaName = key.split('::')[0]; + if (schemaName) names.add(schemaName); + } + return names; + }, [activeState]); + + return ( + + {children} + + ); +} + +/** + * Consumes the active relationship context. Must be called from a component + * that is a descendant of ActiveRelationshipProvider. + * + * @returns {ActiveRelationshipContextValue} The current active relationship state and actions + */ +export function useActiveRelationship(): ActiveRelationshipContextValue { + const context = useContext(ActiveRelationshipContext); + if (!context) { + throw new Error('useActiveRelationship must be used within an ActiveRelationshipProvider'); + } + return context; +} diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx index 99657009..e951f812 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx @@ -20,18 +20,29 @@ */ /** @jsxImportSource @emotion/react */ +import { css } from '@emotion/react'; +import { type Theme, useThemeContext } from '../../theme'; import type { Dictionary } from '@overture-stack/lectern-dictionary'; +import { useCallback, useEffect } from 'react'; import ReactFlow, { Background, BackgroundVariant, Controls, useEdgesState, useNodesState, + type Edge, type NodeTypes, } from 'reactflow'; import 'reactflow/dist/style.css'; import OneCardinalityMarker from '../../theme/icons/OneCardinalityMarker'; -import { getEdgesForDictionary, getNodesForDictionary, type SchemaNodeLayout } from './diagramUtils'; +import { + getEdgesFromMap, + getEdgesWithHighlight, + getNodesForDictionary, + type RelationshipEdgeData, + type SchemaNodeLayout, +} from './diagramUtils'; +import { useActiveRelationship } from './ActiveRelationshipContext'; import { SchemaNode } from './SchemaNode'; const nodeTypes: NodeTypes = { @@ -43,8 +54,37 @@ type EntityRelationshipDiagramProps = { layout?: Partial; }; +const edgeStyles = (theme: Theme) => css` + .react-flow__edge { + cursor: pointer; + } + .react-flow__edge-path { + stroke: ${theme.colors.black}; + stroke-width: 2; + } + .react-flow__edge:hover .react-flow__edge-path { + stroke: ${theme.colors.secondary_dark}; + } + + .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. @@ -52,29 +92,53 @@ type EntityRelationshipDiagramProps = { * columnWidth sets horizontal spacing in pixels between column left edges (default 500), * and rowHeight sets vertical spacing in pixels between row top edges (default 500) */ -export function EntityRelationshipDiagram({ dictionary, layout }: EntityRelationshipDiagramProps) { +export function EntityRelationshipDiagramContent({ dictionary, layout }: EntityRelationshipDiagramProps) { const [nodes, , onNodesChange] = useNodesState(getNodesForDictionary(dictionary, layout)); - const [edges, , onEdgesChange] = useEdgesState(getEdgesForDictionary(dictionary)); + const { activeEdgeIds, activateRelationship, deactivateRelationship, relationshipMap } = useActiveRelationship(); + const [edges, setEdges, onEdgesChange] = useEdgesState(getEdgesFromMap(relationshipMap)); + const theme = useThemeContext(); + + useEffect(() => { + setEdges((currentEdges) => getEdgesWithHighlight(currentEdges, activeEdgeIds, theme.colors.secondary_dark)); + }, [activeEdgeIds, setEdges]); + + const onEdgeClick = useCallback( + (_event: React.MouseEvent, edge: Edge) => { + const edgeData = edge.data as RelationshipEdgeData | undefined; + if (edgeData?.fkIndex !== undefined) { + activateRelationship(edgeData.fkIndex, edgeData.mappingIndex); + } + }, + [activateRelationship], + ); + + const onPaneClick = useCallback(() => { + deactivateRelationship(); + }, [deactivateRelationship]); return ( <> - - - - - + +
+ + + + +
); } diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx index 036609f2..d6c5325a 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx @@ -40,13 +40,17 @@ import { targetHandleStyles, } from '../../theme/emotion/schemaNodeStyles'; import { createFieldHandleId } from './diagramUtils'; +import { useActiveRelationship } from './ActiveRelationshipContext'; export function SchemaNode(props: { data: Schema }) { const { data: schema } = props; const theme = useThemeContext(); + const { activateRelationship, isFieldInActiveRelationship, activeSchemaNames, relationshipMap } = + useActiveRelationship(); + const isInactive = activeSchemaNames !== null && !activeSchemaNames.has(schema.name); return ( -
+
{schema.name} Schema @@ -59,11 +63,30 @@ 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) { + const fkIndex = fkIndices[0]; + const fk = relationshipMap.fkRestrictions[fkIndex]; + const mappingIdx = fk.mappings.findIndex((m) => m.localField === field.name); + activateRelationship(fkIndex, mappingIdx !== -1 ? mappingIdx : undefined); + } + } + : undefined; return ( -
+
{field.name}
diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts index 2a79af6a..41ef6d7b 100644 --- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts +++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts @@ -21,7 +21,7 @@ import type { Dictionary, Schema } from '@overture-stack/lectern-dictionary'; import { type Edge, type Node, MarkerType } from 'reactflow'; -import { ONE_CARDINALITY_MARKER_ID } from '../../theme/icons/OneCardinalityMarker'; +import { ONE_CARDINALITY_MARKER_ID, ONE_CARDINALITY_MARKER_ACTIVE_ID } from '../../theme/icons/OneCardinalityMarker'; export type SchemaFlowNode = Node; @@ -39,6 +39,28 @@ function buildSchemaNode(schema: Schema): Omit { }; } +export const createFieldHandleId = (schemaName: string, fieldName: string, type: 'source' | 'target'): string => + `${schemaName}-${fieldName}-${type}`; + +type FkRestrictionInfo = { + localSchema: string; + foreignSchema: string; + mappings: { localField: string; foreignField: string }[]; + edgeIds: string[]; + fieldKeys: string[]; + localFieldKeys: string[]; + foreignFieldKeys: string[]; +}; + +export type RelationshipEdgeData = { fkIndex: number; mappingIndex: number }; + +export type RelationshipMap = { + fkRestrictions: FkRestrictionInfo[]; + localFieldKeyToFkIndices: Map; + foreignFieldKeyToFkIndices: Map; + fieldKeyToFkIndices: Map; +}; + /** * Converts a dictionary's schemas into positioned ReactFlow nodes arranged in a grid layout. * @@ -66,39 +88,246 @@ export function getNodesForDictionary(dictionary: Dictionary, layout?: Partial - `${schemaName}-${fieldName}-${type}`; - /** - * Converts a dictionary's foreign key relationships into ReactFlow edges connecting schema nodes. + * Builds an FK adjacency graph from the dictionary's foreign key restrictions. + * Each FK restriction is indexed, and adjacency maps allow tracing chains + * up (child→parent) and down (parent→child). * * @param {Dictionary} dictionary — The Lectern dictionary containing schemas with foreign key restrictions - * @returns {Edge[]} Array of ReactFlow edges representing foreign key relationships + * @returns {RelationshipMap} FK adjacency graph for chain tracing */ -export function getEdgesForDictionary(dictionary: Dictionary): Edge[] { - return dictionary.schemas.flatMap((schema) => { - if (!schema.restrictions?.foreignKey) return []; - - return schema.restrictions.foreignKey.flatMap((foreignKey) => { - return foreignKey.mappings.map((mapping) => ({ - id: `${schema.name}-${mapping.local}-to-${foreignKey.schema}-${mapping.foreign}`, - source: foreignKey.schema, - sourceHandle: createFieldHandleId(foreignKey.schema, mapping.foreign, 'source'), - target: schema.name, - targetHandle: createFieldHandleId(schema.name, mapping.local, 'target'), - type: 'smoothstep', - style: { stroke: '#374151', strokeWidth: 2 }, - pathOptions: { - offset: -20, - }, - markerEnd: { - type: MarkerType.Arrow, - width: 20, - height: 20, - color: '#374151', - }, - markerStart: ONE_CARDINALITY_MARKER_ID, - })); +export function buildRelationshipMap(dictionary: Dictionary): RelationshipMap { + const fkRestrictions: FkRestrictionInfo[] = []; + const localFieldKeyToFkIndices = new Map(); + const foreignFieldKeyToFkIndices = new Map(); + const fieldKeyToFkIndices = new Map(); + + const addToList = (map: Map, key: string, index: number) => { + const existing = map.get(key) ?? []; + existing.push(index); + map.set(key, existing); + }; + + dictionary.schemas.forEach((schema) => { + if (!schema.restrictions?.foreignKey) return; + + schema.restrictions.foreignKey.forEach((foreignKey) => { + const fkIndex = fkRestrictions.length; + const mappings: { localField: string; foreignField: string }[] = []; + const edgeIds: string[] = []; + const fieldKeys: string[] = []; + const localFieldKeys: string[] = []; + const foreignFieldKeys: string[] = []; + + foreignKey.mappings.forEach((mapping) => { + const edgeId = `${schema.name}-${mapping.local}-to-${foreignKey.schema}-${mapping.foreign}`; + mappings.push({ localField: mapping.local, foreignField: mapping.foreign }); + edgeIds.push(edgeId); + + const localKey = `${schema.name}::${mapping.local}`; + const foreignKey_ = `${foreignKey.schema}::${mapping.foreign}`; + fieldKeys.push(localKey, foreignKey_); + localFieldKeys.push(localKey); + foreignFieldKeys.push(foreignKey_); + addToList(fieldKeyToFkIndices, localKey, fkIndex); + addToList(fieldKeyToFkIndices, foreignKey_, fkIndex); + addToList(localFieldKeyToFkIndices, localKey, fkIndex); + addToList(foreignFieldKeyToFkIndices, foreignKey_, fkIndex); + }); + + fkRestrictions.push({ + localSchema: schema.name, + foreignSchema: foreignKey.schema, + mappings, + edgeIds, + fieldKeys, + localFieldKeys, + foreignFieldKeys, + }); }); }); + + return { fkRestrictions, localFieldKeyToFkIndices, foreignFieldKeyToFkIndices, fieldKeyToFkIndices }; +} + +/** + * Traces the full FK chain from a clicked edge or field, following parent links upward + * and child links downward to collect all connected edges and field keys. + * + * When `mappingIndex` is provided (edge click), only the specific mapping's edge and + * field pair are collected, and chains are traced through individual fields. + * When `mappingIndex` is omitted (field click), all mappings of the FK are collected. + * + * @param {number} fkIndex — The index into fkRestrictions for the clicked FK + * @param {RelationshipMap} map — The FK adjacency graph + * @param {number} [mappingIndex] — Optional index of the specific mapping within the FK + * @returns {{ edgeIds: Set, fieldKeys: Set, schemaChain: string[] }} All edges and fields in the chain + */ +export function traceChain( + fkIndex: number, + map: RelationshipMap, + mappingIndex?: number, +): { edgeIds: Set; fieldKeys: Set; schemaChain: string[] } { + const edgeIds = new Set(); + const fieldKeys = new Set(); + const visitedEdges = new Set(); + + if (fkIndex < 0 || fkIndex >= map.fkRestrictions.length) return { edgeIds, fieldKeys, schemaChain: [] }; + + const collectMapping = (fkIdx: number, mapIdx: number) => { + const fk = map.fkRestrictions[fkIdx]; + const edgeId = fk.edgeIds[mapIdx]; + if (!edgeId || visitedEdges.has(edgeId)) return; + visitedEdges.add(edgeId); + edgeIds.add(edgeId); + + const mapping = fk.mappings[mapIdx]; + const localKey = `${fk.localSchema}::${mapping.localField}`; + const foreignKey = `${fk.foreignSchema}::${mapping.foreignField}`; + fieldKeys.add(localKey); + fieldKeys.add(foreignKey); + }; + + const collectAllMappings = (fkIdx: number) => { + const fk = map.fkRestrictions[fkIdx]; + for (let i = 0; i < fk.mappings.length; i++) { + collectMapping(fkIdx, i); + } + }; + + // Trace a single field key in a direction, finding which specific mapping in a connected FK uses that field + const traceFieldUp = (foreignFieldKey: string) => { + const indices = map.localFieldKeyToFkIndices.get(foreignFieldKey); + if (!indices) return; + for (const idx of indices) { + const fk = map.fkRestrictions[idx]; + for (let i = 0; i < fk.mappings.length; i++) { + const localKey = `${fk.localSchema}::${fk.mappings[i].localField}`; + if (localKey === foreignFieldKey) { + const edgeId = fk.edgeIds[i]; + if (!visitedEdges.has(edgeId)) { + collectMapping(idx, i); + const fKey = `${fk.foreignSchema}::${fk.mappings[i].foreignField}`; + traceFieldUp(fKey); + } + } + } + } + }; + + const traceFieldDown = (localFieldKey: string) => { + const indices = map.foreignFieldKeyToFkIndices.get(localFieldKey); + if (!indices) return; + for (const idx of indices) { + const fk = map.fkRestrictions[idx]; + for (let i = 0; i < fk.mappings.length; i++) { + const foreignKey = `${fk.foreignSchema}::${fk.mappings[i].foreignField}`; + if (foreignKey === localFieldKey) { + const edgeId = fk.edgeIds[i]; + if (!visitedEdges.has(edgeId)) { + collectMapping(idx, i); + const lKey = `${fk.localSchema}::${fk.mappings[i].localField}`; + traceFieldDown(lKey); + } + } + } + } + }; + + const clickedFk = map.fkRestrictions[fkIndex]; + + if (mappingIndex !== undefined) { + // Edge click: collect only the specific mapping, trace through individual fields + collectMapping(fkIndex, mappingIndex); + const mapping = clickedFk.mappings[mappingIndex]; + const foreignKey = `${clickedFk.foreignSchema}::${mapping.foreignField}`; + const localKey = `${clickedFk.localSchema}::${mapping.localField}`; + traceFieldUp(foreignKey); + traceFieldDown(localKey); + } else { + // Field click: collect all mappings of the FK, trace through all fields + collectAllMappings(fkIndex); + for (const foreignFieldKey of clickedFk.foreignFieldKeys) { + traceFieldUp(foreignFieldKey); + } + for (const localFieldKey of clickedFk.localFieldKeys) { + traceFieldDown(localFieldKey); + } + } + + const visitedFkIndices = new Set(); + for (const edgeId of visitedEdges) { + for (let i = 0; i < map.fkRestrictions.length; i++) { + if (map.fkRestrictions[i].edgeIds.includes(edgeId)) { + visitedFkIndices.add(i); + } + } + } + const schemaChain = buildSchemaChain(visitedFkIndices, map); + + return { edgeIds, fieldKeys, schemaChain }; +} + +function buildSchemaChain(visitedFkIndices: Set, map: RelationshipMap): string[] { + const schemas = new Set(); + for (const idx of visitedFkIndices) { + const fk = map.fkRestrictions[idx]; + schemas.add(fk.foreignSchema); + schemas.add(fk.localSchema); + } + return Array.from(schemas); +} + +/** + * Returns a new edges array with className set based on the active edge set. + * Active edges get 'edge-active', non-active edges get 'edge-inactive', + * and when no relationship is active all edges have no className. + */ +export function getEdgesWithHighlight(edges: Edge[], activeEdgeIds: Set | null, activeColor?: string): Edge[] { + if (!activeEdgeIds) { + return edges.map((edge) => ({ + ...edge, + className: undefined, + markerStart: ONE_CARDINALITY_MARKER_ID, + markerEnd: { type: MarkerType.Arrow, width: 20, height: 20, color: '#374151' }, + })); + } + + return edges.map((edge) => { + const isActive = activeEdgeIds.has(edge.id); + return { + ...edge, + className: isActive ? 'edge-active' : 'edge-inactive', + markerStart: isActive ? ONE_CARDINALITY_MARKER_ACTIVE_ID : ONE_CARDINALITY_MARKER_ID, + markerEnd: { + type: MarkerType.Arrow, + width: 20, + height: 20, + color: isActive ? (activeColor ?? '#374151') : '#374151', + }, + }; + }); +} + +/** + * Derives ReactFlow edges from the relationship map, attaching fkIndex to each edge's data + * + * @param {RelationshipMap} map — The FK adjacency graph built by buildRelationshipMap + * @returns {Edge[]} Array of ReactFlow edges representing foreign key relationships + */ +export function getEdgesFromMap(map: RelationshipMap): Edge[] { + return map.fkRestrictions.flatMap((fk, fkIndex) => + fk.mappings.map((mapping, i) => ({ + id: fk.edgeIds[i], + source: fk.foreignSchema, + sourceHandle: createFieldHandleId(fk.foreignSchema, mapping.foreignField, 'source'), + target: fk.localSchema, + targetHandle: createFieldHandleId(fk.localSchema, mapping.localField, 'target'), + type: 'smoothstep', + pathOptions: { offset: -20 }, + data: { fkIndex, mappingIndex: i } satisfies RelationshipEdgeData, + markerEnd: { type: MarkerType.Arrow, width: 20, height: 20, color: '#374151' }, + markerStart: ONE_CARDINALITY_MARKER_ID, + })), + ); } diff --git a/packages/ui/src/viewer-table/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..02f347b7 --- /dev/null +++ b/packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx @@ -0,0 +1,97 @@ +/* + * + * Copyright (c) 2026 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + */ + +/** @jsxImportSource @emotion/react */ + +import { css } from '@emotion/react'; +import { useActiveRelationship } from '../EntityRelationshipDiagram'; +import { useThemeContext, type Theme } from '../../theme/index'; + +const pillStyle = (theme: Theme) => css` + display: inline-block; + border: 1px solid black; + border-radius: 12px; + padding: 2px 10px; + ${theme.typography.data}; + color: black; +`; + +const arrowStyle = css` + margin: 0 6px; +`; + +const clearButtonStyle = (theme: Theme) => css` + background: none; + border: none; + cursor: pointer; + color: black; + ${theme.typography.data}; + text-decoration: underline; + padding: 0; + margin-top: 4px; +`; + +const chainRowStyle = css` + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 2px; +`; + +const chainItemStyle = css` + display: inline-flex; + align-items: center; +`; + +const chainLabelStyle = css` + font-weight: bold; + padding-right: 5px; +`; + +const DiagramSubtitle = () => { + const { activeSchemaChain, deactivateRelationship } = useActiveRelationship(); + const theme = useThemeContext(); + + if (!activeSchemaChain) { + return Select any key field or edge to highlight a relation.; + } + + return ( +
+
+ Highlighting schema relation: + {activeSchemaChain.map((schema, index) => ( + + {index > 0 && {'\u2192'}} + {schema} + + ))} +
+
+ +
+
+ ); +}; + +export default DiagramSubtitle; 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