Story 5.3: persistance localStorage

This commit is contained in:
2026-02-04 21:10:40 +01:00
parent fb95a39792
commit a4e0cb71e9
7 changed files with 222 additions and 42 deletions

View File

@@ -1,6 +1,6 @@
/**
/**
* Validation du formulaire de contact
* JavaScript vanilla - pas de dépendances
* JavaScript vanilla - pas de dépendances
*/
class FormValidator {
@@ -21,13 +21,13 @@ class FormValidator {
required: true,
minLength: 2,
maxLength: 100,
message: 'Veuillez entrer votre nom (2 caractères minimum)'
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)'
message: 'Veuillez entrer votre prénom (2 caractères minimum)'
},
email: {
required: true,
@@ -36,19 +36,19 @@ class FormValidator {
},
categorie: {
required: true,
message: 'Veuillez sélectionner une catégorie'
message: 'Veuillez sélectionner une catégorie'
},
objet: {
required: true,
minLength: 5,
maxLength: 200,
message: 'Veuillez entrer un objet (5 caractères minimum)'
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)'
message: 'Veuillez entrer votre message (20 caractères minimum)'
}
};
@@ -98,7 +98,7 @@ class FormValidator {
if (isValid && rule.maxLength && value.length > rule.maxLength) {
isValid = false;
errorMessage = `Maximum ${rule.maxLength} caractères`;
errorMessage = `Maximum ${rule.maxLength} caractères`;
}
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', () => {
window.contactFormValidator = new FormValidator('contact-form');
window.contactFormPersistence = new ContactFormPersistence('contact-form');
});

57
assets/js/state.js Normal file
View 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
}
}
};

View File

@@ -2,7 +2,7 @@
## Status
Ready for Dev
review
## Story
@@ -21,34 +21,34 @@ Ready for Dev
## Tasks / Subtasks
- [] **Task 1 : Créer le gestionnaire de stockage** (AC: 6)
- [] Clé unique `portfolio_contact_form`
- [] Méthodes save, load, clear
- [] Gestion des erreurs (localStorage indisponible)
- [x] **Task 1 : Créer le gestionnaire de stockage** (AC: 6)
- [x] Clé unique `portfolio_contact_form`
- [x] Méthodes save, load, clear
- [x] Gestion des erreurs (localStorage indisponible)
- [] **Task 2 : Sauvegarder automatiquement** (AC: 1)
- [] Écouter l'événement `input` sur chaque champ
- [] Debounce pour éviter trop d'écritures (500ms)
- [] Sauvegarder l'état complet
- [x] **Task 2 : Sauvegarder automatiquement** (AC: 1)
- [x] Écouter l'événement `input` sur chaque champ
- [x] Debounce pour éviter trop d'écritures (500ms)
- [x] Sauvegarder l'état complet
- [] **Task 3 : Restaurer au chargement** (AC: 2)
- [] Charger les données au DOMContentLoaded
- [] Pré-remplir chaque champ
- [] Mettre à jour le compteur de caractères
- [x] **Task 3 : Restaurer au chargement** (AC: 2)
- [x] Charger les données au DOMContentLoaded
- [x] Pré-remplir chaque champ
- [x] Mettre à jour le compteur de caractères
- [] **Task 4 : Vider après envoi réussi** (AC: 3)
- [] Appeler clear() après succès (événement formSuccess)
- [] Réinitialiser le formulaire
- [x] **Task 4 : Vider après envoi réussi** (AC: 3)
- [x] Appeler clear() après succès (événement formSuccess)
- [x] Réinitialiser le formulaire
- [] **Task 5 : Bouton "Effacer"** (AC: 4)
- [] Écouter le clic sur le bouton (id="clear-form-btn")
- [] Vider le localStorage
- [] Réinitialiser le formulaire
- [] Confirmation avec confirm()
- [x] **Task 5 : Bouton "Effacer"** (AC: 4)
- [x] Écouter le clic sur le bouton (id="clear-form-btn")
- [x] Vider le localStorage
- [x] Réinitialiser le formulaire
- [x] Confirmation avec confirm()
- [] **Task 6 : Exclure les données sensibles** (AC: 5)
- [] Ne pas stocker csrf_token, password, recaptcha_token
- [] Documenté dans EXCLUDED_FIELDS
- [x] **Task 6 : Exclure les données sensibles** (AC: 5)
- [x] Ne pas stocker csrf_token, password, recaptcha_token
- [x] Documenté dans EXCLUDED_FIELDS
## Dev Notes
@@ -270,7 +270,11 @@ Exemple de données stockées:
## Dev Agent Record
### 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 lordre avec tests à chaque étape.
- Intégrer le stockage dans `contact-form.js` et scripts dans la page.
### File List
| 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/contact-form.js` | Modified | Ajout classe ContactFormPersistence |
| `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
- AppState avec clé unique `portfolio_contact_form`
- Vérification disponibilité localStorage (try/catch)
- Filtrage des champs sensibles (csrf_token, password, recaptcha_token)
- Debounce de 500ms sur la sauvegarde
- Restauration automatique au chargement de la page
- 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
- AppState avec clé unique `portfolio_contact_form` et exclusions sensibles
- Sauvegarde avec debounce 500ms + restauration au chargement
- Bouton Effacer avec confirm + reset complet
- Listener `formSuccess` pour purge post-envoi
- Tests : `powershell -ExecutionPolicy Bypass -File tests/run.ps1`
### Debug Log References
Aucun problème rencontré.
@@ -298,3 +302,4 @@ Aucun problème rencontré.
|------|---------|-------------|--------|
| 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 | Persistance localStorage | Amelia (Dev) |

View File

@@ -136,7 +136,7 @@ include_template('navbar', compact('currentPage'));
<button type="submit" id="submit-btn" class="btn-primary flex-1 justify-center">
Envoyer le message
</button>
<button type="button" class="btn-ghost">
<button type="button" id="clear-form-btn" class="btn-ghost">
Effacer le formulaire
</button>
</div>
@@ -146,6 +146,7 @@ include_template('navbar', compact('currentPage'));
</section>
</main>
<script src="/assets/js/state.js" defer></script>
<script src="/assets/js/contact-form.js" defer></script>
<?php include_template('footer'); ?>

View 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");

View File

@@ -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="message"') !== false, 'missing message error');
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(preg_match('/id="nom"[^>]*required/', $content) === 1, 'nom missing required');

View File

@@ -22,4 +22,5 @@ php (Join-Path $here 'passions.test.php')
php (Join-Path $here 'testimonials.test.php')
php (Join-Path $here 'contact.test.php')
php (Join-Path $here 'contact-validation.test.php')
php (Join-Path $here 'contact-state.test.php')
'OK'