Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d968793
fix: update year to 2026
ethanluc7 Feb 3, 2026
3709c14
feat: add subtitle prop to modal component
ethanluc7 Feb 3, 2026
cc905ad
feat: create diagram view button
ethanluc7 Feb 3, 2026
1b0a3d5
feat: add dictionary view button to toolbar
ethanluc7 Feb 3, 2026
bd02d6b
fix: format fles
ethanluc7 Feb 3, 2026
f729b69
feat: implement entity relationship diagram
ethanluc7 Feb 10, 2026
2d61856
feat: add icons for diagram
ethanluc7 Feb 10, 2026
f840404
feat: add entity relationship diagram to diagram view button
ethanluc7 Feb 10, 2026
1479f36
feat: add entity relationship diagram story
ethanluc7 Feb 10, 2026
36476f5
Merge branch 'main' into 395-render-dictionary-schema
ethanluc7 Feb 10, 2026
2abb827
Merge branch 'main' into 395-render-dictionary-schema
ethanluc7 Feb 10, 2026
288d961
Merge branch '395-render-dictionary-schema' of https://github.com/ove…
ethanluc7 Feb 10, 2026
f66e39f
fix: removed unused imports
ethanluc7 Feb 10, 2026
d69ef67
fix: use empty dictionary instead of full dictionary
ethanluc7 Feb 10, 2026
1448234
feat: add separate file for schema node css
ethanluc7 Feb 11, 2026
e2ec2ed
fix: import types and components in one line
ethanluc7 Feb 11, 2026
6eba979
fix: remove spread
ethanluc7 Feb 11, 2026
7700a63
fix: remove type casting
ethanluc7 Feb 11, 2026
e083d73
fix: reduce height of modal to remove vertical scrolling on container
ethanluc7 Feb 11, 2026
04e0314
fix: remove unused type import
ethanluc7 Feb 11, 2026
ffb4767
refactor: remove max-height from nodes
ethanluc7 Feb 12, 2026
3a9efd9
docs: add docs for functions and props
ethanluc7 Feb 12, 2026
cb0e9d4
feat: add row hover states
ethanluc7 Feb 12, 2026
82b93f8
feat: add edge hover states
ethanluc7 Feb 12, 2026
0554d37
Merge branch 'main' into 396-field-edge-interactions
ethanluc7 Feb 12, 2026
a75c21e
feat: allow modal to take in reactnode as subtitle
ethanluc7 Feb 18, 2026
5c814cf
feat: add highlight states to schema node styles
ethanluc7 Feb 18, 2026
e53cac7
feat: add relationship provider to create relationship to determine h…
ethanluc7 Feb 18, 2026
2941455
feat: add diagram subtitle component to render scheam chain
ethanluc7 Feb 18, 2026
897ca27
feat: update schema node to activate / deactive edges on click
ethanluc7 Feb 18, 2026
f9c88f3
feat: add active and inactive marker
ethanluc7 Feb 18, 2026
f3d58d1
refactor: update index with new utils
ethanluc7 Feb 18, 2026
3ea658c
feat: add dictionaries for testing
ethanluc7 Feb 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/ui/src/common/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -117,7 +119,7 @@ const ModalComponent = ({ children, setIsOpen, isOpen, onAfterOpen, title, subti
<div css={headerStyle(theme)}>
<div>
<span css={titleStyle(theme)}>{title}</span>
{subtitle && <p css={subtitleStyle(theme)}>{subtitle}</p>}
{subtitle && <div css={subtitleStyle(theme)}>{subtitle}</div>}
</div>
<Button iconOnly onClick={() => setIsOpen(false)} icon={<Cancel />} />
</div>
Expand Down
20 changes: 13 additions & 7 deletions packages/ui/src/theme/emotion/schemaNodeStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using css selectors for alternating colours instead of JS props:
https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:nth-child

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`
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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`
Expand Down
16 changes: 15 additions & 1 deletion packages/ui/src/theme/icons/OneCardinalityMarker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<svg style={{ position: 'absolute', top: 0, left: 0 }}>
<defs>
Expand All @@ -43,6 +45,18 @@ const OneCardinalityMarker = ({ color = '#374151' }: OneCardinalityMarkerProps)
>
<line x1="0" y1="-6" x2="0" y2="6" stroke={color} strokeWidth="2" />
</marker>
<marker
id={ONE_CARDINALITY_MARKER_ACTIVE_ID}
markerWidth="20"
markerHeight="20"
viewBox="-10 -10 20 20"
markerUnits="userSpaceOnUse"
orient="auto-start-reverse"
refX="0"
refY="0"
>
<line x1="0" y1="-6" x2="0" y2="6" stroke={activeColor} strokeWidth="2" />
</marker>
</defs>
</svg>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*
* 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<string>;
fieldKeys: Set<string>;
schemaChain: string[];
} | null;
Comment on lines +25 to +29
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommend defining the type without the union to null. That way we can use this type to represent known data, and we can always present union with null if the value could be missing.


type ActiveRelationshipContextValue = {
activeEdgeIds: Set<string> | null;
activeFieldKeys: Set<string> | null;
activeSchemaNames: Set<string> | null;
activeSchemaChain: string[] | null;
Comment on lines +32 to +35
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why union with null instead of just using optional fields?

TS (and the underlying JS) is built to use undefined for missing values with syntax like: activeEdgeIds?: Set<string>

One reason to define it as you have done is if you want to ensure that any variable ActiveRelationshipContextValue has the property declared (even if the value is null). The syntax I suggest above would allow a developer to omit the property when the value is not available.

activateRelationship: (fkIndex: number, mappingIndex?: number) => void;
deactivateRelationship: () => void;
relationshipMap: RelationshipMap;
isFieldInActiveRelationship: (schemaName: string, fieldName: string) => boolean;
};

const ActiveRelationshipContext = createContext<ActiveRelationshipContextValue | null>(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<ActiveRelationshipState>(null);

const activateRelationship = useCallback(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i appreciate the usage of useCallback here 🔥

(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<string>();
for (const key of activeState.fieldKeys) {
const schemaName = key.split('::')[0];
if (schemaName) names.add(schemaName);
}
return names;
}, [activeState]);

return (
<ActiveRelationshipContext.Provider
value={{
activeEdgeIds: activeState?.edgeIds ?? null,
activeFieldKeys: activeState?.fieldKeys ?? null,
Comment on lines +93 to +94
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defining the type of activeEdgeIds as an optional field would simplify this assignment to not require the ?? null, without any loss of information.

activeSchemaNames,
activeSchemaChain: activeState?.schemaChain ?? null,
activateRelationship,
deactivateRelationship,
relationshipMap,
isFieldInActiveRelationship,
}}
>
{children}
</ActiveRelationshipContext.Provider>
);
}

/**
* 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -43,38 +54,91 @@ type EntityRelationshipDiagramProps = {
layout?: Partial<SchemaNodeLayout>;
};

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<SchemaNodeLayout>} layout — Optional overrides for the grid layout of schema nodes.
* maxColumns controls the number of nodes per row before wrapping (default 4),
* columnWidth sets horizontal spacing in pixels between column left edges (default 500),
* and rowHeight sets vertical spacing in pixels between row top edges (default 500)
*/
export function EntityRelationshipDiagram({ dictionary, layout }: EntityRelationshipDiagramProps) {
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 (
<>
<OneCardinalityMarker />
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 20, maxZoom: 1.5, minZoom: 0.5 }}
style={{ width: '100%', height: '100%' }}
defaultViewport={{ x: 0, y: 0, zoom: 1.0 }}
minZoom={0.1}
maxZoom={3}
>
<Controls />
<Background variant={BackgroundVariant.Lines} />
</ReactFlow>
<OneCardinalityMarker activeColor={theme.colors.secondary_dark} />
<div css={edgeStyles(theme)} style={{ width: '100%', height: '100%' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onEdgeClick={onEdgeClick}
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 20, maxZoom: 1.5, minZoom: 0.5 }}
style={{ width: '100%', height: '100%' }}
defaultViewport={{ x: 0, y: 0, zoom: 1.0 }}
minZoom={0.1}
maxZoom={3}
>
<Controls />
<Background variant={BackgroundVariant.Lines} />
</ReactFlow>
</div>
</>
);
}
Loading