feat(linkml-viewer): add visual indicators for slot_usage overrides

- Add green 'slot_usage' badge for slots with class-specific overrides
- Add ✦ markers next to properties that are overridden vs inherited
- Add green left border styling for slots with slot_usage
- Add i18n translations (nl/en) for override indicators
- Merge generic slot definitions with class-specific slot_usage properties

This helps users understand which slot properties come from the generic
slot definition vs which are overridden at the class level via slot_usage.
This commit is contained in:
kempersc 2026-01-09 18:23:21 +01:00
parent 7ec4e05dd4
commit 1ad717767a
2 changed files with 96 additions and 17 deletions

View file

@ -672,6 +672,42 @@
color: var(--warning-color, #f57c00);
}
.linkml-viewer__badge--usage {
background: var(--slot-usage-light, #e8f5e9);
color: var(--slot-usage-color, #388e3c);
font-weight: 500;
}
/* Visual indicator for slots with slot_usage overrides */
.linkml-viewer__item--has-usage {
border-left: 3px solid var(--slot-usage-color, #388e3c);
padding-left: 0.75rem;
}
/* Override markers - show which properties come from slot_usage */
.linkml-viewer__override-marker {
display: inline-block;
font-size: 0.75rem;
margin-right: 0.25rem;
font-weight: 600;
}
.linkml-viewer__override-marker--overridden {
color: var(--slot-usage-color, #388e3c);
cursor: help;
}
.linkml-viewer__override-marker--inherited {
/* Inherited properties don't show any marker */
display: none;
}
/* Label with inline override marker */
.linkml-viewer__label--inline {
display: inline;
margin-right: 0;
}
/* Custodian Type Badge - shows which GLAMORCUBESFIXPHDNT types apply */
.linkml-viewer__custodian-badge {
margin-left: 0.5rem;

View file

@ -968,6 +968,15 @@ const TEXT = {
broadMappings: { nl: 'Bredere mappings:', en: 'Broad Mappings:' },
relatedMappings: { nl: 'Gerelateerde mappings:', en: 'Related Mappings:' },
comments: { nl: 'Opmerkingen:', en: 'Comments:' },
// Slot usage indicator
slotUsageBadge: { nl: 'slot_usage', en: 'slot_usage' },
slotUsageTooltip: {
nl: 'Deze slot heeft klasse-specifieke overschrijvingen gedefinieerd in slot_usage. Eigenschappen met ✦ komen uit de slot_usage, andere zijn overgenomen van de generieke slot-definitie.',
en: 'This slot has class-specific overrides defined in slot_usage. Properties marked with ✦ come from slot_usage, others are inherited from the generic slot definition.'
},
slotUsageOverrideMarker: { nl: '✦', en: '✦' },
inheritedFromGeneric: { nl: 'overgenomen', en: 'inherited' },
overriddenInSlotUsage: { nl: 'overschreven in slot_usage', en: 'overridden in slot_usage' },
};
// Dynamically discover schema files from the modules directory
@ -2048,11 +2057,22 @@ const LinkMLViewerPage: React.FC = () => {
{expandedClassSlots.has(cls.name) && (
<div className="linkml-viewer__class-slots-content">
{cls.slots.map(slotName => {
const slot = slotLookupMap.get(slotName);
if (slot) {
return renderSlotDetails(slot);
// Get generic slot definition from the global slot lookup
const genericSlot = slotLookupMap.get(slotName);
// Get class-specific slot_usage overrides (if any)
const slotUsage = cls.slot_usage?.[slotName];
// Merge generic slot with slot_usage overrides
// slot_usage properties take precedence over generic slot properties
if (genericSlot || slotUsage) {
const mergedSlot: LinkMLSlot = {
name: slotName,
...(genericSlot || {}),
...(slotUsage || {}),
};
return renderSlotDetails(mergedSlot, slotUsage || null);
}
// Fallback: show slot name as tag if definition not found
// Fallback: show slot name as tag if neither generic definition nor slot_usage found
return (
<div key={slotName} className="linkml-viewer__item linkml-viewer__item--slot-fallback">
<h4 className="linkml-viewer__item-name">{slotName}</h4>
@ -2578,7 +2598,8 @@ const LinkMLViewerPage: React.FC = () => {
);
};
const renderSlotDetails = (slot: LinkMLSlot) => {
const renderSlotDetails = (slot: LinkMLSlot, slotUsage: Partial<LinkMLSlot> | null = null) => {
const hasSlotUsageOverrides = slotUsage !== null;
const rangeIsEnum = slot.range && isEnumRange(slot.range);
const enumKey = slot.range ? `${slot.name}:${slot.range}` : '';
const isExpanded = expandedEnumRanges.has(enumKey);
@ -2589,10 +2610,31 @@ const LinkMLViewerPage: React.FC = () => {
// Check if this slot matches the current custodian type filter (multi-select)
const matchesFilter = custodianTypeFilter.size === 0 || custodianTypes.some(t => custodianTypeFilter.has(t));
// Helper to check if a property is overridden in slot_usage
const isOverridden = (property: keyof LinkMLSlot): boolean => {
if (!slotUsage) return false;
return slotUsage[property] !== undefined;
};
// Helper to render override marker
const OverrideMarker = ({ property }: { property: keyof LinkMLSlot }) => {
if (!hasSlotUsageOverrides) return null;
const overridden = isOverridden(property);
return (
<span
className={`linkml-viewer__override-marker ${overridden ? 'linkml-viewer__override-marker--overridden' : 'linkml-viewer__override-marker--inherited'}`}
title={overridden ? t('overriddenInSlotUsage') : t('inheritedFromGeneric')}
>
{overridden ? t('slotUsageOverrideMarker') : ''}
</span>
);
};
return (
<div key={slot.name} className={`linkml-viewer__item ${!matchesFilter ? 'linkml-viewer__item--filtered-out' : ''}`}>
<div key={slot.name} className={`linkml-viewer__item ${!matchesFilter ? 'linkml-viewer__item--filtered-out' : ''} ${hasSlotUsageOverrides ? 'linkml-viewer__item--has-usage' : ''}`}>
<h4 className="linkml-viewer__item-name">
{slot.name}
{hasSlotUsageOverrides && <span className="linkml-viewer__badge linkml-viewer__badge--usage" title={t('slotUsageTooltip')}>{t('slotUsageBadge')}</span>}
{slot.required && <span className="linkml-viewer__badge linkml-viewer__badge--required">{t('required')}</span>}
{slot.multivalued && <span className="linkml-viewer__badge linkml-viewer__badge--multi">{t('multivalued')}</span>}
{slot.identifier && <span className="linkml-viewer__badge linkml-viewer__badge--identifier">{t('identifier')}</span>}
@ -2621,13 +2663,13 @@ const LinkMLViewerPage: React.FC = () => {
{/* Slot URI - semantic predicate */}
{slot.slot_uri && (
<div className="linkml-viewer__slot-uri">
<span className="linkml-viewer__label">{t('slotUri')}</span>
<span className="linkml-viewer__label"><OverrideMarker property="slot_uri" />{t('slotUri')}</span>
<code className="linkml-viewer__uri-value">{slot.slot_uri}</code>
</div>
)}
{slot.range && (
<div className="linkml-viewer__range">
<span className="linkml-viewer__label">{t('range')}</span>
<span className="linkml-viewer__label"><OverrideMarker property="range" />{t('range')}</span>
{rangeIsEnum ? (
<button
className={`linkml-viewer__range-enum-toggle ${isExpanded ? 'linkml-viewer__range-enum-toggle--expanded' : ''}`}
@ -2646,19 +2688,20 @@ const LinkMLViewerPage: React.FC = () => {
{rangeIsEnum && isExpanded && slot.range && renderEnumValues(slot.name, slot.range)}
{slot.description && (
<div className="linkml-viewer__description linkml-viewer__markdown">
<span className="linkml-viewer__label linkml-viewer__label--inline"><OverrideMarker property="description" /></span>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(slot.description)}</ReactMarkdown>
</div>
)}
{slot.pattern && (
<div className="linkml-viewer__pattern">
<span className="linkml-viewer__label">{t('pattern')}</span>
<span className="linkml-viewer__label"><OverrideMarker property="pattern" />{t('pattern')}</span>
<code>{slot.pattern}</code>
</div>
)}
{/* Examples section */}
{slot.examples && slot.examples.length > 0 && (
<div className="linkml-viewer__examples">
<span className="linkml-viewer__label">{t('examples')}</span>
<span className="linkml-viewer__label"><OverrideMarker property="examples" />{t('examples')}</span>
<ul className="linkml-viewer__examples-list">
{slot.examples.map((example, idx) => (
<li key={idx} className="linkml-viewer__example-item">
@ -2674,7 +2717,7 @@ const LinkMLViewerPage: React.FC = () => {
{/* Semantic Mappings - 5 types per LinkML spec */}
{slot.exact_mappings && slot.exact_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label">{t('exactMappings')}</span>
<span className="linkml-viewer__label"><OverrideMarker property="exact_mappings" />{t('exactMappings')}</span>
<div className="linkml-viewer__tag-list">
{slot.exact_mappings.map(mapping => (
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--mapping linkml-viewer__tag--exact">{mapping}</span>
@ -2684,7 +2727,7 @@ const LinkMLViewerPage: React.FC = () => {
)}
{slot.close_mappings && slot.close_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label">{t('closeMappings')}</span>
<span className="linkml-viewer__label"><OverrideMarker property="close_mappings" />{t('closeMappings')}</span>
<div className="linkml-viewer__tag-list">
{slot.close_mappings.map(mapping => (
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--mapping linkml-viewer__tag--close">{mapping}</span>
@ -2694,7 +2737,7 @@ const LinkMLViewerPage: React.FC = () => {
)}
{slot.narrow_mappings && slot.narrow_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label">{t('narrowMappings')}</span>
<span className="linkml-viewer__label"><OverrideMarker property="narrow_mappings" />{t('narrowMappings')}</span>
<div className="linkml-viewer__tag-list">
{slot.narrow_mappings.map(mapping => (
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--mapping linkml-viewer__tag--narrow">{mapping}</span>
@ -2704,7 +2747,7 @@ const LinkMLViewerPage: React.FC = () => {
)}
{slot.broad_mappings && slot.broad_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label">{t('broadMappings')}</span>
<span className="linkml-viewer__label"><OverrideMarker property="broad_mappings" />{t('broadMappings')}</span>
<div className="linkml-viewer__tag-list">
{slot.broad_mappings.map(mapping => (
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--mapping linkml-viewer__tag--broad">{mapping}</span>
@ -2714,7 +2757,7 @@ const LinkMLViewerPage: React.FC = () => {
)}
{slot.related_mappings && slot.related_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label">{t('relatedMappings')}</span>
<span className="linkml-viewer__label"><OverrideMarker property="related_mappings" />{t('relatedMappings')}</span>
<div className="linkml-viewer__tag-list">
{slot.related_mappings.map(mapping => (
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--mapping linkml-viewer__tag--related">{mapping}</span>
@ -2725,7 +2768,7 @@ const LinkMLViewerPage: React.FC = () => {
{/* Comments section */}
{slot.comments && slot.comments.length > 0 && (
<div className="linkml-viewer__comments">
<span className="linkml-viewer__label">{t('comments')}</span>
<span className="linkml-viewer__label"><OverrideMarker property="comments" />{t('comments')}</span>
<ul className="linkml-viewer__comments-list">
{slot.comments.map((comment, idx) => (
<li key={idx} className="linkml-viewer__comment-item">{comment}</li>
@ -2989,7 +3032,7 @@ const LinkMLViewerPage: React.FC = () => {
</button>
{expandedSections.has('slots') && (
<div className="linkml-viewer__section-content">
{slots.map(renderSlotDetails)}
{slots.map(slot => renderSlotDetails(slot))}
</div>
)}
</div>