fix: Improve robustness and error handling for dynamic form fields and sections with safe data access and type coercion.
This commit is contained in:
@@ -29,6 +29,18 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
onChange(field.slug, newValue);
|
||||
};
|
||||
|
||||
// Safe accessors for field properties that might be null/undefined
|
||||
const fieldName = field.name || 'Field';
|
||||
const fieldSlug = field.slug || '';
|
||||
const fieldPlaceholder = field.placeholder || '';
|
||||
const safeOptions = Array.isArray(field.options) ? field.options : [];
|
||||
|
||||
// Safe value coercion helpers
|
||||
const safeString = (v: unknown): string => (typeof v === 'string' ? v : '');
|
||||
const safeStringArray = (v: unknown): string[] => (Array.isArray(v) ? v.filter((item): item is string => typeof item === 'string') : []);
|
||||
const safeNumber = (v: unknown, fallback: number): number => (typeof v === 'number' ? v : fallback);
|
||||
const safeFileArray = (v: unknown): UploadedFile[] => (Array.isArray(v) ? v : []);
|
||||
|
||||
const renderField = () => {
|
||||
switch (field.fieldType) {
|
||||
case 'REPEATER':
|
||||
@@ -38,9 +50,9 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
case 'TEXT':
|
||||
return (
|
||||
<FormInput
|
||||
label={field.name}
|
||||
placeholder={field.placeholder || `Enter ${field.name.toLowerCase()}`}
|
||||
value={(value as string) || ''}
|
||||
label={fieldName}
|
||||
placeholder={fieldPlaceholder || `Enter ${fieldName.toLowerCase()}`}
|
||||
value={safeString(value)}
|
||||
onChange={(val: string) => handleChange(val)}
|
||||
required={field.isRequired}
|
||||
error={error}
|
||||
@@ -50,9 +62,9 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
case 'TEXTAREA':
|
||||
return (
|
||||
<FormTextarea
|
||||
label={field.name}
|
||||
placeholder={field.placeholder || `Enter ${field.name.toLowerCase()}`}
|
||||
value={(value as string) || ''}
|
||||
label={fieldName}
|
||||
placeholder={fieldPlaceholder || `Enter ${fieldName.toLowerCase()}`}
|
||||
value={safeString(value)}
|
||||
onChange={(val: string) => handleChange(val)}
|
||||
required={field.isRequired}
|
||||
rows={4}
|
||||
@@ -62,10 +74,10 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
case 'NUMBER':
|
||||
return (
|
||||
<FormInput
|
||||
label={field.name}
|
||||
label={fieldName}
|
||||
type="number"
|
||||
placeholder={field.placeholder || `Enter ${field.name.toLowerCase()}`}
|
||||
value={(value as string) || ''}
|
||||
placeholder={fieldPlaceholder || `Enter ${fieldName.toLowerCase()}`}
|
||||
value={safeString(value)}
|
||||
onChange={(val: string) => handleChange(val)}
|
||||
required={field.isRequired}
|
||||
error={error}
|
||||
@@ -77,9 +89,9 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
case 'DATE':
|
||||
return (
|
||||
<FormInput
|
||||
label={field.name}
|
||||
label={fieldName}
|
||||
type="date"
|
||||
value={(value as string) || ''}
|
||||
value={safeString(value)}
|
||||
onChange={(val: string) => handleChange(val)}
|
||||
required={field.isRequired}
|
||||
error={error}
|
||||
@@ -89,7 +101,7 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
case 'CHECKBOX':
|
||||
return (
|
||||
<FormCheckbox
|
||||
label={field.name}
|
||||
label={fieldName}
|
||||
checked={Boolean(value)}
|
||||
onChange={(checked: boolean) => handleChange(checked)}
|
||||
/>
|
||||
@@ -99,7 +111,7 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
return (
|
||||
<CheckboxGroup
|
||||
field={field}
|
||||
value={(value as string[]) || []}
|
||||
value={safeStringArray(value)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
@@ -108,7 +120,7 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
return (
|
||||
<RadioGroup
|
||||
field={field}
|
||||
value={(value as string) || ''}
|
||||
value={safeString(value)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
@@ -116,11 +128,11 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
case 'SELECT':
|
||||
return (
|
||||
<FormSelect
|
||||
label={field.name}
|
||||
value={(value as string) || ''}
|
||||
label={fieldName}
|
||||
value={safeString(value)}
|
||||
onChange={(val: string) => handleChange(val)}
|
||||
options={field.options?.map(opt => ({ value: opt.value, label: opt.label })) || []}
|
||||
placeholder={field.placeholder || `Select ${field.name.toLowerCase()}`}
|
||||
options={safeOptions.map(opt => ({ value: opt.value, label: opt.label }))}
|
||||
placeholder={fieldPlaceholder || `Select ${fieldName.toLowerCase()}`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -128,7 +140,7 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
return (
|
||||
<MultiSelect
|
||||
field={field}
|
||||
value={(value as string[]) || []}
|
||||
value={safeStringArray(value)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
@@ -137,7 +149,7 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
return (
|
||||
<RangeSlider
|
||||
field={field}
|
||||
value={(value as number) || field.rangeConfig?.min || 0}
|
||||
value={safeNumber(value, field.rangeConfig?.min || 0)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
@@ -146,13 +158,13 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block text-[14px] font-bold font-serif text-[#00293D] mb-2">
|
||||
{field.name}
|
||||
{fieldName}
|
||||
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<TagInput
|
||||
tags={(value as string[]) || []}
|
||||
tags={safeStringArray(value)}
|
||||
onChange={(tags: string[]) => handleChange(tags)}
|
||||
placeholder={field.placeholder || `Add ${field.name.toLowerCase()}`}
|
||||
placeholder={fieldPlaceholder || `Add ${fieldName.toLowerCase()}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -162,8 +174,8 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
return (
|
||||
<FileUpload
|
||||
field={field}
|
||||
fieldSlug={field.slug}
|
||||
value={(value as UploadedFile[]) || []}
|
||||
fieldSlug={fieldSlug}
|
||||
value={safeFileArray(value)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
@@ -171,9 +183,9 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
default:
|
||||
return (
|
||||
<FormInput
|
||||
label={field.name}
|
||||
placeholder={field.placeholder || `Enter ${field.name.toLowerCase()}`}
|
||||
value={(value as string) || ''}
|
||||
label={fieldName}
|
||||
placeholder={fieldPlaceholder || `Enter ${fieldName.toLowerCase()}`}
|
||||
value={safeString(value)}
|
||||
onChange={(val: string) => handleChange(val)}
|
||||
required={field.isRequired}
|
||||
error={error}
|
||||
@@ -182,18 +194,28 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
}
|
||||
};
|
||||
|
||||
const fieldContent = renderField();
|
||||
// Wrap in try-catch to prevent individual field errors from crashing the entire page
|
||||
try {
|
||||
const fieldContent = renderField();
|
||||
|
||||
// Don't render anything for null fields (like REPEATER)
|
||||
if (fieldContent === null) {
|
||||
return null;
|
||||
// Don't render anything for null fields (like REPEATER)
|
||||
if (fieldContent === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dynamic-field" data-field-slug={fieldSlug}>
|
||||
{fieldContent}
|
||||
</div>
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`Error rendering field "${fieldName}" (${field.fieldType}):`, err);
|
||||
return (
|
||||
<div className="dynamic-field p-3 bg-red-50 border border-red-200 rounded-lg" data-field-slug={fieldSlug}>
|
||||
<p className="text-sm text-red-600">Failed to render field: {fieldName}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dynamic-field" data-field-slug={field.slug}>
|
||||
{fieldContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Checkbox Group Component
|
||||
@@ -204,10 +226,13 @@ interface CheckboxGroupProps {
|
||||
}
|
||||
|
||||
function CheckboxGroup({ field, value, onChange }: CheckboxGroupProps) {
|
||||
const safeValue = Array.isArray(value) ? value : [];
|
||||
const safeOptions = Array.isArray(field.options) ? field.options : [];
|
||||
|
||||
const handleToggle = (optionValue: string) => {
|
||||
const newValue = value.includes(optionValue)
|
||||
? value.filter(v => v !== optionValue)
|
||||
: [...value, optionValue];
|
||||
const newValue = safeValue.includes(optionValue)
|
||||
? safeValue.filter(v => v !== optionValue)
|
||||
: [...safeValue, optionValue];
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
@@ -216,15 +241,15 @@ function CheckboxGroup({ field, value, onChange }: CheckboxGroupProps) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block text-[14px] font-bold font-serif text-[#00293D] mb-2">
|
||||
{field.name}
|
||||
{field.name || 'Field'}
|
||||
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{field.options?.map((option) => {
|
||||
const isChecked = value.includes(option.value);
|
||||
{safeOptions.map((option) => {
|
||||
const isChecked = safeValue.includes(option.value);
|
||||
return (
|
||||
<label key={option.value} className="flex items-center gap-2 cursor-pointer">
|
||||
<div className="relative">
|
||||
@@ -263,14 +288,16 @@ interface RadioGroupProps {
|
||||
}
|
||||
|
||||
function RadioGroup({ field, value, onChange }: RadioGroupProps) {
|
||||
const safeOptions = Array.isArray(field.options) ? field.options : [];
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block text-[14px] font-bold font-serif text-[#00293D] mb-2">
|
||||
{field.name}
|
||||
{field.name || 'Field'}
|
||||
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{field.options?.map((option) => (
|
||||
{safeOptions.map((option) => (
|
||||
<label key={option.value} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
@@ -364,14 +391,17 @@ const multiSelectStyles: StylesConfig<SelectOption, true> = {
|
||||
};
|
||||
|
||||
function MultiSelect({ field, value, onChange }: MultiSelectProps) {
|
||||
const safeFieldOptions = Array.isArray(field.options) ? field.options : [];
|
||||
const safeValue = Array.isArray(value) ? value : [];
|
||||
|
||||
// Convert field options to react-select format
|
||||
const options: SelectOption[] = field.options?.map(opt => ({
|
||||
const options: SelectOption[] = safeFieldOptions.map(opt => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
})) || [];
|
||||
}));
|
||||
|
||||
// Convert current value to react-select format
|
||||
const selectedOptions = options.filter(opt => value.includes(opt.value));
|
||||
const selectedOptions = options.filter(opt => safeValue.includes(opt.value));
|
||||
|
||||
const handleChange = (newValue: MultiValue<SelectOption>) => {
|
||||
onChange(newValue.map(opt => opt.value));
|
||||
@@ -380,7 +410,7 @@ function MultiSelect({ field, value, onChange }: MultiSelectProps) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block text-[14px] font-bold font-serif text-[#00293D] mb-2">
|
||||
{field.name}
|
||||
{field.name || 'Field'}
|
||||
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<Select<SelectOption, true>
|
||||
@@ -388,7 +418,7 @@ function MultiSelect({ field, value, onChange }: MultiSelectProps) {
|
||||
options={options}
|
||||
value={selectedOptions}
|
||||
onChange={handleChange}
|
||||
placeholder={field.placeholder || `Select ${field.name.toLowerCase()}...`}
|
||||
placeholder={field.placeholder || `Select ${(field.name || 'option').toLowerCase()}...`}
|
||||
styles={multiSelectStyles}
|
||||
classNamePrefix="react-select"
|
||||
isClearable
|
||||
@@ -408,15 +438,16 @@ interface RangeSliderProps {
|
||||
}
|
||||
|
||||
function RangeSlider({ field, value, onChange }: RangeSliderProps) {
|
||||
const min = field.rangeConfig?.min || 0;
|
||||
const max = field.rangeConfig?.max || 100;
|
||||
const step = field.rangeConfig?.step || 1;
|
||||
const unit = field.rangeConfig?.unit || '';
|
||||
const rangeConfig = field.rangeConfig || { min: 0, max: 100, step: 1 };
|
||||
const min = rangeConfig.min ?? 0;
|
||||
const max = rangeConfig.max ?? 100;
|
||||
const step = rangeConfig.step ?? 1;
|
||||
const unit = rangeConfig.unit ?? '';
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block text-[14px] font-bold font-serif text-[#00293D] mb-2">
|
||||
{field.name}
|
||||
{field.name || 'Field'}
|
||||
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -482,8 +513,9 @@ function FileUpload({ field, fieldSlug, value, onChange }: FileUploadProps) {
|
||||
const [loadingUrls, setLoadingUrls] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Get allowed formats from field config or use defaults
|
||||
const allowedFormats = field.validation?.allowedFormats || ['PDF', 'DOCX', 'JPG', 'PNG'];
|
||||
const maxFileSize = field.validation?.maxFileSize || 10; // MB
|
||||
const validation = field.validation && typeof field.validation === 'object' ? field.validation : {};
|
||||
const allowedFormats = Array.isArray(validation.allowedFormats) ? validation.allowedFormats : ['PDF', 'DOCX', 'JPG', 'PNG'];
|
||||
const maxFileSize = typeof validation.maxFileSize === 'number' ? validation.maxFileSize : 10; // MB
|
||||
const acceptString = allowedFormats.map(f => `.${f.toLowerCase()}`).join(',');
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
@@ -648,7 +680,7 @@ function FileUpload({ field, fieldSlug, value, onChange }: FileUploadProps) {
|
||||
}
|
||||
|
||||
// Get the key - either from file.key, file.id, or file.url if it's an S3 key
|
||||
const fileKey = file.key || file.id || file.url;
|
||||
const fileKey = file.key || file.id || file.url || '';
|
||||
|
||||
// If the URL is already a full URL (http/https), just open it directly
|
||||
if (fileKey.startsWith('http://') || fileKey.startsWith('https://')) {
|
||||
|
||||
@@ -5,6 +5,40 @@ import Image from 'next/image';
|
||||
import { ProfileSection } from '@/services/profile-sections.service';
|
||||
import DynamicField from './DynamicField';
|
||||
|
||||
// Error boundary to catch rendering errors in individual sections
|
||||
class SectionErrorBoundary extends React.Component<
|
||||
{ sectionName: string; children: React.ReactNode },
|
||||
{ hasError: boolean; error: Error | null }
|
||||
> {
|
||||
constructor(props: { sectionName: string; children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error(`Error rendering section "${this.props.sectionName}":`, error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="bg-white rounded-[20px] p-6 shadow-[0px_10px_20px_rgba(217,217,217,0.5)]">
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-600">
|
||||
Failed to load section: {this.props.sectionName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Map section slugs to icon paths
|
||||
const sectionIcons: Record<string, string> = {
|
||||
'basic-information': '/assets/icons/basic-information-icon.svg',
|
||||
@@ -65,27 +99,32 @@ export default function DynamicSection({ section, formData, onChange, errors }:
|
||||
let iconPath: string = defaultIcon;
|
||||
let isEmoji = false;
|
||||
|
||||
if (section.icon) {
|
||||
if (section.icon.startsWith('/') || section.icon.startsWith('http')) {
|
||||
const sectionName = section.name || 'Section';
|
||||
const sectionSlug = section.slug || '';
|
||||
const sectionIcon = section.icon || '';
|
||||
|
||||
if (sectionIcon) {
|
||||
if (sectionIcon.startsWith('/') || sectionIcon.startsWith('http')) {
|
||||
// It's a valid URL path
|
||||
iconPath = section.icon;
|
||||
} else if (isEmojiString(section.icon)) {
|
||||
iconPath = sectionIcon;
|
||||
} else if (isEmojiString(sectionIcon)) {
|
||||
// It's an emoji
|
||||
isEmoji = true;
|
||||
} else if (iconNameToPath[section.icon.toLowerCase()]) {
|
||||
} else if (iconNameToPath[sectionIcon.toLowerCase()]) {
|
||||
// It's an icon name like "star", "home", etc.
|
||||
iconPath = iconNameToPath[section.icon.toLowerCase()];
|
||||
iconPath = iconNameToPath[sectionIcon.toLowerCase()];
|
||||
} else {
|
||||
// Fallback to slug mapping or default
|
||||
iconPath = sectionIcons[section.slug] || defaultIcon;
|
||||
iconPath = sectionIcons[sectionSlug] || defaultIcon;
|
||||
}
|
||||
} else {
|
||||
// No icon set, use slug mapping or default
|
||||
iconPath = sectionIcons[section.slug] || defaultIcon;
|
||||
iconPath = sectionIcons[sectionSlug] || defaultIcon;
|
||||
}
|
||||
|
||||
// Sort fields by sortOrder
|
||||
const sortedFields = [...(section.fields || [])].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
// Sort fields by sortOrder - safely handle missing/null fields
|
||||
const fields = Array.isArray(section.fields) ? section.fields : [];
|
||||
const sortedFields = [...fields].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
const activeFields = sortedFields.filter(f => f.isActive && !f.isSearchableOnly);
|
||||
|
||||
if (activeFields.length === 0) {
|
||||
@@ -93,48 +132,50 @@ export default function DynamicSection({ section, formData, onChange, errors }:
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={section.slug}
|
||||
className="bg-white rounded-[20px] p-6 shadow-[0px_10px_20px_rgba(217,217,217,0.5)] scroll-mt-[100px]"
|
||||
>
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-[40px] h-[40px] flex items-center justify-center">
|
||||
{isEmoji ? (
|
||||
<span className="text-2xl">{section.icon}</span>
|
||||
) : (
|
||||
<Image
|
||||
src={iconPath}
|
||||
alt={section.name}
|
||||
width={24}
|
||||
height={24}
|
||||
className="object-contain"
|
||||
/>
|
||||
)}
|
||||
<SectionErrorBoundary sectionName={sectionName}>
|
||||
<div
|
||||
id={sectionSlug}
|
||||
className="bg-white rounded-[20px] p-6 shadow-[0px_10px_20px_rgba(217,217,217,0.5)] scroll-mt-[100px]"
|
||||
>
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-[40px] h-[40px] flex items-center justify-center">
|
||||
{isEmoji ? (
|
||||
<span className="text-2xl">{sectionIcon}</span>
|
||||
) : (
|
||||
<Image
|
||||
src={iconPath}
|
||||
alt={sectionName}
|
||||
width={24}
|
||||
height={24}
|
||||
className="object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[18px] font-bold font-serif text-[#00293D]">
|
||||
{sectionName}
|
||||
{section.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</h2>
|
||||
{section.description && (
|
||||
<p className="text-[12px] text-gray-500">{section.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[18px] font-bold font-serif text-[#00293D]">
|
||||
{section.name}
|
||||
{section.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</h2>
|
||||
{section.description && (
|
||||
<p className="text-[12px] text-gray-500">{section.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Fields */}
|
||||
<div className="space-y-4">
|
||||
{activeFields.map((field) => (
|
||||
<DynamicField
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={formData[field.slug]}
|
||||
onChange={onChange}
|
||||
error={errors?.[field.slug]}
|
||||
/>
|
||||
))}
|
||||
{/* Section Fields */}
|
||||
<div className="space-y-4">
|
||||
{activeFields.map((field) => (
|
||||
<DynamicField
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={formData[field.slug]}
|
||||
onChange={onChange}
|
||||
error={errors?.[field.slug]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,40 @@ import Image from 'next/image';
|
||||
import { ProfileSection } from '@/services/profile-sections.service';
|
||||
import DynamicField from './DynamicField';
|
||||
|
||||
// Error boundary to catch rendering errors in repeatable sections
|
||||
class RepeatableSectionErrorBoundary extends React.Component<
|
||||
{ sectionName: string; children: React.ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: { sectionName: string; children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error(`Error rendering repeatable section "${this.props.sectionName}":`, error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="bg-white rounded-[20px] p-6 shadow-[0px_10px_20px_rgba(217,217,217,0.5)]">
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-600">
|
||||
Failed to load section: {this.props.sectionName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Map section slugs to icon paths
|
||||
const sectionIcons: Record<string, string> = {
|
||||
'basic-information': '/assets/icons/basic-information-icon.svg',
|
||||
@@ -88,8 +122,9 @@ export default function RepeatableSection({
|
||||
iconPath = sectionIcons[section.slug] || defaultIcon;
|
||||
}
|
||||
|
||||
// Sort fields by sortOrder and filter active, non-search-only fields
|
||||
const sortedFields = [...(section.fields || [])].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
// Sort fields by sortOrder and filter active, non-search-only fields - safely handle missing/null fields
|
||||
const fields = Array.isArray(section.fields) ? section.fields : [];
|
||||
const sortedFields = [...fields].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
const activeFields = sortedFields.filter(f => f.isActive && !f.isSearchableOnly);
|
||||
|
||||
if (activeFields.length === 0) {
|
||||
@@ -152,7 +187,10 @@ export default function RepeatableSection({
|
||||
return `${section.name} ${index + 1}`;
|
||||
};
|
||||
|
||||
const sectionName = section.name || 'Section';
|
||||
|
||||
return (
|
||||
<RepeatableSectionErrorBoundary sectionName={sectionName}>
|
||||
<div
|
||||
id={section.slug}
|
||||
className="bg-white rounded-[20px] p-6 shadow-[0px_10px_20px_rgba(217,217,217,0.5)] scroll-mt-[100px]"
|
||||
@@ -166,7 +204,7 @@ export default function RepeatableSection({
|
||||
) : (
|
||||
<Image
|
||||
src={iconPath}
|
||||
alt={section.name}
|
||||
alt={sectionName}
|
||||
width={24}
|
||||
height={24}
|
||||
className="object-contain"
|
||||
@@ -175,7 +213,7 @@ export default function RepeatableSection({
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[18px] font-bold font-serif text-[#00293D]">
|
||||
{section.name}
|
||||
{sectionName}
|
||||
{section.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</h2>
|
||||
{section.description && (
|
||||
@@ -268,8 +306,9 @@ export default function RepeatableSection({
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Another {section.name}
|
||||
Add Another {sectionName}
|
||||
</button>
|
||||
</div>
|
||||
</RepeatableSectionErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,9 @@ export default function EditProfilePage() {
|
||||
|
||||
// Then apply field default values where no existing data
|
||||
sortedSections.forEach(section => {
|
||||
section.fields?.forEach(field => {
|
||||
const sectionFields = Array.isArray(section.fields) ? section.fields : [];
|
||||
sectionFields.forEach(field => {
|
||||
if (!field || !field.slug) return; // Skip malformed fields
|
||||
if (field.defaultValue && initialData[field.slug] === undefined) {
|
||||
// Parse default value based on field type
|
||||
switch (field.fieldType) {
|
||||
@@ -186,12 +188,13 @@ export default function EditProfilePage() {
|
||||
const repErrors: Record<string, Record<string, Record<string, string>>> = {};
|
||||
|
||||
sections.forEach(section => {
|
||||
const sectionFields = Array.isArray(section.fields) ? section.fields : [];
|
||||
if (section.isRepeatable) {
|
||||
// Validate repeatable section entries
|
||||
const entries = repeatableData[section.slug] || [{}];
|
||||
entries.forEach((entry, entryIndex) => {
|
||||
section.fields?.forEach(field => {
|
||||
if (field.isRequired && field.isActive && !field.isSearchableOnly) {
|
||||
sectionFields.forEach(field => {
|
||||
if (field && field.isRequired && field.isActive && !field.isSearchableOnly) {
|
||||
const value = entry[field.slug];
|
||||
const isEmpty =
|
||||
value === undefined ||
|
||||
@@ -209,8 +212,8 @@ export default function EditProfilePage() {
|
||||
});
|
||||
} else {
|
||||
// Validate regular section fields
|
||||
section.fields?.forEach(field => {
|
||||
if (field.isRequired && field.isActive && !field.isSearchableOnly) {
|
||||
sectionFields.forEach(field => {
|
||||
if (field && field.isRequired && field.isActive && !field.isSearchableOnly) {
|
||||
const value = formData[field.slug];
|
||||
const isEmpty =
|
||||
value === undefined ||
|
||||
|
||||
Reference in New Issue
Block a user