feat: implement comprehensive field validation logic and update UI components to enforce validation constraints
This commit is contained in:
@@ -56,6 +56,7 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
onChange={(val: string) => handleChange(val)}
|
||||
required={field.isRequired}
|
||||
error={error}
|
||||
maxLength={field.validation?.maxLength}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -68,6 +69,8 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
onChange={(val: string) => handleChange(val)}
|
||||
required={field.isRequired}
|
||||
rows={4}
|
||||
maxLength={field.validation?.maxLength}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -95,6 +98,8 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
onChange={(val: string) => handleChange(val)}
|
||||
required={field.isRequired}
|
||||
error={error}
|
||||
min={field.validation?.minDate}
|
||||
max={field.validation?.maxDate}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@ interface FormInputProps {
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
min?: number | string;
|
||||
max?: number | string;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export function FormInput({ label, value, onChange, placeholder, type = 'text', disabled, required, error, min, max }: FormInputProps) {
|
||||
export function FormInput({ label, value, onChange, placeholder, type = 'text', disabled, required, error, min, max, maxLength }: FormInputProps) {
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<label className="block text-[14px] font-bold font-serif text-[#00293D] mb-2">
|
||||
@@ -28,6 +29,7 @@ export function FormInput({ label, value, onChange, placeholder, type = 'text',
|
||||
disabled={disabled}
|
||||
min={min}
|
||||
max={max}
|
||||
maxLength={maxLength}
|
||||
className={`w-full h-[40px] px-4 border rounded-[15px] text-[14px] font-serif text-[#00293D] placeholder:text-[#00293D]/40 focus:outline-none disabled:bg-gray-50 disabled:text-[#00293D]/50 ${
|
||||
error ? 'border-red-500 focus:border-red-500' : 'border-[#00293D]/20 focus:border-[#E58625]'
|
||||
}`}
|
||||
|
||||
@@ -8,9 +8,10 @@ interface FormTextareaProps {
|
||||
rows?: number;
|
||||
maxLength?: number;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function FormTextarea({ label, value, onChange, placeholder, rows = 4, maxLength, required }: FormTextareaProps) {
|
||||
export function FormTextarea({ label, value, onChange, placeholder, rows = 4, maxLength, required, error }: FormTextareaProps) {
|
||||
return (
|
||||
<div className="flex-1">
|
||||
{label && (
|
||||
@@ -25,13 +26,16 @@ export function FormTextarea({ label, value, onChange, placeholder, rows = 4, ma
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
maxLength={maxLength}
|
||||
className="w-full px-4 py-3 border border-[#00293D]/20 rounded-[10px] text-[14px] font-serif text-[#00293D] placeholder:text-[#00293D]/40 focus:outline-none focus:border-[#E58625] resize-none"
|
||||
className={`w-full px-4 py-3 border rounded-[10px] text-[14px] font-serif text-[#00293D] placeholder:text-[#00293D]/40 focus:outline-none resize-none ${
|
||||
error ? 'border-red-500 focus:border-red-500' : 'border-[#00293D]/20 focus:border-[#E58625]'
|
||||
}`}
|
||||
/>
|
||||
{maxLength && (
|
||||
<p className="text-right text-[12px] text-[#00293D]/50 font-serif mt-1">
|
||||
{value.length}/{maxLength}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-[12px] text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import DynamicSection from './components/DynamicSection';
|
||||
import RepeatableSection, { RepeatableEntryData } from './components/RepeatableSection';
|
||||
import { profileSectionsService, ProfileSection, AgentTypeSectionsResponse } from '@/services/profile-sections.service';
|
||||
import { agentsService, AgentProfile, FieldValueInput } from '@/services/agents.service';
|
||||
import { validateFieldValue } from '@/utils/validateFieldValue';
|
||||
import { usersService } from '@/services/users.service';
|
||||
import { MobileBackButton } from '@/components/layout/MobileBackButton';
|
||||
|
||||
@@ -116,11 +117,37 @@ export default function EditProfilePage() {
|
||||
try {
|
||||
const fieldValuesResponse = await agentsService.getFieldValues();
|
||||
if (fieldValuesResponse.fieldValues) {
|
||||
// Build a lookup of current valid option values per enumerated-field slug
|
||||
const enumTypes = new Set(['CHECKBOX_GROUP', 'MULTI_SELECT', 'SELECT', 'RADIO']);
|
||||
const validOptionsBySlug = new Map<string, Set<string>>();
|
||||
sortedSections.forEach((section) => {
|
||||
const sectionFields = Array.isArray(section.fields) ? section.fields : [];
|
||||
sectionFields.forEach((field) => {
|
||||
if (!field?.slug || !enumTypes.has(field.fieldType)) return;
|
||||
const opts = Array.isArray(field.options) ? field.options : [];
|
||||
validOptionsBySlug.set(
|
||||
field.slug,
|
||||
new Set(opts.map((o: { value: string }) => String(o.value))),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
fieldValuesResponse.fieldValues.forEach(fv => {
|
||||
// Only set if value exists and is not null/undefined
|
||||
if (fv.value !== null && fv.value !== undefined) {
|
||||
initialData[fv.fieldSlug] = fv.value;
|
||||
if (fv.value === null || fv.value === undefined) return;
|
||||
let val: unknown = fv.value;
|
||||
// Strip orphan option values from stored arrays/strings so the form
|
||||
// never carries stale selections forward on the next save.
|
||||
const validValues = validOptionsBySlug.get(fv.fieldSlug);
|
||||
if (validValues) {
|
||||
if (Array.isArray(val)) {
|
||||
val = (val as unknown[])
|
||||
.filter((v) => validValues.has(String(v)))
|
||||
.filter((v, i, arr) => arr.indexOf(v) === i);
|
||||
} else if (typeof val === 'string' && !validValues.has(val)) {
|
||||
val = '';
|
||||
}
|
||||
}
|
||||
initialData[fv.fieldSlug] = val;
|
||||
});
|
||||
}
|
||||
} catch (fieldValueErr) {
|
||||
@@ -204,18 +231,12 @@ export default function EditProfilePage() {
|
||||
const entries = repeatableData[section.slug] || [{}];
|
||||
entries.forEach((entry, entryIndex) => {
|
||||
sectionFields.forEach(field => {
|
||||
if (field && field.isRequired && field.isActive && !field.isSearchableOnly) {
|
||||
const value = entry[field.slug];
|
||||
const isEmpty =
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
(Array.isArray(value) && value.length === 0);
|
||||
|
||||
if (isEmpty) {
|
||||
if (field && field.isActive && !field.isSearchableOnly) {
|
||||
const err = validateFieldValue(field, entry[field.slug]);
|
||||
if (err) {
|
||||
if (!repErrors[section.slug]) repErrors[section.slug] = {};
|
||||
if (!repErrors[section.slug][entryIndex]) repErrors[section.slug][entryIndex] = {};
|
||||
repErrors[section.slug][entryIndex][field.slug] = `${field.name} is required`;
|
||||
repErrors[section.slug][entryIndex][field.slug] = err;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -223,16 +244,10 @@ export default function EditProfilePage() {
|
||||
} else {
|
||||
// Validate regular section fields
|
||||
sectionFields.forEach(field => {
|
||||
if (field && field.isRequired && field.isActive && !field.isSearchableOnly) {
|
||||
const value = formData[field.slug];
|
||||
const isEmpty =
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
(Array.isArray(value) && value.length === 0);
|
||||
|
||||
if (isEmpty) {
|
||||
errors[field.slug] = `${field.name} is required`;
|
||||
if (field && field.isActive && !field.isSearchableOnly) {
|
||||
const err = validateFieldValue(field, formData[field.slug]);
|
||||
if (err) {
|
||||
errors[field.slug] = err;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -119,24 +119,19 @@ export function TestimonialsSection({ content }: { content?: TestimonialsContent
|
||||
</div>
|
||||
|
||||
{/* Stats Section */}
|
||||
<div className="flex justify-center items-center gap-8 md:gap-12 mb-10">
|
||||
<div className="flex flex-col sm:flex-row justify-center items-center gap-4 sm:gap-8 md:gap-12 mb-10">
|
||||
{data.stats.map((stat, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<div key={index} className="flex items-center gap-3 whitespace-nowrap">
|
||||
<Image
|
||||
src={stat.iconPath}
|
||||
alt=""
|
||||
width={27}
|
||||
height={27}
|
||||
/>
|
||||
<div>
|
||||
<span className="font-serif font-bold text-[14px] leading-normal text-[#00293d]">
|
||||
{stat.boldText}
|
||||
</span>
|
||||
<br />
|
||||
<span className="font-serif font-normal text-[14px] leading-normal text-[#00293d]">
|
||||
{stat.normalText}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-serif text-[14px] leading-normal text-[#00293d]">
|
||||
<span className="font-bold">{stat.boldText}</span>{' '}
|
||||
<span className="font-normal">{stat.normalText}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface FieldValidation {
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
minDate?: string; // ISO date (YYYY-MM-DD) for DATE fields
|
||||
maxDate?: string; // ISO date (YYYY-MM-DD) for DATE fields
|
||||
allowedFormats?: string[];
|
||||
maxFileSize?: number;
|
||||
}
|
||||
|
||||
@@ -291,22 +291,11 @@ export function mapFieldValuesToAvailability(fieldValues: FieldValueResponse[]):
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
// Drop stale/orphan options. Backend returns `valueLabel[i]` equal to the raw `value[i]`
|
||||
// when no option match is found (admin renamed/removed the option). Those are orphans —
|
||||
// skip them entirely so old selections don't linger on the public profile.
|
||||
const resolvedLabels: string[] = [];
|
||||
rawValues.forEach((val, i) => {
|
||||
const raw = String(val || '').trim();
|
||||
if (!raw) return;
|
||||
const fromBackend = labelValues[i];
|
||||
const hasResolvedLabel =
|
||||
fromBackend &&
|
||||
typeof fromBackend === 'string' &&
|
||||
fromBackend.trim() &&
|
||||
fromBackend !== raw; // backend-resolved label must DIFFER from raw value
|
||||
if (!hasResolvedLabel) return; // orphan — drop
|
||||
resolvedLabels.push(formatIfSnakeCase(fromBackend));
|
||||
});
|
||||
// Backend now strips orphan entries and returns only resolvable option labels,
|
||||
// so we trust valueLabel as-is.
|
||||
const resolvedLabels: string[] = labelValues
|
||||
.filter((l): l is string => typeof l === 'string' && l.trim().length > 0)
|
||||
.map(formatIfSnakeCase);
|
||||
|
||||
// Deduplicate by normalized comparison (case/whitespace insensitive)
|
||||
const seen = new Set<string>();
|
||||
|
||||
92
src/utils/validateFieldValue.ts
Normal file
92
src/utils/validateFieldValue.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ProfileField } from '@/services/profile-sections.service';
|
||||
|
||||
/**
|
||||
* Returns an error message if the given value fails validation for the field,
|
||||
* or null if it passes. Enforces:
|
||||
* - required
|
||||
* - TEXT/TEXTAREA: minLength, maxLength, pattern
|
||||
* - NUMBER/RANGE: min, max
|
||||
* - DATE: minDate, maxDate
|
||||
* - CHECKBOX_GROUP / MULTI_SELECT / TAG_INPUT / RADIO / SELECT: required non-empty
|
||||
*/
|
||||
export function validateFieldValue(field: ProfileField, value: unknown): string | null {
|
||||
const v = field.validation ?? {};
|
||||
const fieldName = field.name || 'This field';
|
||||
|
||||
const isEmpty =
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
(Array.isArray(value) && value.length === 0);
|
||||
|
||||
if (field.isRequired && isEmpty) {
|
||||
return `${fieldName} is required`;
|
||||
}
|
||||
if (isEmpty) return null; // optional + empty → OK
|
||||
|
||||
switch (field.fieldType) {
|
||||
case 'TEXT':
|
||||
case 'TEXTAREA': {
|
||||
const str = String(value);
|
||||
if (typeof v.minLength === 'number' && str.length < v.minLength) {
|
||||
return `${fieldName} must be at least ${v.minLength} characters`;
|
||||
}
|
||||
if (typeof v.maxLength === 'number' && str.length > v.maxLength) {
|
||||
return `${fieldName} must be no more than ${v.maxLength} characters`;
|
||||
}
|
||||
if (v.pattern && typeof v.pattern === 'string') {
|
||||
try {
|
||||
if (!new RegExp(v.pattern).test(str)) {
|
||||
return `${fieldName} has an invalid format`;
|
||||
}
|
||||
} catch {
|
||||
// Invalid regex in admin config — silently skip
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'NUMBER':
|
||||
case 'RANGE': {
|
||||
const n = typeof value === 'number' ? value : parseFloat(String(value));
|
||||
if (!isFinite(n)) return `${fieldName} must be a number`;
|
||||
if (typeof v.min === 'number' && n < v.min) {
|
||||
return `${fieldName} must be at least ${v.min}`;
|
||||
}
|
||||
if (typeof v.max === 'number' && n > v.max) {
|
||||
return `${fieldName} must be no more than ${v.max}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'DATE': {
|
||||
const str = String(value).slice(0, 10); // YYYY-MM-DD
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(str)) {
|
||||
return `${fieldName} must be a valid date`;
|
||||
}
|
||||
if (v.minDate && str < v.minDate) {
|
||||
return `${fieldName} must be on or after ${v.minDate}`;
|
||||
}
|
||||
if (v.maxDate && str > v.maxDate) {
|
||||
return `${fieldName} must be on or before ${v.maxDate}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'TAG_INPUT': {
|
||||
// string items — apply maxLength per item if defined
|
||||
if (Array.isArray(value) && typeof v.maxLength === 'number') {
|
||||
const tooLong = (value as unknown[]).find(
|
||||
(item) => typeof item === 'string' && item.length > v.maxLength!,
|
||||
);
|
||||
if (tooLong) {
|
||||
return `Each ${fieldName.toLowerCase()} entry must be no more than ${v.maxLength} characters`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user