feat: implement comprehensive field validation logic and update UI components to enforce validation constraints
This commit is contained in:
@@ -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