483 lines
14 KiB
JavaScript
483 lines
14 KiB
JavaScript
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;
|