✨ Story 5.2: validation JS contact
This commit is contained in:
217
assets/js/contact-form.js
Normal file
217
assets/js/contact-form.js
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* 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() {
|
||||||
|
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)'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.keys(this.rules).forEach((fieldName) => {
|
||||||
|
this.fields[fieldName] = this.form.querySelector(`[name="${fieldName}"]`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
|
||||||
|
|
||||||
|
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 = '';
|
||||||
|
|
||||||
|
if (rule.required && !value) {
|
||||||
|
isValid = false;
|
||||||
|
errorMessage = rule.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValid && rule.minLength && value.length < rule.minLength) {
|
||||||
|
isValid = false;
|
||||||
|
errorMessage = rule.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValid && rule.maxLength && value.length > rule.maxLength) {
|
||||||
|
isValid = false;
|
||||||
|
errorMessage = `Maximum ${rule.maxLength} caractères`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValid && rule.email && value) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(value)) {
|
||||||
|
isValid = false;
|
||||||
|
errorMessage = rule.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()) {
|
||||||
|
const firstError = Object.keys(this.errors).find((key) => this.errors[key]);
|
||||||
|
if (firstError && this.fields[firstError]) {
|
||||||
|
this.fields[firstError].focus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const entreprise = this.form.querySelector('[name="entreprise"]');
|
||||||
|
if (entreprise) {
|
||||||
|
formData.entreprise = entreprise.value.trim();
|
||||||
|
}
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.contactFormValidator = new FormValidator('contact-form');
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Ready for Dev
|
review
|
||||||
|
|
||||||
## Story
|
## Story
|
||||||
|
|
||||||
@@ -21,29 +21,29 @@ Ready for Dev
|
|||||||
|
|
||||||
## Tasks / Subtasks
|
## Tasks / Subtasks
|
||||||
|
|
||||||
- [] **Task 1 : Créer le validateur de formulaire** (AC: 6)
|
- [x] **Task 1 : Créer le validateur de formulaire** (AC: 6)
|
||||||
- [] Créer `assets/js/contact-form.js`
|
- [x] Créer `assets/js/contact-form.js`
|
||||||
- [] Classe ou objet `FormValidator`
|
- [x] Classe ou objet `FormValidator`
|
||||||
- [] Méthodes de validation par type de champ
|
- [x] Méthodes de validation par type de champ
|
||||||
|
|
||||||
- [] **Task 2 : Implémenter la validation au blur** (AC: 1)
|
- [x] **Task 2 : Implémenter la validation au blur** (AC: 1)
|
||||||
- [] Écouter l'événement `blur` sur chaque champ
|
- [x] Écouter l'événement `blur` sur chaque champ
|
||||||
- [] Valider le champ concerné
|
- [x] Valider le champ concerné
|
||||||
- [] Afficher/masquer l'erreur
|
- [x] Afficher/masquer l'erreur
|
||||||
|
|
||||||
- [] **Task 3 : Implémenter la validation à la soumission** (AC: 1)
|
- [x] **Task 3 : Implémenter la validation à la soumission** (AC: 1)
|
||||||
- [] Écouter l'événement `submit`
|
- [x] Écouter l'événement `submit`
|
||||||
- [] Valider tous les champs
|
- [x] Valider tous les champs
|
||||||
- [] Empêcher l'envoi si erreurs
|
- [x] Empêcher l'envoi si erreurs
|
||||||
|
|
||||||
- [] **Task 4 : Afficher les erreurs** (AC: 2, 3, 4)
|
- [x] **Task 4 : Afficher les erreurs** (AC: 2, 3, 4)
|
||||||
- [] Message sous le champ (data-error)
|
- [x] Message sous le champ (data-error)
|
||||||
- [] Bordure rouge sur le champ (classes Tailwind)
|
- [x] Bordure rouge sur le champ (classes Tailwind)
|
||||||
- [] Messages clairs et actionnables
|
- [x] Messages clairs et actionnables
|
||||||
|
|
||||||
- [] **Task 5 : Gérer l'état du bouton** (AC: 5)
|
- [x] **Task 5 : Gérer l'état du bouton** (AC: 5)
|
||||||
- [] Désactiver si erreurs
|
- [x] Désactiver si erreurs
|
||||||
- [] Réactiver quand tout est valide
|
- [x] Réactiver quand tout est valide
|
||||||
|
|
||||||
## Dev Notes
|
## Dev Notes
|
||||||
|
|
||||||
@@ -320,25 +320,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
## Dev Agent Record
|
## Dev Agent Record
|
||||||
|
|
||||||
### Agent Model Used
|
### Agent Model Used
|
||||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
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 List
|
||||||
| File | Action | Description |
|
| File | Action | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| `assets/js/contact-form.js` | Created | Classe FormValidator avec validation complète |
|
| `assets/js/contact-form.js` | Created | Classe FormValidator avec validation complète |
|
||||||
| `pages/contact.php` | Modified | Lien vers le script JS, classes Tailwind pour erreurs |
|
| `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
|
### Completion Notes
|
||||||
- Classe FormValidator en JavaScript vanilla (pas de dépendances)
|
- Task 1 : classe FormValidator et règles de validation mises en place (JS vanilla)
|
||||||
- Validation au blur et à la soumission
|
- Task 2 : validation au blur + gestion des erreurs champ par champ
|
||||||
- Messages d'erreur sous chaque champ avec data-error
|
- Task 3 : validation à la soumission et blocage si erreurs
|
||||||
- Bordure rouge sur les champs invalides (classes Tailwind)
|
- Task 4 : messages d'erreur + bordures invalides configurés
|
||||||
- Bouton submit désactivé si erreurs (updateSubmitButton)
|
- Task 5 : désactivation/réactivation du bouton d'envoi
|
||||||
- Compteur de caractères en temps réel
|
- Tests : `powershell -ExecutionPolicy Bypass -File tests/run.ps1`
|
||||||
- Focus automatique sur le premier champ en erreur
|
|
||||||
- Validation email avec regex
|
|
||||||
- Événement 'validSubmit' dispatché quand tout est valide
|
|
||||||
- Gestion du reset du formulaire
|
|
||||||
|
|
||||||
### Debug Log References
|
### Debug Log References
|
||||||
Aucun problème rencontré.
|
Aucun problème rencontré.
|
||||||
@@ -349,3 +352,4 @@ Aucun problème rencontré.
|
|||||||
|------|---------|-------------|--------|
|
|------|---------|-------------|--------|
|
||||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||||
| 2026-01-24 | 1.0 | Implémentation complète | James (Dev) |
|
| 2026-01-24 | 1.0 | Implémentation complète | James (Dev) |
|
||||||
|
| 2026-02-04 | 1.1 | Validation JS côté client | Amelia (Dev) |
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ include_template('navbar', compact('currentPage'));
|
|||||||
autocomplete="family-name"
|
autocomplete="family-name"
|
||||||
placeholder="Dupont"
|
placeholder="Dupont"
|
||||||
>
|
>
|
||||||
|
<p class="error-message hidden" data-error="nom"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -55,6 +56,7 @@ include_template('navbar', compact('currentPage'));
|
|||||||
autocomplete="given-name"
|
autocomplete="given-name"
|
||||||
placeholder="Marie"
|
placeholder="Marie"
|
||||||
>
|
>
|
||||||
|
<p class="error-message hidden" data-error="prenom"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ include_template('navbar', compact('currentPage'));
|
|||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
placeholder="marie.dupont@example.com"
|
placeholder="marie.dupont@example.com"
|
||||||
>
|
>
|
||||||
|
<p class="error-message hidden" data-error="email"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -95,6 +98,7 @@ include_template('navbar', compact('currentPage'));
|
|||||||
<option value="poste">Je souhaite vous proposer un poste</option>
|
<option value="poste">Je souhaite vous proposer un poste</option>
|
||||||
<option value="autre">Autre</option>
|
<option value="autre">Autre</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p class="error-message hidden" data-error="categorie"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -108,6 +112,7 @@ include_template('navbar', compact('currentPage'));
|
|||||||
maxlength="200"
|
maxlength="200"
|
||||||
placeholder="Résumez votre demande en quelques mots"
|
placeholder="Résumez votre demande en quelques mots"
|
||||||
>
|
>
|
||||||
|
<p class="error-message hidden" data-error="objet"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -121,13 +126,14 @@ include_template('navbar', compact('currentPage'));
|
|||||||
rows="6"
|
rows="6"
|
||||||
placeholder="Décrivez votre projet ou votre demande..."
|
placeholder="Décrivez votre projet ou votre demande..."
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<p class="error-message hidden" data-error="message"></p>
|
||||||
<p class="text-xs text-text-muted mt-2">
|
<p class="text-xs text-text-muted mt-2">
|
||||||
<span id="message-count">0</span> / 5000 caractères
|
<span id="message-count">0</span> / 5000 caractères
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row gap-4 pt-4">
|
<div class="flex flex-col sm:flex-row gap-4 pt-4">
|
||||||
<button type="submit" class="btn-primary flex-1 justify-center">
|
<button type="submit" id="submit-btn" class="btn-primary flex-1 justify-center">
|
||||||
Envoyer le message
|
Envoyer le message
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-ghost">
|
<button type="button" class="btn-ghost">
|
||||||
@@ -140,18 +146,6 @@ include_template('navbar', compact('currentPage'));
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script src="/assets/js/contact-form.js" defer></script>
|
||||||
const messageField = document.getElementById('message');
|
|
||||||
const messageCount = document.getElementById('message-count');
|
|
||||||
|
|
||||||
if (messageField && messageCount) {
|
|
||||||
const updateCount = () => {
|
|
||||||
messageCount.textContent = String(messageField.value.length);
|
|
||||||
};
|
|
||||||
|
|
||||||
updateCount();
|
|
||||||
messageField.addEventListener('input', updateCount);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<?php include_template('footer'); ?>
|
<?php include_template('footer'); ?>
|
||||||
|
|||||||
23
tests/contact-validation.test.php
Normal file
23
tests/contact-validation.test.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
function assertTrue($cond, $msg) {
|
||||||
|
if (!$cond) {
|
||||||
|
fwrite(STDERR, $msg . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = __DIR__ . '/../assets/js/contact-form.js';
|
||||||
|
assertTrue(file_exists($path), 'missing contact-form.js');
|
||||||
|
$content = file_get_contents($path);
|
||||||
|
|
||||||
|
assertTrue(strpos($content, 'class FormValidator') !== false, 'missing FormValidator class');
|
||||||
|
assertTrue(strpos($content, 'validateField') !== false, 'missing validateField');
|
||||||
|
assertTrue(strpos($content, 'validateAll') !== false, 'missing validateAll');
|
||||||
|
assertTrue(strpos($content, 'handleSubmit') !== false, 'missing handleSubmit');
|
||||||
|
assertTrue(strpos($content, 'updateSubmitButton') !== false, 'missing updateSubmitButton');
|
||||||
|
assertTrue(preg_match('/addEventListener\\(\\s*[\'\"]blur[\'\"]/', $content) === 1, 'missing blur listener');
|
||||||
|
assertTrue(preg_match('/addEventListener\\(\\s*[\'\"]submit[\'\"]/', $content) === 1, 'missing submit listener');
|
||||||
|
assertTrue(strpos($content, 'input-error') !== false, 'missing input-error handling');
|
||||||
|
assertTrue(strpos($content, 'submitBtn.disabled') !== false, 'missing submit disable');
|
||||||
|
|
||||||
|
fwrite(STDOUT, "OK\n");
|
||||||
@@ -33,6 +33,14 @@ assertTrue(strpos($content, 'label for="objet"') !== false, 'missing objet label
|
|||||||
assertTrue(strpos($content, 'id="objet"') !== false, 'missing objet input');
|
assertTrue(strpos($content, 'id="objet"') !== false, 'missing objet input');
|
||||||
assertTrue(strpos($content, 'label for="message"') !== false, 'missing message label');
|
assertTrue(strpos($content, 'label for="message"') !== false, 'missing message label');
|
||||||
assertTrue(strpos($content, 'id="message"') !== false, 'missing message textarea');
|
assertTrue(strpos($content, 'id="message"') !== false, 'missing message textarea');
|
||||||
|
assertTrue(strpos($content, 'data-error="nom"') !== false, 'missing nom error');
|
||||||
|
assertTrue(strpos($content, 'data-error="prenom"') !== false, 'missing prenom error');
|
||||||
|
assertTrue(strpos($content, 'data-error="email"') !== false, 'missing email error');
|
||||||
|
assertTrue(strpos($content, 'data-error="categorie"') !== false, 'missing categorie error');
|
||||||
|
assertTrue(strpos($content, 'data-error="objet"') !== false, 'missing objet error');
|
||||||
|
assertTrue(strpos($content, 'data-error="message"') !== false, 'missing message error');
|
||||||
|
assertTrue(strpos($content, 'id="submit-btn"') !== false, 'missing submit id');
|
||||||
|
assertTrue(strpos($content, '/assets/js/contact-form.js') !== false, 'missing contact script');
|
||||||
|
|
||||||
assertTrue(preg_match('/id="nom"[^>]*required/', $content) === 1, 'nom missing required');
|
assertTrue(preg_match('/id="nom"[^>]*required/', $content) === 1, 'nom missing required');
|
||||||
assertTrue(preg_match('/id="prenom"[^>]*required/', $content) === 1, 'prenom missing required');
|
assertTrue(preg_match('/id="prenom"[^>]*required/', $content) === 1, 'prenom missing required');
|
||||||
|
|||||||
@@ -21,4 +21,5 @@ php (Join-Path $here 'about.test.php')
|
|||||||
php (Join-Path $here 'passions.test.php')
|
php (Join-Path $here 'passions.test.php')
|
||||||
php (Join-Path $here 'testimonials.test.php')
|
php (Join-Path $here 'testimonials.test.php')
|
||||||
php (Join-Path $here 'contact.test.php')
|
php (Join-Path $here 'contact.test.php')
|
||||||
|
php (Join-Path $here 'contact-validation.test.php')
|
||||||
'OK'
|
'OK'
|
||||||
|
|||||||
Reference in New Issue
Block a user