✨ Story 5.3: persistance localStorage
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Validation du formulaire de contact
|
* Validation du formulaire de contact
|
||||||
* JavaScript vanilla - pas de dépendances
|
* JavaScript vanilla - pas de dépendances
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class FormValidator {
|
class FormValidator {
|
||||||
@@ -21,13 +21,13 @@ class FormValidator {
|
|||||||
required: true,
|
required: true,
|
||||||
minLength: 2,
|
minLength: 2,
|
||||||
maxLength: 100,
|
maxLength: 100,
|
||||||
message: 'Veuillez entrer votre nom (2 caractères minimum)'
|
message: 'Veuillez entrer votre nom (2 caractères minimum)'
|
||||||
},
|
},
|
||||||
prenom: {
|
prenom: {
|
||||||
required: true,
|
required: true,
|
||||||
minLength: 2,
|
minLength: 2,
|
||||||
maxLength: 100,
|
maxLength: 100,
|
||||||
message: 'Veuillez entrer votre prénom (2 caractères minimum)'
|
message: 'Veuillez entrer votre prénom (2 caractères minimum)'
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -36,19 +36,19 @@ class FormValidator {
|
|||||||
},
|
},
|
||||||
categorie: {
|
categorie: {
|
||||||
required: true,
|
required: true,
|
||||||
message: 'Veuillez sélectionner une catégorie'
|
message: 'Veuillez sélectionner une catégorie'
|
||||||
},
|
},
|
||||||
objet: {
|
objet: {
|
||||||
required: true,
|
required: true,
|
||||||
minLength: 5,
|
minLength: 5,
|
||||||
maxLength: 200,
|
maxLength: 200,
|
||||||
message: 'Veuillez entrer un objet (5 caractères minimum)'
|
message: 'Veuillez entrer un objet (5 caractères minimum)'
|
||||||
},
|
},
|
||||||
message: {
|
message: {
|
||||||
required: true,
|
required: true,
|
||||||
minLength: 20,
|
minLength: 20,
|
||||||
maxLength: 5000,
|
maxLength: 5000,
|
||||||
message: 'Veuillez entrer votre message (20 caractères minimum)'
|
message: 'Veuillez entrer votre message (20 caractères minimum)'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ class FormValidator {
|
|||||||
|
|
||||||
if (isValid && rule.maxLength && value.length > rule.maxLength) {
|
if (isValid && rule.maxLength && value.length > rule.maxLength) {
|
||||||
isValid = false;
|
isValid = false;
|
||||||
errorMessage = `Maximum ${rule.maxLength} caractères`;
|
errorMessage = `Maximum ${rule.maxLength} caractères`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isValid && rule.email && value) {
|
if (isValid && rule.email && value) {
|
||||||
@@ -212,6 +212,89 @@ class FormValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ContactFormPersistence {
|
||||||
|
constructor(formId) {
|
||||||
|
this.form = document.getElementById(formId);
|
||||||
|
if (!this.form) return;
|
||||||
|
|
||||||
|
this.debounceTimer = null;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadSavedData();
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
this.form.addEventListener('input', () => {
|
||||||
|
clearTimeout(this.debounceTimer);
|
||||||
|
this.debounceTimer = setTimeout(() => this.saveData(), 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearBtn = document.getElementById('clear-form-btn');
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', () => this.clearForm());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.form.addEventListener('formSuccess', () => {
|
||||||
|
AppState.clearFormData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveData() {
|
||||||
|
const formData = new FormData(this.form);
|
||||||
|
const data = {};
|
||||||
|
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
data[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
AppState.saveFormData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSavedData() {
|
||||||
|
const savedData = AppState.getFormData();
|
||||||
|
if (!savedData) return;
|
||||||
|
|
||||||
|
Object.keys(savedData).forEach((key) => {
|
||||||
|
const field = this.form.querySelector(`[name="${key}"]`);
|
||||||
|
if (field && savedData[key]) {
|
||||||
|
field.value = savedData[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageField = this.form.querySelector('[name="message"]');
|
||||||
|
const countEl = document.getElementById('message-count');
|
||||||
|
if (messageField && countEl) {
|
||||||
|
countEl.textContent = messageField.value.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearForm() {
|
||||||
|
if (!confirm('Êtes-vous sûr de vouloir effacer le formulaire ?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppState.clearFormData();
|
||||||
|
this.form.reset();
|
||||||
|
|
||||||
|
const countEl = document.getElementById('message-count');
|
||||||
|
if (countEl) {
|
||||||
|
countEl.textContent = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.form.querySelectorAll('.input-error').forEach((el) => {
|
||||||
|
el.classList.remove('input-error');
|
||||||
|
});
|
||||||
|
this.form.querySelectorAll('[data-error]').forEach((el) => {
|
||||||
|
el.classList.add('hidden');
|
||||||
|
el.textContent = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
window.contactFormValidator = new FormValidator('contact-form');
|
window.contactFormValidator = new FormValidator('contact-form');
|
||||||
|
window.contactFormPersistence = new ContactFormPersistence('contact-form');
|
||||||
});
|
});
|
||||||
|
|||||||
57
assets/js/state.js
Normal file
57
assets/js/state.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Gestionnaire d'état pour le localStorage
|
||||||
|
*/
|
||||||
|
const AppState = {
|
||||||
|
STORAGE_KEY: 'portfolio_contact_form',
|
||||||
|
EXCLUDED_FIELDS: ['csrf_token', 'password', 'recaptcha_token'],
|
||||||
|
|
||||||
|
isStorageAvailable() {
|
||||||
|
try {
|
||||||
|
const test = '__storage_test__';
|
||||||
|
localStorage.setItem(test, test);
|
||||||
|
localStorage.removeItem(test);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
saveFormData(data) {
|
||||||
|
if (!this.isStorageAvailable()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filteredData = {};
|
||||||
|
Object.keys(data).forEach((key) => {
|
||||||
|
if (!this.EXCLUDED_FIELDS.includes(key)) {
|
||||||
|
filteredData[key] = data[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filteredData));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Impossible de sauvegarder dans localStorage:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getFormData() {
|
||||||
|
if (!this.isStorageAvailable()) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(this.STORAGE_KEY);
|
||||||
|
return data ? JSON.parse(data) : null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Impossible de charger depuis localStorage:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearFormData() {
|
||||||
|
if (!this.isStorageAvailable()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(this.STORAGE_KEY);
|
||||||
|
} catch (e) {
|
||||||
|
// Silencieux
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Ready for Dev
|
review
|
||||||
|
|
||||||
## Story
|
## Story
|
||||||
|
|
||||||
@@ -21,34 +21,34 @@ Ready for Dev
|
|||||||
|
|
||||||
## Tasks / Subtasks
|
## Tasks / Subtasks
|
||||||
|
|
||||||
- [] **Task 1 : Créer le gestionnaire de stockage** (AC: 6)
|
- [x] **Task 1 : Créer le gestionnaire de stockage** (AC: 6)
|
||||||
- [] Clé unique `portfolio_contact_form`
|
- [x] Clé unique `portfolio_contact_form`
|
||||||
- [] Méthodes save, load, clear
|
- [x] Méthodes save, load, clear
|
||||||
- [] Gestion des erreurs (localStorage indisponible)
|
- [x] Gestion des erreurs (localStorage indisponible)
|
||||||
|
|
||||||
- [] **Task 2 : Sauvegarder automatiquement** (AC: 1)
|
- [x] **Task 2 : Sauvegarder automatiquement** (AC: 1)
|
||||||
- [] Écouter l'événement `input` sur chaque champ
|
- [x] Écouter l'événement `input` sur chaque champ
|
||||||
- [] Debounce pour éviter trop d'écritures (500ms)
|
- [x] Debounce pour éviter trop d'écritures (500ms)
|
||||||
- [] Sauvegarder l'état complet
|
- [x] Sauvegarder l'état complet
|
||||||
|
|
||||||
- [] **Task 3 : Restaurer au chargement** (AC: 2)
|
- [x] **Task 3 : Restaurer au chargement** (AC: 2)
|
||||||
- [] Charger les données au DOMContentLoaded
|
- [x] Charger les données au DOMContentLoaded
|
||||||
- [] Pré-remplir chaque champ
|
- [x] Pré-remplir chaque champ
|
||||||
- [] Mettre à jour le compteur de caractères
|
- [x] Mettre à jour le compteur de caractères
|
||||||
|
|
||||||
- [] **Task 4 : Vider après envoi réussi** (AC: 3)
|
- [x] **Task 4 : Vider après envoi réussi** (AC: 3)
|
||||||
- [] Appeler clear() après succès (événement formSuccess)
|
- [x] Appeler clear() après succès (événement formSuccess)
|
||||||
- [] Réinitialiser le formulaire
|
- [x] Réinitialiser le formulaire
|
||||||
|
|
||||||
- [] **Task 5 : Bouton "Effacer"** (AC: 4)
|
- [x] **Task 5 : Bouton "Effacer"** (AC: 4)
|
||||||
- [] Écouter le clic sur le bouton (id="clear-form-btn")
|
- [x] Écouter le clic sur le bouton (id="clear-form-btn")
|
||||||
- [] Vider le localStorage
|
- [x] Vider le localStorage
|
||||||
- [] Réinitialiser le formulaire
|
- [x] Réinitialiser le formulaire
|
||||||
- [] Confirmation avec confirm()
|
- [x] Confirmation avec confirm()
|
||||||
|
|
||||||
- [] **Task 6 : Exclure les données sensibles** (AC: 5)
|
- [x] **Task 6 : Exclure les données sensibles** (AC: 5)
|
||||||
- [] Ne pas stocker csrf_token, password, recaptcha_token
|
- [x] Ne pas stocker csrf_token, password, recaptcha_token
|
||||||
- [] Documenté dans EXCLUDED_FIELDS
|
- [x] Documenté dans EXCLUDED_FIELDS
|
||||||
|
|
||||||
## Dev Notes
|
## Dev Notes
|
||||||
|
|
||||||
@@ -270,7 +270,11 @@ Exemple de données stockées:
|
|||||||
## 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 à 6 dans l’ordre avec tests à chaque étape.
|
||||||
|
- Intégrer le stockage dans `contact-form.js` et scripts dans la page.
|
||||||
|
|
||||||
### File List
|
### File List
|
||||||
| File | Action | Description |
|
| File | Action | Description |
|
||||||
@@ -278,16 +282,16 @@ Claude Opus 4.5 (claude-opus-4-5-20251101)
|
|||||||
| `assets/js/state.js` | Created | Objet AppState pour gestion localStorage |
|
| `assets/js/state.js` | Created | Objet AppState pour gestion localStorage |
|
||||||
| `assets/js/contact-form.js` | Modified | Ajout classe ContactFormPersistence |
|
| `assets/js/contact-form.js` | Modified | Ajout classe ContactFormPersistence |
|
||||||
| `pages/contact.php` | Modified | Bouton Effacer + script state.js |
|
| `pages/contact.php` | Modified | Bouton Effacer + script state.js |
|
||||||
|
| `tests/contact-state.test.php` | Added | Tests persistance localStorage |
|
||||||
|
| `tests/contact.test.php` | Modified | Vérification clear button + scripts |
|
||||||
|
| `tests/run.ps1` | Modified | Ajout du test contact-state |
|
||||||
|
|
||||||
### Completion Notes
|
### Completion Notes
|
||||||
- AppState avec clé unique `portfolio_contact_form`
|
- AppState avec clé unique `portfolio_contact_form` et exclusions sensibles
|
||||||
- Vérification disponibilité localStorage (try/catch)
|
- Sauvegarde avec debounce 500ms + restauration au chargement
|
||||||
- Filtrage des champs sensibles (csrf_token, password, recaptcha_token)
|
- Bouton Effacer avec confirm + reset complet
|
||||||
- Debounce de 500ms sur la sauvegarde
|
- Listener `formSuccess` pour purge post-envoi
|
||||||
- Restauration automatique au chargement de la page
|
- Tests : `powershell -ExecutionPolicy Bypass -File tests/run.ps1`
|
||||||
- Bouton "Effacer" avec confirmation et reset complet
|
|
||||||
- Événement `formSuccess` pour vider après envoi réussi
|
|
||||||
- Scripts chargés avec defer pour ne pas bloquer le rendu
|
|
||||||
|
|
||||||
### Debug Log References
|
### Debug Log References
|
||||||
Aucun problème rencontré.
|
Aucun problème rencontré.
|
||||||
@@ -298,3 +302,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 | Persistance localStorage | Amelia (Dev) |
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ include_template('navbar', compact('currentPage'));
|
|||||||
<button type="submit" id="submit-btn" 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" id="clear-form-btn" class="btn-ghost">
|
||||||
Effacer le formulaire
|
Effacer le formulaire
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,6 +146,7 @@ include_template('navbar', compact('currentPage'));
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script src="/assets/js/state.js" defer></script>
|
||||||
<script src="/assets/js/contact-form.js" defer></script>
|
<script src="/assets/js/contact-form.js" defer></script>
|
||||||
|
|
||||||
<?php include_template('footer'); ?>
|
<?php include_template('footer'); ?>
|
||||||
|
|||||||
31
tests/contact-state.test.php
Normal file
31
tests/contact-state.test.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
function assertTrue($cond, $msg) {
|
||||||
|
if (!$cond) {
|
||||||
|
fwrite(STDERR, $msg . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$statePath = __DIR__ . '/../assets/js/state.js';
|
||||||
|
assertTrue(file_exists($statePath), 'missing state.js');
|
||||||
|
$stateContent = file_get_contents($statePath);
|
||||||
|
|
||||||
|
assertTrue(strpos($stateContent, 'const AppState') !== false, 'missing AppState');
|
||||||
|
assertTrue(strpos($stateContent, 'portfolio_contact_form') !== false, 'missing storage key');
|
||||||
|
assertTrue(strpos($stateContent, 'EXCLUDED_FIELDS') !== false, 'missing excluded fields');
|
||||||
|
assertTrue(strpos($stateContent, 'csrf_token') !== false, 'missing csrf exclusion');
|
||||||
|
assertTrue(strpos($stateContent, 'password') !== false, 'missing password exclusion');
|
||||||
|
assertTrue(strpos($stateContent, 'recaptcha_token') !== false, 'missing recaptcha exclusion');
|
||||||
|
assertTrue(strpos($stateContent, 'isStorageAvailable') !== false, 'missing isStorageAvailable');
|
||||||
|
assertTrue(strpos($stateContent, 'saveFormData') !== false, 'missing saveFormData');
|
||||||
|
assertTrue(strpos($stateContent, 'getFormData') !== false, 'missing getFormData');
|
||||||
|
assertTrue(strpos($stateContent, 'clearFormData') !== false, 'missing clearFormData');
|
||||||
|
|
||||||
|
$contactJs = file_get_contents(__DIR__ . '/../assets/js/contact-form.js');
|
||||||
|
assertTrue(strpos($contactJs, 'class ContactFormPersistence') !== false, 'missing ContactFormPersistence');
|
||||||
|
assertTrue(strpos($contactJs, 'formSuccess') !== false, 'missing formSuccess listener');
|
||||||
|
assertTrue(preg_match('/setTimeout\([^,]+,\s*500\)/', $contactJs) === 1, 'missing debounce 500ms');
|
||||||
|
assertTrue(strpos($contactJs, 'clear-form-btn') !== false, 'missing clear button hook');
|
||||||
|
assertTrue(strpos($contactJs, 'confirm(') !== false, 'missing confirm');
|
||||||
|
|
||||||
|
fwrite(STDOUT, "OK\n");
|
||||||
@@ -40,6 +40,8 @@ assertTrue(strpos($content, 'data-error="categorie"') !== false, 'missing catego
|
|||||||
assertTrue(strpos($content, 'data-error="objet"') !== false, 'missing objet error');
|
assertTrue(strpos($content, 'data-error="objet"') !== false, 'missing objet error');
|
||||||
assertTrue(strpos($content, 'data-error="message"') !== false, 'missing message error');
|
assertTrue(strpos($content, 'data-error="message"') !== false, 'missing message error');
|
||||||
assertTrue(strpos($content, 'id="submit-btn"') !== false, 'missing submit id');
|
assertTrue(strpos($content, 'id="submit-btn"') !== false, 'missing submit id');
|
||||||
|
assertTrue(strpos($content, 'id="clear-form-btn"') !== false, 'missing clear button id');
|
||||||
|
assertTrue(strpos($content, '/assets/js/state.js') !== false, 'missing state script');
|
||||||
assertTrue(strpos($content, '/assets/js/contact-form.js') !== false, 'missing contact script');
|
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');
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ 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')
|
php (Join-Path $here 'contact-validation.test.php')
|
||||||
|
php (Join-Path $here 'contact-state.test.php')
|
||||||
'OK'
|
'OK'
|
||||||
|
|||||||
Reference in New Issue
Block a user