UNPKG

@agentman/chat-widget

Version:

Agentman Chat Widget for easy integration with web applications

733 lines (595 loc) 22 kB
# Universal Form System ## Overview A comprehensive, extensible form system that can handle any type of form through a unified schema and rendering pipeline. This system is designed to work with MCP responses and handle everything from simple contact forms to complex multi-step wizards. ## Form Schema Definition ```typescript // types/FormSchema.ts export interface UniversalFormSchema { // Form identification id: string; type: string; // 'lead', 'payment', 'survey', 'application', etc. version: string; // Form metadata metadata?: { title?: string; description?: string; icon?: string; estimatedTime?: string; // "2 minutes" category?: string; }; // Field definitions fields: FormField[]; // Form configuration config?: FormConfig; // Submission handling submission?: SubmissionConfig; // Validation rules validation?: FormValidation; } export interface FormField { // Basic properties id: string; type: FieldType; label: string; placeholder?: string; defaultValue?: any; required?: boolean; disabled?: boolean; readonly?: boolean; hidden?: boolean; // Validation validation?: FieldValidation; // Conditional display conditions?: FieldCondition[]; // Layout layout?: FieldLayout; // Type-specific properties options?: SelectOption[]; // For select, radio, checkbox min?: number | string; // For number, date, time max?: number | string; step?: number; // For number, range rows?: number; // For textarea accept?: string; // For file input multiple?: boolean; // For file, select // Additional metadata metadata?: Record<string, any>; } export type FieldType = // Text inputs | 'text' | 'email' | 'tel' | 'url' | 'password' | 'search' // Number inputs | 'number' | 'range' | 'currency' // Date/Time | 'date' | 'time' | 'datetime' | 'month' | 'week' // Choices | 'select' | 'radio' | 'checkbox' | 'toggle' | 'chips' // Text areas | 'textarea' | 'rich-text' | 'markdown' | 'code' // Files | 'file' | 'image' | 'signature' // Special | 'hidden' | 'autocomplete' | 'country' | 'rating' | 'color' // Complex | 'repeater' | 'matrix' | 'location' | 'custom'; export interface FieldValidation { rules?: ValidationRule[]; customValidator?: string; // Function name asyncValidator?: string; // For server-side validation errorMessage?: string; warningMessage?: string; } export interface ValidationRule { type: 'required' | 'min' | 'max' | 'minLength' | 'maxLength' | 'pattern' | 'email' | 'url' | 'custom' | 'remote'; value?: any; message?: string; } export interface FieldCondition { field: string; // Field ID to watch operator: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than' | 'in' | 'not_in' | 'empty' | 'not_empty'; value?: any; action?: 'show' | 'hide' | 'enable' | 'disable' | 'require'; } export interface FormConfig { layout?: 'single-column' | 'two-column' | 'grid' | 'wizard' | 'tabs'; columns?: number; theme?: 'default' | 'minimal' | 'material' | 'bootstrap'; submitButton?: { text: string; loadingText?: string; successText?: string; position?: 'left' | 'right' | 'center' | 'sticky'; style?: 'primary' | 'secondary' | 'success' | 'danger'; }; validation?: { mode: 'onBlur' | 'onChange' | 'onSubmit'; showErrors: 'inline' | 'summary' | 'both'; scrollToError?: boolean; }; features?: { autoSave?: boolean; progressBar?: boolean; fieldCounter?: boolean; requiredIndicator?: boolean; }; } export interface SubmissionConfig { endpoint?: string; method?: 'POST' | 'PUT' | 'PATCH'; headers?: Record<string, string>; transform?: (data: any) => any; // Transform before submission successMessage?: string; errorMessage?: string; redirectUrl?: string; confirmationEmail?: boolean; webhook?: string; } ``` ## Form Renderer Implementation ```typescript // components/FormRenderer.ts import { BaseComponent } from './BaseComponent'; import { UniversalFormSchema, FormField } from '../types/FormSchema'; import { FieldRendererRegistry } from './FieldRendererRegistry'; import { FormValidator } from './FormValidator'; import { ConditionalLogic } from './ConditionalLogic'; export class FormRenderer extends BaseComponent { private schema: UniversalFormSchema; private fieldRenderers: FieldRendererRegistry; private validator: FormValidator; private conditionalLogic: ConditionalLogic; private formData: Record<string, any> = {}; private errors: Record<string, string> = {}; private touched: Set<string> = new Set(); constructor(config: any) { super(config); this.schema = this.data as UniversalFormSchema; this.fieldRenderers = new FieldRendererRegistry(); this.validator = new FormValidator(this.schema); this.conditionalLogic = new ConditionalLogic(this.schema); // Initialize form data with default values this.initializeFormData(); } render(): HTMLElement { this.container = this.createElement('div', 'universal-form'); this.container.setAttribute('data-form-id', this.schema.id); // Form header if (this.schema.metadata) { const header = this.createFormHeader(); this.container.appendChild(header); } // Progress bar for wizard layout if (this.schema.config?.layout === 'wizard') { const progressBar = this.createProgressBar(); this.container.appendChild(progressBar); } // Form element const form = document.createElement('form'); form.className = `universal-form__form universal-form__form--${this.schema.config?.layout || 'single-column'}`; form.noValidate = true; // We'll handle validation // Render fields const fieldsContainer = this.createFieldsContainer(); form.appendChild(fieldsContainer); // Submit button if (this.schema.config?.submitButton !== false) { const submitSection = this.createSubmitSection(); form.appendChild(submitSection); } // Attach event listeners this.attachFormEventListeners(form); this.container.appendChild(form); // Apply initial conditional logic this.applyConditionalLogic(); return this.container; } private createFormHeader(): HTMLElement { const header = this.createElement('div', 'universal-form__header'); const meta = this.schema.metadata!; if (meta.icon) { const icon = this.createElement('div', 'universal-form__icon', meta.icon); header.appendChild(icon); } if (meta.title) { const title = this.createElement('h2', 'universal-form__title', this.escapeHtml(meta.title)); header.appendChild(title); } if (meta.description) { const desc = this.createElement('p', 'universal-form__description', this.escapeHtml(meta.description)); header.appendChild(desc); } if (meta.estimatedTime) { const time = this.createElement('span', 'universal-form__time', `⏱ ${meta.estimatedTime}`); header.appendChild(time); } return header; } private createFieldsContainer(): HTMLElement { const container = this.createElement('div', 'universal-form__fields'); if (this.schema.config?.layout === 'grid' && this.schema.config.columns) { container.style.gridTemplateColumns = `repeat(${this.schema.config.columns}, 1fr)`; } this.schema.fields.forEach(field => { const fieldElement = this.createField(field); container.appendChild(fieldElement); }); return container; } private createField(field: FormField): HTMLElement { const wrapper = this.createElement('div', 'universal-form__field-wrapper'); wrapper.setAttribute('data-field-id', field.id); wrapper.setAttribute('data-field-type', field.type); // Apply layout classes if (field.layout?.width) { wrapper.style.gridColumn = `span ${field.layout.width}`; } // Label if (field.label && field.type !== 'hidden') { const label = this.createLabel(field); wrapper.appendChild(label); } // Field input const input = this.fieldRenderers.render(field, { value: this.formData[field.id], onChange: (value: any) => this.handleFieldChange(field.id, value), onBlur: () => this.handleFieldBlur(field.id), error: this.errors[field.id] }); wrapper.appendChild(input); // Error message container const errorContainer = this.createElement('div', 'universal-form__error'); errorContainer.setAttribute('data-error-for', field.id); wrapper.appendChild(errorContainer); // Help text if (field.metadata?.helpText) { const help = this.createElement('div', 'universal-form__help', field.metadata.helpText); wrapper.appendChild(help); } return wrapper; } private createLabel(field: FormField): HTMLElement { const label = document.createElement('label'); label.className = 'universal-form__label'; label.htmlFor = `field-${field.id}`; const text = document.createElement('span'); text.textContent = field.label; label.appendChild(text); if (field.required) { const required = this.createElement('span', 'universal-form__required', '*'); label.appendChild(required); } if (field.metadata?.tooltip) { const tooltip = this.createElement('span', 'universal-form__tooltip', 'ⓘ'); tooltip.title = field.metadata.tooltip; label.appendChild(tooltip); } return label; } private createSubmitSection(): HTMLElement { const section = this.createElement('div', 'universal-form__submit-section'); const config = this.schema.config?.submitButton || { text: 'Submit' }; const button = document.createElement('button'); button.type = 'submit'; button.className = `universal-form__submit universal-form__submit--${config.style || 'primary'}`; button.textContent = config.text; if (config.position) { section.style.textAlign = config.position; } section.appendChild(button); return section; } private attachFormEventListeners(form: HTMLFormElement): void { // Form submission form.addEventListener('submit', async (e) => { e.preventDefault(); await this.handleSubmit(); }); // Field changes for conditional logic form.addEventListener('change', () => { this.applyConditionalLogic(); }); // Auto-save if enabled if (this.schema.config?.features?.autoSave) { let saveTimeout: any; form.addEventListener('input', () => { clearTimeout(saveTimeout); saveTimeout = setTimeout(() => this.autoSave(), 2000); }); } } private handleFieldChange(fieldId: string, value: any): void { this.formData[fieldId] = value; // Clear error when user starts typing if (this.errors[fieldId]) { delete this.errors[fieldId]; this.updateFieldError(fieldId, ''); } // Validate on change if configured if (this.schema.config?.validation?.mode === 'onChange') { this.validateField(fieldId); } // Emit change event if (this.callbacks.onChange) { this.callbacks.onChange(fieldId, value); } } private handleFieldBlur(fieldId: string): void { this.touched.add(fieldId); // Validate on blur if configured if (this.schema.config?.validation?.mode === 'onBlur') { this.validateField(fieldId); } } private async handleSubmit(): Promise<void> { // Show loading state this.setSubmitLoading(true); try { // Validate all fields const isValid = await this.validateForm(); if (!isValid) { this.setSubmitLoading(false); this.showValidationErrors(); return; } // Transform data if needed let submitData = { ...this.formData }; if (this.schema.submission?.transform) { submitData = this.schema.submission.transform(submitData); } // Call submit callback if (this.callbacks.onSubmit) { await this.callbacks.onSubmit(submitData); } // Handle success this.handleSubmitSuccess(); } catch (error) { this.handleSubmitError(error); } finally { this.setSubmitLoading(false); } } private async validateForm(): Promise<boolean> { const errors = await this.validator.validateForm(this.formData); this.errors = errors; // Update UI with errors Object.keys(errors).forEach(fieldId => { this.updateFieldError(fieldId, errors[fieldId]); }); return Object.keys(errors).length === 0; } private validateField(fieldId: string): void { const field = this.schema.fields.find(f => f.id === fieldId); if (!field) return; const error = this.validator.validateField(field, this.formData[fieldId]); if (error) { this.errors[fieldId] = error; this.updateFieldError(fieldId, error); } else { delete this.errors[fieldId]; this.updateFieldError(fieldId, ''); } } private updateFieldError(fieldId: string, error: string): void { const errorElement = this.container?.querySelector(`[data-error-for="${fieldId}"]`); if (errorElement) { errorElement.textContent = error; errorElement.classList.toggle('visible', !!error); } const fieldWrapper = this.container?.querySelector(`[data-field-id="${fieldId}"]`); fieldWrapper?.classList.toggle('has-error', !!error); } private applyConditionalLogic(): void { const updates = this.conditionalLogic.evaluate(this.formData); updates.forEach(update => { const fieldWrapper = this.container?.querySelector(`[data-field-id="${update.fieldId}"]`) as HTMLElement; if (!fieldWrapper) return; switch (update.action) { case 'show': fieldWrapper.style.display = ''; break; case 'hide': fieldWrapper.style.display = 'none'; break; case 'enable': const input = fieldWrapper.querySelector('input, select, textarea') as HTMLInputElement; if (input) input.disabled = false; break; case 'disable': const inputD = fieldWrapper.querySelector('input, select, textarea') as HTMLInputElement; if (inputD) inputD.disabled = true; break; case 'require': const field = this.schema.fields.find(f => f.id === update.fieldId); if (field) field.required = true; break; } }); } private initializeFormData(): void { this.schema.fields.forEach(field => { if (field.defaultValue !== undefined) { this.formData[field.id] = field.defaultValue; } else { this.formData[field.id] = this.getDefaultValue(field.type); } }); // Apply prefill data if provided if (this.data.prefillData) { Object.assign(this.formData, this.data.prefillData); } } private getDefaultValue(type: FieldType): any { switch (type) { case 'checkbox': return false; case 'number': case 'range': return 0; case 'select': case 'radio': return ''; default: return ''; } } private setSubmitLoading(loading: boolean): void { const button = this.container?.querySelector('.universal-form__submit') as HTMLButtonElement; if (!button) return; button.disabled = loading; button.classList.toggle('loading', loading); if (loading && this.schema.config?.submitButton?.loadingText) { button.textContent = this.schema.config.submitButton.loadingText; } else if (!loading && this.schema.config?.submitButton?.text) { button.textContent = this.schema.config.submitButton.text; } } private handleSubmitSuccess(): void { // Show success message if (this.schema.submission?.successMessage) { this.showMessage(this.schema.submission.successMessage, 'success'); } // Update button text const button = this.container?.querySelector('.universal-form__submit') as HTMLButtonElement; if (button && this.schema.config?.submitButton?.successText) { button.textContent = this.schema.config.submitButton.successText; button.classList.add('success'); } // Reset form if configured if (this.schema.config?.features?.resetOnSubmit) { this.resetForm(); } } private handleSubmitError(error: any): void { const message = this.schema.submission?.errorMessage || 'An error occurred. Please try again.'; this.showMessage(message, 'error'); if (this.callbacks.onError) { this.callbacks.onError(error); } } private showMessage(message: string, type: 'success' | 'error'): void { const messageEl = this.createElement('div', `universal-form__message universal-form__message--${type}`, message); this.container?.insertBefore(messageEl, this.container.firstChild); setTimeout(() => messageEl.remove(), 5000); } private showValidationErrors(): void { if (this.schema.config?.validation?.scrollToError) { const firstError = this.container?.querySelector('.has-error'); firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } if (this.schema.config?.validation?.showErrors === 'summary' || this.schema.config?.validation?.showErrors === 'both') { this.showErrorSummary(); } } private showErrorSummary(): void { const summary = this.createElement('div', 'universal-form__error-summary'); summary.innerHTML = '<h4>Please correct the following errors:</h4>'; const list = document.createElement('ul'); Object.entries(this.errors).forEach(([fieldId, error]) => { const field = this.schema.fields.find(f => f.id === fieldId); const li = document.createElement('li'); li.textContent = `${field?.label || fieldId}: ${error}`; list.appendChild(li); }); summary.appendChild(list); this.container?.insertBefore(summary, this.container.firstChild); setTimeout(() => summary.remove(), 10000); } private autoSave(): void { console.log('Auto-saving form data:', this.formData); // Implement auto-save logic (localStorage, server, etc.) } private resetForm(): void { this.initializeFormData(); this.errors = {}; this.touched.clear(); this.render(); // Re-render the form } // Public API public getFormData(): Record<string, any> { return { ...this.formData }; } public setFormData(data: Record<string, any>): void { Object.assign(this.formData, data); this.applyConditionalLogic(); } public isValid(): boolean { return Object.keys(this.errors).length === 0; } public reset(): void { this.resetForm(); } } ``` ## Field Renderer Registry ```typescript // components/FieldRendererRegistry.ts export class FieldRendererRegistry { private renderers: Map<FieldType, FieldRenderer> = new Map(); constructor() { this.registerDefaultRenderers(); } private registerDefaultRenderers(): void { // Text inputs this.register('text', new TextFieldRenderer()); this.register('email', new EmailFieldRenderer()); this.register('tel', new TelFieldRenderer()); this.register('password', new PasswordFieldRenderer()); // Number inputs this.register('number', new NumberFieldRenderer()); this.register('range', new RangeFieldRenderer()); this.register('currency', new CurrencyFieldRenderer()); // Date/Time this.register('date', new DateFieldRenderer()); this.register('time', new TimeFieldRenderer()); // Choices this.register('select', new SelectFieldRenderer()); this.register('radio', new RadioFieldRenderer()); this.register('checkbox', new CheckboxFieldRenderer()); this.register('toggle', new ToggleFieldRenderer()); // Text areas this.register('textarea', new TextareaFieldRenderer()); this.register('rich-text', new RichTextFieldRenderer()); // Files this.register('file', new FileFieldRenderer()); // Special this.register('rating', new RatingFieldRenderer()); this.register('signature', new SignatureFieldRenderer()); } register(type: FieldType, renderer: FieldRenderer): void { this.renderers.set(type, renderer); } render(field: FormField, context: FieldRenderContext): HTMLElement { const renderer = this.renderers.get(field.type) || new TextFieldRenderer(); return renderer.render(field, context); } } // Base field renderer abstract class FieldRenderer { abstract render(field: FormField, context: FieldRenderContext): HTMLElement; protected createInput(type: string, field: FormField, context: FieldRenderContext): HTMLInputElement { const input = document.createElement('input'); input.type = type; input.id = `field-${field.id}`; input.name = field.id; input.className = 'universal-form__input'; if (field.placeholder) input.placeholder = field.placeholder; if (field.required) input.required = true; if (field.disabled) input.disabled = true; if (field.readonly) input.readOnly = true; input.value = context.value || ''; input.addEventListener('input', (e) => { context.onChange((e.target as HTMLInputElement).value); }); input.addEventListener('blur', () => { context.onBlur(); }); return input; } } ``` ## Next Steps 1. See `FORM_SUBMISSION_FLOW.md` for submission handling 2. Check `COMPONENT_REGISTRY.md` for component integration 3. Review `MCP_RESPONSE_STRUCTURE.md` for form responses 4. Read `TESTING_GUIDE.md` for form testing