glam/frontend/src/pages/LinkMLViewerPage.tsx
2025-11-27 10:58:53 +01:00

529 lines
19 KiB
TypeScript

/**
* LinkMLViewerPage.tsx - LinkML Schema Viewer Page
*
* Displays LinkML schema files with:
* - Sidebar listing schemas by category (main, classes, enums, slots)
* - Visual display of selected schema showing classes, slots, and enums
* - Raw YAML view toggle
* - Schema metadata and documentation
*/
import React, { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
loadSchema,
loadSchemaRaw,
type LinkMLSchema,
type LinkMLClass,
type LinkMLSlot,
type LinkMLEnum,
type SchemaFile,
extractClasses,
extractSlots,
extractEnums,
} from '../lib/linkml/schema-loader';
import './LinkMLViewerPage.css';
// Dynamically discover schema files from the modules directory
interface SchemaCategory {
name: string;
displayName: string;
files: SchemaFile[];
}
const LinkMLViewerPage: React.FC = () => {
const [categories, setCategories] = useState<SchemaCategory[]>([]);
const [selectedSchema, setSelectedSchema] = useState<SchemaFile | null>(null);
const [schema, setSchema] = useState<LinkMLSchema | null>(null);
const [rawYaml, setRawYaml] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'visual' | 'raw'>('visual');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['classes', 'enums', 'slots']));
// Initialize schema file list
useEffect(() => {
const initializeSchemas = async () => {
setIsLoading(true);
try {
// Fetch the directory listings
const classFiles = await fetchSchemaFiles('modules/classes', 'class');
const enumFiles = await fetchSchemaFiles('modules/enums', 'enum');
const slotFiles = await fetchSchemaFiles('modules/slots', 'slot');
const cats: SchemaCategory[] = [
{
name: 'main',
displayName: 'Main Schema',
files: [{ name: '01_custodian_name_modular', path: '01_custodian_name_modular.yaml', category: 'main' }]
},
{
name: 'class',
displayName: 'Classes',
files: classFiles
},
{
name: 'enum',
displayName: 'Enumerations',
files: enumFiles
},
{
name: 'slot',
displayName: 'Slots',
files: slotFiles
}
];
setCategories(cats);
// Select main schema by default
if (cats[0]?.files.length > 0) {
setSelectedSchema(cats[0].files[0]);
}
} catch (err) {
setError('Failed to initialize schema list');
console.error(err);
} finally {
setIsLoading(false);
}
};
initializeSchemas();
}, []);
// Fetch schema files from a directory by fetching the index
const fetchSchemaFiles = async (_dirPath: string, category: SchemaFile['category']): Promise<SchemaFile[]> => {
try {
// Try to fetch a manifest or list files
// For now, we'll use a hardcoded list based on what we know exists
if (category === 'class') {
const knownClasses = [
'Appellation', 'ArchiveOrganizationType', 'AuxiliaryDigitalPlatform', 'AuxiliaryPlace',
'BioCustodianType', 'CommercialOrganizationType', 'ConfidenceMeasure', 'Container',
'Country', 'Custodian', 'CustodianCollection', 'CustodianLegalStatus', 'CustodianName',
'CustodianObservation', 'CustodianPlace', 'CustodianType', 'DigitalPlatform',
'DigitalPlatformType', 'EducationProviderType', 'EncompassingBody', 'FeatureCustodianType',
'FeaturePlace', 'GalleryType', 'HeritageSocietyType', 'HolySiteType', 'Identifier',
'IntangibleHeritageGroupType', 'LibraryType', 'MuseumType', 'NGOType',
'OfficialInstitutionType', 'OrganizationalChangeEvent', 'OrganizationalStructure',
'PersonObservation', 'PersonalCollectionType', 'ReconstructionActivity',
'ResearchCenterType', 'Settlement', 'SourceDocument', 'Subregion',
'TasteSmellHeritageType', 'TimeSpan', 'UnknownType'
];
return knownClasses.map(name => ({
name,
path: `modules/classes/${name}.yaml`,
category: 'class'
}));
} else if (category === 'enum') {
const knownEnums = [
'AgentTypeEnum', 'AppellationTypeEnum', 'AuxiliaryDigitalPlatformTypeEnum',
'AuxiliaryPlaceTypeEnum', 'CustodianPrimaryTypeEnum', 'EncompassingBodyTypeEnum',
'EntityTypeEnum', 'FeatureTypeEnum', 'LegalStatusEnum', 'OrganizationalChangeEventTypeEnum',
'OrganizationalUnitTypeEnum', 'OrganizationBranchTypeEnum', 'PlaceSpecificityEnum',
'ReconstructionActivityTypeEnum', 'SourceDocumentTypeEnum', 'StaffRoleTypeEnum'
];
return knownEnums.map(name => ({
name,
path: `modules/enums/${name}.yaml`,
category: 'enum'
}));
} else if (category === 'slot') {
// Just show a sample of slots - there are 130+
const sampleSlots = [
'hc_id', 'preferred_label', 'alternative_names', 'description', 'identifiers',
'custodian_type', 'legal_status', 'place_designation', 'digital_platform',
'has_collection', 'organizational_structure', 'encompasses_body', 'created', 'modified'
];
return sampleSlots.map(name => ({
name,
path: `modules/slots/${name}.yaml`,
category: 'slot'
}));
}
return [];
} catch (err) {
console.error(`Failed to fetch files from ${_dirPath}:`, err);
return [];
}
};
// Load selected schema
useEffect(() => {
if (!selectedSchema) return;
const loadSelectedSchema = async () => {
setIsLoading(true);
setError(null);
try {
const [schemaData, yamlContent] = await Promise.all([
loadSchema(selectedSchema.path),
loadSchemaRaw(selectedSchema.path)
]);
setSchema(schemaData);
setRawYaml(yamlContent);
} catch (err) {
setError(`Failed to load schema: ${selectedSchema.name}`);
console.error(err);
} finally {
setIsLoading(false);
}
};
loadSelectedSchema();
}, [selectedSchema]);
const toggleSection = (section: string) => {
setExpandedSections(prev => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
};
const renderClassDetails = (cls: LinkMLClass) => (
<div key={cls.name} className="linkml-viewer__item">
<h4 className="linkml-viewer__item-name">
{cls.name}
{cls.abstract && <span className="linkml-viewer__badge linkml-viewer__badge--abstract">abstract</span>}
</h4>
{cls.class_uri && (
<div className="linkml-viewer__uri">
<span className="linkml-viewer__label">URI:</span>
<code>{cls.class_uri}</code>
</div>
)}
{cls.description && (
<div className="linkml-viewer__description linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{cls.description}</ReactMarkdown>
</div>
)}
{cls.slots && cls.slots.length > 0 && (
<div className="linkml-viewer__slots-list">
<span className="linkml-viewer__label">Slots:</span>
<div className="linkml-viewer__tag-list">
{cls.slots.map(slot => (
<span key={slot} className="linkml-viewer__tag">{slot}</span>
))}
</div>
</div>
)}
{cls.exact_mappings && cls.exact_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label">Exact Mappings:</span>
<div className="linkml-viewer__tag-list">
{cls.exact_mappings.map(mapping => (
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--mapping">{mapping}</span>
))}
</div>
</div>
)}
{cls.close_mappings && cls.close_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label">Close Mappings:</span>
<div className="linkml-viewer__tag-list">
{cls.close_mappings.map(mapping => (
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--close">{mapping}</span>
))}
</div>
</div>
)}
</div>
);
const renderSlotDetails = (slot: LinkMLSlot) => (
<div key={slot.name} className="linkml-viewer__item">
<h4 className="linkml-viewer__item-name">
{slot.name}
{slot.required && <span className="linkml-viewer__badge linkml-viewer__badge--required">required</span>}
{slot.multivalued && <span className="linkml-viewer__badge linkml-viewer__badge--multi">multivalued</span>}
</h4>
{slot.slot_uri && (
<div className="linkml-viewer__uri">
<span className="linkml-viewer__label">URI:</span>
<code>{slot.slot_uri}</code>
</div>
)}
{slot.range && (
<div className="linkml-viewer__range">
<span className="linkml-viewer__label">Range:</span>
<code>{slot.range}</code>
</div>
)}
{slot.description && (
<div className="linkml-viewer__description linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{slot.description}</ReactMarkdown>
</div>
)}
{slot.pattern && (
<div className="linkml-viewer__pattern">
<span className="linkml-viewer__label">Pattern:</span>
<code>{slot.pattern}</code>
</div>
)}
</div>
);
const renderEnumDetails = (enumDef: LinkMLEnum) => (
<div key={enumDef.name} className="linkml-viewer__item">
<h4 className="linkml-viewer__item-name">{enumDef.name}</h4>
{enumDef.description && (
<div className="linkml-viewer__description linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{enumDef.description}</ReactMarkdown>
</div>
)}
{enumDef.permissible_values && (
<div className="linkml-viewer__enum-values">
<span className="linkml-viewer__label">Permissible Values:</span>
<div className="linkml-viewer__value-list">
{Object.entries(enumDef.permissible_values).map(([value, details]) => (
<div key={value} className="linkml-viewer__value-item">
<code className="linkml-viewer__value-name">{value}</code>
{details.description && (
<div className="linkml-viewer__value-desc linkml-viewer__markdown linkml-viewer__markdown--compact">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{details.description}</ReactMarkdown>
</div>
)}
{details.meaning && (
<span className="linkml-viewer__value-meaning">
<span className="linkml-viewer__label">meaning:</span> {details.meaning}
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
);
const renderVisualView = () => {
if (!schema) return null;
const classes = extractClasses(schema);
const slots = extractSlots(schema);
const enums = extractEnums(schema);
return (
<div className="linkml-viewer__visual">
{/* Schema Metadata */}
<div className="linkml-viewer__metadata">
<h2 className="linkml-viewer__schema-name">{schema.name || schema.title || 'Unnamed Schema'}</h2>
{schema.description && (
<div className="linkml-viewer__schema-desc linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{schema.description}</ReactMarkdown>
</div>
)}
{schema.id && (
<div className="linkml-viewer__schema-id">
<span className="linkml-viewer__label">ID:</span>
<code>{schema.id}</code>
</div>
)}
{schema.version && (
<div className="linkml-viewer__schema-version">
<span className="linkml-viewer__label">Version:</span>
<code>{schema.version}</code>
</div>
)}
</div>
{/* Prefixes */}
{schema.prefixes && Object.keys(schema.prefixes).length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('prefixes')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('prefixes') ? '▼' : '▶'}
</span>
Prefixes ({Object.keys(schema.prefixes).length})
</button>
{expandedSections.has('prefixes') && (
<div className="linkml-viewer__prefix-list">
{Object.entries(schema.prefixes).map(([prefix, uri]) => (
<div key={prefix} className="linkml-viewer__prefix-item">
<code className="linkml-viewer__prefix-name">{prefix}:</code>
<span className="linkml-viewer__prefix-uri">{uri}</span>
</div>
))}
</div>
)}
</div>
)}
{/* Classes */}
{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>
Classes ({classes.length})
</button>
{expandedSections.has('classes') && (
<div className="linkml-viewer__section-content">
{classes.map(renderClassDetails)}
</div>
)}
</div>
)}
{/* Slots */}
{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>
Slots ({slots.length})
</button>
{expandedSections.has('slots') && (
<div className="linkml-viewer__section-content">
{slots.map(renderSlotDetails)}
</div>
)}
</div>
)}
{/* Enums */}
{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>
Enumerations ({enums.length})
</button>
{expandedSections.has('enums') && (
<div className="linkml-viewer__section-content">
{enums.map(renderEnumDetails)}
</div>
)}
</div>
)}
{/* Imports */}
{schema.imports && schema.imports.length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('imports')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('imports') ? '▼' : '▶'}
</span>
Imports ({schema.imports.length})
</button>
{expandedSections.has('imports') && (
<div className="linkml-viewer__import-list">
{schema.imports.map(imp => (
<div key={imp} className="linkml-viewer__import-item">
<code>{imp}</code>
</div>
))}
</div>
)}
</div>
)}
</div>
);
};
const renderRawView = () => {
if (!rawYaml) return null;
return (
<div className="linkml-viewer__raw">
<pre className="linkml-viewer__yaml">{rawYaml}</pre>
</div>
);
};
return (
<div className="linkml-viewer-page">
{/* Left Sidebar - Schema Files */}
<aside className="linkml-viewer-page__sidebar">
<h2 className="linkml-viewer-page__sidebar-title">LinkML Schemas</h2>
{categories.map(category => (
<div key={category.name} className="linkml-viewer-page__category">
<h3 className="linkml-viewer-page__category-title">
{category.displayName}
<span className="linkml-viewer-page__category-count">({category.files.length})</span>
</h3>
<div className="linkml-viewer-page__file-list">
{category.files.map(file => (
<button
key={file.path}
className={`linkml-viewer-page__file-item ${
selectedSchema?.path === file.path ? 'linkml-viewer-page__file-item--active' : ''
}`}
onClick={() => setSelectedSchema(file)}
>
{file.name}
</button>
))}
</div>
</div>
))}
</aside>
{/* Main Content */}
<main className="linkml-viewer-page__main">
<header className="linkml-viewer-page__header">
<h1 className="linkml-viewer-page__title">
{selectedSchema ? selectedSchema.name : 'LinkML Schema Viewer'}
</h1>
<div className="linkml-viewer-page__tabs">
<button
className={`linkml-viewer-page__tab ${viewMode === 'visual' ? 'linkml-viewer-page__tab--active' : ''}`}
onClick={() => setViewMode('visual')}
>
Visual View
</button>
<button
className={`linkml-viewer-page__tab ${viewMode === 'raw' ? 'linkml-viewer-page__tab--active' : ''}`}
onClick={() => setViewMode('raw')}
>
Raw YAML
</button>
</div>
</header>
<div className="linkml-viewer-page__content">
{isLoading ? (
<div className="linkml-viewer-page__loading">Loading schema...</div>
) : error ? (
<div className="linkml-viewer-page__error">{error}</div>
) : viewMode === 'visual' ? (
renderVisualView()
) : (
renderRawView()
)}
</div>
</main>
</div>
);
};
export default LinkMLViewerPage;