glam/docs/plan/frontend/02-design-patterns.md
kempersc fa5680f0dd Add initial versions of custodian hub UML diagrams in Mermaid and PlantUML formats
- 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.
2025-11-22 14:33:51 +01:00

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