feat(linkml): Add slot_usage comparison popup in schema viewer

- Add 'slot_usage' type to SchemaElementPopup for comparing generic slots vs class overrides
- Show side-by-side comparison table with property, generic value, and override value
- Display green 'changed' badges for modified properties
- Add dual navigation buttons (Go to class / Go to slot)
- Include comprehensive dark mode support
- Match styling to main page's comparison view (green color scheme)
This commit is contained in:
kempersc 2026-01-14 19:55:57 +01:00
parent 853419d6c2
commit a981bb7ca3
4 changed files with 466 additions and 11 deletions

View file

@ -477,3 +477,214 @@
font-size: 24px; font-size: 24px;
} }
} }
/* ========================================
Slot Usage Comparison View Styles
Matches LinkMLViewerPage.css formatting
======================================== */
/* Overrides list - shown at top of comparison view */
.schema-popup__overrides-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 4px;
}
.schema-popup__override-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
background: #e8f5e9;
color: #388e3c;
border: 1px solid #388e3c;
}
/* Comparison panel container - matches main page */
.schema-popup__slot-comparison {
margin-top: 0.5rem;
border: 1px solid #388e3c;
border-radius: 8px;
overflow: hidden;
background: white;
}
/* Comparison header with three columns */
.schema-popup__slot-comparison-header {
display: grid;
grid-template-columns: 90px 1fr 1fr;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #e8f5e9;
border-bottom: 1px solid #388e3c;
font-weight: 600;
font-size: 11px;
color: #388e3c;
}
.schema-popup__slot-comparison-header h5 {
margin: 0;
font-size: 11px;
font-weight: 600;
}
/* Comparison content rows */
.schema-popup__slot-comparison-content {
padding: 0.25rem 0;
}
.schema-popup__slot-comparison-row {
display: grid;
grid-template-columns: 90px 1fr 1fr;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
font-size: 11px;
border-bottom: 1px solid #e0e0e0;
align-items: start;
}
.schema-popup__slot-comparison-row:last-child {
border-bottom: none;
}
.schema-popup__slot-comparison-row--changed {
background: rgba(56, 142, 60, 0.05);
}
/* Property label column */
.schema-popup__slot-comparison-label {
font-weight: 500;
color: #666;
font-size: 10px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
}
/* Generic value column */
.schema-popup__slot-comparison-generic {
word-break: break-word;
}
.schema-popup__slot-comparison-generic code,
.schema-popup__slot-comparison-override code {
background: #f5f5f5;
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 10px;
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
/* Override value column */
.schema-popup__slot-comparison-override {
word-break: break-word;
display: flex;
align-items: flex-start;
gap: 0.375rem;
flex-wrap: wrap;
}
.schema-popup__slot-comparison-override--diff code {
background: #e8f5e9;
border: 1px solid #388e3c;
}
/* Diff badge */
.schema-popup__slot-comparison-diff-badge {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
background: #388e3c;
color: white;
padding: 0.125rem 0.25rem;
border-radius: 3px;
white-space: nowrap;
}
/* Empty/inherited states */
.schema-popup__slot-comparison-empty,
.schema-popup__slot-comparison-inherited {
font-style: italic;
color: #999;
font-size: 10px;
}
/* Dual action buttons */
.schema-popup__actions--dual {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.schema-popup__actions--dual .schema-popup__navigate {
flex: 1;
min-width: 100px;
justify-content: center;
font-size: 12px;
padding: 6px 12px;
}
.schema-popup__navigate--secondary {
background: linear-gradient(135deg, #059669 0%, #10b981 100%) !important;
}
.schema-popup__navigate--secondary:hover {
background: linear-gradient(135deg, #047857 0%, #059669 100%) !important;
}
/* Dark mode for slot_usage comparison */
[data-theme="dark"] .schema-popup__override-badge {
background: rgba(56, 142, 60, 0.15);
color: #86efac;
border-color: #4ade80;
}
[data-theme="dark"] .schema-popup__slot-comparison {
border-color: #4ade80;
background: #1e293b;
}
[data-theme="dark"] .schema-popup__slot-comparison-header {
background: rgba(56, 142, 60, 0.15);
color: #86efac;
border-bottom-color: #4ade80;
}
[data-theme="dark"] .schema-popup__slot-comparison-row {
border-bottom-color: #334155;
}
[data-theme="dark"] .schema-popup__slot-comparison-row--changed {
background: rgba(56, 142, 60, 0.1);
}
[data-theme="dark"] .schema-popup__slot-comparison-label {
color: #94a3b8;
}
[data-theme="dark"] .schema-popup__slot-comparison-generic code,
[data-theme="dark"] .schema-popup__slot-comparison-override code {
background: #334155;
color: #e2e8f0;
}
[data-theme="dark"] .schema-popup__slot-comparison-override--diff code {
background: rgba(56, 142, 60, 0.15);
border-color: #4ade80;
color: #86efac;
}
[data-theme="dark"] .schema-popup__slot-comparison-diff-badge {
background: #4ade80;
color: #1e293b;
}
[data-theme="dark"] .schema-popup__slot-comparison-empty,
[data-theme="dark"] .schema-popup__slot-comparison-inherited {
color: #64748b;
}

View file

@ -17,7 +17,7 @@ import { linkmlSchemaService } from '../../lib/linkml/linkml-schema-service';
import { isTargetInsideAny } from '../../utils/dom'; import { isTargetInsideAny } from '../../utils/dom';
import './SchemaElementPopup.css'; import './SchemaElementPopup.css';
export type SchemaElementType = 'class' | 'slot' | 'enum'; export type SchemaElementType = 'class' | 'slot' | 'enum' | 'slot_usage';
interface SchemaElementPopupProps { interface SchemaElementPopupProps {
/** The element name to look up */ /** The element name to look up */
@ -30,6 +30,10 @@ interface SchemaElementPopupProps {
onNavigate: (elementName: string, elementType: SchemaElementType) => void; onNavigate: (elementName: string, elementType: SchemaElementType) => void;
/** Initial position (optional, defaults to center) */ /** Initial position (optional, defaults to center) */
initialPosition?: { x: number; y: number }; initialPosition?: { x: number; y: number };
/** For slot_usage type: the slot name being overridden */
slotName?: string;
/** For slot_usage type: list of overridden properties */
overrides?: string[];
} }
interface Position { interface Position {
@ -71,14 +75,44 @@ interface ElementInfo {
}; };
} }
/**
* Slot usage comparison info - shows how a class overrides a generic slot
*/
interface SlotUsageComparisonInfo {
slotName: string;
className: string;
overrides: string[];
genericSlot: {
description?: string;
range?: string;
required?: boolean;
multivalued?: boolean;
slot_uri?: string;
pattern?: string;
identifier?: boolean;
};
slotUsage: {
description?: string;
range?: string;
required?: boolean;
multivalued?: boolean;
slot_uri?: string;
pattern?: string;
identifier?: boolean;
};
}
export const SchemaElementPopup: React.FC<SchemaElementPopupProps> = ({ export const SchemaElementPopup: React.FC<SchemaElementPopupProps> = ({
elementName, elementName,
elementType, elementType,
onClose, onClose,
onNavigate, onNavigate,
initialPosition, initialPosition,
slotName,
overrides,
}) => { }) => {
const [elementInfo, setElementInfo] = useState<ElementInfo | null>(null); const [elementInfo, setElementInfo] = useState<ElementInfo | null>(null);
const [slotUsageComparison, setSlotUsageComparison] = useState<SlotUsageComparisonInfo | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -151,6 +185,49 @@ export const SchemaElementPopup: React.FC<SchemaElementPopupProps> = ({
valueCount: enumInfo.values?.length || 0, valueCount: enumInfo.values?.length || 0,
}; };
} }
} else if (elementType === 'slot_usage' && slotName) {
// For slot_usage comparison: load both generic slot definition and class's slot_usage
const [genericSlotDef, classDef] = await Promise.all([
linkmlSchemaService.getSlotDefinition(slotName),
linkmlSchemaService.getClassDefinition(elementName),
]);
if (genericSlotDef && classDef) {
const slotUsage = classDef.slot_usage?.[slotName] || {};
setSlotUsageComparison({
slotName,
className: elementName,
overrides: overrides || [],
genericSlot: {
description: genericSlotDef.description,
range: genericSlotDef.range,
required: genericSlotDef.required,
multivalued: genericSlotDef.multivalued,
slot_uri: genericSlotDef.slot_uri,
pattern: genericSlotDef.pattern,
identifier: genericSlotDef.identifier,
},
slotUsage: {
description: slotUsage.description,
range: slotUsage.range,
required: slotUsage.required,
multivalued: slotUsage.multivalued,
slot_uri: slotUsage.slot_uri,
pattern: slotUsage.pattern,
identifier: slotUsage.identifier,
},
});
// Set a minimal info object for the slot_usage case
info = {
name: `${elementName}.${slotName}`,
type: 'slot_usage',
description: slotUsage.description || genericSlotDef.description,
};
} else {
setError(`Could not find slot "${slotName}" or class "${elementName}"`);
}
} }
if (info) { if (info) {
@ -167,7 +244,7 @@ export const SchemaElementPopup: React.FC<SchemaElementPopupProps> = ({
}; };
loadInfo(); loadInfo();
}, [elementName, elementType]); }, [elementName, elementType, slotName, overrides]);
// Center the popup on initial load if no position provided // Center the popup on initial load if no position provided
useLayoutEffect(() => { useLayoutEffect(() => {
@ -350,13 +427,15 @@ export const SchemaElementPopup: React.FC<SchemaElementPopupProps> = ({
'class': '#3B82F6', // Blue 'class': '#3B82F6', // Blue
'slot': '#10B981', // Green 'slot': '#10B981', // Green
'enum': '#F59E0B', // Amber 'enum': '#F59E0B', // Amber
'slot_usage': '#8B5CF6', // Purple for slot_usage comparison
}; };
const displayType = type === 'slot_usage' ? 'slot override' : type;
return ( return (
<span <span
className="schema-popup__type-badge" className="schema-popup__type-badge"
style={{ backgroundColor: colors[type] }} style={{ backgroundColor: colors[type] }}
> >
{type} {displayType}
</span> </span>
); );
}; };
@ -423,14 +502,130 @@ export const SchemaElementPopup: React.FC<SchemaElementPopupProps> = ({
{!loading && !error && elementInfo && ( {!loading && !error && elementInfo && (
<> <>
{/* Description */} {/* Slot Usage Comparison View - matches LinkMLViewerPage format */}
{elementInfo.description && ( {elementType === 'slot_usage' && slotUsageComparison && (
<div className="schema-popup__section"> <>
<span className="schema-popup__section-label">Description</span> {/* Header info - class and slot names */}
<p className="schema-popup__description">{elementInfo.description}</p> <div className="schema-popup__section">
</div> <span className="schema-popup__section-label">Class</span>
<code className="schema-popup__value">{slotUsageComparison.className}</code>
</div>
<div className="schema-popup__section">
<span className="schema-popup__section-label">Slot</span>
<code className="schema-popup__value">{slotUsageComparison.slotName}</code>
</div>
{/* Overridden properties summary as badges */}
{slotUsageComparison.overrides.length > 0 && (
<div className="schema-popup__section">
<span className="schema-popup__section-label">Overridden Properties</span>
<div className="schema-popup__overrides-list">
{slotUsageComparison.overrides.map(prop => (
<span key={prop} className="schema-popup__override-badge">{prop}</span>
))}
</div>
</div>
)}
{/* Side-by-side comparison - uses same structure as main page */}
<div className="schema-popup__slot-comparison">
<div className="schema-popup__slot-comparison-header">
<span></span>
<h5>Generic Slot</h5>
<h5>Class Override</h5>
</div>
<div className="schema-popup__slot-comparison-content">
{/* Compare each property */}
{(['range', 'description', 'required', 'multivalued', 'slot_uri', 'pattern', 'identifier'] as const).map(prop => {
const genericVal = slotUsageComparison.genericSlot[prop];
const overrideVal = slotUsageComparison.slotUsage[prop];
const hasGeneric = genericVal !== undefined && genericVal !== null;
const hasOverride = overrideVal !== undefined && overrideVal !== null;
const isChanged = hasOverride && hasGeneric && genericVal !== overrideVal;
const isNewInOverride = hasOverride && !hasGeneric;
// Skip if neither has a value
if (!hasGeneric && !hasOverride) return null;
const formatValue = (val: unknown): string => {
if (val === undefined || val === null) return '';
if (typeof val === 'boolean') return val ? 'true' : 'false';
const str = String(val);
// Longer truncation for description, shorter for other fields
const maxLen = prop === 'description' ? 80 : 50;
return str.length > maxLen ? str.substring(0, maxLen - 3) + '...' : str;
};
return (
<div
key={prop}
className={`schema-popup__slot-comparison-row ${isChanged || isNewInOverride ? 'schema-popup__slot-comparison-row--changed' : ''}`}
>
<div className="schema-popup__slot-comparison-label">{prop}</div>
<div className="schema-popup__slot-comparison-generic">
{hasGeneric ? (
<code title={typeof genericVal === 'string' ? genericVal : undefined}>
{formatValue(genericVal)}
</code>
) : (
<span className="schema-popup__slot-comparison-empty">(not defined)</span>
)}
</div>
<div className={`schema-popup__slot-comparison-override ${isChanged || isNewInOverride ? 'schema-popup__slot-comparison-override--diff' : ''}`}>
{hasOverride ? (
<>
<code title={typeof overrideVal === 'string' ? overrideVal : undefined}>
{formatValue(overrideVal)}
</code>
{(isChanged || isNewInOverride) && (
<span className="schema-popup__slot-comparison-diff-badge">changed</span>
)}
</>
) : (
<span className="schema-popup__slot-comparison-inherited">(inherited)</span>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Navigate buttons - one for class, one for slot */}
<div className="schema-popup__actions schema-popup__actions--dual">
<button
className="schema-popup__navigate"
onClick={() => {
onNavigate(slotUsageComparison.className, 'class');
onClose();
}}
>
Go to class
</button>
<button
className="schema-popup__navigate schema-popup__navigate--secondary"
onClick={() => {
onNavigate(slotUsageComparison.slotName, 'slot');
onClose();
}}
>
Go to slot
</button>
</div>
</>
)} )}
{/* Regular element view (non-slot_usage) */}
{elementType !== 'slot_usage' && (
<>
{/* 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 */} {/* URI */}
{elementInfo.uri && ( {elementInfo.uri && (
<div className="schema-popup__section"> <div className="schema-popup__section">
@ -524,6 +719,8 @@ export const SchemaElementPopup: React.FC<SchemaElementPopupProps> = ({
Go to {elementType} Go to {elementType}
</button> </button>
</div> </div>
</>
)}
</> </>
)} )}
</div> </div>

View file

@ -1984,6 +1984,41 @@ class LinkMLSchemaService {
return counts; return counts;
} }
/**
* Get the raw ClassDefinition for a class.
* Useful for accessing slot_usage and other class-level metadata directly.
*/
async getClassDefinition(className: string): Promise<ClassDefinition | null> {
await this.initialize();
const schema = this.classSchemas.get(className);
const classDef = schema?.classes?.[className];
if (!classDef) {
debugLog(`[LinkMLSchemaService] getClassDefinition: class ${className} not found`);
return null;
}
return classDef;
}
/**
* Get the raw SlotDefinition for a slot.
* Useful for accessing all properties including pattern, identifier, etc.
*/
async getSlotDefinition(slotName: string): Promise<SlotDefinition | null> {
await this.initialize();
const slotDef = this.slotSchemas.get(slotName);
if (!slotDef) {
debugLog(`[LinkMLSchemaService] getSlotDefinition: slot ${slotName} not found`);
return null;
}
return slotDef;
}
/** /**
* Get dependency count for a single class. * Get dependency count for a single class.
* More efficient than getting full import/export info when only count is needed. * More efficient than getting full import/export info when only count is needed.

View file

@ -1301,7 +1301,12 @@ const LinkMLViewerPage: React.FC = () => {
const [ontologyPopupCurie, setOntologyPopupCurie] = useState<string | null>(null); const [ontologyPopupCurie, setOntologyPopupCurie] = useState<string | null>(null);
// State for schema element popup (shows when clicking class/slot/enum links in Imports/Exports sections) // State for schema element popup (shows when clicking class/slot/enum links in Imports/Exports sections)
const [schemaElementPopup, setSchemaElementPopup] = useState<{ name: string; type: SchemaElementType } | null>(null); const [schemaElementPopup, setSchemaElementPopup] = useState<{
name: string;
type: SchemaElementType;
slotName?: string;
overrides?: string[];
} | null>(null);
// Sync custodian filter to URL params // Sync custodian filter to URL params
useEffect(() => { useEffect(() => {
@ -3298,7 +3303,12 @@ const LinkMLViewerPage: React.FC = () => {
<button <button
key={cls} key={cls}
className="linkml-viewer__exports-link" className="linkml-viewer__exports-link"
onClick={() => setSchemaElementPopup({ name: cls, type: 'class' })} onClick={() => setSchemaElementPopup({
name: cls,
type: 'slot_usage',
slotName: slot.name,
overrides: overrides
})}
title={`Overrides: ${overrides.join(', ')}`} title={`Overrides: ${overrides.join(', ')}`}
> >
{cls} <span className="linkml-viewer__exports-via">{overrides.length} {overrides.length === 1 ? 'override' : 'overrides'}</span> {cls} <span className="linkml-viewer__exports-via">{overrides.length} {overrides.length === 1 ? 'override' : 'overrides'}</span>
@ -4164,6 +4174,8 @@ const LinkMLViewerPage: React.FC = () => {
<SchemaElementPopup <SchemaElementPopup
elementName={schemaElementPopup.name} elementName={schemaElementPopup.name}
elementType={schemaElementPopup.type} elementType={schemaElementPopup.type}
slotName={schemaElementPopup.slotName}
overrides={schemaElementPopup.overrides}
onClose={() => setSchemaElementPopup(null)} onClose={() => setSchemaElementPopup(null)}
onNavigate={handleSchemaElementNavigate} onNavigate={handleSchemaElementNavigate}
/> />