@agentman/chat-widget
Version:
Agentman Chat Widget for easy integration with web applications
613 lines (505 loc) • 16.3 kB
Markdown
# 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