fix: Improve robustness and error handling for dynamic form fields and sections with safe data access and type coercion.

This commit is contained in:
pradeepkumar
2026-02-24 21:43:05 +05:30
parent 6d467cc8c0
commit c8f9045a38
4 changed files with 234 additions and 119 deletions

View File

@@ -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,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();
// Don't render anything for null fields (like REPEATER)
@@ -190,10 +204,18 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
}
return (
<div className="dynamic-field" data-field-slug={field.slug}>
<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>
);
}
}
// 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://')) {

View File

@@ -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,19 +132,20 @@ export default function DynamicSection({ section, formData, onChange, errors }:
}
return (
<SectionErrorBoundary sectionName={sectionName}>
<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]"
>
{/* 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>
<span className="text-2xl">{sectionIcon}</span>
) : (
<Image
src={iconPath}
alt={section.name}
alt={sectionName}
width={24}
height={24}
className="object-contain"
@@ -114,7 +154,7 @@ export default function DynamicSection({ section, formData, onChange, errors }:
</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 && (
@@ -136,5 +176,6 @@ export default function DynamicSection({ section, formData, onChange, errors }:
))}
</div>
</div>
</SectionErrorBoundary>
);
}

View File

@@ -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>
);
}

View File

@@ -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 ||