glam/frontend/src/components/linkml/SchemaElementPopup.tsx
kempersc 7c7d8c0270 feat: Add SchemaElementPopup component for displaying LinkML schema element previews
- Implemented a draggable, resizable, and minimizable popup component for displaying previews of LinkML schema elements (classes, slots, enums).
- Integrated loading states and error handling for fetching element information.
- Added navigation functionality to go to full element view.
- Enhanced user experience with type badges and detailed descriptions for each element type.

chore: Migrate AudioEventSegment, BayNumber, BoxNumber, and BudgetStatus classes to new YAML schema format

- Created new YAML definitions for AudioEventSegment, BayNumber, BoxNumber, and BudgetStatus classes with detailed descriptions and attributes.
- Migrated from deprecated slots to new class structures as part of Rule 53.
- Updated imports and prefixes for consistency across schemas.

chore: Archive deprecated slots for audio_event_segments, bay_number, and box_number

- Archived previous slot definitions for audio_event_segments, bay_number, and box_number to maintain historical records.
- Updated slot descriptions and ensured proper URI mappings for future reference.
2026-01-14 15:13:06 +01:00

549 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SchemaElementPopup.tsx
*
* A popup component that displays LinkML schema element previews when clicking on
* class/slot/enum names in the Imports/Exports accordion sections.
*
* Features:
* - Draggable (drag from header)
* - Minimizable (collapse to title bar only)
* - Resizable (drag edges and corners)
* - Shows preview info for classes, slots, and enums
* - "Go to" button to navigate to full element view
*/
import React, { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react';
import { linkmlSchemaService } from '../../lib/linkml/linkml-schema-service';
import { isTargetInsideAny } from '../../utils/dom';
import './SchemaElementPopup.css';
export type SchemaElementType = 'class' | 'slot' | 'enum';
interface SchemaElementPopupProps {
/** The element name to look up */
elementName: string;
/** Type of element */
elementType: SchemaElementType;
/** Called when user wants to close the popup */
onClose: () => void;
/** Called when user clicks "Go to" button */
onNavigate: (elementName: string, elementType: SchemaElementType) => void;
/** Initial position (optional, defaults to center) */
initialPosition?: { x: number; y: number };
}
interface Position {
x: number;
y: number;
}
interface Size {
width: number;
height: number;
}
type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' | null;
// Minimum panel dimensions
const MIN_WIDTH = 300;
const MIN_HEIGHT = 150;
const DEFAULT_WIDTH = 400;
const DEFAULT_HEIGHT = 320;
/**
* Unified element info structure
*/
interface ElementInfo {
name: string;
type: SchemaElementType;
description?: string;
uri?: string;
parentClass?: string;
range?: string;
required?: boolean;
multivalued?: boolean;
slotCount?: number;
valueCount?: number;
mappings?: {
exact?: string[];
close?: string[];
related?: string[];
};
}
export const SchemaElementPopup: React.FC<SchemaElementPopupProps> = ({
elementName,
elementType,
onClose,
onNavigate,
initialPosition,
}) => {
const [elementInfo, setElementInfo] = useState<ElementInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Draggable state
const [position, setPosition] = useState<Position | null>(initialPosition || null);
const [isPositioned, setIsPositioned] = useState(!!initialPosition);
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<{ x: number; y: number; posX: number; posY: number } | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
// Minimized state
const [isMinimized, setIsMinimized] = useState(false);
// Resize state
const [size, setSize] = useState<Size>({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT });
const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState<ResizeDirection>(null);
const resizeStartRef = useRef<{
x: number;
y: number;
width: number;
height: number;
posX: number;
posY: number;
} | null>(null);
// Load element information
useEffect(() => {
const loadInfo = async () => {
setLoading(true);
setError(null);
try {
let info: ElementInfo | null = null;
if (elementType === 'class') {
const semanticInfo = await linkmlSchemaService.getClassSemanticInfo(elementName);
if (semanticInfo) {
info = {
name: semanticInfo.className,
type: 'class',
description: semanticInfo.description,
uri: semanticInfo.classUri,
parentClass: semanticInfo.parentClass,
slotCount: semanticInfo.slots?.length || 0,
mappings: semanticInfo.mappings,
};
}
} else if (elementType === 'slot') {
const slotInfo = await linkmlSchemaService.getSlotInfo(elementName);
if (slotInfo) {
info = {
name: slotInfo.name,
type: 'slot',
description: slotInfo.description,
uri: slotInfo.slotUri,
range: slotInfo.range,
required: slotInfo.required,
multivalued: slotInfo.multivalued,
};
}
} else if (elementType === 'enum') {
const enumInfo = await linkmlSchemaService.getEnumSemanticInfo(elementName);
if (enumInfo) {
info = {
name: enumInfo.enumName,
type: 'enum',
description: enumInfo.description,
// EnumSemanticInfo doesn't have URI - leave undefined
valueCount: enumInfo.values?.length || 0,
};
}
}
if (info) {
setElementInfo(info);
} else {
setError(`Could not find ${elementType} "${elementName}"`);
}
} catch (err) {
console.error('[SchemaElementPopup] Error loading element info:', err);
setError(`Failed to load: ${err}`);
} finally {
setLoading(false);
}
};
loadInfo();
}, [elementName, elementType]);
// Center the popup on initial load if no position provided
useLayoutEffect(() => {
if (!initialPosition && panelRef.current && !position) {
const rect = panelRef.current.getBoundingClientRect();
setPosition({
x: (window.innerWidth - rect.width) / 2,
y: (window.innerHeight - rect.height) / 3,
});
setIsPositioned(true);
}
}, [initialPosition, position]);
// Drag handlers
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (isTargetInsideAny(e.target, ['.schema-popup__close', '.schema-popup__minimize', '.schema-popup__navigate'])) {
return;
}
e.preventDefault();
setIsDragging(true);
const panel = panelRef.current;
if (panel) {
const rect = panel.getBoundingClientRect();
const currentX = position?.x ?? rect.left;
const currentY = position?.y ?? rect.top;
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
posX: currentX,
posY: currentY,
};
}
}, [position]);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isDragging || !dragStartRef.current) return;
const deltaX = e.clientX - dragStartRef.current.x;
const deltaY = e.clientY - dragStartRef.current.y;
const newX = dragStartRef.current.posX + deltaX;
const newY = dragStartRef.current.posY + deltaY;
const maxX = window.innerWidth - 100;
const maxY = window.innerHeight - 50;
setPosition({
x: Math.max(0, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY)),
});
}, [isDragging]);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
dragStartRef.current = null;
}, []);
// Add/remove global mouse event listeners for dragging
useEffect(() => {
if (isDragging) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
} else {
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isDragging, handleMouseMove, handleMouseUp]);
// Toggle minimize
const toggleMinimize = useCallback(() => {
setIsMinimized(prev => !prev);
}, []);
// Resize handlers
const handleResizeMouseDown = useCallback((direction: ResizeDirection) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
setResizeDirection(direction);
const panel = panelRef.current;
if (panel) {
const rect = panel.getBoundingClientRect();
resizeStartRef.current = {
x: e.clientX,
y: e.clientY,
width: size.width,
height: size.height,
posX: position?.x ?? rect.left,
posY: position?.y ?? rect.top,
};
}
}, [size, position]);
const handleResizeMouseMove = useCallback((e: MouseEvent) => {
if (!isResizing || !resizeStartRef.current || !resizeDirection) return;
const deltaX = e.clientX - resizeStartRef.current.x;
const deltaY = e.clientY - resizeStartRef.current.y;
let newWidth = resizeStartRef.current.width;
let newHeight = resizeStartRef.current.height;
let newX = resizeStartRef.current.posX;
let newY = resizeStartRef.current.posY;
if (resizeDirection.includes('e')) {
newWidth = Math.max(MIN_WIDTH, resizeStartRef.current.width + deltaX);
}
if (resizeDirection.includes('w')) {
const potentialWidth = resizeStartRef.current.width - deltaX;
if (potentialWidth >= MIN_WIDTH) {
newWidth = potentialWidth;
newX = resizeStartRef.current.posX + deltaX;
}
}
if (resizeDirection.includes('s')) {
newHeight = Math.max(MIN_HEIGHT, resizeStartRef.current.height + deltaY);
}
if (resizeDirection.includes('n')) {
const potentialHeight = resizeStartRef.current.height - deltaY;
if (potentialHeight >= MIN_HEIGHT) {
newHeight = potentialHeight;
newY = resizeStartRef.current.posY + deltaY;
}
}
setSize({ width: newWidth, height: newHeight });
if (resizeDirection.includes('w') || resizeDirection.includes('n')) {
setPosition({ x: newX, y: newY });
}
}, [isResizing, resizeDirection]);
const handleResizeMouseUp = useCallback(() => {
setIsResizing(false);
setResizeDirection(null);
resizeStartRef.current = null;
}, []);
// Add/remove global mouse event listeners for resizing
useEffect(() => {
if (isResizing) {
window.addEventListener('mousemove', handleResizeMouseMove);
window.addEventListener('mouseup', handleResizeMouseUp);
document.body.style.userSelect = 'none';
}
return () => {
window.removeEventListener('mousemove', handleResizeMouseMove);
window.removeEventListener('mouseup', handleResizeMouseUp);
if (!isDragging) {
document.body.style.userSelect = '';
}
};
}, [isResizing, handleResizeMouseMove, handleResizeMouseUp, isDragging]);
// Handle navigate button click
const handleNavigate = useCallback(() => {
onNavigate(elementName, elementType);
onClose();
}, [elementName, elementType, onNavigate, onClose]);
// Render type badge
const renderTypeBadge = (type: SchemaElementType) => {
const colors: Record<SchemaElementType, string> = {
'class': '#3B82F6', // Blue
'slot': '#10B981', // Green
'enum': '#F59E0B', // Amber
};
return (
<span
className="schema-popup__type-badge"
style={{ backgroundColor: colors[type] }}
>
{type}
</span>
);
};
// Calculate panel style
const panelStyle: React.CSSProperties = {
width: size.width,
height: isMinimized ? 'auto' : size.height,
visibility: isPositioned ? 'visible' : 'hidden',
...(position && {
position: 'fixed',
left: position.x,
top: position.y,
}),
};
return (
<div
ref={panelRef}
className={`schema-popup ${isMinimized ? 'schema-popup--minimized' : ''} ${isDragging ? 'schema-popup--dragging' : ''}`}
style={panelStyle}
>
{/* Header */}
<div
className="schema-popup__header"
onMouseDown={handleMouseDown}
>
<div className="schema-popup__title">
<code className="schema-popup__name">{elementName}</code>
{renderTypeBadge(elementType)}
</div>
<div className="schema-popup__controls">
<button
className="schema-popup__minimize"
onClick={toggleMinimize}
title={isMinimized ? 'Expand' : 'Minimize'}
>
{isMinimized ? '□' : ''}
</button>
<button
className="schema-popup__close"
onClick={onClose}
title="Close"
>
×
</button>
</div>
</div>
{/* Content */}
{!isMinimized && (
<div className="schema-popup__content">
{loading && (
<div className="schema-popup__loading">
Loading {elementType} information...
</div>
)}
{error && (
<div className="schema-popup__error">
{error}
</div>
)}
{!loading && !error && elementInfo && (
<>
{/* Description */}
{elementInfo.description && (
<div className="schema-popup__section">
<span className="schema-popup__section-label">Description</span>
<p className="schema-popup__description">{elementInfo.description}</p>
</div>
)}
{/* URI */}
{elementInfo.uri && (
<div className="schema-popup__section">
<span className="schema-popup__section-label">URI</span>
<code className="schema-popup__uri">{elementInfo.uri}</code>
</div>
)}
{/* Parent class (for classes) */}
{elementInfo.parentClass && (
<div className="schema-popup__section">
<span className="schema-popup__section-label">Parent Class</span>
<code className="schema-popup__value">{elementInfo.parentClass}</code>
</div>
)}
{/* Range (for slots) */}
{elementInfo.range && (
<div className="schema-popup__section">
<span className="schema-popup__section-label">Range</span>
<code className="schema-popup__value">{elementInfo.range}</code>
</div>
)}
{/* Properties (for slots) */}
{elementInfo.type === 'slot' && (
<div className="schema-popup__section schema-popup__properties">
{elementInfo.required !== undefined && (
<span className={`schema-popup__prop ${elementInfo.required ? 'schema-popup__prop--true' : ''}`}>
{elementInfo.required ? 'required' : 'optional'}
</span>
)}
{elementInfo.multivalued !== undefined && (
<span className={`schema-popup__prop ${elementInfo.multivalued ? 'schema-popup__prop--true' : ''}`}>
{elementInfo.multivalued ? 'multivalued' : 'single-valued'}
</span>
)}
</div>
)}
{/* Slot count (for classes) */}
{elementInfo.slotCount !== undefined && elementInfo.slotCount > 0 && (
<div className="schema-popup__section">
<span className="schema-popup__section-label">Slots</span>
<span className="schema-popup__count">{elementInfo.slotCount} slots</span>
</div>
)}
{/* Value count (for enums) */}
{elementInfo.valueCount !== undefined && elementInfo.valueCount > 0 && (
<div className="schema-popup__section">
<span className="schema-popup__section-label">Values</span>
<span className="schema-popup__count">{elementInfo.valueCount} permissible values</span>
</div>
)}
{/* Mappings (summary) */}
{elementInfo.mappings && (
(elementInfo.mappings.exact?.length ||
elementInfo.mappings.close?.length ||
elementInfo.mappings.related?.length) ? (
<div className="schema-popup__section">
<span className="schema-popup__section-label">Mappings</span>
<div className="schema-popup__mappings">
{elementInfo.mappings.exact?.length ? (
<span className="schema-popup__mapping-badge schema-popup__mapping-badge--exact">
{elementInfo.mappings.exact.length} exact
</span>
) : null}
{elementInfo.mappings.close?.length ? (
<span className="schema-popup__mapping-badge schema-popup__mapping-badge--close">
{elementInfo.mappings.close.length} close
</span>
) : null}
{elementInfo.mappings.related?.length ? (
<span className="schema-popup__mapping-badge schema-popup__mapping-badge--related">
{elementInfo.mappings.related.length} related
</span>
) : null}
</div>
</div>
) : null
)}
{/* Navigate button */}
<div className="schema-popup__actions">
<button
className="schema-popup__navigate"
onClick={handleNavigate}
>
Go to {elementType}
</button>
</div>
</>
)}
</div>
)}
{/* Resize handles */}
{!isMinimized && (
<>
<div className="schema-popup__resize schema-popup__resize--n" onMouseDown={handleResizeMouseDown('n')} />
<div className="schema-popup__resize schema-popup__resize--s" onMouseDown={handleResizeMouseDown('s')} />
<div className="schema-popup__resize schema-popup__resize--e" onMouseDown={handleResizeMouseDown('e')} />
<div className="schema-popup__resize schema-popup__resize--w" onMouseDown={handleResizeMouseDown('w')} />
<div className="schema-popup__resize schema-popup__resize--ne" onMouseDown={handleResizeMouseDown('ne')} />
<div className="schema-popup__resize schema-popup__resize--nw" onMouseDown={handleResizeMouseDown('nw')} />
<div className="schema-popup__resize schema-popup__resize--se" onMouseDown={handleResizeMouseDown('se')} />
<div className="schema-popup__resize schema-popup__resize--sw" onMouseDown={handleResizeMouseDown('sw')} />
</>
)}
</div>
);
};
export default SchemaElementPopup;