feat(linkml-viewer): UX improvements - entry counts, deep links, settings persistence
All checks were successful
Deploy Frontend / build-and-deploy (push) Successful in 4m4s
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:
parent
eff3153f3f
commit
3e6c2367ad
3 changed files with 334 additions and 58 deletions
140
.opencode/rules/class-files-no-inline-slots.md
Normal file
140
.opencode/rules/class-files-no-inline-slots.md
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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,65 +3292,26 @@ 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">
|
||||
<div className="linkml-viewer__section linkml-viewer__section--flat">
|
||||
{classes.map(renderClassDetails)}
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
<div className="linkml-viewer__section linkml-viewer__section--flat">
|
||||
{slots.map(slot => renderSlotDetails(slot))}
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
<div className="linkml-viewer__section linkml-viewer__section--flat">
|
||||
{enums.map(renderEnumDetails)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Developer Tools Section - Prefixes and Imports */}
|
||||
{((schema.prefixes && Object.keys(schema.prefixes).length > 0) || (schema.imports && schema.imports.length > 0)) && (
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue