feat(linkml-viewer): UX improvements - entry counts, deep links, settings persistence
All checks were successful
Deploy Frontend / build-and-deploy (push) Successful in 4m4s

- 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
This commit is contained in:
kempersc 2026-01-11 21:42:35 +01:00
parent eff3153f3f
commit 3e6c2367ad
3 changed files with 334 additions and 58 deletions

View file

@ -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

View file

@ -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;

View file

@ -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<string>('');
const [categoryFilters, setCategoryFilters] = useState<Record<string, boolean>>({
main: true,
class: true,
enum: true,
slot: true,
const [categoryFilters, setCategoryFilters] = useState<Record<string, boolean>>(() => {
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<boolean>(false);
@ -1146,13 +1176,47 @@ const LinkMLViewerPage: React.FC = () => {
const [hoveredCustodianType, setHoveredCustodianType] = useState<CustodianTypeCode | null>(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<Set<string>>(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 = () => {
<div className="linkml-viewer__visual">
{/* Schema Metadata */}
<div className="linkml-viewer__metadata">
<h2 className="linkml-viewer__schema-name">{formatDisplayName(schema.name || schema.title || '') || t('unnamedSchema')}</h2>
<h2 className="linkml-viewer__schema-name">
{formatDisplayName(schema.name || schema.title || '') || t('unnamedSchema')}
<span className="linkml-viewer__info-icon" data-tooltip={t('linkmlFileNameTooltip')}></span>
{(matchingClassCount > 0 || matchingEnumCount > 0 || matchingSlotCount > 0) && (
<span className="linkml-viewer__entry-count">
({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')}`})
</span>
)}
</h2>
{schema.description && (
<div className="linkml-viewer__schema-desc linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(schema.description)}</ReactMarkdown>
@ -3152,63 +3292,24 @@ const LinkMLViewerPage: React.FC = () => {
)}
</div>
{/* Classes */}
{/* Classes - rendered directly without collapsible header */}
{classes.length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('classes')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('classes') ? '▼' : '▶'}
</span>
{t('classes')} ({custodianTypeFilter.size > 0 ? `${matchingClassCount}/${classes.length}` : classes.length})
</button>
{expandedSections.has('classes') && (
<div className="linkml-viewer__section-content">
{classes.map(renderClassDetails)}
</div>
)}
<div className="linkml-viewer__section linkml-viewer__section--flat">
{classes.map(renderClassDetails)}
</div>
)}
{/* Slots */}
{/* Slots - rendered directly without collapsible header */}
{slots.length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('slots')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('slots') ? '▼' : '▶'}
</span>
{t('slots')} ({custodianTypeFilter.size > 0 ? `${matchingSlotCount}/${slots.length}` : slots.length})
</button>
{expandedSections.has('slots') && (
<div className="linkml-viewer__section-content">
{slots.map(slot => renderSlotDetails(slot))}
</div>
)}
<div className="linkml-viewer__section linkml-viewer__section--flat">
{slots.map(slot => renderSlotDetails(slot))}
</div>
)}
{/* Enums */}
{/* Enums - rendered directly without collapsible header */}
{enums.length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('enums')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('enums') ? '▼' : '▶'}
</span>
{t('enumerations')} ({custodianTypeFilter.size > 0 ? `${matchingEnumCount}/${enums.length}` : enums.length})
</button>
{expandedSections.has('enums') && (
<div className="linkml-viewer__section-content">
{enums.map(renderEnumDetails)}
</div>
)}
<div className="linkml-viewer__section linkml-viewer__section--flat">
{enums.map(renderEnumDetails)}
</div>
)}
@ -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}
</button>