Changes
This commit is contained in:
482
src/components/registration/DynamicRegistrationForm.js
Normal file
482
src/components/registration/DynamicRegistrationForm.js
Normal file
@@ -0,0 +1,482 @@
|
||||
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 (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No step data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Step Header */}
|
||||
{stepData.description && (
|
||||
<p
|
||||
className="text-brand-purple"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{stepData.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 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 (
|
||||
<div key={section.id} className="space-y-4">
|
||||
{/* Section Title */}
|
||||
{section.title && (
|
||||
<h2
|
||||
className="text-2xl font-semibold text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{section.title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Section Description */}
|
||||
{section.description && (
|
||||
<p className="text-muted-foreground">{section.description}</p>
|
||||
)}
|
||||
|
||||
{/* Fields */}
|
||||
<div className="space-y-4">
|
||||
{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 (
|
||||
<DynamicFormField
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={formData[field.id]}
|
||||
onChange={handleFieldChange}
|
||||
errors={getFieldErrors(field.id)}
|
||||
formData={formData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`row-${rowIndex}`} className={gridCols}>
|
||||
{row.map((field) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className={getWidthClass(field.width)}
|
||||
>
|
||||
<DynamicFormField
|
||||
field={field}
|
||||
value={formData[field.id]}
|
||||
onChange={handleFieldChange}
|
||||
errors={getFieldErrors(field.id)}
|
||||
formData={formData}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* DynamicStepIndicator - Renders step progress indicator
|
||||
*/
|
||||
export const DynamicStepIndicator = ({ steps, currentStep }) => {
|
||||
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
{sortedSteps.map((step, index) => {
|
||||
const stepNumber = index + 1;
|
||||
const isActive = stepNumber === currentStep;
|
||||
const isCompleted = stepNumber < currentStep;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex items-center flex-1">
|
||||
{/* Step Circle */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center text-lg font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-brand-purple text-white'
|
||||
: isCompleted
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? '✓' : stepNumber}
|
||||
</div>
|
||||
<span
|
||||
className={`mt-2 text-sm text-center hidden md:block ${
|
||||
isActive ? 'text-brand-purple font-medium' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connector Line */}
|
||||
{index < sortedSteps.length - 1 && (
|
||||
<div
|
||||
className={`flex-1 h-1 mx-4 rounded ${
|
||||
isCompleted
|
||||
? 'bg-green-500'
|
||||
: 'bg-[var(--neutral-800)]'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user