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