first commit
This commit is contained in:
205
packages/plugins/forms/src/validation.ts
Normal file
205
packages/plugins/forms/src/validation.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Server-side submission validation.
|
||||
*
|
||||
* Validates submitted data against the form's field definitions.
|
||||
* These rules mirror what the client-side script checks, but server
|
||||
* validation is authoritative — never trust the client.
|
||||
*/
|
||||
|
||||
import type { FieldType, FormField } from "./types.js";
|
||||
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: ValidationError[];
|
||||
/** Sanitized/coerced values */
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const URL_RE = /^https?:\/\/.+/;
|
||||
const TEL_RE = /^[+\d][\d\s()-]*$/;
|
||||
|
||||
/**
|
||||
* Validate submission data against form field definitions.
|
||||
*
|
||||
* Returns sanitized data with proper type coercion and all validation
|
||||
* errors. Conditionally hidden fields are excluded from validation
|
||||
* if their condition is not met.
|
||||
*/
|
||||
export function validateSubmission(
|
||||
fields: FormField[],
|
||||
data: Record<string, unknown>,
|
||||
): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
const validated: Record<string, unknown> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
// Skip conditionally hidden fields
|
||||
if (field.condition && !evaluateCondition(field.condition, data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = data[field.name];
|
||||
const value = typeof raw === "string" ? raw.trim() : raw;
|
||||
const isEmpty = value === undefined || value === null || value === "";
|
||||
|
||||
// Required check
|
||||
if (field.required && isEmpty) {
|
||||
errors.push({ field: field.name, message: `${field.label} is required` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip further validation if empty and not required
|
||||
if (isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
const typeError = validateFieldType(field, value);
|
||||
if (typeError) {
|
||||
errors.push({ field: field.name, message: typeError });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validation rules
|
||||
const ruleErrors = validateFieldRules(field, value);
|
||||
for (const msg of ruleErrors) {
|
||||
errors.push({ field: field.name, message: msg });
|
||||
}
|
||||
|
||||
if (ruleErrors.length === 0) {
|
||||
validated[field.name] = coerceValue(field.type, value);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors, data: validated };
|
||||
}
|
||||
|
||||
function validateFieldType(field: FormField, value: unknown): string | null {
|
||||
if (typeof value !== "string" && field.type !== "checkbox" && field.type !== "number") {
|
||||
return `${field.label} has an invalid value`;
|
||||
}
|
||||
|
||||
const strValue = String(value);
|
||||
|
||||
switch (field.type) {
|
||||
case "email":
|
||||
if (!EMAIL_RE.test(strValue)) return `${field.label} must be a valid email address`;
|
||||
break;
|
||||
case "url":
|
||||
if (!URL_RE.test(strValue)) return `${field.label} must be a valid URL`;
|
||||
break;
|
||||
case "tel":
|
||||
if (!TEL_RE.test(strValue)) return `${field.label} must be a valid phone number`;
|
||||
break;
|
||||
case "number": {
|
||||
const num = Number(value);
|
||||
if (Number.isNaN(num)) return `${field.label} must be a number`;
|
||||
break;
|
||||
}
|
||||
case "date":
|
||||
if (Number.isNaN(Date.parse(strValue))) return `${field.label} must be a valid date`;
|
||||
break;
|
||||
case "select":
|
||||
case "radio":
|
||||
if (field.options && !field.options.some((o) => o.value === strValue)) {
|
||||
return `${field.label} has an invalid selection`;
|
||||
}
|
||||
break;
|
||||
case "checkbox-group": {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
if (field.options) {
|
||||
const validValues = new Set(field.options.map((o) => o.value));
|
||||
for (const v of values) {
|
||||
if (!validValues.has(String(v))) {
|
||||
return `${field.label} contains an invalid selection`;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateFieldRules(field: FormField, value: unknown): string[] {
|
||||
const errors: string[] = [];
|
||||
const v = field.validation;
|
||||
if (!v) return errors;
|
||||
|
||||
const strValue = String(value);
|
||||
|
||||
if (v.minLength !== undefined && strValue.length < v.minLength) {
|
||||
errors.push(`${field.label} must be at least ${v.minLength} characters`);
|
||||
}
|
||||
if (v.maxLength !== undefined && strValue.length > v.maxLength) {
|
||||
errors.push(`${field.label} must be at most ${v.maxLength} characters`);
|
||||
}
|
||||
|
||||
if (field.type === "number") {
|
||||
const num = Number(value);
|
||||
if (v.min !== undefined && num < v.min) {
|
||||
errors.push(`${field.label} must be at least ${v.min}`);
|
||||
}
|
||||
if (v.max !== undefined && num > v.max) {
|
||||
errors.push(`${field.label} must be at most ${v.max}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (v.pattern) {
|
||||
try {
|
||||
const re = new RegExp(v.pattern);
|
||||
if (!re.test(strValue)) {
|
||||
errors.push(v.patternMessage || `${field.label} has an invalid format`);
|
||||
}
|
||||
} catch {
|
||||
// Invalid regex in config — skip pattern check
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function coerceValue(type: FieldType, value: unknown): unknown {
|
||||
switch (type) {
|
||||
case "number":
|
||||
return Number(value);
|
||||
case "checkbox":
|
||||
return value === "on" || value === "true" || value === true;
|
||||
case "checkbox-group":
|
||||
return Array.isArray(value) ? value : [value];
|
||||
default:
|
||||
return typeof value === "string" ? value.trim() : value;
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateCondition(
|
||||
condition: { field: string; op: string; value?: string },
|
||||
data: Record<string, unknown>,
|
||||
): boolean {
|
||||
const fieldValue = data[condition.field];
|
||||
const strValue =
|
||||
fieldValue === undefined || fieldValue === null
|
||||
? ""
|
||||
: String(fieldValue as string | number | boolean);
|
||||
const isFilled = strValue !== "";
|
||||
|
||||
switch (condition.op) {
|
||||
case "eq":
|
||||
return strValue === (condition.value ?? "");
|
||||
case "neq":
|
||||
return strValue !== (condition.value ?? "");
|
||||
case "filled":
|
||||
return isFilled;
|
||||
case "empty":
|
||||
return !isFilled;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user