356 lines
11 KiB
Markdown
356 lines
11 KiB
Markdown
# Story 5.2: Validation JavaScript Côté Client
|
||
|
||
## Status
|
||
|
||
review
|
||
|
||
## Story
|
||
|
||
**As a** visiteur,
|
||
**I want** être informé immédiatement si je fais une erreur de saisie,
|
||
**so that** je corrige avant d'envoyer et j'évite les allers-retours.
|
||
|
||
## Acceptance Criteria
|
||
|
||
1. La validation JavaScript s'exécute à la soumission ET à la perte de focus (blur)
|
||
2. Les messages d'erreur sont affichés sous chaque champ concerné
|
||
3. Les champs en erreur sont visuellement distingués (bordure rouge, icône)
|
||
4. Le message d'erreur est clair et indique comment corriger
|
||
5. Le bouton d'envoi est désactivé tant que le formulaire contient des erreurs
|
||
6. La validation est en JavaScript vanilla (pas de bibliothèque)
|
||
|
||
## Tasks / Subtasks
|
||
|
||
- [x] **Task 1 : Créer le validateur de formulaire** (AC: 6)
|
||
- [x] Créer `assets/js/contact-form.js`
|
||
- [x] Classe ou objet `FormValidator`
|
||
- [x] Méthodes de validation par type de champ
|
||
|
||
- [x] **Task 2 : Implémenter la validation au blur** (AC: 1)
|
||
- [x] Écouter l'événement `blur` sur chaque champ
|
||
- [x] Valider le champ concerné
|
||
- [x] Afficher/masquer l'erreur
|
||
|
||
- [x] **Task 3 : Implémenter la validation à la soumission** (AC: 1)
|
||
- [x] Écouter l'événement `submit`
|
||
- [x] Valider tous les champs
|
||
- [x] Empêcher l'envoi si erreurs
|
||
|
||
- [x] **Task 4 : Afficher les erreurs** (AC: 2, 3, 4)
|
||
- [x] Message sous le champ (data-error)
|
||
- [x] Bordure rouge sur le champ (classes Tailwind)
|
||
- [x] Messages clairs et actionnables
|
||
|
||
- [x] **Task 5 : Gérer l'état du bouton** (AC: 5)
|
||
- [x] Désactiver si erreurs
|
||
- [x] Réactiver quand tout est valide
|
||
|
||
## Dev Notes
|
||
|
||
### Structure assets/js/contact-form.js
|
||
|
||
```javascript
|
||
/**
|
||
* Validation du formulaire de contact
|
||
* JavaScript vanilla - pas de dépendances
|
||
*/
|
||
|
||
class FormValidator {
|
||
constructor(formId) {
|
||
this.form = document.getElementById(formId);
|
||
if (!this.form) return;
|
||
|
||
this.submitBtn = document.getElementById('submit-btn');
|
||
this.fields = {};
|
||
this.errors = {};
|
||
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
// Définir les règles de validation
|
||
this.rules = {
|
||
nom: {
|
||
required: true,
|
||
minLength: 2,
|
||
maxLength: 100,
|
||
message: 'Veuillez entrer votre nom (2 caractères minimum)'
|
||
},
|
||
prenom: {
|
||
required: true,
|
||
minLength: 2,
|
||
maxLength: 100,
|
||
message: 'Veuillez entrer votre prénom (2 caractères minimum)'
|
||
},
|
||
email: {
|
||
required: true,
|
||
email: true,
|
||
message: 'Veuillez entrer une adresse email valide'
|
||
},
|
||
categorie: {
|
||
required: true,
|
||
message: 'Veuillez sélectionner une catégorie'
|
||
},
|
||
objet: {
|
||
required: true,
|
||
minLength: 5,
|
||
maxLength: 200,
|
||
message: 'Veuillez entrer un objet (5 caractères minimum)'
|
||
},
|
||
message: {
|
||
required: true,
|
||
minLength: 20,
|
||
maxLength: 5000,
|
||
message: 'Veuillez entrer votre message (20 caractères minimum)'
|
||
}
|
||
};
|
||
|
||
// Récupérer les champs
|
||
Object.keys(this.rules).forEach(fieldName => {
|
||
this.fields[fieldName] = this.form.querySelector(`[name="${fieldName}"]`);
|
||
});
|
||
|
||
this.bindEvents();
|
||
}
|
||
|
||
bindEvents() {
|
||
// Validation au blur
|
||
Object.keys(this.fields).forEach(fieldName => {
|
||
const field = this.fields[fieldName];
|
||
if (field) {
|
||
field.addEventListener('blur', () => this.validateField(fieldName));
|
||
field.addEventListener('input', () => this.clearError(fieldName));
|
||
}
|
||
});
|
||
|
||
// Validation à la soumission
|
||
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
|
||
|
||
// Compteur de caractères pour le message
|
||
const messageField = this.fields.message;
|
||
if (messageField) {
|
||
messageField.addEventListener('input', () => this.updateCharCount());
|
||
}
|
||
}
|
||
|
||
validateField(fieldName) {
|
||
const field = this.fields[fieldName];
|
||
const rule = this.rules[fieldName];
|
||
|
||
if (!field || !rule) return true;
|
||
|
||
const value = field.value.trim();
|
||
let isValid = true;
|
||
let errorMessage = '';
|
||
|
||
// Required
|
||
if (rule.required && !value) {
|
||
isValid = false;
|
||
errorMessage = rule.message;
|
||
}
|
||
|
||
// Min length
|
||
if (isValid && rule.minLength && value.length < rule.minLength) {
|
||
isValid = false;
|
||
errorMessage = rule.message;
|
||
}
|
||
|
||
// Max length
|
||
if (isValid && rule.maxLength && value.length > rule.maxLength) {
|
||
isValid = false;
|
||
errorMessage = `Maximum ${rule.maxLength} caractères`;
|
||
}
|
||
|
||
// Email format
|
||
if (isValid && rule.email && value) {
|
||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
if (!emailRegex.test(value)) {
|
||
isValid = false;
|
||
errorMessage = rule.message;
|
||
}
|
||
}
|
||
|
||
// Afficher ou masquer l'erreur
|
||
if (isValid) {
|
||
this.clearError(fieldName);
|
||
} else {
|
||
this.showError(fieldName, errorMessage);
|
||
}
|
||
|
||
this.errors[fieldName] = !isValid;
|
||
this.updateSubmitButton();
|
||
|
||
return isValid;
|
||
}
|
||
|
||
validateAll() {
|
||
let allValid = true;
|
||
|
||
Object.keys(this.rules).forEach(fieldName => {
|
||
if (!this.validateField(fieldName)) {
|
||
allValid = false;
|
||
}
|
||
});
|
||
|
||
return allValid;
|
||
}
|
||
|
||
showError(fieldName, message) {
|
||
const field = this.fields[fieldName];
|
||
const errorEl = this.form.querySelector(`[data-error="${fieldName}"]`);
|
||
|
||
if (field) {
|
||
field.classList.add('input-error');
|
||
field.setAttribute('aria-invalid', 'true');
|
||
}
|
||
|
||
if (errorEl) {
|
||
errorEl.textContent = message;
|
||
errorEl.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
clearError(fieldName) {
|
||
const field = this.fields[fieldName];
|
||
const errorEl = this.form.querySelector(`[data-error="${fieldName}"]`);
|
||
|
||
if (field) {
|
||
field.classList.remove('input-error');
|
||
field.removeAttribute('aria-invalid');
|
||
}
|
||
|
||
if (errorEl) {
|
||
errorEl.textContent = '';
|
||
errorEl.classList.add('hidden');
|
||
}
|
||
|
||
this.errors[fieldName] = false;
|
||
this.updateSubmitButton();
|
||
}
|
||
|
||
updateSubmitButton() {
|
||
const hasErrors = Object.values(this.errors).some(err => err);
|
||
|
||
if (this.submitBtn) {
|
||
this.submitBtn.disabled = hasErrors;
|
||
}
|
||
}
|
||
|
||
updateCharCount() {
|
||
const messageField = this.fields.message;
|
||
const countEl = document.getElementById('message-count');
|
||
|
||
if (messageField && countEl) {
|
||
countEl.textContent = messageField.value.length;
|
||
}
|
||
}
|
||
|
||
handleSubmit(e) {
|
||
e.preventDefault();
|
||
|
||
if (!this.validateAll()) {
|
||
// Focus sur le premier champ en erreur
|
||
const firstError = Object.keys(this.errors).find(key => this.errors[key]);
|
||
if (firstError && this.fields[firstError]) {
|
||
this.fields[firstError].focus();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Si valide, déclencher l'envoi (géré par une autre partie du code)
|
||
this.form.dispatchEvent(new CustomEvent('validSubmit'));
|
||
}
|
||
|
||
getFormData() {
|
||
const formData = {};
|
||
Object.keys(this.fields).forEach(fieldName => {
|
||
if (this.fields[fieldName]) {
|
||
formData[fieldName] = this.fields[fieldName].value.trim();
|
||
}
|
||
});
|
||
// Ajouter le champ entreprise (optionnel)
|
||
const entreprise = this.form.querySelector('[name="entreprise"]');
|
||
if (entreprise) {
|
||
formData.entreprise = entreprise.value.trim();
|
||
}
|
||
return formData;
|
||
}
|
||
}
|
||
|
||
// Initialisation
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
window.contactFormValidator = new FormValidator('contact-form');
|
||
});
|
||
```
|
||
|
||
### Messages d'Erreur
|
||
|
||
| Champ | Message |
|
||
|-------|---------|
|
||
| nom | Veuillez entrer votre nom (2 caractères minimum) |
|
||
| prenom | Veuillez entrer votre prénom (2 caractères minimum) |
|
||
| email | Veuillez entrer une adresse email valide |
|
||
| categorie | Veuillez sélectionner une catégorie |
|
||
| objet | Veuillez entrer un objet (5 caractères minimum) |
|
||
| message | Veuillez entrer votre message (20 caractères minimum) |
|
||
|
||
### Règles de Validation
|
||
|
||
| Champ | Required | Min | Max | Format |
|
||
|-------|----------|-----|-----|--------|
|
||
| nom | Oui | 2 | 100 | - |
|
||
| prenom | Oui | 2 | 100 | - |
|
||
| email | Oui | - | 255 | email |
|
||
| entreprise | Non | - | 200 | - |
|
||
| categorie | Oui | - | - | - |
|
||
| objet | Oui | 5 | 200 | - |
|
||
| message | Oui | 20 | 5000 | - |
|
||
|
||
## Testing
|
||
|
||
- [] La validation se déclenche au blur
|
||
- [] La validation se déclenche à la soumission
|
||
- [] Les messages d'erreur s'affichent sous les champs
|
||
- [] Les champs en erreur ont une bordure rouge
|
||
- [] Le bouton est désactivé si erreurs
|
||
- [] Le compteur de caractères fonctionne
|
||
- [] Le focus va au premier champ en erreur
|
||
- [] Email invalide est détecté
|
||
|
||
## Dev Agent Record
|
||
|
||
### Agent Model Used
|
||
GPT-5 Codex
|
||
|
||
### Implementation Plan
|
||
- Implémenter les tâches 1 à 5 dans l’ordre avec tests à chaque étape.
|
||
- Mettre à jour le formulaire pour les hooks JS (data-error, id submit).
|
||
|
||
### File List
|
||
| File | Action | Description |
|
||
|------|--------|-------------|
|
||
| `assets/js/contact-form.js` | Created | Classe FormValidator avec validation complète |
|
||
| `pages/contact.php` | Modified | Hooks JS (data-error, submit-btn, script) |
|
||
| `tests/contact-validation.test.php` | Added | Tests du validateur JS (présence/méthodes) |
|
||
| `tests/contact.test.php` | Modified | Vérifications markup contact + data-error |
|
||
| `tests/run.ps1` | Modified | Ajout du test contact-validation |
|
||
|
||
### Completion Notes
|
||
- Task 1 : classe FormValidator et règles de validation mises en place (JS vanilla)
|
||
- Task 2 : validation au blur + gestion des erreurs champ par champ
|
||
- Task 3 : validation à la soumission et blocage si erreurs
|
||
- Task 4 : messages d'erreur + bordures invalides configurés
|
||
- Task 5 : désactivation/réactivation du bouton d'envoi
|
||
- Tests : `powershell -ExecutionPolicy Bypass -File tests/run.ps1`
|
||
|
||
### Debug Log References
|
||
Aucun problème rencontré.
|
||
|
||
## Change Log
|
||
|
||
| Date | Version | Description | Author |
|
||
|------|---------|-------------|--------|
|
||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||
| 2026-01-24 | 1.0 | Implémentation complète | James (Dev) |
|
||
| 2026-02-04 | 1.1 | Validation JS côté client | Amelia (Dev) |
|