UNPKG

@uplink-protocol/form-controller

Version:

Reactive multi-step form controller with dynamic validation and state management

532 lines (437 loc) 15.1 kB
# Form Controller Documentation A flexible, reactive form management system for both multi-step and single-step forms with advanced validation capabilities. ## Table of Contents 1. [Installation](#installation) 2. [Form Controller Basics](#form-controller-basics) 3. [Creating a Form](#creating-a-form) - [Single-Step Form](#single-step-form) - [Multi-Step Form](#multi-step-form) 4. [Field Validation](#field-validation) - [Built-in Validators](#built-in-validators) - [Custom Validators](#custom-validators) - [Dynamic Validators](#dynamic-validators) 5. [Reactive Bindings](#reactive-bindings) 6. [API Reference](#api-reference) ## Installation ```bash npm install @uplink-protocol/form-controller ``` ## Form Controller Basics The Form Controller provides a reactive system for managing form state, validation, and navigation between form steps. It can be used for both simple single-page forms and complex multi-step wizards. Key features: - Reactive state management - Built-in and custom validation - Dynamic, context-aware validators - Step navigation and state tracking - Flexible field configurations ## Creating a Form ### Single-Step Form Even though the controller is designed for multi-step forms, you can easily use it for a single form by defining just one step: ```javascript // Import the form controller const { FormController } = require('@uplink-protocol/form-controller'); // Create a form configuration with a single step const formConfig = { steps: [ { id: 'contact-info', title: 'Contact Information', fields: { name: { id: 'name', type: 'text', label: 'Full Name', required: true, value: '' }, email: { id: 'email', type: 'email', label: 'Email Address', required: true, value: '', validation: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ } }, phone: { id: 'phone', type: 'tel', label: 'Phone Number', value: '' } } } ] }; // Initialize the form controller const form = FormController(formConfig); // Connect form to the UI document.getElementById('contactForm').addEventListener('submit', (e) => { e.preventDefault(); if (form.methods.validateForm(true)) { const formData = form.methods.getFlatData(); console.log('Form data:', formData); // Submit data to server } }); // Connect input fields document.querySelectorAll('input, select, textarea').forEach(element => { const fieldId = element.id; const stepId = 'contact-info'; // The ID of our only step // Set initial values if they exist if (form.bindings.formData.current[stepId]?.[fieldId]) { element.value = form.bindings.formData.current[stepId][fieldId]; } // Add change event listeners element.addEventListener('input', (e) => { form.methods.updateField(stepId, fieldId, e.target.value); }); }); // Display validation errors form.bindings.fieldErrors.subscribe((errors) => { Object.entries(errors['contact-info'] || {}).forEach(([fieldId, errorMsg]) => { const errorElement = document.getElementById(`${fieldId}-error`); if (errorElement) { errorElement.textContent = errorMsg; } }); }); ``` ### Multi-Step Form For multi-step forms, define multiple steps in the configuration: ```javascript const formConfig = { steps: [ { id: 'personal', title: 'Personal Information', fields: { firstName: { id: 'firstName', type: 'text', label: 'First Name', required: true, value: '' }, lastName: { id: 'lastName', type: 'text', label: 'Last Name', required: true, value: '' } } }, { id: 'contact', title: 'Contact Information', fields: { email: { id: 'email', type: 'email', label: 'Email', required: true, value: '', validation: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ } }, phone: { id: 'phone', type: 'tel', label: 'Phone', value: '' } } }, { id: 'preferences', title: 'Preferences', fields: { notifications: { id: 'notifications', type: 'checkbox', label: 'Receive notifications', value: false } } } ] }; const form = FormController(formConfig); // Setup navigation buttons document.getElementById('nextBtn').addEventListener('click', () => { form.methods.nextStep(); renderCurrentStep(); }); document.getElementById('prevBtn').addEventListener('click', () => { form.methods.prevStep(); renderCurrentStep(); }); document.getElementById('submitBtn').addEventListener('click', () => { if (form.methods.validateForm(true)) { const formData = form.methods.getFlatData(); console.log('Form data:', formData); // Submit data to server } }); // Update UI when step changes form.bindings.currentStep.subscribe(renderCurrentStep); function renderCurrentStep() { const currentStep = form.bindings.currentStep.current; // Update step title and fields document.getElementById('stepTitle').textContent = currentStep.title; // Clear and render fields for current step const fieldsContainer = document.getElementById('formFields'); fieldsContainer.innerHTML = ''; Object.entries(currentStep.fields).forEach(([fieldId, field]) => { // Create input elements based on field type // ...field rendering logic... // Attach event listeners const input = document.getElementById(fieldId); if (input) { input.value = form.bindings.formData.current[currentStep.id][fieldId] || ''; input.addEventListener('input', (e) => { form.methods.updateField(currentStep.id, fieldId, e.target.value); }); } }); // Show/hide prev/next buttons document.getElementById('prevBtn').style.display = form.bindings.isFirstStep.current ? 'none' : 'block'; document.getElementById('nextBtn').style.display = form.bindings.isLastStep.current ? 'none' : 'block'; document.getElementById('submitBtn').style.display = form.bindings.isLastStep.current ? 'block' : 'none'; } // Initial render renderCurrentStep(); ``` ## Field Validation The form controller provides several ways to validate fields: ### Built-in Validators Built-in validators are based on the field's properties: ```javascript const emailField = { id: 'email', type: 'email', label: 'Email Address', required: true, value: '', validation: { // Email pattern validation pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, // Min/max length validation minLength: 5, maxLength: 100 } }; const ageField = { id: 'age', type: 'number', label: 'Age', value: '', validation: { // Range validation min: 18, max: 120 } }; ``` ### Custom Validators For more complex validation, you can use custom validator functions: ```javascript const passwordField = { id: 'password', type: 'password', label: 'Password', required: true, value: '', validation: { // Custom validator function custom: (value) => { // Password must contain at least one uppercase letter, one lowercase letter, // one number, and be at least 8 characters long const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/; if (!regex.test(value)) { return 'Password must have at least 8 characters with uppercase, lowercase, and a number'; } return true; // Valid } } }; ``` ### Dynamic Validators Dynamic validators are powerful validation functions that have access to the entire form context. This allows for validation rules that depend on other field values. #### Registering a Dynamic Validator ```javascript // Require the form controller const { FormController } = require('@uplink-protocol/form-controller'); // Create form configuration const formConfig = {/* ...form config... */}; // Initialize the form controller const form = FormController(formConfig); // Register a dynamic validator that makes a field required based on another field's value form.methods.registerValidator('requiredIf', (value, context) => { const { dependsOn, // ID of the field this depends on dependsOnValue // Value that should trigger the requirement } = context.field.validation.dynamicValidatorParams || {}; // Skip if not properly configured if (!dependsOn) return true; // Get the value of the dependent field const dependentValue = context.formData[dependsOn]; // Determine if this field should be required const isRequired = dependsOnValue !== undefined ? dependentValue === dependsOnValue // Required if the dependent field equals the specified value : Boolean(dependentValue); // Required if the dependent field has any truthy value // If required but empty, return error message if (isRequired && (value === undefined || value === null || value === '')) { return `${context.field.label} is required when ${dependsOn} is specified`; } // Otherwise valid return true; }); // Register a validator that checks if two fields match form.methods.registerValidator('equals', (value, context) => { const { matchField, errorMessage } = context.field.validation.dynamicValidatorParams || {}; if (!matchField) return true; const fieldToMatch = context.formData[matchField]; if (value !== fieldToMatch) { return errorMessage || `Must match ${matchField}`; } return true; }); ``` #### Using Dynamic Validators in Fields ```javascript // Example: Payment form where credit card fields are required // only if payment method is 'creditCard' const paymentMethodField = { id: 'paymentMethod', type: 'select', label: 'Payment Method', required: true, value: '', options: [ { label: 'Credit Card', value: 'creditCard' }, { label: 'PayPal', value: 'paypal' }, { label: 'Bank Transfer', value: 'bankTransfer' } ] }; const cardNumberField = { id: 'cardNumber', type: 'text', label: 'Card Number', value: '', validation: { dynamicValidator: 'requiredIf', dynamicValidatorParams: { dependsOn: 'paymentMethod', dependsOnValue: 'creditCard', errorMessage: 'Card number is required for credit card payments' } } }; // Example: Password confirmation field that must match the password field const passwordField = { id: 'password', type: 'password', label: 'Password', required: true, value: '' }; const confirmPasswordField = { id: 'confirmPassword', type: 'password', label: 'Confirm Password', required: true, value: '', validation: { dynamicValidator: 'equals', dynamicValidatorParams: { matchField: 'password', errorMessage: 'Passwords must match' } } }; ``` ## Reactive Bindings The form controller uses a reactive binding system to update the UI whenever form state changes. ```javascript // Subscribe to form state changes form.bindings.formData.subscribe(formData => { console.log('Form data updated:', formData); }); // Track step validity form.bindings.stepsValidity.subscribe(validityMap => { const currentStepId = form.bindings.currentStep.current.id; const isCurrentStepValid = validityMap[currentStepId]; document.getElementById('nextBtn').disabled = !isCurrentStepValid; }); // Show/hide navigation buttons based on current step form.bindings.isFirstStep.subscribe(isFirst => { document.getElementById('prevBtn').style.display = isFirst ? 'none' : 'block'; }); form.bindings.isLastStep.subscribe(isLast => { document.getElementById('nextBtn').style.display = isLast ? 'none' : 'block'; document.getElementById('submitBtn').style.display = isLast ? 'block' : 'none'; }); // Display validation errors form.bindings.fieldErrors.subscribe(errors => { const currentStepId = form.bindings.currentStep.current.id; const stepErrors = errors[currentStepId] || {}; // Clear all error messages document.querySelectorAll('.error-message').forEach(el => { el.textContent = ''; }); // Display new error messages Object.entries(stepErrors).forEach(([fieldId, errorMsg]) => { const errorEl = document.getElementById(`${fieldId}-error`); if (errorEl) { errorEl.textContent = errorMsg; errorEl.classList.add('visible'); } }); }); ``` ## API Reference ### Initialization ```javascript const form = FormController(formConfig); ``` ### Navigation Methods - **nextStep()** - Navigate to the next step (if current step is valid) - **prevStep()** - Navigate to the previous step - **goToStep(stepIndex)** - Navigate to a specific step by index ### Form Data Methods - **updateField(stepId, fieldId, value)** - Update a field's value - **getStepData(stepId)** - Get form data for a specific step - **getAllData()** - Get all form data organized by steps - **getFlatData()** - Get flattened form data merged from all steps ### Validation Methods - **validateField(stepId, fieldId, value, showErrors)** - Validate a single field - **validateStep(stepId, showErrors)** - Validate an entire step - **validateCurrentStep(showErrors)** - Validate the current step - **validateForm(showErrors)** - Validate the entire form - **registerValidator(name, validatorFn)** - Register a custom dynamic validator - **unregisterValidator(name)** - Remove a custom validator ### Form Management Methods - **submitForm()** - Validate and submit the form - **resetForm()** - Reset the form to initial values - **updateConfig(newConfig)** - Update form configuration (for dynamic forms) - **addStep(step, index)** - Add a new step dynamically - **removeStep(stepId)** - Remove a step dynamically ### Reactive Bindings - **config** - The current form configuration - **currentStepIndex** - Index of the current step - **currentStep** - The current step object - **formData** - Form data for all steps - **stepsValidity** - Validation state for each step - **isCurrentStepValid** - Whether the current step is valid - **fieldErrors** - Validation error messages - **totalSteps** - Total number of steps - **isLastStep** - Whether the current step is the last one - **isFirstStep** - Whether the current step is the first one - **isFormValid** - Whether the entire form is valid