UNPKG

@agentman/chat-widget

Version:

Agentman Chat Widget for easy integration with web applications

613 lines (505 loc) 16.3 kB
# Component Registry System ## Overview The Component Registry provides a flexible system for registering and rendering UI components based on MCP response types. This allows for dynamic, type-safe component rendering without hardcoding component mappings. ## Base Component Interface ```typescript // types/UIComponent.ts export interface UIComponent { render(): HTMLElement; update(data: any): void; destroy(): void; getId(): string; getType(): string; } export interface ComponentCallbacks { onAction?: (action: string, data: any) => void; onSubmit?: (data: any) => Promise<void>; onChange?: (field: string, value: any) => void; onError?: (error: Error) => void; onReady?: () => void; } export interface ComponentConfig { data: any; callbacks?: ComponentCallbacks; options?: ComponentOptions; } export interface ComponentOptions { theme?: 'light' | 'dark' | 'auto'; locale?: string; animations?: boolean; accessibility?: AccessibilityOptions; } ``` ## Abstract Base Component ```typescript // components/BaseComponent.ts export abstract class BaseComponent implements UIComponent { protected id: string; protected type: string; protected container: HTMLElement | null = null; protected data: any; protected callbacks: ComponentCallbacks; protected options: ComponentOptions; protected destroyed: boolean = false; constructor(config: ComponentConfig) { this.id = this.generateId(); this.type = this.constructor.name; this.data = config.data; this.callbacks = config.callbacks || {}; this.options = config.options || {}; } abstract render(): HTMLElement; update(data: any): void { if (this.destroyed) { throw new Error('Cannot update destroyed component'); } this.data = { ...this.data, ...data }; if (this.container) { const newElement = this.render(); this.container.replaceWith(newElement); this.container = newElement; } } destroy(): void { if (this.destroyed) return; this.destroyed = true; this.cleanup(); if (this.container) { this.container.remove(); this.container = null; } } getId(): string { return this.id; } getType(): string { return this.type; } protected cleanup(): void { // Override in subclasses for cleanup logic } protected generateId(): string { return `component-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } protected createElement(tag: string, className?: string, innerHTML?: string): HTMLElement { const element = document.createElement(tag); if (className) element.className = className; if (innerHTML) element.innerHTML = innerHTML; return element; } protected escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } protected emit(action: string, data?: any): void { if (this.callbacks.onAction) { this.callbacks.onAction(action, data); } } protected handleError(error: Error): void { console.error(`Error in component ${this.id}:`, error); if (this.callbacks.onError) { this.callbacks.onError(error); } } } ``` ## Component Registry Implementation ```typescript // ComponentRegistry.ts export type ComponentConstructor = new (config: ComponentConfig) => UIComponent; export class ComponentRegistry { private components: Map<string, ComponentConstructor> = new Map(); private instances: Map<string, UIComponent> = new Map(); /** * Register a component type */ register(type: string, component: ComponentConstructor): void { if (this.components.has(type)) { console.warn(`Component type "${type}" is already registered. Overwriting.`); } this.components.set(type, component); } /** * Register multiple components at once */ registerMultiple(components: Record<string, ComponentConstructor>): void { Object.entries(components).forEach(([type, component]) => { this.register(type, component); }); } /** * Get a component constructor */ get(type: string): ComponentConstructor | undefined { return this.components.get(type); } /** * Check if a component type is registered */ has(type: string): boolean { return this.components.has(type); } /** * List all registered component types */ list(): string[] { return Array.from(this.components.keys()); } /** * Create and track a component instance */ createComponent(type: string, config: ComponentConfig): UIComponent | null { const ComponentClass = this.get(type); if (!ComponentClass) { console.error(`Component type "${type}" not found in registry`); return null; } try { const instance = new ComponentClass(config); this.instances.set(instance.getId(), instance); return instance; } catch (error) { console.error(`Failed to create component of type "${type}":`, error); return null; } } /** * Get a component instance by ID */ getInstance(id: string): UIComponent | undefined { return this.instances.get(id); } /** * Destroy a component instance */ destroyInstance(id: string): void { const instance = this.instances.get(id); if (instance) { instance.destroy(); this.instances.delete(id); } } /** * Destroy all component instances */ destroyAll(): void { this.instances.forEach(instance => instance.destroy()); this.instances.clear(); } /** * Get statistics about registered components */ getStats(): { registered: number; instances: number; types: string[] } { return { registered: this.components.size, instances: this.instances.size, types: this.list() }; } } ``` ## Built-in Components ### 1. ProductCard Component ```typescript // components/ProductCard.ts import { BaseComponent } from './BaseComponent'; export interface ProductData { id: string; title: string; description?: string; price: number; compareAtPrice?: number; currency: string; image: string; badge?: string; rating?: number; reviewCount?: number; variants?: Array<{ id: string; title: string; price: number; available: boolean; }>; inStock: boolean; } export class ProductCard extends BaseComponent { render(): HTMLElement { const product = this.data as ProductData; this.container = this.createElement('div', 'product-card'); this.container.innerHTML = ` <div class="product-card__image"> <img src="${this.escapeHtml(product.image)}" alt="${this.escapeHtml(product.title)}"> ${product.badge ? `<span class="product-card__badge">${this.escapeHtml(product.badge)}</span>` : ''} </div> <div class="product-card__content"> <h3 class="product-card__title">${this.escapeHtml(product.title)}</h3> ${product.rating ? this.renderRating(product.rating, product.reviewCount) : ''} <div class="product-card__price"> <span class="price">${product.currency}${product.price}</span> ${product.compareAtPrice ? `<span class="compare-price">${product.currency}${product.compareAtPrice}</span>` : ''} </div> <button class="product-card__add-to-cart" ${!product.inStock ? 'disabled' : ''}> ${product.inStock ? 'Add to Cart' : 'Out of Stock'} </button> </div> `; this.attachEventListeners(); return this.container; } private renderRating(rating: number, reviewCount?: number): string { const stars = '★'.repeat(Math.floor(rating)) + '☆'.repeat(5 - Math.floor(rating)); return ` <div class="product-card__rating"> <span class="stars">${stars}</span> ${reviewCount ? `<span class="count">(${reviewCount})</span>` : ''} </div> `; } private attachEventListeners(): void { const addToCartBtn = this.container?.querySelector('.product-card__add-to-cart'); addToCartBtn?.addEventListener('click', () => { this.emit('add-to-cart', { productId: this.data.id }); }); } } ``` ### 2. FormRenderer Component See `UNIVERSAL_FORM_SYSTEM.md` for complete form component implementation. ### 3. SuccessComponent ```typescript // components/SuccessComponent.ts import { BaseComponent } from './BaseComponent'; export interface SuccessData { title: string; message: string; icon?: string; details?: { confirmationNumber?: string; timestamp?: string; nextSteps?: string[]; }; actions?: Array<{ label: string; action: string; primary?: boolean; }>; } export class SuccessComponent extends BaseComponent { render(): HTMLElement { const data = this.data as SuccessData; this.container = this.createElement('div', 'success-component'); const icon = this.createElement('div', 'success-component__icon'); icon.innerHTML = data.icon || '✓'; const title = this.createElement('h3', 'success-component__title', this.escapeHtml(data.title)); const message = this.createElement('p', 'success-component__message', this.escapeHtml(data.message)); this.container.appendChild(icon); this.container.appendChild(title); this.container.appendChild(message); if (data.details) { const details = this.createDetails(data.details); this.container.appendChild(details); } if (data.actions && data.actions.length > 0) { const actions = this.createActions(data.actions); this.container.appendChild(actions); } return this.container; } private createDetails(details: any): HTMLElement { const container = this.createElement('div', 'success-component__details'); if (details.confirmationNumber) { container.innerHTML += `<p>Confirmation #: <strong>${this.escapeHtml(details.confirmationNumber)}</strong></p>`; } if (details.nextSteps) { const steps = this.createElement('ul', 'success-component__next-steps'); details.nextSteps.forEach((step: string) => { const li = this.createElement('li', '', this.escapeHtml(step)); steps.appendChild(li); }); container.appendChild(steps); } return container; } private createActions(actions: any[]): HTMLElement { const container = this.createElement('div', 'success-component__actions'); actions.forEach(action => { const button = this.createElement('button', `success-component__button ${action.primary ? 'primary' : 'secondary'}`, this.escapeHtml(action.label) ); button.addEventListener('click', () => { this.emit(action.action, {}); }); container.appendChild(button); }); return container; } } ``` ## Creating Custom Components ### Step 1: Define Your Component ```typescript // components/custom/MyCustomComponent.ts import { BaseComponent } from '../BaseComponent'; export interface MyCustomData { // Define your data structure title: string; items: Array<{ id: string; name: string; value: number }>; } export class MyCustomComponent extends BaseComponent { render(): HTMLElement { const data = this.data as MyCustomData; this.container = this.createElement('div', 'my-custom-component'); // Build your component UI const title = this.createElement('h2', 'title', this.escapeHtml(data.title)); this.container.appendChild(title); const list = this.createElement('ul', 'items'); data.items.forEach(item => { const li = this.createElement('li', 'item'); li.innerHTML = `${this.escapeHtml(item.name)}: <strong>${item.value}</strong>`; li.addEventListener('click', () => this.handleItemClick(item)); list.appendChild(li); }); this.container.appendChild(list); return this.container; } private handleItemClick(item: any): void { this.emit('item-clicked', item); } update(data: Partial<MyCustomData>): void { // Custom update logic if needed super.update(data); } protected cleanup(): void { // Clean up any resources (timers, subscriptions, etc.) console.log('Cleaning up MyCustomComponent'); } } ``` ### Step 2: Register Your Component ```typescript // In your initialization code import { ComponentRegistry } from './ComponentRegistry'; import { MyCustomComponent } from './components/custom/MyCustomComponent'; const registry = new ComponentRegistry(); // Register the custom component registry.register('my-custom', MyCustomComponent); // Or register multiple at once registry.registerMultiple({ 'my-custom': MyCustomComponent, 'another-custom': AnotherCustomComponent }); ``` ### Step 3: Use Your Component ```typescript // When handling MCP response const response: MCPResponse = { uiType: 'my-custom', structuredContent: { /* ... */ }, _meta: { title: 'Custom Component Example', items: [ { id: '1', name: 'Item One', value: 100 }, { id: '2', name: 'Item Two', value: 200 } ] } }; // Create and render the component const component = registry.createComponent('my-custom', { data: response._meta, callbacks: { onAction: (action, data) => { console.log('Component action:', action, data); } } }); if (component) { const element = component.render(); document.getElementById('chat-container').appendChild(element); } ``` ## Component Lifecycle ```typescript // Example showing full component lifecycle class ComponentLifecycleExample { private registry: ComponentRegistry; private activeComponent: UIComponent | null = null; constructor() { this.registry = new ComponentRegistry(); this.registerComponents(); } private registerComponents(): void { // Register all components at startup this.registry.registerMultiple({ 'product-card': ProductCard, 'product-grid': ProductGrid, 'form-lead': LeadFormComponent, 'success': SuccessComponent, 'error': ErrorComponent }); } async handleMCPResponse(response: MCPResponse): Promise<void> { // 1. Create component this.activeComponent = this.registry.createComponent(response.uiType, { data: response._meta, callbacks: { onAction: this.handleComponentAction.bind(this), onError: this.handleComponentError.bind(this), onReady: () => console.log('Component ready') } }); if (!this.activeComponent) { console.error('Failed to create component'); return; } // 2. Render component const element = this.activeComponent.render(); document.getElementById('container').appendChild(element); // 3. Update component (if needed) setTimeout(() => { this.activeComponent?.update({ /* new data */ }); }, 5000); // 4. Destroy component (when done) // this.activeComponent.destroy(); } private handleComponentAction(action: string, data: any): void { console.log('Component action:', action, data); // Handle specific actions switch (action) { case 'add-to-cart': this.addToCart(data); break; case 'form-submit': this.submitForm(data); break; // ... more actions } } private handleComponentError(error: Error): void { console.error('Component error:', error); // Show error to user } cleanup(): void { // Destroy all components when done this.registry.destroyAll(); } } ``` ## Best Practices 1. **Always extend BaseComponent** for consistency 2. **Use type-safe data interfaces** for each component 3. **Emit events** instead of direct DOM manipulation 4. **Clean up resources** in the cleanup() method 5. **Handle errors gracefully** with try-catch blocks 6. **Make components reusable** and configurable 7. **Document your custom components** with JSDoc 8. **Test components in isolation** before integration ## Next Steps 1. See `UNIVERSAL_FORM_SYSTEM.md` for form components 2. Check `SHOPIFY_MCP_INTEGRATION.md` for Shopify components 3. Review `CHAT_WIDGET_IMPLEMENTATION.md` for integration 4. Read `TESTING_GUIDE.md` for component testing