veza/apps/web/src/utils/validation.ts
2025-12-12 21:34:34 -05:00

387 lines
11 KiB
TypeScript

/**
* Utilitaires de validation de formulaires réutilisables avec messages d'erreur.
*/
export type ValidationResult = string | null;
export type Validator<T = any> = (value: T) => ValidationResult;
/**
* Messages d'erreur de validation (préparés pour i18n)
*/
export const validationMessages = {
required: 'This field is required',
email: 'Invalid email address',
minLength: (min: number) => `Minimum ${min} characters`,
maxLength: (max: number) => `Maximum ${max} characters`,
min: (min: number) => `Minimum value is ${min}`,
max: (max: number) => `Maximum value is ${max}`,
pattern: 'Invalid format',
url: 'Invalid URL',
number: 'Must be a number',
integer: 'Must be an integer',
positive: 'Must be a positive number',
phone: 'Invalid phone number',
date: 'Invalid date',
dateMin: (min: Date) => `Date must be after ${min.toLocaleDateString()}`,
dateMax: (max: Date) => `Date must be before ${max.toLocaleDateString()}`,
fileSize: (maxSize: number) =>
`File size must be less than ${formatFileSize(maxSize)}`,
fileType: (allowedTypes: string[]) =>
`File type must be one of: ${allowedTypes.join(', ')}`,
};
/**
* Formatage de la taille de fichier
*/
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`;
}
/**
* Validators de base
*/
export const validators = {
/**
* Vérifie que le champ est requis
*/
required: (value: any): ValidationResult => {
if (value === null || value === undefined || value === '') {
return validationMessages.required;
}
if (typeof value === 'string' && !value.trim()) {
return validationMessages.required;
}
if (Array.isArray(value) && value.length === 0) {
return validationMessages.required;
}
if (typeof value === 'object' && Object.keys(value).length === 0) {
return validationMessages.required;
}
return null;
},
/**
* Vérifie que la valeur est un email valide
*/
email: (value: string): ValidationResult => {
if (!value) return null; // required est géré séparément
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
return validationMessages.email;
}
return null;
},
/**
* Vérifie la longueur minimale
*/
minLength: (min: number): Validator<string> => {
return (value: string): ValidationResult => {
if (!value) return null; // required est géré séparément
if (value.length < min) {
return validationMessages.minLength(min);
}
return null;
};
},
/**
* Vérifie la longueur maximale
*/
maxLength: (max: number): Validator<string> => {
return (value: string): ValidationResult => {
if (!value) return null; // required est géré séparément
if (value.length > max) {
return validationMessages.maxLength(max);
}
return null;
};
},
/**
* Vérifie la valeur minimale (pour les nombres)
*/
min: (min: number): Validator<number> => {
return (value: number): ValidationResult => {
if (value === null || value === undefined) return null;
if (typeof value !== 'number' || isNaN(value)) return null;
if (value < min) {
return validationMessages.min(min);
}
return null;
};
},
/**
* Vérifie la valeur maximale (pour les nombres)
*/
max: (max: number): Validator<number> => {
return (value: number): ValidationResult => {
if (value === null || value === undefined) return null;
if (typeof value !== 'number' || isNaN(value)) return null;
if (value > max) {
return validationMessages.max(max);
}
return null;
};
},
/**
* Vérifie que la valeur correspond à un pattern (regex)
*/
pattern: (regex: RegExp): Validator<string> => {
return (value: string): ValidationResult => {
if (!value) return null; // required est géré séparément
if (!regex.test(value)) {
return validationMessages.pattern;
}
return null;
};
},
/**
* Vérifie que la valeur est une URL valide
*/
url: (value: string): ValidationResult => {
if (!value) return null; // required est géré séparément
try {
new URL(value);
return null;
} catch {
return validationMessages.url;
}
},
/**
* Vérifie que la valeur est un nombre
*/
number: (value: any): ValidationResult => {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number' && !isNaN(value)) return null;
if (typeof value === 'string' && !isNaN(Number(value))) return null;
return validationMessages.number;
},
/**
* Vérifie que la valeur est un entier
*/
integer: (value: any): ValidationResult => {
if (value === null || value === undefined || value === '') return null;
const num = typeof value === 'number' ? value : Number(value);
if (isNaN(num)) return validationMessages.number;
if (!Number.isInteger(num)) {
return validationMessages.integer;
}
return null;
},
/**
* Vérifie que la valeur est positive
*/
positive: (value: number): ValidationResult => {
if (value === null || value === undefined) return null;
if (typeof value !== 'number' || isNaN(value)) return null;
if (value <= 0) {
return validationMessages.positive;
}
return null;
},
/**
* Vérifie que la valeur est un numéro de téléphone valide
*/
phone: (value: string): ValidationResult => {
if (!value) return null; // required est géré séparément
const phoneRegex = /^[+]?[(]?[0-9]{3}[)]?[-\s.]?[0-9]{3}[-\s.]?[0-9]{4,6}$/;
if (!phoneRegex.test(value.replace(/\s+/g, ''))) {
return validationMessages.phone;
}
return null;
},
/**
* Vérifie que la valeur est une date valide
*/
date: (value: any): ValidationResult => {
if (!value) return null; // required est géré séparément
if (value instanceof Date && !isNaN(value.getTime())) return null;
if (typeof value === 'string' && !isNaN(Date.parse(value))) return null;
return validationMessages.date;
},
/**
* Vérifie que la date est après une date minimale
*/
dateMin: (min: Date): Validator<Date> => {
return (value: Date): ValidationResult => {
if (!value) return null; // required est géré séparément
const date = value instanceof Date ? value : new Date(value);
if (isNaN(date.getTime())) return validationMessages.date;
if (date < min) {
return validationMessages.dateMin(min);
}
return null;
};
},
/**
* Vérifie que la date est avant une date maximale
*/
dateMax: (max: Date): Validator<Date> => {
return (value: Date): ValidationResult => {
if (!value) return null; // required est géré séparément
const date = value instanceof Date ? value : new Date(value);
if (isNaN(date.getTime())) return validationMessages.date;
if (date > max) {
return validationMessages.dateMax(max);
}
return null;
};
},
/**
* Vérifie la taille d'un fichier
*/
fileSize: (maxSize: number): Validator<File | File[]> => {
return (value: File | File[]): ValidationResult => {
if (!value) return null; // required est géré séparément
const files = Array.isArray(value) ? value : [value];
for (const file of files) {
if (file.size > maxSize) {
return validationMessages.fileSize(maxSize);
}
}
return null;
};
},
/**
* Vérifie le type d'un fichier
*/
fileType: (allowedTypes: string[]): Validator<File | File[]> => {
return (value: File | File[]): ValidationResult => {
if (!value) return null; // required est géré séparément
const files = Array.isArray(value) ? value : [value];
for (const file of files) {
const fileType = file.type || '';
const fileName = file.name || '';
const fileExtension = `.${fileName.split('.').pop()?.toLowerCase()}`;
const isAllowed = allowedTypes.some((type) => {
if (type.startsWith('.')) {
return type.toLowerCase() === fileExtension;
}
if (type.includes('/')) {
return (
fileType === type || fileType.startsWith(`${type.split('/')[0]}/`)
);
}
return false;
});
if (!isAllowed) {
return validationMessages.fileType(allowedTypes);
}
}
return null;
};
},
};
/**
* Combinaison de validators (tous doivent passer)
*/
export function combineValidators<T>(
...validators: Validator<T>[]
): Validator<T> {
return (value: T): ValidationResult => {
for (const validator of validators) {
const error = validator(value);
if (error) {
return error;
}
}
return null;
};
}
/**
* Validation conditionnelle (validator optionnel)
*/
export function optionalValidator<T>(validator: Validator<T>): Validator<T> {
return (value: T): ValidationResult => {
if (value === null || value === undefined || value === '') {
return null; // Si vide, la validation est ignorée
}
return validator(value);
};
}
/**
* Validation personnalisée avec message d'erreur
*/
export function customValidator<T>(
validate: (value: T) => boolean,
errorMessage: string,
): Validator<T> {
return (value: T): ValidationResult => {
if (!validate(value)) {
return errorMessage;
}
return null;
};
}
/**
* Validation d'objet (pour les formulaires complexes)
*/
export function validateObject<T extends Record<string, any>>(
object: T,
validators: Record<keyof T, Validator<any>>,
): Record<keyof T, string> | null {
const errors: Partial<Record<keyof T, string>> = {};
let hasErrors = false;
for (const key in validators) {
if (Object.prototype.hasOwnProperty.call(validators, key)) {
const validator = validators[key];
const error = validator(object[key]);
if (error) {
errors[key] = error;
hasErrors = true;
}
}
}
return hasErrors ? (errors as Record<keyof T, string>) : null;
}
/**
* Valide un email avec retour détaillé pour validation en temps réel
* @param email - L'adresse email à valider
* @returns Objet avec valid (boolean) et message (string optionnel)
*/
export function validateEmail(email: string): {
valid: boolean;
message?: string;
} {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[-a-zA-Z0-9.]+\.[a-zA-Z]{2,}$/;
if (!email) {
return { valid: false, message: 'Email is required' };
}
if (email.length > 254) {
return { valid: false, message: 'Email is too long' };
}
if (!emailRegex.test(email)) {
return { valid: false, message: 'Invalid email format' };
}
return { valid: true };
}