394 lines
11 KiB
TypeScript
394 lines
11 KiB
TypeScript
/**
|
|
* Utilitaires de validation de formulaires réutilisables avec messages d'erreur.
|
|
*/
|
|
|
|
export type ValidationResult = string | null;
|
|
export type Validator<T = unknown> = (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: unknown): ValidationResult => {
|
|
if (value === null || value === undefined) {
|
|
return validationMessages.required;
|
|
}
|
|
if (typeof value === 'string' && 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 as object).length === 0
|
|
) {
|
|
if (value instanceof Date) return null; // Date is an object but empty keys check assumes plain object
|
|
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: unknown): 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: unknown): 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: unknown): 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, unknown>>(
|
|
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 };
|
|
}
|