From 3e6c2367ad4ea258b22eb7283541265e81208d26 Mon Sep 17 00:00:00 2001 From: kempersc Date: Sun, 11 Jan 2026 21:42:35 +0100 Subject: [PATCH] feat(linkml-viewer): UX improvements - entry counts, deep links, settings persistence - Add entry count badge next to schema file name showing (xC, yE, zS) counts - Add tooltip explaining LinkML file names vs class names - Remove redundant section headers (Classes, Enums, Slots collapsible sections) - Add URL params for enum (?enum=) and slot (?slot=) deep linking - Persist category filters, dev tools visibility, and legend visibility to localStorage - Set 'Main Schema' filter to OFF by default (confusing for users) - Add Rule 48: Class files must not define inline slots --- .../rules/class-files-no-inline-slots.md | 140 +++++++++++ frontend/src/pages/LinkMLViewerPage.css | 22 ++ frontend/src/pages/LinkMLViewerPage.tsx | 230 +++++++++++++----- 3 files changed, 334 insertions(+), 58 deletions(-) create mode 100644 .opencode/rules/class-files-no-inline-slots.md diff --git a/.opencode/rules/class-files-no-inline-slots.md b/.opencode/rules/class-files-no-inline-slots.md new file mode 100644 index 0000000000..880ed6526e --- /dev/null +++ b/.opencode/rules/class-files-no-inline-slots.md @@ -0,0 +1,140 @@ +# Rule 48: Class Files Must Not Define Inline Slots + +🚨 **CRITICAL**: LinkML class files in `schemas/20251121/linkml/modules/classes/` MUST NOT define their own slots inline. All slots MUST be imported from the centralized `modules/slots/` directory. + +## Problem Statement + +When class files define their own slots (e.g., `AccessRestriction.yaml` defining its own slot properties), this creates: + +1. **Duplication**: Same slot semantics defined in multiple places +2. **Inconsistency**: Slot definitions may diverge between files +3. **Frontend Issues**: LinkML viewer cannot properly render slot relationships +4. **Maintenance Burden**: Changes require updates in multiple locations + +## Architecture Requirement + +``` +schemas/20251121/linkml/ +β”œβ”€β”€ modules/ +β”‚ β”œβ”€β”€ classes/ # Class definitions ONLY +β”‚ β”‚ └── *.yaml # NO inline slot definitions +β”‚ β”œβ”€β”€ slots/ # ALL slot definitions go here +β”‚ β”‚ └── *.yaml # One file per slot or logical group +β”‚ └── enums/ # Enumeration definitions +``` + +## Correct Pattern + +**Class file** (`modules/classes/AccessRestriction.yaml`): + +```yaml +id: https://nde.nl/ontology/hc/class/AccessRestriction +name: AccessRestriction +prefixes: + hc: https://nde.nl/ontology/hc/ + linkml: https://w3id.org/linkml/ + +imports: + - linkml:types + - ../slots/restriction_type # Import slot from centralized location + - ../slots/restriction_reason + - ../slots/applies_from + - ../slots/applies_until + +default_range: string + +classes: + AccessRestriction: + class_uri: hc:AccessRestriction + description: >- + Describes access restrictions on heritage collections or items. + slots: + - restriction_type # Reference slot by name + - restriction_reason + - applies_from + - applies_until +``` + +**Slot file** (`modules/slots/restriction_type.yaml`): + +```yaml +id: https://nde.nl/ontology/hc/slot/restriction_type +name: restriction_type +prefixes: + hc: https://nde.nl/ontology/hc/ + schema: http://schema.org/ + linkml: https://w3id.org/linkml/ + +imports: + - linkml:types + +slots: + restriction_type: + slot_uri: hc:restrictionType + description: The type of access restriction applied. + range: string + exact_mappings: + - schema:accessMode +``` + +## Anti-Pattern (WRONG) + +**DO NOT** define slots inline in class files: + +```yaml +# WRONG - AccessRestriction.yaml with inline slots +classes: + AccessRestriction: + slots: + - restriction_type + +slots: # ❌ DO NOT define slots here + restriction_type: + description: Type of restriction + range: string +``` + +## Identifying Violations + +To find class files that incorrectly define slots: + +```bash +# Find class files with inline slot definitions +grep -l "^slots:" schemas/20251121/linkml/modules/classes/*.yaml +``` + +Files that match need refactoring: +1. Extract slot definitions to `modules/slots/` +2. Add imports for the extracted slots +3. Remove inline `slots:` section from class file + +## Known Violations to Fix + +The following class files have been identified as defining their own slots and require refactoring: + +- `AccessRestriction.yaml` - visible at https://nde.nl/ontology/hc/class/AccessRestriction +- (Add others as discovered) + +## Migration Workflow + +1. **Identify inline slots** in class file +2. **Check if slot exists** in `modules/slots/` +3. **If exists**: Remove inline definition, add import +4. **If not exists**: Create new slot file in `modules/slots/`, then add import +5. **Validate**: Run `linkml-validate` to ensure schema integrity +6. **Update manifest**: Regenerate `manifest.json` if needed + +## Rationale + +- **Single Source of Truth**: Each slot defined exactly once +- **Reusability**: Slots can be used across multiple classes +- **Frontend Compatibility**: LinkML viewer depends on centralized slots for proper edge rendering in UML diagrams +- **Semantic Consistency**: `slot_uri` and mappings defined once, applied everywhere +- **Maintenance**: Changes to slot semantics applied in one place + +## See Also + +- Rule 38: Slot Centralization and Semantic URI Requirements +- Rule 39: Slot Naming Convention (RiC-O Style) +- Rule 42: No Ontology Prefixes in Slot Names +- Rule 43: Slot Nouns Must Be Singular diff --git a/frontend/src/pages/LinkMLViewerPage.css b/frontend/src/pages/LinkMLViewerPage.css index 3307a0c5dd..411f03672c 100644 --- a/frontend/src/pages/LinkMLViewerPage.css +++ b/frontend/src/pages/LinkMLViewerPage.css @@ -479,6 +479,23 @@ font-weight: 600; color: var(--text-primary, #212121); margin: 0 0 0.5rem 0; + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.25rem; +} + +.linkml-viewer__schema-name .linkml-viewer__info-icon { + font-size: 0.875rem; + margin-left: 0.25rem; +} + +.linkml-viewer__entry-count { + font-size: 0.75rem; + color: var(--text-secondary, #757575); + font-weight: 400; + margin-left: 0.5rem; + white-space: nowrap; } .linkml-viewer__schema-desc { @@ -505,6 +522,11 @@ margin-bottom: 1.5rem; } +/* Flat section - renders content directly without collapsible header */ +.linkml-viewer__section--flat { + margin-bottom: 0; +} + .linkml-viewer__section-header { display: flex; align-items: center; diff --git a/frontend/src/pages/LinkMLViewerPage.tsx b/frontend/src/pages/LinkMLViewerPage.tsx index 6e7013973e..fad78ee65e 100644 --- a/frontend/src/pages/LinkMLViewerPage.tsx +++ b/frontend/src/pages/LinkMLViewerPage.tsx @@ -1019,6 +1019,15 @@ const TEXT = { // Copy functionality copyUri: { nl: 'URI kopiΓ«ren', en: 'Copy URI' }, uriCopied: { nl: 'URI gekopieerd!', en: 'URI copied!' }, + // Schema file name tooltip + linkmlFileNameTooltip: { + nl: 'Dit is de LinkML-bestandsnaam. Deze komt vaak overeen met de klassenaam, maar niet altijd. Een bestand kan meerdere klassen, enumeraties of slots bevatten.', + en: 'This is the LinkML file name. It often corresponds to the class name, but not always. A single file can contain multiple classes, enumerations, or slots.' + }, + // Entry count abbreviations + entryCountClasses: { nl: 'K', en: 'C' }, + entryCountEnums: { nl: 'E', en: 'E' }, + entryCountSlots: { nl: 'S', en: 'S' }, }; // Dynamically discover schema files from the modules directory @@ -1058,14 +1067,35 @@ const LinkMLViewerPage: React.FC = () => { const [_custodianTypesLoaded, setCustodianTypesLoaded] = useState(false); // State for sidebar search and category filters + // Category filters are persisted in localStorage const [sidebarSearch, setSidebarSearch] = useState(''); - const [categoryFilters, setCategoryFilters] = useState>({ - main: true, - class: true, - enum: true, - slot: true, + const [categoryFilters, setCategoryFilters] = useState>(() => { + try { + const saved = localStorage.getItem('linkml-viewer-category-filters'); + if (saved) { + return JSON.parse(saved); + } + } catch { + // Ignore parse errors + } + // Default: main OFF (confusing), others ON + return { + main: false, + class: true, + enum: true, + slot: true, + }; }); + // Persist category filters to localStorage + useEffect(() => { + try { + localStorage.setItem('linkml-viewer-category-filters', JSON.stringify(categoryFilters)); + } catch { + // Ignore localStorage errors (e.g., private browsing) + } + }, [categoryFilters]); + // State for sidebar collapse const [sidebarCollapsed, setSidebarCollapsed] = useState(false); @@ -1146,13 +1176,47 @@ const LinkMLViewerPage: React.FC = () => { const [hoveredCustodianType, setHoveredCustodianType] = useState(null); // State for slot_usage legend visibility toggle - const [showSlotUsageLegend, setShowSlotUsageLegend] = useState(false); + // Persisted in localStorage + const [showSlotUsageLegend, setShowSlotUsageLegend] = useState(() => { + try { + const saved = localStorage.getItem('linkml-viewer-show-legend'); + return saved === 'true'; + } catch { + return false; + } + }); + + // Persist legend visibility to localStorage + useEffect(() => { + try { + localStorage.setItem('linkml-viewer-show-legend', showSlotUsageLegend ? 'true' : 'false'); + } catch { + // Ignore localStorage errors (e.g., private browsing) + } + }, [showSlotUsageLegend]); // State for Settings menu visibility const [showSettingsMenu, setShowSettingsMenu] = useState(false); // State for Developer tools menu visibility - const [showDeveloperTools, setShowDeveloperTools] = useState(false); + // Persisted in localStorage + const [showDeveloperTools, setShowDeveloperTools] = useState(() => { + try { + const saved = localStorage.getItem('linkml-viewer-show-devtools'); + return saved === 'true'; + } catch { + return false; + } + }); + + // Persist developer tools visibility to localStorage + useEffect(() => { + try { + localStorage.setItem('linkml-viewer-show-devtools', showDeveloperTools ? 'true' : 'false'); + } catch { + // Ignore localStorage errors (e.g., private browsing) + } + }, [showDeveloperTools]); // State for sidebar category collapse (Classes, Enums, Slots headers) const [collapsedCategories, setCollapsedCategories] = useState>(new Set()); @@ -1624,12 +1688,48 @@ const LinkMLViewerPage: React.FC = () => { } }, [categories, setSearchParams]); + // Navigate to an enum by updating URL params and selecting the schema file + const navigateToEnum = useCallback((enumName: string) => { + setSearchParams({ enum: enumName }); + setHighlightedClass(enumName); // Reuse highlight state for enums + + // Find and select the enum file + const enumFile = categories + .find(cat => cat.name === 'enum') + ?.files.find(file => file.name === enumName); + + if (enumFile) { + setSelectedSchema(enumFile); + // Ensure enums section is expanded + setExpandedSections(prev => new Set([...prev, 'enums'])); + } + }, [categories, setSearchParams]); + + // Navigate to a slot by updating URL params and selecting the schema file + const navigateToSlot = useCallback((slotName: string) => { + setSearchParams({ slot: slotName }); + setHighlightedClass(slotName); // Reuse highlight state for slots + + // Find and select the slot file + const slotFile = categories + .find(cat => cat.name === 'slot') + ?.files.find(file => file.name === slotName); + + if (slotFile) { + setSelectedSchema(slotFile); + // Ensure slots section is expanded + setExpandedSections(prev => new Set([...prev, 'slots'])); + } + }, [categories, setSearchParams]); + // Track if initialization has already happened (prevents re-init on URL param changes) const isInitializedRef = useRef(false); // Handle URL parameters for deep linking (only used on initial mount) const handleUrlParams = useCallback((cats: SchemaCategory[], currentSearchParams: URLSearchParams) => { const classParam = currentSearchParams.get('class'); + const enumParam = currentSearchParams.get('enum'); + const slotParam = currentSearchParams.get('slot'); if (classParam) { setHighlightedClass(classParam); @@ -1644,6 +1744,32 @@ const LinkMLViewerPage: React.FC = () => { // Ensure classes section is expanded setExpandedSections(prev => new Set([...prev, 'classes'])); } + } else if (enumParam) { + setHighlightedClass(enumParam); + + // Find the schema file that contains this enum + const enumFile = cats + .find(cat => cat.name === 'enum') + ?.files.find(file => file.name === enumParam); + + if (enumFile) { + setSelectedSchema(enumFile); + // Ensure enums section is expanded + setExpandedSections(prev => new Set([...prev, 'enums'])); + } + } else if (slotParam) { + setHighlightedClass(slotParam); + + // Find the schema file that contains this slot + const slotFile = cats + .find(cat => cat.name === 'slot') + ?.files.find(file => file.name === slotParam); + + if (slotFile) { + setSelectedSchema(slotFile); + // Ensure slots section is expanded + setExpandedSections(prev => new Set([...prev, 'slots'])); + } } }, []); @@ -1675,7 +1801,9 @@ const LinkMLViewerPage: React.FC = () => { // Select main schema by default if no URL param set the schema const classParam = searchParams.get('class'); - if (!classParam && cats[0]?.files.length > 0) { + const enumParam = searchParams.get('enum'); + const slotParam = searchParams.get('slot'); + if (!classParam && !enumParam && !slotParam && cats[0]?.files.length > 0) { setSelectedSchema(cats[0].files[0]); } @@ -3117,7 +3245,19 @@ const LinkMLViewerPage: React.FC = () => {
{/* Schema Metadata */}
-

{formatDisplayName(schema.name || schema.title || '') || t('unnamedSchema')}

+

+ {formatDisplayName(schema.name || schema.title || '') || t('unnamedSchema')} + β“˜ + {(matchingClassCount > 0 || matchingEnumCount > 0 || matchingSlotCount > 0) && ( + + ({matchingClassCount > 0 && `${matchingClassCount}${t('entryCountClasses')}`} + {matchingClassCount > 0 && (matchingEnumCount > 0 || matchingSlotCount > 0) && ', '} + {matchingEnumCount > 0 && `${matchingEnumCount}${t('entryCountEnums')}`} + {matchingEnumCount > 0 && matchingSlotCount > 0 && ', '} + {matchingSlotCount > 0 && `${matchingSlotCount}${t('entryCountSlots')}`}) + + )} +

{schema.description && (
{transformContent(schema.description)} @@ -3152,63 +3292,24 @@ const LinkMLViewerPage: React.FC = () => { )}
- {/* Classes */} + {/* Classes - rendered directly without collapsible header */} {classes.length > 0 && ( -
- - {expandedSections.has('classes') && ( -
- {classes.map(renderClassDetails)} -
- )} +
+ {classes.map(renderClassDetails)}
)} - {/* Slots */} + {/* Slots - rendered directly without collapsible header */} {slots.length > 0 && ( -
- - {expandedSections.has('slots') && ( -
- {slots.map(slot => renderSlotDetails(slot))} -
- )} +
+ {slots.map(slot => renderSlotDetails(slot))}
)} - {/* Enums */} + {/* Enums - rendered directly without collapsible header */} {enums.length > 0 && ( -
- - {expandedSections.has('enums') && ( -
- {enums.map(renderEnumDetails)} -
- )} +
+ {enums.map(renderEnumDetails)}
)} @@ -3406,7 +3507,20 @@ const LinkMLViewerPage: React.FC = () => { className={`linkml-viewer-page__file-item ${ selectedSchema?.path === file.path ? 'linkml-viewer-page__file-item--active' : '' }`} - onClick={() => setSelectedSchema(file)} + onClick={() => { + setSelectedSchema(file); + // Update URL params based on category type for deep linking + if (category.name === 'class') { + navigateToClass(file.name); + } else if (category.name === 'enum') { + navigateToEnum(file.name); + } else if (category.name === 'slot') { + navigateToSlot(file.name); + } else { + // For main schemas, clear the deep link params + setSearchParams({}); + } + }} > {file.name}