À propos de ce cours
Chapitre 9 — Les objets littéraux, JSON et les collections · TP 9 : Gérer un carnet de contacts avec les objets et JSON
• représenter des données réelles par des objets et des tableaux d'objets ;
• transformer des données sans les modifier (spread, map, filter) ;
• utiliser un dictionnaire, un Set et le format JSON ;
• valider des données et gérer les erreurs d'import.
Présentation
Vous allez écrire le « moteur » d'un carnet de contacts: création et vérification des contacts, recherche, filtre par catégorie,
favoris, statistiques par ville, tri, et échange des données au format JSON. L'interface (formulaire, liste, boutons) est fournie dans
app.js, car la manipulation de la page n'est vue qu'au chapitre 12 : vous écrivez uniquement les fonctions de contacts.js, qui
reçoivent des données et renvoient des données. Une page de 21 tests automatiques vous indique ce qui fonctionne. Les questions vous guident ; aucune solution n'est donnée.

Figure 1 — Au départ, le carnet est vide et les tests sont tous rouges.

Figure 2 — L'objectif : contacts triés (favori en tête), erreurs de saisie, statistiques.

Figure 3 — L'export du carnet au format JSON.
Fichiers de départ
⬇ Télécharger les fichiers de départ du TP (tp9-depart.zip)
Téléchargez le dossier de départ (bouton ci-dessous). Un contact est un objet
{ id, nom, telephone, email, ville, categorie, favori }. Lisez tests.js: chaque test est un exemple précis du résultat attendu.
contacts.js (à compléter)
// =====================================================================
// TP 9 — Carnet de contacts : VOS FONCTIONS (les données uniquement)
// Un contact est un objet : { id, nom, telephone, email, ville, categorie, favori }
// Ces fonctions ne touchent pas à la page : elles reçoivent des données et
// renvoient des données. L'affichage (app.js) et les tests (tests.js) sont fournis.
// =====================================================================
// 1. Créer un contact propre à partir des données saisies.
// - nom : espaces inutiles retirés, chaque mot avec une majuscule ("amina tazi" → "Amina Tazi")
// - telephone : tous les espaces retirés ; email : en minuscules, sans espaces autour
// - ville : espaces retirés autour ; categorie : "amis" par défaut ; favori : false
function creerContact(donnees, id) {
// À COMPLÉTER
}
// 2. Vérifier un contact : renvoie un TABLEAU de messages d'erreur (vide si tout va bien)
// - "Le nom doit contenir au moins 2 caractères."
// - "Le téléphone doit contenir 10 chiffres et commencer par 0."
// - "L'e-mail n'est pas valide." (seulement s'il est renseigné et ne contient pas @)
function verifierContact(contact) {
// À COMPLÉTER
}
// 3. Rechercher : contacts dont le nom, la ville ou l'e-mail contient le texte (sans tenir compte des majuscules)
function rechercher(contacts, texte) {
// À COMPLÉTER
}
// 4. Filtrer par catégorie ("toutes" renvoie tous les contacts)
function filtrerParCategorie(contacts, categorie) {
// À COMPLÉTER
}
// 5. Basculer le favori du contact d'identifiant id.
// Renvoie un NOUVEAU tableau : le tableau et les contacts d'origine ne doivent pas être modifiés.
function basculerFavori(contacts, id) {
// À COMPLÉTER
}
// 6. Compter les contacts par ville : { Rabat: 2, Fès: 1, … }
function compterParVille(contacts) {
// À COMPLÉTER
}
// 7. Liste des villes distinctes, triées par ordre alphabétique
function villesDistinctes(contacts) {
// À COMPLÉTER
}
// 8. Trier par nom (ordre alphabétique français), favoris en premier ; sans modifier le tableau reçu
function trier(contacts) {
// À COMPLÉTER
}
// 9. Exporter en texte JSON lisible (indentation de 2 espaces)
function exporterJSON(contacts) {
// À COMPLÉTER
}
// 10. Importer un texte JSON : renvoie un tableau de contacts recréés avec creerContact
// (identifiants 1, 2, 3…) ; lance une Error("JSON invalide") si le texte n'est pas
// du JSON ou n'est pas un tableau.
function importerJSON(texte) {
// À COMPLÉTER
}
tests.js (fourni, à lire)
// =====================================================================
// Tests automatiques (fourni — ne pas modifier)
// =====================================================================
(function () {
const liste = document.querySelector("#tests");
let reussis = 0, total = 0;
function test(description, calcul, attendu) {
total++;
const li = document.createElement("li");
let obtenu;
try { obtenu = calcul(); } catch (e) { obtenu = "Erreur : " + e.message; }
const ok = JSON.stringify(obtenu) === JSON.stringify(attendu);
if (ok) reussis++;
li.className = ok ? "ok" : "ko";
li.textContent = (ok ? "✓ " : "✗ ") + description;
if (!ok) {
const d = document.createElement("small");
d.textContent = `attendu ${JSON.stringify(attendu)}, obtenu ${JSON.stringify(obtenu)}`;
li.append(d);
}
liste.append(li);
}
const c = (nom, ville, categorie = "amis", favori = false, id = 1) =>
({ id, nom, telephone: "0600000000", email: "", ville, categorie, favori });
const base = [c("Salma Idrissi", "Rabat", "travail", false, 1), c("Karim Benali", "Fès", "amis", true, 2), c("Élodie Martin", "Rabat", "famille", false, 3)];
test("creerContact nettoie le nom", () => creerContact({ nom: " amina TAZI ", telephone: "06 12 34 56 78", email: " Amina@ISTA.ma ", ville: " Casablanca " }, 7).nom, "Amina Tazi");
test("creerContact : objet complet", () => creerContact({ nom: "amina tazi", telephone: "06 12 34 56 78", email: " Amina@ISTA.ma ", ville: " Casablanca " }, 7),
{ id: 7, nom: "Amina Tazi", telephone: "0612345678", email: "amina@ista.ma", ville: "Casablanca", categorie: "amis", favori: false });
test("creerContact garde la catégorie choisie", () => creerContact({ nom: "a b", telephone: "", email: "", ville: "", categorie: "travail" }, 1).categorie, "travail");
test("verifierContact : contact correct → []", () => verifierContact(c("Salma Idrissi", "Rabat")), []);
test("verifierContact : nom trop court et téléphone faux", () => verifierContact({ ...c("A", "Rabat"), telephone: "12345" }).length, 2);
test("verifierContact : e-mail sans @", () => verifierContact({ ...c("Salma", "Rabat"), email: "salma.ista.ma" }), ["L'e-mail n'est pas valide."]);
test("rechercher(\"rab\") trouve les 2 contacts de Rabat", () => rechercher(base, "rab").map((x) => x.id), [1, 3]);
test("rechercher(\"KARIM\") ignore les majuscules", () => rechercher(base, "KARIM").length, 1);
test("rechercher(\"\") renvoie tout", () => rechercher(base, "").length, 3);
test("filtrerParCategorie(\"famille\")", () => filtrerParCategorie(base, "famille").map((x) => x.nom), ["Élodie Martin"]);
test("filtrerParCategorie(\"toutes\")", () => filtrerParCategorie(base, "toutes").length, 3);
test("basculerFavori : le contact 1 devient favori", () => basculerFavori(base, 1).find((x) => x.id === 1).favori, true);
test("basculerFavori ne modifie pas l'original", () => { const r = basculerFavori(base, 1); return [base[0].favori, r !== base, r[1] === base[1]]; }, [false, true, true]);
test("compterParVille", () => compterParVille(base), { Rabat: 2, "Fès": 1 });
test("villesDistinctes triées", () => villesDistinctes(base), ["Fès", "Rabat"]);
test("trier : favori d'abord, puis Élodie avant Salma", () => trier(base).map((x) => x.nom), ["Karim Benali", "Élodie Martin", "Salma Idrissi"]);
test("trier ne modifie pas le tableau reçu", () => { const r = trier(base); return [Array.isArray(r), base.map((x) => x.id)]; }, [true, [1, 2, 3]]);
test("exporterJSON produit un texte indenté", () => exporterJSON([base[0]]).split("\n")[2], ' "id": 1,');
test("importerJSON relit un export", () => importerJSON(exporterJSON(base)).map((x) => [x.id, x.nom]), [[1, "Salma Idrissi"], [2, "Karim Benali"], [3, "Élodie Martin"]]);
test("importerJSON refuse un texte invalide", () => { try { importerJSON("{nom: 'x'}"); return "aucune erreur"; } catch (e) { return e.message; } }, "JSON invalide");
test("importerJSON refuse un JSON qui n'est pas un tableau", () => { try { importerJSON('{"nom": "x"}'); return "aucune erreur"; } catch (e) { return e.message; } }, "JSON invalide");
const score = document.querySelector("#score");
score.textContent = `${reussis} / ${total}`;
score.classList.toggle("complet", reussis === total);
})();
app.js (fourni)
// =====================================================================
// Interface du carnet (fourni — ne pas modifier). Utilise vos fonctions.
// =====================================================================
const $ = (s) => document.querySelector(s);
let prochainId = 1;
let contacts = [
{ nom: "salma idrissi", telephone: "06 61 22 33 44", email: "salma@ista.ma", ville: "Rabat", categorie: "travail" },
{ nom: "KARIM benali", telephone: "0670112233", email: "", ville: "Fès", categorie: "amis" },
{ nom: "élodie martin", telephone: "0522 33 44 55", email: "Elodie.Martin@mail.fr", ville: "Rabat", categorie: "famille" },
{ nom: "yassine alaoui", telephone: "0655443322", email: "yassine@ista.ma", ville: "Casablanca", categorie: "travail" },
].map((d) => creerContact(d, prochainId++)).filter(Boolean);
function afficher() {
let visibles = filtrerParCategorie(contacts, $("#filtre").value) ?? contacts;
visibles = rechercher(visibles, $("#recherche").value) ?? visibles;
visibles = trier(visibles) ?? visibles;
const ul = $("#liste");
ul.replaceChildren();
for (const ct of visibles) {
const li = document.createElement("li");
const fav = document.createElement("button");
fav.className = "favori" + (ct.favori ? " oui" : "");
fav.textContent = "★";
fav.title = "Favori";
fav.addEventListener("click", () => { contacts = basculerFavori(contacts, ct.id) ?? contacts; afficher(); });
const infos = document.createElement("div");
const nom = document.createElement("div"); nom.className = "nom"; nom.textContent = ct.nom;
const details = document.createElement("div"); details.className = "details";
details.textContent = [ct.telephone, ct.email, ct.ville].filter(Boolean).join(" · ");
infos.append(nom, details);
const cat = document.createElement("span"); cat.className = "categorie " + ct.categorie; cat.textContent = ct.categorie;
const suppr = document.createElement("button"); suppr.className = "supprimer"; suppr.textContent = "×"; suppr.title = "Supprimer";
suppr.addEventListener("click", () => { contacts = contacts.filter((x) => x.id !== ct.id); afficher(); });
li.append(fav, infos, cat, suppr);
ul.append(li);
}
$("#compteur").textContent = `${visibles.length} contact(s) affiché(s) sur ${contacts.length}`;
const villes = villesDistinctes(contacts);
$("#villes").textContent = villes ? `${villes.length} ville(s) : ${villes.join(", ")}` : "";
$("#parVille").replaceChildren(...Object.entries(compterParVille(contacts) ?? {}).map(([v, n]) => {
const li = document.createElement("li"); li.textContent = `${v} : ${n}`; return li;
}));
}
$("#formulaire").addEventListener("submit", (ev) => {
ev.preventDefault();
const nouveau = creerContact({ nom: $("#nom").value, telephone: $("#telephone").value, email: $("#email").value, ville: $("#ville").value, categorie: $("#categorie").value }, prochainId);
const erreurs = nouveau ? verifierContact(nouveau) ?? [] : ["creerContact n'est pas encore écrite."];
$("#erreurs").replaceChildren(...erreurs.map((e) => { const li = document.createElement("li"); li.textContent = e; return li; }));
if (erreurs.length > 0) return;
contacts = [...contacts, nouveau];
prochainId++;
$("#formulaire").reset();
afficher();
});
$("#recherche").addEventListener("input", afficher);
$("#filtre").addEventListener("change", afficher);
$("#exporter").addEventListener("click", () => {
$("#json").value = exporterJSON(contacts) ?? "";
$("#messageJson").textContent = "Carnet exporté.";
});
$("#importer").addEventListener("click", () => {
try {
const importes = importerJSON($("#json").value);
if (!Array.isArray(importes)) throw new Error("importerJSON n'est pas encore écrite.");
contacts = importes;
prochainId = contacts.length + 1;
$("#messageJson").textContent = `${contacts.length} contact(s) importé(s).`;
} catch (e) {
$("#messageJson").textContent = "Import impossible : " + e.message;
}
afficher();
});
afficher();
🛠 TP · Partie 1 — Créer et vérifier un contact
Mission : Complétez creerContact et verifierContact (tests 1 à 6).
Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :
- Dans
creerContact, comment récupérez-vous les champs dedonneesen une ligne, avec"amis"comme catégorie par défaut ? - Pour transformer « amina TAZI » en « Amina Tazi », découpez le travail : retirer les espaces autour, découper en mots, mettre chaque mot en forme, recoller. Quelles méthodes des chaînes et des tableaux utilisez-vous ?
- Comment retirez-vous tous les espaces du téléphone, et pas seulement ceux du début et de la fin ?
verifierContactrenvoie un tableau de messages. Pourquoi un tableau plutôt qu'un simpletrue/false?- Comment vérifiez-vous qu'un téléphone contient exactement 10 chiffres et commence par 0 ? (une expression régulière, leçon 5.4, ou une boucle)
Je vérifie / je rends :
- ☐ Tests 1 à 6 au vert ; les 4 contacts de départ s'affichent avec des noms propres.
🛠 TP · Partie 2 — Rechercher, filtrer, mettre en favori
Mission : Complétez rechercher, filtrerParCategorie et basculerFavori (tests 7 à 13).
Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :
- Pour la recherche, comment comparez-vous sans tenir compte des majuscules ? Comment testez-vous les trois champs (nom, ville, e-mail) sans écrire trois conditions ? (indice :
some) - Que doit renvoyer
rechercherpour un texte vide ? Vérifiez que votre code le fait sans cas particulier. basculerFavorine doit modifier ni le tableau ni les objets reçus. Pourquoicontacts.find(…).favori = trueest-il interdit ici ? Quelle combinaison demapet de spread utilisez-vous ?- Le test 13 vérifie aussi que les contacts non modifiés sont les mêmes objets qu'avant. Pourquoi est-ce une bonne chose (mémoire, comparaison rapide) ?
Je vérifie / je rends :
- ☐ Tests 7 à 13 au vert ; la recherche, le filtre et l'étoile fonctionnent dans la page.
🛠 TP · Partie 3 — Statistiques et tri
Mission : Complétez compterParVille, villesDistinctes et trier (tests 14 à 17).
Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :
compterParVilleest un dictionnaire de comptage (leçon 9.2). Comment ignorez-vous un contact sans ville ?- Pour les villes distinctes, quelle collection supprime les doublons ? Comment la transformez-vous en tableau trié ?
trierplace les favoris en premier, puis trie par nom. Écrivez la fonction de comparaison en deux étapes. Comment soustraire deux booléens donne-t-il un nombre utilisable ?- Pourquoi « Élodie » doit-elle être placée avant « Salma » ? Quelle méthode des chaînes compare en tenant compte des accents ?
sortmodifie le tableau sur place : comment trier une copie ?
Je vérifie / je rends :
- ☐ Tests 14 à 17 au vert ; les statistiques s'affichent.
🛠 TP · Partie 4 — Échanger en JSON
Mission : Complétez exporterJSON et importerJSON (tests 18 à 21).
Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :
- Comment obtenez-vous un JSON indenté de 2 espaces ?
- Dans
importerJSON, quelles sont les deux situations d'erreur à détecter ? Comment transformez-vous laSyntaxErrordeJSON.parseenError("JSON invalide")? - Pourquoi repasser chaque contact importé dans
creerContactplutôt que de garder les objets tels quels ? Pensez à un fichier modifié à la main. - Le favori doit être conservé à l'import, mais
creerContactle remet àfalse. Comment le restaurez-vous sans modifiercreerContact? - Exportez votre carnet, modifiez le JSON à la main dans la zone de texte (ajoutez un contact), puis importez-le. Introduisez ensuite une virgule en trop : que se passe-t-il ?
Je vérifie / je rends :
- ☐ 21 / 21 ; export et import fonctionnent dans la page.
🛠 TP · Partie 5 — Bonus
Mission : Allez plus loin.
Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :
- Rendez la recherche insensible aux accents : « elodie » doit trouver « Élodie ». (indice :
normalize("NFD")et une expression régulière) - Refusez l'ajout d'un contact dont le téléphone existe déjà, avec un
Setdes téléphones. - Ajoutez une fonction
exporterCSV(contacts)qui produit une ligne par contact, séparateur « ; », avec une ligne d'en-tête. - Au chapitre 20, vous enregistrerez le carnet dans le
localStorage: quelles deux fonctions de ce TP réutiliserez-vous ?
Je vérifie / je rends :
- ☐ Les bonus réalisés, décrits dans le compte rendu.
Scénarios de recette
Le corrigé de l'enseignant réussit tous ces scénarios :
- La page de tests affiche 21 / 21.
- Au chargement : 4 contacts aux noms nettoyés, triés : Élodie Martin, Karim Benali, Salma Idrissi, Yassine Alaoui ; « 3 ville(s) : Casablanca, Fès, Rabat » ; « Rabat : 2 ».
- Recherche « rab » : 2 contacts ; filtre « Travail » : 2 contacts.
- Étoile sur Yassine : il passe en tête de liste.
- Saisie « x » / « 123 » : deux messages d'erreur, aucun contact ajouté.
- Saisie « nadia el amrani », « 06 11 22 33 44 », Tanger, Famille : « Nadia El Amrani » est ajoutée, 4 villes.
- Export : le JSON indenté contient
"nom": "Nadia El Amrani". - Import de
{pas du json}: « Import impossible : JSON invalide », le carnet est intact. - Import d'un tableau d'un contact favori : 1 contact affiché, avec son étoile.
Grille d'évaluation indicative
| Critère | Points |
|---|---|
| Partie 1 — création (déstructuration, nettoyage) et vérification | 5 |
| Partie 2 — recherche, filtre, favori sans modifier les données | 4 |
| Partie 3 — dictionnaire, Set et tri | 4 |
| Partie 4 — export et import JSON avec gestion des erreurs | 4 |
| Qualité du code et compte rendu (scénarios de recette) | 3 |
| Total (bonus : jusqu'à +3) | 20 |
← Les collections Map et Set