diff --git a/frontend/src/components/linkml/SchemaElementPopup.css b/frontend/src/components/linkml/SchemaElementPopup.css index 9356bc4298..6e409cc8ba 100644 --- a/frontend/src/components/linkml/SchemaElementPopup.css +++ b/frontend/src/components/linkml/SchemaElementPopup.css @@ -477,3 +477,214 @@ 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; +} diff --git a/frontend/src/components/linkml/SchemaElementPopup.tsx b/frontend/src/components/linkml/SchemaElementPopup.tsx index f6fdcd186c..f2a48169d8 100644 --- a/frontend/src/components/linkml/SchemaElementPopup.tsx +++ b/frontend/src/components/linkml/SchemaElementPopup.tsx @@ -17,7 +17,7 @@ import { linkmlSchemaService } from '../../lib/linkml/linkml-schema-service'; import { isTargetInsideAny } from '../../utils/dom'; import './SchemaElementPopup.css'; -export type SchemaElementType = 'class' | 'slot' | 'enum'; +export type SchemaElementType = 'class' | 'slot' | 'enum' | 'slot_usage'; interface SchemaElementPopupProps { /** The element name to look up */ @@ -30,6 +30,10 @@ interface SchemaElementPopupProps { onNavigate: (elementName: string, elementType: SchemaElementType) => void; /** Initial position (optional, defaults to center) */ 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 { @@ -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 = ({ elementName, elementType, onClose, onNavigate, initialPosition, + slotName, + overrides, }) => { const [elementInfo, setElementInfo] = useState(null); + const [slotUsageComparison, setSlotUsageComparison] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -151,6 +185,49 @@ export const SchemaElementPopup: React.FC = ({ 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) { @@ -167,7 +244,7 @@ export const SchemaElementPopup: React.FC = ({ }; loadInfo(); - }, [elementName, elementType]); + }, [elementName, elementType, slotName, overrides]); // Center the popup on initial load if no position provided useLayoutEffect(() => { @@ -350,13 +427,15 @@ export const SchemaElementPopup: React.FC = ({ 'class': '#3B82F6', // Blue 'slot': '#10B981', // Green 'enum': '#F59E0B', // Amber + 'slot_usage': '#8B5CF6', // Purple for slot_usage comparison }; + const displayType = type === 'slot_usage' ? 'slot override' : type; return ( - {type} + {displayType} ); }; @@ -423,14 +502,130 @@ export const SchemaElementPopup: React.FC = ({ {!loading && !error && elementInfo && ( <> - {/* Description */} - {elementInfo.description && ( -
- Description -

{elementInfo.description}

-
+ {/* Slot Usage Comparison View - matches LinkMLViewerPage format */} + {elementType === 'slot_usage' && slotUsageComparison && ( + <> + {/* Header info - class and slot names */} +
+ Class + {slotUsageComparison.className} +
+
+ Slot + {slotUsageComparison.slotName} +
+ + {/* Overridden properties summary as badges */} + {slotUsageComparison.overrides.length > 0 && ( +
+ Overridden Properties +
+ {slotUsageComparison.overrides.map(prop => ( + {prop} + ))} +
+
+ )} + + {/* Side-by-side comparison - uses same structure as main page */} +
+
+ +
Generic Slot
+
Class Override
+
+
+ {/* 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 ( +
+
{prop}
+
+ {hasGeneric ? ( + + {formatValue(genericVal)} + + ) : ( + (not defined) + )} +
+
+ {hasOverride ? ( + <> + + {formatValue(overrideVal)} + + {(isChanged || isNewInOverride) && ( + changed + )} + + ) : ( + (inherited) + )} +
+
+ ); + })} +
+
+ + {/* Navigate buttons - one for class, one for slot */} +
+ + +
+ )} + {/* Regular element view (non-slot_usage) */} + {elementType !== 'slot_usage' && ( + <> + {/* Description */} + {elementInfo.description && ( +
+ Description +

{elementInfo.description}

+
+ )} + {/* URI */} {elementInfo.uri && (
@@ -524,6 +719,8 @@ export const SchemaElementPopup: React.FC = ({ Go to {elementType} →
+ + )} )} diff --git a/frontend/src/lib/linkml/linkml-schema-service.ts b/frontend/src/lib/linkml/linkml-schema-service.ts index 7927c0a053..ef9f2b5f21 100644 --- a/frontend/src/lib/linkml/linkml-schema-service.ts +++ b/frontend/src/lib/linkml/linkml-schema-service.ts @@ -1984,6 +1984,41 @@ class LinkMLSchemaService { 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 { + 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 { + 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. * More efficient than getting full import/export info when only count is needed. diff --git a/frontend/src/pages/LinkMLViewerPage.tsx b/frontend/src/pages/LinkMLViewerPage.tsx index 70fe5ce5b1..4e94316a52 100644 --- a/frontend/src/pages/LinkMLViewerPage.tsx +++ b/frontend/src/pages/LinkMLViewerPage.tsx @@ -1301,7 +1301,12 @@ const LinkMLViewerPage: React.FC = () => { const [ontologyPopupCurie, setOntologyPopupCurie] = useState(null); // 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 useEffect(() => { @@ -3298,7 +3303,12 @@ const LinkMLViewerPage: React.FC = () => {