import React, { useMemo, useCallback } from 'react'; import DynamicFormField, { getWidthClass } from './DynamicFormField'; /** * DynamicRegistrationForm - Renders the entire registration form from schema * * Features: * - Renders steps and sections based on schema * - Handles conditional field visibility * - Supports step navigation * - Validates fields per step */ const DynamicRegistrationForm = ({ schema, formData, onFormDataChange, currentStep, errors = {}, }) => { // Get current step data const stepData = useMemo(() => { const steps = schema?.steps || []; const sortedSteps = [...steps].sort((a, b) => a.order - b.order); return sortedSteps[currentStep - 1] || null; }, [schema, currentStep]); // Evaluate conditional rules to determine which fields are visible const hiddenFields = useMemo(() => { const rules = schema?.conditional_rules || []; const hidden = new Set(); // First pass: collect fields that have "show" rules (hidden by default) for (const rule of rules) { if (rule.action === 'show') { rule.target_fields?.forEach((fieldId) => hidden.add(fieldId)); } } // Second pass: evaluate rules and show/hide fields for (const rule of rules) { const { trigger_field, trigger_operator = 'equals', trigger_value, action, target_fields = [], } = rule; const fieldValue = formData[trigger_field]; let conditionMet = false; switch (trigger_operator) { case 'equals': conditionMet = fieldValue === trigger_value; break; case 'not_equals': conditionMet = fieldValue !== trigger_value; break; case 'contains': conditionMet = Array.isArray(fieldValue) ? fieldValue.includes(trigger_value) : String(fieldValue || '').includes(trigger_value); break; case 'not_empty': conditionMet = Boolean(fieldValue); break; case 'empty': conditionMet = !Boolean(fieldValue); break; default: conditionMet = false; } if (conditionMet) { if (action === 'show') { target_fields.forEach((fieldId) => hidden.delete(fieldId)); } else if (action === 'hide') { target_fields.forEach((fieldId) => hidden.add(fieldId)); } } } return hidden; }, [schema, formData]); // Handle field change const handleFieldChange = useCallback( (fieldId, value) => { onFormDataChange((prev) => ({ ...prev, [fieldId]: value, })); }, [onFormDataChange] ); // Check if a field is visible const isFieldVisible = useCallback( (fieldId) => { return !hiddenFields.has(fieldId); }, [hiddenFields] ); // Get errors for a specific field const getFieldErrors = useCallback( (fieldId) => { return errors[fieldId] || []; }, [errors] ); // Group fields by their width for rendering const groupFieldsByRow = (fields) => { const rows = []; let currentRow = []; let currentRowWidth = 0; const visibleFields = fields.filter((f) => isFieldVisible(f.id)); for (const field of visibleFields) { const width = field.width || 'full'; let widthValue = 1; if (width === 'half') widthValue = 0.5; else if (width === 'third') widthValue = 0.33; else if (width === 'two-thirds') widthValue = 0.67; if (currentRowWidth + widthValue > 1) { if (currentRow.length > 0) { rows.push(currentRow); } currentRow = [field]; currentRowWidth = widthValue; } else { currentRow.push(field); currentRowWidth += widthValue; } } if (currentRow.length > 0) { rows.push(currentRow); } return rows; }; if (!stepData) { return (
No step data available
); } return (
{/* Step Header */} {stepData.description && (

{stepData.description}

)} {/* Sections */} {stepData.sections ?.sort((a, b) => a.order - b.order) .map((section) => { const visibleFields = section.fields?.filter((f) => isFieldVisible(f.id) ); // Skip empty sections if (!visibleFields || visibleFields.length === 0) { return null; } const fieldRows = groupFieldsByRow( section.fields?.sort((a, b) => a.order - b.order) || [] ); return (
{/* Section Title */} {section.title && (

{section.title}

)} {/* Section Description */} {section.description && (

{section.description}

)} {/* Fields */}
{fieldRows.map((row, rowIndex) => { // Determine grid class based on field widths const hasThird = row.some((f) => f.width === 'third'); const hasHalf = row.some((f) => f.width === 'half'); const gridCols = hasThird ? 'grid md:grid-cols-3 gap-4' : hasHalf ? 'grid md:grid-cols-2 gap-4' : ''; if (row.length === 1 && !hasHalf && !hasThird) { // Single full-width field const field = row[0]; return ( ); } return (
{row.map((field) => (
))}
); })}
); })}
); }; /** * DynamicStepIndicator - Renders step progress indicator */ export const DynamicStepIndicator = ({ steps, currentStep }) => { const sortedSteps = [...steps].sort((a, b) => a.order - b.order); return (
{sortedSteps.map((step, index) => { const stepNumber = index + 1; const isActive = stepNumber === currentStep; const isCompleted = stepNumber < currentStep; return (
{/* Step Circle */}
{isCompleted ? '✓' : stepNumber}
{/* Connector Line */} {index < sortedSteps.length - 1 && (
)}
); })}
); }; /** * Validate a single step based on schema */ export const validateStep = (stepData, formData, hiddenFields) => { const errors = {}; if (!stepData?.sections) return { isValid: true, errors }; for (const section of stepData.sections) { // Check section-level validation (e.g., atLeastOne) const sectionValidation = section.validation || {}; if (sectionValidation.atLeastOne) { const fieldIds = (section.fields || []).map((f) => f.id); const hasValue = fieldIds.some((id) => { if (hiddenFields.has(id)) return true; // Skip hidden fields const value = formData[id]; return Boolean(value); }); if (!hasValue) { // Add error to first field in section const firstFieldId = fieldIds[0]; if (firstFieldId) { errors[firstFieldId] = [ sectionValidation.message || `At least one field in ${section.title || 'this section'} is required`, ]; } } } // Check field-level validation for (const field of section.fields || []) { const { id, required, validation = {}, type, label } = field; // Skip hidden fields if (hiddenFields.has(id)) continue; // Skip client-only fields for server validation if (field.client_only && field.id !== 'confirmPassword') continue; const value = formData[id]; // Required check if (required) { const isEmpty = value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0); if (isEmpty) { errors[id] = [`${label || id} is required`]; continue; } } // Skip further validation if value is empty if (!value && value !== false) continue; // Type-specific validation const fieldErrors = []; if (type === 'email') { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(value)) { fieldErrors.push('Please enter a valid email address'); } } if (type === 'password') { if (validation.minLength && value.length < validation.minLength) { fieldErrors.push( `Password must be at least ${validation.minLength} characters` ); } } if (type === 'text' || type === 'textarea') { if (validation.minLength && value.length < validation.minLength) { fieldErrors.push( `${label || id} must be at least ${validation.minLength} characters` ); } if (validation.maxLength && value.length > validation.maxLength) { fieldErrors.push( `${label || id} must be at most ${validation.maxLength} characters` ); } } // Match field validation (for confirmPassword) if (validation.matchField) { if (value !== formData[validation.matchField]) { fieldErrors.push('Passwords do not match'); } } if (fieldErrors.length > 0) { errors[id] = fieldErrors; } } } return { isValid: Object.keys(errors).length === 0, errors, }; }; /** * Evaluate conditional rules to get hidden fields set */ export const evaluateConditionalRules = (schema, formData) => { const rules = schema?.conditional_rules || []; const hidden = new Set(); // First pass: collect fields that have "show" rules (hidden by default) for (const rule of rules) { if (rule.action === 'show') { rule.target_fields?.forEach((fieldId) => hidden.add(fieldId)); } } // Second pass: evaluate rules and show/hide fields for (const rule of rules) { const { trigger_field, trigger_operator = 'equals', trigger_value, action, target_fields = [], } = rule; const fieldValue = formData[trigger_field]; let conditionMet = false; switch (trigger_operator) { case 'equals': conditionMet = fieldValue === trigger_value; break; case 'not_equals': conditionMet = fieldValue !== trigger_value; break; case 'contains': conditionMet = Array.isArray(fieldValue) ? fieldValue.includes(trigger_value) : String(fieldValue || '').includes(trigger_value); break; case 'not_empty': conditionMet = Boolean(fieldValue); break; case 'empty': conditionMet = !Boolean(fieldValue); break; default: conditionMet = false; } if (conditionMet) { if (action === 'show') { target_fields.forEach((fieldId) => hidden.delete(fieldId)); } else if (action === 'hide') { target_fields.forEach((fieldId) => hidden.add(fieldId)); } } } return hidden; }; export default DynamicRegistrationForm;