UNPKG

ecfr-navigator

Version:

A lightweight, reusable Vue 3 component with Pinia integration for navigating hierarchical eCFR-style content in existing Vue applications.

627 lines (547 loc) 16.2 kB
import { ref, reactive, computed, watch } from 'vue'; import { useStorageEngine } from './useStorageEngine.js'; /** * Dynamic form renderer with built-in storage engine and validation * Supports rendering complex forms from JSON schema with real-time validation */ export function useDynamicFormRenderer(options = {}) { const { storageKey = 'dynamic_form', autoSave = true, enableValidation = true, enableConditionalLogic = true, enableCalculations = true } = options; // Initialize storage engine const storage = useStorageEngine({ storageKey, autoSave, enableVersioning: true, enableEncryption: true, compressionEnabled: true }); // Form schema and state const formSchema = ref({}); const fieldComponents = reactive(new Map()); const conditionalRules = reactive(new Map()); const calculationRules = reactive(new Map()); const validationErrors = reactive(new Map()); const touchedFields = reactive(new Set()); const dependencyGraph = reactive(new Map()); // Built-in field types with their component mappings const defaultFieldTypes = { text: 'MTextField', email: 'MTextField', password: 'MTextField', tel: 'MTextField', number: 'MTextField', date: 'MTextField', textarea: 'MTextField', select: 'MSelect', checkbox: 'MCheckbox', radio: 'MRadioGroup', slider: 'MSlider', switch: 'MSwitch', file: 'MFileInput', currency: 'MCurrencyInput', ssn: 'MSSNInput' }; // Validation rules library const validationRules = { required: (value, message = 'This field is required') => { if (value === null || value === undefined || value === '') { return message; } return true; }, minLength: (min, message) => (value) => { if (value && value.length < min) { return message || `Minimum length is ${min} characters`; } return true; }, maxLength: (max, message) => (value) => { if (value && value.length > max) { return message || `Maximum length is ${max} characters`; } return true; }, pattern: (regex, message) => (value) => { if (value && !regex.test(value)) { return message || 'Invalid format'; } return true; }, email: (value, message = 'Please enter a valid email address') => { if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { return message; } return true; }, phone: (value, message = 'Please enter a valid phone number') => { if (value && !/^\(?(\d{3})\)?[-.\s]?(\d{3})[-.\s]?(\d{4})$/.test(value)) { return message; } return true; }, ssn: (value, message = 'Please enter a valid SSN (XXX-XX-XXXX)') => { if (value && !/^\d{3}-\d{2}-\d{4}$/.test(value)) { return message; } return true; }, minValue: (min, message) => (value) => { if (value !== null && value !== undefined && Number(value) < min) { return message || `Minimum value is ${min}`; } return true; }, maxValue: (max, message) => (value) => { if (value !== null && value !== undefined && Number(value) > max) { return message || `Maximum value is ${max}`; } return true; }, custom: (validatorFn, message) => (value, allData) => { const result = validatorFn(value, allData); return result === true ? true : (message || result); } }; /** * Load form schema and initialize the form */ const loadSchema = (schema) => { formSchema.value = schema; initializeForm(); }; /** * Initialize form based on schema */ const initializeForm = () => { if (!formSchema.value.fields) return; // Clear existing state fieldComponents.clear(); conditionalRules.clear(); calculationRules.clear(); validationErrors.clear(); dependencyGraph.clear(); // Process each field in the schema formSchema.value.fields.forEach(field => { processField(field); }); // Set default values if (formSchema.value.defaults) { Object.entries(formSchema.value.defaults).forEach(([path, value]) => { storage.setProperty(path, value); }); } // Initialize validation watchers if (enableValidation) { setupValidationWatchers(); } // Initialize conditional logic if (enableConditionalLogic) { setupConditionalLogic(); } // Initialize calculations if (enableCalculations) { setupCalculations(); } }; /** * Process individual field from schema */ const processField = (field) => { const { id, type = 'text', component, validation = [], conditional, calculation, dependencies = [] } = field; // Register field component fieldComponents.set(id, { ...field, component: component || defaultFieldTypes[type] || 'MTextField' }); // Setup validation rules if (validation.length > 0) { const validators = validation.map(rule => { if (typeof rule === 'string') { return validationRules[rule]; } else if (typeof rule === 'object') { const { type: ruleType, ...params } = rule; return validationRules[ruleType](...Object.values(params), rule.message); } return rule; }); storage.addValidator(id, (value, allData) => { for (const validator of validators) { const result = validator(value, allData); if (result !== true) { return result; } } return true; }); } // Setup conditional rules if (conditional) { conditionalRules.set(id, conditional); } // Setup calculation rules if (calculation) { calculationRules.set(id, calculation); } // Build dependency graph if (dependencies.length > 0) { dependencyGraph.set(id, dependencies); dependencies.forEach(dep => { if (!dependencyGraph.has(dep)) { dependencyGraph.set(dep, []); } dependencyGraph.get(dep).push(id); }); } }; /** * Setup validation watchers */ const setupValidationWatchers = () => { fieldComponents.forEach((field, fieldId) => { storage.watchProperty(fieldId, (value) => { validateField(fieldId); }); }); }; /** * Setup conditional logic watchers */ const setupConditionalLogic = () => { conditionalRules.forEach((rule, fieldId) => { const { dependencies = [], condition } = rule; dependencies.forEach(dep => { storage.watchProperty(dep, () => { evaluateConditional(fieldId, rule); }); }); // Initial evaluation evaluateConditional(fieldId, rule); }); }; /** * Setup calculation watchers */ const setupCalculations = () => { calculationRules.forEach((rule, fieldId) => { const { dependencies = [], formula } = rule; dependencies.forEach(dep => { storage.watchProperty(dep, () => { executeCalculation(fieldId, rule); }); }); // Initial calculation executeCalculation(fieldId, rule); }); }; /** * Validate a specific field */ const validateField = (fieldId) => { const field = fieldComponents.get(fieldId); if (!field) return true; const value = storage.getProperty(fieldId); const allData = storage.storage.data; // Clear existing errors validationErrors.delete(fieldId); // Run validation if field is touched or has value if (touchedFields.has(fieldId) || (value !== null && value !== undefined && value !== '')) { const result = storage.validateAll(); if (result !== true && result[fieldId]) { validationErrors.set(fieldId, result[fieldId]); return false; } } return true; }; /** * Evaluate conditional logic for a field */ const evaluateConditional = (fieldId, rule) => { const { condition, action = 'show' } = rule; const allData = storage.storage.data; try { // Create a safe evaluation context const context = { data: allData, get: (path) => storage.getProperty(path), has: (path) => storage.hasProperty(path), Math, Number, String, Array, Object }; // Evaluate condition function const result = typeof condition === 'function' ? condition(context) : evaluateExpression(condition, context); // Apply action based on result const field = fieldComponents.get(fieldId); if (field) { switch (action) { case 'show': field.visible = result; break; case 'enable': field.disabled = !result; break; case 'require': field.required = result; break; } } } catch (error) { console.warn(`Conditional evaluation error for field ${fieldId}:`, error); } }; /** * Execute calculation for a field */ const executeCalculation = (fieldId, rule) => { const { formula, type = 'number' } = rule; const allData = storage.storage.data; try { const context = { data: allData, get: (path) => storage.getProperty(path, 0), has: (path) => storage.hasProperty(path), Math, Number, String, Array, Object }; const result = typeof formula === 'function' ? formula(context) : evaluateExpression(formula, context); // Format result based on type let formattedResult; switch (type) { case 'currency': formattedResult = Number(result).toFixed(2); break; case 'percentage': formattedResult = (Number(result) * 100).toFixed(1); break; case 'integer': formattedResult = Math.round(Number(result)); break; default: formattedResult = result; } storage.setProperty(fieldId, formattedResult); } catch (error) { console.warn(`Calculation error for field ${fieldId}:`, error); } }; /** * Safe expression evaluation */ const evaluateExpression = (expression, context) => { // Simple expression evaluator - in production, use a proper expression parser const func = new Function(...Object.keys(context), `return ${expression}`); return func(...Object.values(context)); }; /** * Set field value with validation and dependency updates */ const setFieldValue = (fieldId, value) => { storage.setProperty(fieldId, value); touchedFields.add(fieldId); // Trigger dependent field updates if (dependencyGraph.has(fieldId)) { const dependents = dependencyGraph.get(fieldId); dependents.forEach(depFieldId => { // Re-evaluate conditionals if (conditionalRules.has(depFieldId)) { evaluateConditional(depFieldId, conditionalRules.get(depFieldId)); } // Re-run calculations if (calculationRules.has(depFieldId)) { executeCalculation(depFieldId, calculationRules.get(depFieldId)); } // Re-validate dependent fields validateField(depFieldId); }); } // Validate current field validateField(fieldId); }; /** * Get field value */ const getFieldValue = (fieldId, defaultValue) => { return storage.getProperty(fieldId, defaultValue); }; /** * Get field error message */ const getFieldError = (fieldId) => { return validationErrors.get(fieldId) || null; }; /** * Check if field has error */ const hasFieldError = (fieldId) => { return validationErrors.has(fieldId); }; /** * Touch a field (mark as interacted with) */ const touchField = (fieldId) => { touchedFields.add(fieldId); validateField(fieldId); }; /** * Get field configuration */ const getFieldConfig = (fieldId) => { return fieldComponents.get(fieldId) || null; }; /** * Check if field should be visible */ const isFieldVisible = (fieldId) => { const field = fieldComponents.get(fieldId); return field ? field.visible !== false : true; }; /** * Check if field is disabled */ const isFieldDisabled = (fieldId) => { const field = fieldComponents.get(fieldId); return field ? field.disabled === true : false; }; /** * Validate entire form */ const validateForm = () => { let isValid = true; fieldComponents.forEach((field, fieldId) => { touchedFields.add(fieldId); if (!validateField(fieldId)) { isValid = false; } }); return isValid; }; /** * Submit form with validation */ const submitForm = async (submitHandler) => { const isValid = validateForm(); if (!isValid) { return { success: false, errors: Object.fromEntries(validationErrors), message: 'Please correct the errors before submitting' }; } try { const result = await submitHandler(storage.storage.data); return { success: true, data: result, message: 'Form submitted successfully' }; } catch (error) { return { success: false, error: error.message, message: 'Submission failed' }; } }; /** * Reset form to initial state */ const resetForm = () => { storage.clear(); validationErrors.clear(); touchedFields.clear(); // Reset to default values if (formSchema.value.defaults) { Object.entries(formSchema.value.defaults).forEach(([path, value]) => { storage.setProperty(path, value); }); } }; /** * Export form data */ const exportForm = (format = 'json') => { return storage.exportData(format); }; /** * Import form data */ const importForm = (data, format = 'json') => { return storage.importData(data, format); }; // Computed properties const isFormValid = computed(() => validationErrors.size === 0); const formData = computed(() => storage.storage.data); const hasUnsavedChanges = computed(() => storage.hasUnsavedChanges); const formErrors = computed(() => Object.fromEntries(validationErrors)); return { // Core functionality loadSchema, resetForm, submitForm, validateForm, // Field operations setFieldValue, getFieldValue, touchField, getFieldConfig, getFieldError, hasFieldError, isFieldVisible, isFieldDisabled, // State formSchema: readonly(formSchema), fieldComponents: readonly(fieldComponents), isFormValid, formData, formErrors, hasUnsavedChanges, // Storage operations storage, exportForm, importForm, // Advanced features addCustomValidator: (fieldId, validator) => storage.addValidator(fieldId, validator), addCustomCalculation: (fieldId, rule) => { calculationRules.set(fieldId, rule); setupCalculations(); }, addConditionalRule: (fieldId, rule) => { conditionalRules.set(fieldId, rule); setupConditionalLogic(); } }; } /** * Create a readonly proxy */ function readonly(obj) { return new Proxy(obj, { set() { console.warn('Direct mutation not allowed'); return false; } }); }