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