diff --git a/frontend/src/pages/LinkMLViewerPage.css b/frontend/src/pages/LinkMLViewerPage.css index b6cb9e0403..363acaab6e 100644 --- a/frontend/src/pages/LinkMLViewerPage.css +++ b/frontend/src/pages/LinkMLViewerPage.css @@ -815,6 +815,174 @@ font-size: 0.75rem; } +/* ============================================ + Slot Usage Comparison View + Side-by-side generic vs override comparison + ============================================ */ + +/* Compare button badge */ +.linkml-viewer__badge--compare { + background: transparent; + border: 1px solid var(--slot-usage-color, #388e3c); + color: var(--slot-usage-color, #388e3c); + cursor: pointer; + padding: 0.125rem 0.375rem; + font-size: 0.6875rem; + border-radius: 4px; + transition: all 0.2s ease; + font-weight: 500; +} + +.linkml-viewer__badge--compare:hover { + background: var(--slot-usage-light, #e8f5e9); +} + +.linkml-viewer__badge--compare-active { + background: var(--slot-usage-color, #388e3c); + color: white; +} + +.linkml-viewer__badge--compare-active:hover { + background: #2e7d32; +} + +/* Comparison panel container */ +.linkml-viewer__slot-comparison { + margin-top: 1rem; + border: 1px solid var(--slot-usage-color, #388e3c); + border-radius: 8px; + overflow: hidden; + background: white; + animation: slideDown 0.2s ease-out; +} + +/* Comparison header with two columns */ +.linkml-viewer__slot-comparison-header { + display: grid; + grid-template-columns: 120px 1fr 1fr; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--slot-usage-light, #e8f5e9); + border-bottom: 1px solid var(--slot-usage-color, #388e3c); + font-weight: 600; + font-size: 0.8125rem; + color: var(--slot-usage-color, #388e3c); +} + +.linkml-viewer__slot-comparison-header h5 { + margin: 0; + font-size: 0.8125rem; +} + +.linkml-viewer__slot-comparison-header h5:first-of-type { + /* Empty first column for property name */ +} + +/* Comparison content rows */ +.linkml-viewer__slot-comparison-content { + padding: 0.25rem 0; +} + +.linkml-viewer__slot-comparison-row { + display: grid; + grid-template-columns: 120px 1fr 1fr; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + align-items: start; +} + +.linkml-viewer__slot-comparison-row:last-child { + border-bottom: none; +} + +.linkml-viewer__slot-comparison-row--changed { + background: rgba(56, 142, 60, 0.05); +} + +/* Property label column */ +.linkml-viewer__slot-comparison-label { + font-weight: 500; + color: var(--text-secondary, #666); + font-size: 0.75rem; +} + +/* Generic value column */ +.linkml-viewer__slot-comparison-generic { + word-break: break-word; +} + +.linkml-viewer__slot-comparison-generic code, +.linkml-viewer__slot-comparison-override code { + background: var(--surface-secondary, #f5f5f5); + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-family: 'Monaco', 'Menlo', monospace; + font-size: 0.6875rem; + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Override value column */ +.linkml-viewer__slot-comparison-override { + word-break: break-word; + display: flex; + align-items: flex-start; + gap: 0.375rem; + flex-wrap: wrap; +} + +.linkml-viewer__slot-comparison-override--diff code { + background: var(--slot-usage-light, #e8f5e9); + border: 1px solid var(--slot-usage-color, #388e3c); +} + +/* Diff badge */ +.linkml-viewer__slot-comparison-diff-badge { + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + background: var(--slot-usage-color, #388e3c); + color: white; + padding: 0.125rem 0.25rem; + border-radius: 3px; + white-space: nowrap; +} + +/* Empty/inherited states */ +.linkml-viewer__slot-comparison-empty, +.linkml-viewer__slot-comparison-inherited { + font-style: italic; + color: var(--text-tertiary, #999); + font-size: 0.75rem; +} + +/* Responsive - stack on small screens */ +@media (max-width: 640px) { + .linkml-viewer__slot-comparison-header, + .linkml-viewer__slot-comparison-row { + grid-template-columns: 1fr; + gap: 0.25rem; + } + + .linkml-viewer__slot-comparison-header h5:first-of-type { + display: none; + } + + .linkml-viewer__slot-comparison-generic, + .linkml-viewer__slot-comparison-override { + padding-left: 0.5rem; + border-left: 2px solid var(--border-color, #e0e0e0); + } + + .linkml-viewer__slot-comparison-override--diff { + border-left-color: var(--slot-usage-color, #388e3c); + } +} + /* Custodian Type Badge - shows which GLAMORCUBESFIXPHDNT types apply */ .linkml-viewer__custodian-badge { margin-left: 0.5rem; diff --git a/frontend/src/pages/LinkMLViewerPage.tsx b/frontend/src/pages/LinkMLViewerPage.tsx index 0cda157fa0..0164b975f6 100644 --- a/frontend/src/pages/LinkMLViewerPage.tsx +++ b/frontend/src/pages/LinkMLViewerPage.tsx @@ -996,6 +996,17 @@ const TEXT = { nl: 'Eigenschappen zonder ✦ zijn overgenomen van de generieke slot-definitie', en: 'Properties without ✦ are inherited from the generic slot definition' }, + // Slot usage comparison view + slotUsageCompareToggle: { nl: 'Vergelijk', en: 'Compare' }, + slotUsageCompareTooltip: { + nl: 'Toon generieke definitie naast slot_usage overschrijvingen', + en: 'Show generic definition alongside slot_usage overrides' + }, + slotUsageCompareGeneric: { nl: 'Generieke Definitie', en: 'Generic Definition' }, + slotUsageCompareOverride: { nl: 'slot_usage Overschrijving', en: 'slot_usage Override' }, + slotUsageCompareDiff: { nl: 'gewijzigd', en: 'changed' }, + slotUsageCompareInherited: { nl: '(overgenomen)', en: '(inherited)' }, + slotUsageCompareNotDefined: { nl: '(niet gedefinieerd)', en: '(not defined)' }, }; // Dynamically discover schema files from the modules directory @@ -1125,6 +1136,24 @@ const LinkMLViewerPage: React.FC = () => { // State for slot_usage legend visibility toggle const [showSlotUsageLegend, setShowSlotUsageLegend] = useState(false); + // State for slot_usage comparison view - tracks which slots have comparison expanded + // Key format: "className:slotName" + const [expandedSlotComparisons, setExpandedSlotComparisons] = useState>(new Set()); + + // Toggle comparison view for a specific slot + const toggleSlotComparison = (className: string, slotName: string) => { + const key = `${className}:${slotName}`; + setExpandedSlotComparisons(prev => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + // State for filtering schema elements by custodian type (click on polyhedron face or legend) // Multi-select: Set of selected type codes (empty = no filter) // Initialize from URL params (e.g., ?custodian=G,L,A) @@ -2092,7 +2121,7 @@ const LinkMLViewerPage: React.FC = () => { ...(genericSlot || {}), ...(slotUsage || {}), }; - return renderSlotDetails(mergedSlot, slotUsage || null); + return renderSlotDetails(mergedSlot, slotUsage || null, genericSlot || null, cls.name); } // Fallback: show slot name as tag if neither generic definition nor slot_usage found return ( @@ -2620,7 +2649,12 @@ const LinkMLViewerPage: React.FC = () => { ); }; - const renderSlotDetails = (slot: LinkMLSlot, slotUsage: Partial | null = null) => { + const renderSlotDetails = ( + slot: LinkMLSlot, + slotUsage: Partial | null = null, + genericSlot: LinkMLSlot | null = null, + className: string | null = null + ) => { const hasSlotUsageOverrides = slotUsage !== null; const rangeIsEnum = slot.range && isEnumRange(slot.range); const enumKey = slot.range ? `${slot.name}:${slot.range}` : ''; @@ -2632,6 +2666,10 @@ 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)); + // Check if comparison view is expanded for this slot + const comparisonKey = className ? `${className}:${slot.name}` : slot.name; + const showComparison = expandedSlotComparisons.has(comparisonKey); + // Helper to check if a property is overridden in slot_usage const isOverridden = (property: keyof LinkMLSlot): boolean => { if (!slotUsage) return false; @@ -2657,6 +2695,19 @@ const LinkMLViewerPage: React.FC = () => {

{slot.name} {hasSlotUsageOverrides && {t('slotUsageBadge')}} + {/* Compare button - only show when slot_usage exists and we have both generic and override */} + {hasSlotUsageOverrides && genericSlot && className && ( + + )} {slot.required && {t('required')}} {slot.multivalued && {t('multivalued')}} {slot.identifier && {t('identifier')}} @@ -2798,6 +2849,73 @@ const LinkMLViewerPage: React.FC = () => { )} + + {/* Side-by-side comparison view - only shown when expanded */} + {showComparison && hasSlotUsageOverrides && genericSlot && ( +
+
+
{t('slotUsageCompareGeneric')}
+
{t('slotUsageCompareOverride')}
+
+
+ {/* Compare key properties side by side */} + {renderComparisonRow('range', genericSlot.range, slotUsage?.range)} + {renderComparisonRow('description', genericSlot.description, slotUsage?.description)} + {renderComparisonRow('required', genericSlot.required, slotUsage?.required)} + {renderComparisonRow('multivalued', genericSlot.multivalued, slotUsage?.multivalued)} + {renderComparisonRow('slot_uri', genericSlot.slot_uri, slotUsage?.slot_uri)} + {renderComparisonRow('pattern', genericSlot.pattern, slotUsage?.pattern)} + {renderComparisonRow('identifier', genericSlot.identifier, slotUsage?.identifier)} +
+
+ )} + + ); + }; + + // Helper function to render a comparison row + const renderComparisonRow = ( + property: string, + genericValue: string | boolean | number | undefined | null, + overrideValue: string | boolean | number | undefined | null + ) => { + const hasGeneric = genericValue !== undefined && genericValue !== null; + const hasOverride = overrideValue !== undefined && overrideValue !== null; + const isChanged = hasOverride && hasGeneric && genericValue !== overrideValue; + const isNewInOverride = hasOverride && !hasGeneric; + + // Don't show row if neither has a value + if (!hasGeneric && !hasOverride) return null; + + const formatValue = (val: string | boolean | number | undefined | null): string => { + if (val === undefined || val === null) return ''; + if (typeof val === 'boolean') return val ? 'true' : 'false'; + if (typeof val === 'number') return String(val); + // Truncate long strings for display + const str = String(val); + return str.length > 80 ? str.substring(0, 77) + '...' : str; + }; + + return ( +
+
{property}
+
+ {hasGeneric ? ( + {formatValue(genericValue)} + ) : ( + {t('slotUsageCompareNotDefined')} + )} +
+
+ {hasOverride ? ( + <> + {formatValue(overrideValue)} + {(isChanged || isNewInOverride) && {t('slotUsageCompareDiff')}} + + ) : ( + {t('slotUsageCompareInherited')} + )} +
); };