- Introduced custodian_hub_v3.mmd, custodian_hub_v4_final.mmd, and custodian_hub_v5_FINAL.mmd for Mermaid representation. - Created custodian_hub_FINAL.puml and custodian_hub_v3.puml for PlantUML representation. - Defined entities such as CustodianReconstruction, Identifier, TimeSpan, Agent, CustodianName, CustodianObservation, ReconstructionActivity, Appellation, ConfidenceMeasure, Custodian, LanguageCode, and SourceDocument. - Established relationships and associations between entities, including temporal extents, observations, and reconstruction activities. - Incorporated enumerations for various types, statuses, and classifications relevant to custodians and their activities.
1209 lines
30 KiB
Markdown
1209 lines
30 KiB
Markdown
# Frontend Design Patterns
|
|
|
|
## Overview
|
|
|
|
This document outlines the design patterns to be used in the Heritage Custodian Frontend application. These patterns ensure consistency, maintainability, and scalability across the codebase.
|
|
|
|
## Core Design Patterns
|
|
|
|
### 1. Component Composition Pattern
|
|
|
|
**Problem**: Need to build complex UI from simple, reusable components.
|
|
|
|
**Solution**: Use composition over inheritance with React components.
|
|
|
|
```typescript
|
|
// Base component
|
|
interface CardProps {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
export const Card: React.FC<CardProps> = ({ children, className = '' }) => {
|
|
return (
|
|
<div className={`rounded-lg shadow-md p-4 ${className}`}>
|
|
{children}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Composed components
|
|
export const CardHeader: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
return <div className="border-b pb-2 mb-3">{children}</div>;
|
|
};
|
|
|
|
export const CardBody: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
return <div className="text-gray-700">{children}</div>;
|
|
};
|
|
|
|
// Usage
|
|
<Card>
|
|
<CardHeader>
|
|
<h3>Rijksmuseum</h3>
|
|
</CardHeader>
|
|
<CardBody>
|
|
<p>National museum of the Netherlands</p>
|
|
</CardBody>
|
|
</Card>
|
|
```
|
|
|
|
### 2. Container/Presenter Pattern
|
|
|
|
**Problem**: Mixing business logic with presentation logic makes components hard to test and reuse.
|
|
|
|
**Solution**: Separate containers (smart components) from presenters (dumb components).
|
|
|
|
```typescript
|
|
// Presenter component (dumb)
|
|
interface CustodianListViewProps {
|
|
custodians: CustodianObservation[];
|
|
isLoading: boolean;
|
|
error?: Error;
|
|
onSelect: (custodian: CustodianObservation) => void;
|
|
}
|
|
|
|
export const CustodianListView: React.FC<CustodianListViewProps> = ({
|
|
custodians,
|
|
isLoading,
|
|
error,
|
|
onSelect,
|
|
}) => {
|
|
if (isLoading) return <LoadingSpinner />;
|
|
if (error) return <ErrorMessage error={error} />;
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{custodians.map((custodian) => (
|
|
<CustodianCard
|
|
key={custodian.id}
|
|
custodian={custodian}
|
|
onClick={() => onSelect(custodian)}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Container component (smart)
|
|
export const CustodianListContainer: React.FC = () => {
|
|
const { data, isLoading, error } = useQuery('custodians', fetchCustodians);
|
|
const navigate = useNavigate();
|
|
|
|
const handleSelect = (custodian: CustodianObservation) => {
|
|
navigate(`/custodian/${custodian.id}`);
|
|
};
|
|
|
|
return (
|
|
<CustodianListView
|
|
custodians={data || []}
|
|
isLoading={isLoading}
|
|
error={error}
|
|
onSelect={handleSelect}
|
|
/>
|
|
);
|
|
};
|
|
```
|
|
|
|
### 3. Custom Hook Pattern
|
|
|
|
**Problem**: Reusing stateful logic across components.
|
|
|
|
**Solution**: Extract logic into custom hooks.
|
|
|
|
```typescript
|
|
// Custom hook for RDF operations
|
|
export function useRDF(initialFormat: RDFFormat = 'turtle') {
|
|
const [store, setStore] = useState(() => new N3.Store());
|
|
const [format, setFormat] = useState(initialFormat);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
const loadRDF = useCallback(async (content: string) => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const parser = new N3.Parser({ format });
|
|
const newStore = new N3.Store();
|
|
|
|
parser.parse(content, (error, quad) => {
|
|
if (error) throw error;
|
|
if (quad) newStore.addQuad(quad);
|
|
});
|
|
|
|
setStore(newStore);
|
|
} catch (err) {
|
|
setError(err as Error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [format]);
|
|
|
|
const query = useCallback((sparql: string) => {
|
|
// SPARQL query execution
|
|
const engine = new SPARQLEngine(store);
|
|
return engine.execute(sparql);
|
|
}, [store]);
|
|
|
|
return {
|
|
store,
|
|
format,
|
|
setFormat,
|
|
loadRDF,
|
|
query,
|
|
isLoading,
|
|
error,
|
|
};
|
|
}
|
|
|
|
// Usage in component
|
|
function RDFExplorer() {
|
|
const { store, loadRDF, query, isLoading } = useRDF();
|
|
|
|
// Component logic using the hook
|
|
}
|
|
```
|
|
|
|
### 4. Render Props Pattern
|
|
|
|
**Problem**: Sharing component logic while maintaining flexibility in rendering.
|
|
|
|
**Solution**: Use render props for flexible component composition.
|
|
|
|
```typescript
|
|
interface DataFetcherProps<T> {
|
|
url: string;
|
|
children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode;
|
|
}
|
|
|
|
function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
|
|
const [data, setData] = useState<T | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetch(url)
|
|
.then(res => res.json())
|
|
.then(setData)
|
|
.catch(setError)
|
|
.finally(() => setLoading(false));
|
|
}, [url]);
|
|
|
|
return <>{children(data, loading, error)}</>;
|
|
}
|
|
|
|
// Usage
|
|
<DataFetcher<CustodianObservation[]> url="/api/custodians">
|
|
{(custodians, loading, error) => {
|
|
if (loading) return <Spinner />;
|
|
if (error) return <Error message={error.message} />;
|
|
return <CustodianList custodians={custodians!} />;
|
|
}}
|
|
</DataFetcher>
|
|
```
|
|
|
|
### 5. Higher-Order Component (HOC) Pattern
|
|
|
|
**Problem**: Adding common functionality to multiple components.
|
|
|
|
**Solution**: Wrap components with HOCs to inject functionality.
|
|
|
|
```typescript
|
|
// HOC for authentication
|
|
function withAuth<P extends object>(
|
|
Component: React.ComponentType<P>
|
|
): React.FC<P> {
|
|
return (props: P) => {
|
|
const { user, isLoading } = useAuth();
|
|
const navigate = useNavigate();
|
|
|
|
useEffect(() => {
|
|
if (!isLoading && !user) {
|
|
navigate('/login');
|
|
}
|
|
}, [user, isLoading, navigate]);
|
|
|
|
if (isLoading) return <LoadingSpinner />;
|
|
if (!user) return null;
|
|
|
|
return <Component {...props} />;
|
|
};
|
|
}
|
|
|
|
// Usage
|
|
const ProtectedDashboard = withAuth(Dashboard);
|
|
|
|
// HOC for error boundary
|
|
function withErrorBoundary<P extends object>(
|
|
Component: React.ComponentType<P>,
|
|
fallback?: React.ComponentType<{ error: Error }>
|
|
): React.FC<P> {
|
|
return class WithErrorBoundary extends React.Component<P, { hasError: boolean; error?: Error }> {
|
|
constructor(props: P) {
|
|
super(props);
|
|
this.state = { hasError: false };
|
|
}
|
|
|
|
static getDerivedStateFromError(error: Error) {
|
|
return { hasError: true, error };
|
|
}
|
|
|
|
render() {
|
|
if (this.state.hasError) {
|
|
const Fallback = fallback || DefaultErrorFallback;
|
|
return <Fallback error={this.state.error!} />;
|
|
}
|
|
|
|
return <Component {...this.props} />;
|
|
}
|
|
};
|
|
}
|
|
```
|
|
|
|
### 6. Factory Pattern for Visualizations
|
|
|
|
**Problem**: Creating different types of visualizations based on data type.
|
|
|
|
**Solution**: Use factory pattern to instantiate appropriate visualization.
|
|
|
|
```typescript
|
|
// Visualization factory
|
|
type VisualizationType = 'network' | 'timeline' | 'map' | 'tree' | 'chart';
|
|
|
|
interface Visualization {
|
|
render(container: HTMLElement, data: any): void;
|
|
update(data: any): void;
|
|
destroy(): void;
|
|
}
|
|
|
|
class NetworkVisualization implements Visualization {
|
|
private simulation: d3.Simulation<any, any> | null = null;
|
|
|
|
render(container: HTMLElement, data: { nodes: Node[], edges: Edge[] }): void {
|
|
const svg = d3.select(container).append('svg');
|
|
this.simulation = d3.forceSimulation(data.nodes)
|
|
.force('link', d3.forceLink(data.edges))
|
|
.force('charge', d3.forceManyBody())
|
|
.force('center', d3.forceCenter());
|
|
|
|
// Rendering logic
|
|
}
|
|
|
|
update(data: any): void {
|
|
if (this.simulation) {
|
|
this.simulation.nodes(data.nodes);
|
|
this.simulation.alpha(1).restart();
|
|
}
|
|
}
|
|
|
|
destroy(): void {
|
|
if (this.simulation) {
|
|
this.simulation.stop();
|
|
}
|
|
}
|
|
}
|
|
|
|
class TimelineVisualization implements Visualization {
|
|
render(container: HTMLElement, data: Event[]): void {
|
|
// Timeline rendering logic
|
|
}
|
|
|
|
update(data: Event[]): void {
|
|
// Update timeline
|
|
}
|
|
|
|
destroy(): void {
|
|
// Cleanup
|
|
}
|
|
}
|
|
|
|
class VisualizationFactory {
|
|
static create(type: VisualizationType): Visualization {
|
|
switch (type) {
|
|
case 'network':
|
|
return new NetworkVisualization();
|
|
case 'timeline':
|
|
return new TimelineVisualization();
|
|
case 'map':
|
|
return new MapVisualization();
|
|
case 'tree':
|
|
return new TreeVisualization();
|
|
case 'chart':
|
|
return new ChartVisualization();
|
|
default:
|
|
throw new Error(`Unknown visualization type: ${type}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
const viz = VisualizationFactory.create('network');
|
|
viz.render(containerRef.current, graphData);
|
|
```
|
|
|
|
### 7. Observer Pattern (Pub/Sub)
|
|
|
|
**Problem**: Decoupling components that need to react to events.
|
|
|
|
**Solution**: Implement publish-subscribe pattern.
|
|
|
|
```typescript
|
|
type EventCallback = (data: any) => void;
|
|
|
|
class EventEmitter {
|
|
private events: Map<string, Set<EventCallback>> = new Map();
|
|
|
|
on(event: string, callback: EventCallback): () => void {
|
|
if (!this.events.has(event)) {
|
|
this.events.set(event, new Set());
|
|
}
|
|
this.events.get(event)!.add(callback);
|
|
|
|
// Return unsubscribe function
|
|
return () => this.off(event, callback);
|
|
}
|
|
|
|
off(event: string, callback: EventCallback): void {
|
|
const callbacks = this.events.get(event);
|
|
if (callbacks) {
|
|
callbacks.delete(callback);
|
|
}
|
|
}
|
|
|
|
emit(event: string, data?: any): void {
|
|
const callbacks = this.events.get(event);
|
|
if (callbacks) {
|
|
callbacks.forEach(callback => callback(data));
|
|
}
|
|
}
|
|
|
|
clear(): void {
|
|
this.events.clear();
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
export const eventBus = new EventEmitter();
|
|
|
|
// Usage in component
|
|
function CustodianMap() {
|
|
useEffect(() => {
|
|
const unsubscribe = eventBus.on('custodian:selected', (custodian) => {
|
|
// Highlight custodian on map
|
|
highlightMarker(custodian.id);
|
|
});
|
|
|
|
return unsubscribe; // Cleanup on unmount
|
|
}, []);
|
|
|
|
return <MapContainer />;
|
|
}
|
|
|
|
// Emit event from another component
|
|
function CustodianList() {
|
|
const handleClick = (custodian: CustodianObservation) => {
|
|
eventBus.emit('custodian:selected', custodian);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{custodians.map(c => (
|
|
<button onClick={() => handleClick(c)}>{c.name}</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 8. Builder Pattern for Complex Queries
|
|
|
|
**Problem**: Constructing complex SPARQL queries with many optional parts.
|
|
|
|
**Solution**: Use builder pattern for fluent query construction.
|
|
|
|
```typescript
|
|
class SPARQLQueryBuilder {
|
|
private prefixes: Map<string, string> = new Map();
|
|
private selectVars: string[] = [];
|
|
private wherePatterns: string[] = [];
|
|
private filterExpressions: string[] = [];
|
|
private orderByClauses: string[] = [];
|
|
private limitValue?: number;
|
|
private offsetValue?: number;
|
|
|
|
prefix(prefix: string, uri: string): this {
|
|
this.prefixes.set(prefix, uri);
|
|
return this;
|
|
}
|
|
|
|
select(...vars: string[]): this {
|
|
this.selectVars.push(...vars);
|
|
return this;
|
|
}
|
|
|
|
where(pattern: string): this {
|
|
this.wherePatterns.push(pattern);
|
|
return this;
|
|
}
|
|
|
|
filter(expression: string): this {
|
|
this.filterExpressions.push(expression);
|
|
return this;
|
|
}
|
|
|
|
orderBy(clause: string): this {
|
|
this.orderByClauses.push(clause);
|
|
return this;
|
|
}
|
|
|
|
limit(value: number): this {
|
|
this.limitValue = value;
|
|
return this;
|
|
}
|
|
|
|
offset(value: number): this {
|
|
this.offsetValue = value;
|
|
return this;
|
|
}
|
|
|
|
build(): string {
|
|
let query = '';
|
|
|
|
// Add prefixes
|
|
for (const [prefix, uri] of this.prefixes) {
|
|
query += `PREFIX ${prefix}: <${uri}>\n`;
|
|
}
|
|
|
|
// Add SELECT
|
|
query += `\nSELECT ${this.selectVars.join(' ')}\n`;
|
|
|
|
// Add WHERE
|
|
query += 'WHERE {\n';
|
|
query += this.wherePatterns.map(p => ` ${p}`).join('\n');
|
|
|
|
if (this.filterExpressions.length > 0) {
|
|
query += '\n' + this.filterExpressions.map(f => ` FILTER(${f})`).join('\n');
|
|
}
|
|
|
|
query += '\n}\n';
|
|
|
|
// Add ORDER BY
|
|
if (this.orderByClauses.length > 0) {
|
|
query += `ORDER BY ${this.orderByClauses.join(' ')}\n`;
|
|
}
|
|
|
|
// Add LIMIT and OFFSET
|
|
if (this.limitValue !== undefined) {
|
|
query += `LIMIT ${this.limitValue}\n`;
|
|
}
|
|
if (this.offsetValue !== undefined) {
|
|
query += `OFFSET ${this.offsetValue}\n`;
|
|
}
|
|
|
|
return query;
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
const query = new SPARQLQueryBuilder()
|
|
.prefix('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#')
|
|
.prefix('hc', 'https://w3id.org/heritage/custodian/')
|
|
.select('?custodian', '?name', '?type')
|
|
.where('?custodian a hc:CustodianObservation .')
|
|
.where('?custodian hc:name ?name .')
|
|
.where('?custodian hc:institution_type ?type .')
|
|
.filter('LANG(?name) = "en"')
|
|
.orderBy('?name')
|
|
.limit(50)
|
|
.build();
|
|
```
|
|
|
|
### 9. Strategy Pattern for Export Formats
|
|
|
|
**Problem**: Supporting multiple export formats without complex conditionals.
|
|
|
|
**Solution**: Define strategy interface with concrete implementations for each format.
|
|
|
|
```typescript
|
|
interface ExportStrategy {
|
|
export(data: CustodianObservation[]): string | Blob;
|
|
getFileExtension(): string;
|
|
getMimeType(): string;
|
|
}
|
|
|
|
class JSONExportStrategy implements ExportStrategy {
|
|
export(data: CustodianObservation[]): string {
|
|
return JSON.stringify(data, null, 2);
|
|
}
|
|
|
|
getFileExtension(): string {
|
|
return 'json';
|
|
}
|
|
|
|
getMimeType(): string {
|
|
return 'application/json';
|
|
}
|
|
}
|
|
|
|
class CSVExportStrategy implements ExportStrategy {
|
|
export(data: CustodianObservation[]): string {
|
|
const header = ['ID', 'Name', 'Type', 'City', 'Country'];
|
|
const rows = data.map(c => [
|
|
c.id,
|
|
c.name,
|
|
c.institution_type,
|
|
c.locations?.[0]?.city || '',
|
|
c.locations?.[0]?.country || '',
|
|
]);
|
|
|
|
return [header, ...rows]
|
|
.map(row => row.map(cell => `"${cell}"`).join(','))
|
|
.join('\n');
|
|
}
|
|
|
|
getFileExtension(): string {
|
|
return 'csv';
|
|
}
|
|
|
|
getMimeType(): string {
|
|
return 'text/csv';
|
|
}
|
|
}
|
|
|
|
class RDFTurtleExportStrategy implements ExportStrategy {
|
|
export(data: CustodianObservation[]): string {
|
|
const transformer = new CustodianTransformer();
|
|
const triples = data.flatMap(c => transformer.toRDF(c));
|
|
return serializeTriples(triples, 'turtle');
|
|
}
|
|
|
|
getFileExtension(): string {
|
|
return 'ttl';
|
|
}
|
|
|
|
getMimeType(): string {
|
|
return 'text/turtle';
|
|
}
|
|
}
|
|
|
|
class ExportService {
|
|
private strategies: Map<string, ExportStrategy> = new Map([
|
|
['json', new JSONExportStrategy()],
|
|
['csv', new CSVExportStrategy()],
|
|
['turtle', new RDFTurtleExportStrategy()],
|
|
]);
|
|
|
|
export(data: CustodianObservation[], format: string): void {
|
|
const strategy = this.strategies.get(format);
|
|
if (!strategy) {
|
|
throw new Error(`Unsupported export format: ${format}`);
|
|
}
|
|
|
|
const content = strategy.export(data);
|
|
const blob = new Blob([content], { type: strategy.getMimeType() });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `custodians.${strategy.getFileExtension()}`;
|
|
a.click();
|
|
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
const exportService = new ExportService();
|
|
exportService.export(selectedCustodians, 'csv');
|
|
```
|
|
|
|
### 10. Adapter Pattern for RDF Formats
|
|
|
|
**Problem**: Working with different RDF serialization formats uniformly.
|
|
|
|
**Solution**: Create adapters that provide consistent interface.
|
|
|
|
```typescript
|
|
interface RDFAdapter {
|
|
parse(content: string): Promise<Triple[]>;
|
|
serialize(triples: Triple[]): Promise<string>;
|
|
validate(content: string): boolean;
|
|
}
|
|
|
|
class TurtleAdapter implements RDFAdapter {
|
|
async parse(content: string): Promise<Triple[]> {
|
|
const parser = new N3.Parser({ format: 'turtle' });
|
|
return new Promise((resolve, reject) => {
|
|
const triples: Triple[] = [];
|
|
parser.parse(content, (error, quad) => {
|
|
if (error) reject(error);
|
|
if (quad) triples.push(quadToTriple(quad));
|
|
else resolve(triples);
|
|
});
|
|
});
|
|
}
|
|
|
|
async serialize(triples: Triple[]): Promise<string> {
|
|
const writer = new N3.Writer({ format: 'turtle' });
|
|
triples.forEach(t => writer.addQuad(tripleToQuad(t)));
|
|
return new Promise((resolve, reject) => {
|
|
writer.end((error, result) => {
|
|
if (error) reject(error);
|
|
else resolve(result);
|
|
});
|
|
});
|
|
}
|
|
|
|
validate(content: string): boolean {
|
|
try {
|
|
const parser = new N3.Parser({ format: 'turtle' });
|
|
parser.parse(content);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
class JSONLDAdapter implements RDFAdapter {
|
|
async parse(content: string): Promise<Triple[]> {
|
|
const doc = JSON.parse(content);
|
|
const nquads = await jsonld.toRDF(doc, { format: 'application/n-quads' });
|
|
return parseNQuads(nquads);
|
|
}
|
|
|
|
async serialize(triples: Triple[]): Promise<string> {
|
|
const nquads = serializeToNQuads(triples);
|
|
const doc = await jsonld.fromRDF(nquads, { format: 'application/n-quads' });
|
|
return JSON.stringify(doc, null, 2);
|
|
}
|
|
|
|
validate(content: string): boolean {
|
|
try {
|
|
const doc = JSON.parse(content);
|
|
return doc['@context'] !== undefined;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
class RDFAdapterFactory {
|
|
private adapters: Map<RDFFormat, RDFAdapter> = new Map([
|
|
['turtle', new TurtleAdapter()],
|
|
['jsonld', new JSONLDAdapter()],
|
|
['ntriples', new NTriplesAdapter()],
|
|
['rdfxml', new RDFXMLAdapter()],
|
|
]);
|
|
|
|
getAdapter(format: RDFFormat): RDFAdapter {
|
|
const adapter = this.adapters.get(format);
|
|
if (!adapter) {
|
|
throw new Error(`No adapter found for format: ${format}`);
|
|
}
|
|
return adapter;
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
const factory = new RDFAdapterFactory();
|
|
const adapter = factory.getAdapter('turtle');
|
|
const triples = await adapter.parse(rdfContent);
|
|
```
|
|
|
|
## UI Component Patterns
|
|
|
|
### 11. Compound Components Pattern
|
|
|
|
**Problem**: Creating components with implicit parent-child relationships.
|
|
|
|
**Solution**: Use React Context for compound components.
|
|
|
|
```typescript
|
|
interface TabsContextValue {
|
|
activeTab: string;
|
|
setActiveTab: (tab: string) => void;
|
|
}
|
|
|
|
const TabsContext = createContext<TabsContextValue | null>(null);
|
|
|
|
function Tabs({ children, defaultTab }: { children: React.ReactNode; defaultTab: string }) {
|
|
const [activeTab, setActiveTab] = useState(defaultTab);
|
|
|
|
return (
|
|
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
|
|
<div className="tabs">{children}</div>
|
|
</TabsContext.Provider>
|
|
);
|
|
}
|
|
|
|
function TabList({ children }: { children: React.ReactNode }) {
|
|
return <div className="tab-list flex border-b">{children}</div>;
|
|
}
|
|
|
|
function Tab({ value, children }: { value: string; children: React.ReactNode }) {
|
|
const context = useContext(TabsContext);
|
|
if (!context) throw new Error('Tab must be used within Tabs');
|
|
|
|
const isActive = context.activeTab === value;
|
|
|
|
return (
|
|
<button
|
|
className={`px-4 py-2 ${isActive ? 'border-b-2 border-blue-500' : ''}`}
|
|
onClick={() => context.setActiveTab(value)}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function TabPanel({ value, children }: { value: string; children: React.ReactNode }) {
|
|
const context = useContext(TabsContext);
|
|
if (!context) throw new Error('TabPanel must be used within Tabs');
|
|
|
|
if (context.activeTab !== value) return null;
|
|
|
|
return <div className="tab-panel p-4">{children}</div>;
|
|
}
|
|
|
|
// Export compound component
|
|
export const TabsComponent = Object.assign(Tabs, {
|
|
List: TabList,
|
|
Tab: Tab,
|
|
Panel: TabPanel,
|
|
});
|
|
|
|
// Usage
|
|
<TabsComponent defaultTab="overview">
|
|
<TabsComponent.List>
|
|
<TabsComponent.Tab value="overview">Overview</TabsComponent.Tab>
|
|
<TabsComponent.Tab value="visualizations">Visualizations</TabsComponent.Tab>
|
|
<TabsComponent.Tab value="query">Query</TabsComponent.Tab>
|
|
</TabsComponent.List>
|
|
|
|
<TabsComponent.Panel value="overview">
|
|
<OverviewContent />
|
|
</TabsComponent.Panel>
|
|
<TabsComponent.Panel value="visualizations">
|
|
<VisualizationsContent />
|
|
</TabsComponent.Panel>
|
|
<TabsComponent.Panel value="query">
|
|
<QueryContent />
|
|
</TabsComponent.Panel>
|
|
</TabsComponent>
|
|
```
|
|
|
|
### 12. Controlled vs Uncontrolled Components
|
|
|
|
**Problem**: Balancing component flexibility and control.
|
|
|
|
**Solution**: Support both controlled and uncontrolled modes.
|
|
|
|
```typescript
|
|
interface SearchBoxProps {
|
|
// Controlled mode
|
|
value?: string;
|
|
onChange?: (value: string) => void;
|
|
|
|
// Uncontrolled mode
|
|
defaultValue?: string;
|
|
|
|
// Common props
|
|
placeholder?: string;
|
|
onSearch?: (value: string) => void;
|
|
}
|
|
|
|
function SearchBox({
|
|
value: controlledValue,
|
|
onChange,
|
|
defaultValue = '',
|
|
placeholder = 'Search...',
|
|
onSearch,
|
|
}: SearchBoxProps) {
|
|
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
|
|
// Determine if controlled or uncontrolled
|
|
const isControlled = controlledValue !== undefined;
|
|
const value = isControlled ? controlledValue : internalValue;
|
|
|
|
const handleChange = (newValue: string) => {
|
|
if (!isControlled) {
|
|
setInternalValue(newValue);
|
|
}
|
|
onChange?.(newValue);
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
onSearch?.(value);
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit}>
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => handleChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
/>
|
|
<button type="submit">Search</button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
// Uncontrolled usage
|
|
<SearchBox
|
|
defaultValue="Rijksmuseum"
|
|
onSearch={(query) => performSearch(query)}
|
|
/>
|
|
|
|
// Controlled usage
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
<SearchBox
|
|
value={searchQuery}
|
|
onChange={setSearchQuery}
|
|
onSearch={(query) => performSearch(query)}
|
|
/>
|
|
```
|
|
|
|
## State Management Patterns
|
|
|
|
### 13. Zustand Store Slices
|
|
|
|
**Problem**: Organizing large state stores.
|
|
|
|
**Solution**: Split store into logical slices.
|
|
|
|
```typescript
|
|
// stores/slices/rdfSlice.ts
|
|
export interface RDFSlice {
|
|
triples: Triple[];
|
|
format: RDFFormat;
|
|
isLoading: boolean;
|
|
error: Error | null;
|
|
loadRDF: (content: string, format: RDFFormat) => Promise<void>;
|
|
clearRDF: () => void;
|
|
}
|
|
|
|
export const createRDFSlice: StateCreator<RDFSlice> = (set, get) => ({
|
|
triples: [],
|
|
format: 'turtle',
|
|
isLoading: false,
|
|
error: null,
|
|
|
|
loadRDF: async (content, format) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const adapter = new RDFAdapterFactory().getAdapter(format);
|
|
const triples = await adapter.parse(content);
|
|
set({ triples, format, isLoading: false });
|
|
} catch (error) {
|
|
set({ error: error as Error, isLoading: false });
|
|
}
|
|
},
|
|
|
|
clearRDF: () => set({ triples: [], error: null }),
|
|
});
|
|
|
|
// stores/slices/querySlice.ts
|
|
export interface QuerySlice {
|
|
currentQuery: string;
|
|
results: QueryResult[];
|
|
isExecuting: boolean;
|
|
setQuery: (query: string) => void;
|
|
executeQuery: () => Promise<void>;
|
|
}
|
|
|
|
export const createQuerySlice: StateCreator<QuerySlice> = (set, get) => ({
|
|
currentQuery: '',
|
|
results: [],
|
|
isExecuting: false,
|
|
|
|
setQuery: (query) => set({ currentQuery: query }),
|
|
|
|
executeQuery: async () => {
|
|
set({ isExecuting: true });
|
|
try {
|
|
const { triples } = get() as any; // Access RDF slice
|
|
const engine = new SPARQLEngine(triples);
|
|
const results = await engine.execute(get().currentQuery);
|
|
set({ results, isExecuting: false });
|
|
} catch (error) {
|
|
console.error('Query execution failed:', error);
|
|
set({ isExecuting: false });
|
|
}
|
|
},
|
|
});
|
|
|
|
// stores/index.ts
|
|
import { create } from 'zustand';
|
|
import { devtools } from 'zustand/middleware';
|
|
|
|
type Store = RDFSlice & QuerySlice & UISlice;
|
|
|
|
export const useStore = create<Store>()(
|
|
devtools(
|
|
(...a) => ({
|
|
...createRDFSlice(...a),
|
|
...createQuerySlice(...a),
|
|
...createUISlice(...a),
|
|
})
|
|
)
|
|
);
|
|
```
|
|
|
|
## Performance Patterns
|
|
|
|
### 14. Memoization Pattern
|
|
|
|
**Problem**: Expensive computations running on every render.
|
|
|
|
**Solution**: Use useMemo and useCallback appropriately.
|
|
|
|
```typescript
|
|
function CustodianNetwork({ custodians }: { custodians: CustodianObservation[] }) {
|
|
// Memoize expensive graph computation
|
|
const graphData = useMemo(() => {
|
|
const nodes = custodians.map(c => ({ id: c.id, label: c.name }));
|
|
const edges = computeRelationships(custodians);
|
|
return { nodes, edges };
|
|
}, [custodians]); // Only recompute if custodians change
|
|
|
|
// Memoize callback to prevent child re-renders
|
|
const handleNodeClick = useCallback((nodeId: string) => {
|
|
const custodian = custodians.find(c => c.id === nodeId);
|
|
if (custodian) {
|
|
eventBus.emit('custodian:selected', custodian);
|
|
}
|
|
}, [custodians]);
|
|
|
|
return <NetworkGraph data={graphData} onNodeClick={handleNodeClick} />;
|
|
}
|
|
|
|
// Memoized component (React.memo)
|
|
export const CustodianCard = React.memo<CustodianCardProps>(
|
|
({ custodian, onClick }) => {
|
|
return (
|
|
<div onClick={() => onClick(custodian)}>
|
|
<h3>{custodian.name}</h3>
|
|
<p>{custodian.institution_type}</p>
|
|
</div>
|
|
);
|
|
},
|
|
(prevProps, nextProps) => {
|
|
// Custom comparison function
|
|
return prevProps.custodian.id === nextProps.custodian.id;
|
|
}
|
|
);
|
|
```
|
|
|
|
### 15. Debouncing and Throttling
|
|
|
|
**Problem**: Handling high-frequency events efficiently.
|
|
|
|
**Solution**: Debounce user input, throttle scroll/resize events.
|
|
|
|
```typescript
|
|
// Custom hook for debouncing
|
|
function useDebounce<T>(value: T, delay: number): T {
|
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
|
|
useEffect(() => {
|
|
const handler = setTimeout(() => {
|
|
setDebouncedValue(value);
|
|
}, delay);
|
|
|
|
return () => {
|
|
clearTimeout(handler);
|
|
};
|
|
}, [value, delay]);
|
|
|
|
return debouncedValue;
|
|
}
|
|
|
|
// Usage in search
|
|
function SearchComponent() {
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
|
|
|
const { data } = useQuery(
|
|
['search', debouncedSearchTerm],
|
|
() => searchCustodians(debouncedSearchTerm),
|
|
{ enabled: debouncedSearchTerm.length > 0 }
|
|
);
|
|
|
|
return (
|
|
<input
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
placeholder="Search custodians..."
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Custom hook for throttling
|
|
function useThrottle<T extends (...args: any[]) => any>(
|
|
callback: T,
|
|
delay: number
|
|
): T {
|
|
const lastRun = useRef(Date.now());
|
|
|
|
return useCallback((...args: Parameters<T>) => {
|
|
const now = Date.now();
|
|
if (now - lastRun.current >= delay) {
|
|
callback(...args);
|
|
lastRun.current = now;
|
|
}
|
|
}, [callback, delay]) as T;
|
|
}
|
|
|
|
// Usage for scroll events
|
|
function InfiniteScrollList() {
|
|
const loadMore = useThrottle(() => {
|
|
// Load more items
|
|
}, 200);
|
|
|
|
useEffect(() => {
|
|
window.addEventListener('scroll', loadMore);
|
|
return () => window.removeEventListener('scroll', loadMore);
|
|
}, [loadMore]);
|
|
|
|
return <div>...</div>;
|
|
}
|
|
```
|
|
|
|
## Error Handling Patterns
|
|
|
|
### 16. Error Boundary Pattern
|
|
|
|
**Problem**: Gracefully handling component errors.
|
|
|
|
**Solution**: Implement error boundaries.
|
|
|
|
```typescript
|
|
interface ErrorBoundaryProps {
|
|
children: React.ReactNode;
|
|
fallback?: React.ComponentType<{ error: Error; reset: () => void }>;
|
|
}
|
|
|
|
interface ErrorBoundaryState {
|
|
hasError: boolean;
|
|
error?: Error;
|
|
}
|
|
|
|
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
constructor(props: ErrorBoundaryProps) {
|
|
super(props);
|
|
this.state = { hasError: false };
|
|
}
|
|
|
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
return { hasError: true, error };
|
|
}
|
|
|
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
|
console.error('Error boundary caught error:', error, errorInfo);
|
|
// Send to error tracking service
|
|
reportError(error, errorInfo);
|
|
}
|
|
|
|
reset = () => {
|
|
this.setState({ hasError: false, error: undefined });
|
|
};
|
|
|
|
render() {
|
|
if (this.state.hasError && this.state.error) {
|
|
const Fallback = this.props.fallback || DefaultErrorFallback;
|
|
return <Fallback error={this.state.error} reset={this.reset} />;
|
|
}
|
|
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
// Default fallback component
|
|
function DefaultErrorFallback({ error, reset }: { error: Error; reset: () => void }) {
|
|
return (
|
|
<div className="error-boundary">
|
|
<h2>Something went wrong</h2>
|
|
<pre>{error.message}</pre>
|
|
<button onClick={reset}>Try again</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Usage
|
|
<ErrorBoundary fallback={CustomErrorFallback}>
|
|
<CustodianVisualization />
|
|
</ErrorBoundary>
|
|
```
|
|
|
|
## Testing Patterns
|
|
|
|
### 17. Test Data Builders
|
|
|
|
**Problem**: Creating test data is repetitive and brittle.
|
|
|
|
**Solution**: Use builder pattern for test data.
|
|
|
|
```typescript
|
|
class CustodianBuilder {
|
|
private custodian: Partial<CustodianObservation> = {
|
|
id: 'test-id',
|
|
name: 'Test Museum',
|
|
institution_type: 'MUSEUM',
|
|
};
|
|
|
|
withId(id: string): this {
|
|
this.custodian.id = id;
|
|
return this;
|
|
}
|
|
|
|
withName(name: string): this {
|
|
this.custodian.name = name;
|
|
return this;
|
|
}
|
|
|
|
withType(type: InstitutionType): this {
|
|
this.custodian.institution_type = type;
|
|
return this;
|
|
}
|
|
|
|
withLocation(city: string, country: string): this {
|
|
this.custodian.locations = [{
|
|
city,
|
|
country,
|
|
latitude: 0,
|
|
longitude: 0,
|
|
}];
|
|
return this;
|
|
}
|
|
|
|
build(): CustodianObservation {
|
|
return this.custodian as CustodianObservation;
|
|
}
|
|
}
|
|
|
|
// Usage in tests
|
|
describe('CustodianCard', () => {
|
|
it('displays museum information', () => {
|
|
const museum = new CustodianBuilder()
|
|
.withName('Rijksmuseum')
|
|
.withType('MUSEUM')
|
|
.withLocation('Amsterdam', 'NL')
|
|
.build();
|
|
|
|
render(<CustodianCard custodian={museum} />);
|
|
|
|
expect(screen.getByText('Rijksmuseum')).toBeInTheDocument();
|
|
expect(screen.getByText('MUSEUM')).toBeInTheDocument();
|
|
});
|
|
});
|
|
```
|
|
|
|
## Related Documents
|
|
|
|
- [01-architecture.md](01-architecture.md) - System architecture
|
|
- [03-tdd-strategy.md](03-tdd-strategy.md) - Testing strategy
|
|
- [04-example-ld-mapping.md](04-example-ld-mapping.md) - Example LD reuse patterns
|
|
- [05-d3-visualization.md](05-d3-visualization.md) - D3.js patterns
|