7.2 · Formulaires
Les diapositives de la séance 7
- écrire un formulaire HTML correctement étiqueté
- récupérer les éléments du DOM et les typer sans utiliser
any - intercepter l’événement
submitet neutraliser le rechargement de page - lire la valeur d’un champ texte, d’une case à cocher et d’une liste déroulante
- valider une saisie côté client et afficher un message d’erreur lisible
- expliquer pourquoi une validation côté client ne constitue pas une protection
Le DOM et l’affichage de données (6.2 · fetch), les classes (3.1 · Classes et encapsulation) et l’asynchronisme (6.1 · async / await).
La séance 7 est plus courte que les autres. En séance, faites les exercices marqués « essentiel » des chapitres 7.1 et 7.2. Ce qui n’est pas terminé se finit en autonomie, avec le composant minimal du chapitre 7.3. La page Préparer l’évaluation 2 propose ensuite un plan de travail étape par étape.
Le HTML d’un formulaire
src/pages/index.astro
<form id="myForm">
<label for="name">Name:</label>
<input type="text" id="name" />
<label for="isStudent">Student</label>
<input type="checkbox" id="isStudent" />
<button type="submit">Submit</button>
<div id="result"></div>
</form>Chaque élément porte un id : c’est par là que le TypeScript le retrouvera.
L’attribut for d’un <label> doit valoir l’id du champ qu’il décrit. Ce n’est pas de la décoration : c’est ce qui permet à un lecteur d’écran d’annoncer le bon libellé, et ce qui fait que cliquer sur le texte place le curseur dans le champ.
Les types de champs les plus utiles :
| Balise | Rôle | Comment lire la valeur |
|---|---|---|
<input type="text"> |
texte sur une ligne | .value |
<input type="password"> |
mot de passe masqué | .value |
<input type="number"> |
nombre | .value (une chaîne !) ou .valueAsNumber |
<input type="date"> |
date avec sélecteur | .value (format AAAA-MM-JJ) |
<input type="checkbox"> |
case à cocher | .checked |
<input type="radio"> |
choix unique dans un groupe | .checked sur chaque bouton |
<textarea> |
texte multiligne | .value |
<select> |
liste déroulante | .value |
Récupérer et typer les éléments
document.getElementById retourne HTMLElement | null : le compilateur ignore quelle balise se cache derrière cet id, et sait seulement qu’elle pourrait ne pas exister. Or HTMLElement n’a ni .value ni .checked.
Deux écritures possibles :
// Assertion : « fais-moi confiance, c'est un input »
const nameInput = document.getElementById("name") as HTMLInputElement;
// Vérification : le compilateur restreint le type tout seul
const el = document.getElementById("name");
if (el instanceof HTMLInputElement) {
console.log(el.value);
}La seconde est plus sûre : si l’id a été renommé dans le HTML, la première plante à l’exécution avec Cannot read properties of null, tandis que la seconde passe simplement dans le else.
Le code du cours regroupe tout dans une classe, instanciée une fois la page chargée :
src/scripts/app.ts
window.onload = () => {
new MyFormManager();
};
class MyFormManager {
myForm: HTMLFormElement;
nameInput: HTMLInputElement;
result: HTMLDivElement;
isStudent: HTMLInputElement;
constructor() {
this.myForm = document.getElementById("myForm") as HTMLFormElement;
this.nameInput = document.getElementById("name") as HTMLInputElement;
this.result = document.getElementById("result") as HTMLDivElement;
this.isStudent = document.getElementById("isStudent") as HTMLInputElement;
this.myForm.addEventListener("submit", this.handleSubmit.bind(this));
}
}window.onload n’est pas une précaution facultative. Un <script> placé avant le formulaire s’exécute alors que les <input> n’existent pas encore dans le DOM : getElementById retourne null, et l’assertion as HTMLInputElement ne vous prévient de rien puisqu’elle disparaît à la transpilation. L’erreur n’apparaît qu’au premier accès à .value.
Pourquoi .bind(this) ?
Dans une méthode, this désigne l’instance qui appelle la méthode. Quand c’est le navigateur qui l’appelle, comme écouteur d’événement, this n’est plus votre objet. Voir 3.1 · Les classes.
this.myForm.addEventListener("submit", this.handleSubmit.bind(this));Quand le navigateur déclenche l’événement, il appelle la fonction en fixant this sur l’élément qui a reçu l’événement : ici le <form>. Votre méthode s’attendrait donc à trouver this.nameInput sur un formulaire, où cette propriété n’existe pas.
.bind(this) fige this sur l’instance de MyFormManager. Une fonction fléchée produit le même effet, puisqu’elle ne crée pas son propre this (voir 2.1 · Fonctions fléchées) :
this.myForm.addEventListener("submit", (event) => this.handleSubmit(event));submit et preventDefault
Par défaut, soumettre un formulaire fait recharger la page : le navigateur envoie les champs au serveur et affiche la réponse. C’est le comportement historique du web, et il est toujours actif.
Si vous voulez traiter la saisie en JavaScript, vous devez l’annuler :
src/scripts/app.ts
handleSubmit(event: Event) {
event.preventDefault(); // sans cette ligne, la page se recharge
const name = this.nameInput.value;
if (name.length < 4) {
this.result.style.color = "red";
this.result.textContent = "Name must be at least 4 characters long.";
} else {
this.result.style.color = "green";
if (this.isStudent.checked) {
this.result.textContent = `Name Ok: ${name}, you are a student`;
} else {
this.result.textContent = `Name Ok: ${name}, you are not a student`;
}
}
}C’est le symptôme à reconnaître : vous cliquez sur Submit, un message apparaît une fraction de seconde puis tout disparaît, et l’URL a gagné un ?. Vous n’avez pas un bug d’affichage : votre page s’est rechargée, et tout votre état JavaScript a été détruit. Il manque event.preventDefault().
Notez aussi que l’événement submit se déclenche pour toutes les manières de soumettre : clic sur le bouton, mais aussi touche Entrée dans un champ texte. Écoutez submit sur le formulaire, jamais click sur le bouton.
Valider la saisie
Le navigateur sait déjà valider certaines contraintes, sans une ligne de code :
<input type="text" id="name" required minlength="4" />
<input type="email" id="mail" required />
<input type="number" id="age" min="0" max="130" />Utilisez ces attributs : ils donnent un retour immédiat et accessible. Complétez ensuite en TypeScript pour tout ce que le HTML ne sait pas exprimer : cohérence entre deux champs, unicité, format métier.
.value retourne toujours une chaîne, y compris pour <input type="number">. Donc input.value + 1 sur la saisie 41 donne "411", pas 42. Convertissez explicitement avec Number(input.value) ou utilisez input.valueAsNumber, et testez le résultat avec Number.isNaN.
Elle sert au confort de l’utilisateur : signaler une faute de frappe tout de suite plutôt qu’après un aller-retour réseau. Elle ne constitue en aucun cas une protection.
Tout ce qui s’exécute dans le navigateur est sous le contrôle de celui qui l’utilise. N’importe qui peut supprimer l’attribut required depuis les outils de développement, modifier votre fonction de validation, ou ignorer complètement votre page et envoyer la requête à la main avec curl.
Toute donnée arrivant sur un serveur doit être revalidée par ce serveur, sans aucune hypothèse sur son origine. C’est ainsi qu’on évite les injections SQL, les XSS stockés et les données incohérentes en base. La validation client est une commodité ; la validation serveur est la seule qui compte.
Exercices
Exercice 7.5 : Reproduire et casser
essentiel
Partez de l’exemple du cours et faites-le tourner avec Astro.
- Vérifiez le fonctionnement : saisie de moins de 4 caractères en rouge, sinon message vert mentionnant la case à cocher.
- Commentez
event.preventDefault(), rechargez, soumettez. Décrivez précisément ce que vous observez dans la page et dans la barre d’adresse. - Rétablissez la ligne. Remplacez ensuite
.bind(this)parthis.handleSubmitseul et soumettez : lisez le message d’erreur de la console et expliquez-le.
Critère de réussite : vous savez relier chacun des deux symptômes à sa cause.
2. Sans preventDefault(). Le message apparaît puis disparaît en une fraction de seconde, et l’URL devient http://localhost:4321/?. Ce n’est pas un clignotement d’affichage : le navigateur a repris son comportement historique, il a soumis le formulaire au serveur en GET sur l’URL courante, et la page a été entièrement rechargée. Tout l’état JavaScript (dont le contenu du <div id="result">) a été détruit avec l’ancienne page.
Le ? est seul, sans paramètres, parce que les <input> de l’exemple n’ont pas d’attribut name : seuls les champs nommés sont envoyés. Ajoutez name="name" à l’un d’eux et l’URL deviendra ?name=Remi, ce qui rend la soumission encore plus visible.
3. Sans .bind(this). La console affiche :
Uncaught TypeError: Cannot read properties of undefined (reading 'value')
En passant this.handleSubmit seul, vous ne transmettez que la fonction, détachée de l’objet sur lequel vous l’avez lue. Lorsque le navigateur l’appelle, il fixe this sur l’élément qui porte l’écouteur, c’est-à-dire le <form>. La ligne this.nameInput cherche donc une propriété nameInput sur un formulaire HTML : elle vaut undefined, et le .value qui suit lève l’exception.
Le point clé. Ces deux bugs ont la même signature déroutante (« ça marchait, et là ça ne marche plus »), mais des causes opposées. Le premier est un problème de cycle de vie de la page : rien n’est cassé, votre page a simplement été remplacée. Le second est un problème de liaison de this : en JavaScript, this est déterminé par la façon dont la fonction est appelée, pas par l’endroit où elle est écrite. .bind(this) fige cette valeur ; une fonction fléchée (event) => this.handleSubmit(event) obtient le même résultat autrement, puisqu’elle ne crée pas son propre this.
Exercice 7.6 : Ajouter un restaurant depuis un formulaire
essentiel transfert
Cet exercice relie les deux chapitres de la séance : un formulaire dans une page Astro, qui crée une ressource sur l’API json-server du chapitre 7.1.
- Dans
src/pages/ajout.astro, écrivez un formulaire avec deux champs, le nom et la description d’un restaurant, un bouton d’envoi et un paragraphe de message. - Dans
src/scripts/ajout.ts, à la soumission et sans recharger la page : refusez un nom vide avec un message ; sinon envoyez unPOSTàhttp://localhost:3000/restaurants, aveccategoryIds: []. - Affichez
« Le Test » ajouté avec l'identifiant 9f1cà partir de la réponse du serveur, puis videz le formulaire. Affichez un message clair si le serveur répond une erreur ou ne répond pas.
Réussite : le nouveau restaurant apparaît dans db.json, l’identifiant affiché est celui que le serveur a créé, et aucun élément n’est typé avec as sans vérification.
src/pages/ajout.astro
---
---
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Ajouter un restaurant</title>
</head>
<body>
<form id="ajout">
<label for="nom">Nom :</label>
<input type="text" id="nom" />
<label for="description">Description :</label>
<input type="text" id="description" />
<button type="submit">Ajouter</button>
</form>
<p id="message"></p>
<script src="../scripts/ajout.ts"></script>
</body>
</html>src/scripts/ajout.ts
interface NouveauRestaurant {
name: string;
description: string;
categoryIds: string[];
}
interface Restaurant extends NouveauRestaurant {
id: string;
}
class FormulaireRestaurant {
constructor(
private form: HTMLFormElement,
private nom: HTMLInputElement,
private description: HTMLInputElement,
private message: HTMLParagraphElement
) {
this.form.addEventListener("submit", this.envoyer.bind(this));
}
private async envoyer(event: SubmitEvent): Promise<void> {
event.preventDefault();
const nom = this.nom.value.trim();
if (nom === "") {
this.message.textContent = "Le nom est obligatoire.";
return;
}
const donnees: NouveauRestaurant = {
name: nom,
description: this.description.value.trim(),
categoryIds: [],
};
try {
const response = await fetch("http://localhost:3000/restaurants", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(donnees),
});
if (!response.ok) {
this.message.textContent = `Échec de l'ajout (statut ${response.status}).`;
return;
}
const cree: Restaurant = await response.json();
this.message.textContent = `« ${cree.name} » ajouté avec l'identifiant ${cree.id}`;
this.form.reset();
} catch {
this.message.textContent = "Le serveur ne répond pas : json-server est-il lancé ?";
}
}
}
const form = document.getElementById("ajout");
const nom = document.getElementById("nom");
const description = document.getElementById("description");
const message = document.getElementById("message");
if (
form instanceof HTMLFormElement &&
nom instanceof HTMLInputElement &&
description instanceof HTMLInputElement &&
message instanceof HTMLParagraphElement
) {
new FormulaireRestaurant(form, nom, description, message);
}Le point clé : l’identifiant affiché vient de la réponse au POST, pas du formulaire. C’est le serveur qui crée la ressource et lui attribue son id ; le client n’en sait rien avant d’avoir lu le corps de la réponse, d’où le type Restaurant (avec id) distinct de NouveauRestaurant (sans).
Les trois issues possibles sont traitées séparément : saisie invalide (aucune requête n’est envoyée), réponse d’erreur (response.ok faux : fetch ne lève pas d’exception pour un 400 ou un 500), et serveur injoignable (là, fetch lève une exception, d’où le try).
Les quatre éléments sont vérifiés avec instanceof avant de construire l’objet : si un id change dans la page, rien ne se passe au lieu d’un plantage sur null. Et .bind(this) garde this sur l’instance, comme dans l’exemple du cours.
L’erreur classique : oublier event.preventDefault(). La page se recharge aussitôt, et le message disparaît avant d’avoir été lu, parfois même avant la fin du fetch.
Exercice 7.7 : Un formulaire d’inscription complet
essentiel
Construisez un formulaire comportant : un nom, un courriel, une date de naissance, un niveau d’études dans un <select>, et une case « j’accepte les conditions ».
À la soumission, sans recharger la page :
- validez que le nom fait au moins deux caractères, que le courriel contient un
@, que la date est dans le passé, qu’un niveau est choisi et que la case est cochée ; - affichez tous les messages d’erreur d’un coup, sous forme de liste, pas seulement le premier ;
- si tout est valide, affichez un récapitulatif et remettez le formulaire à zéro avec
this.myForm.reset().
Écrivez une méthode valider(): string[] qui retourne le tableau des messages d’erreur, vide si tout va bien. handleSubmit se contente ensuite de tester erreurs.length === 0. Cela sépare la logique de validation de l’affichage, et rend la méthode testable.
Critère de réussite : aucune assertion as sur un élément qui pourrait être absent, et les messages désignent clairement le champ fautif.
src/pages/index.astro
<form id="inscription">
<label for="nom">Nom :</label>
<input type="text" id="nom" name="nom" required minlength="2" />
<label for="courriel">Courriel :</label>
<input type="email" id="courriel" name="courriel" required />
<label for="naissance">Date de naissance :</label>
<input type="date" id="naissance" name="naissance" required />
<label for="niveau">Niveau d'études :</label>
<select id="niveau" name="niveau" required>
<option value="">Choisir un niveau</option>
<option value="licence">Licence</option>
<option value="master">Master</option>
<option value="doctorat">Doctorat</option>
</select>
<label for="conditions">J'accepte les conditions</label>
<input type="checkbox" id="conditions" name="conditions" />
<button type="submit">S'inscrire</button>
</form>
<div id="resultat"></div>
<script src="../scripts/app.ts"></script>src/scripts/app.ts
// Récupère un élément et vérifie son type : pas d'assertion "as"
function requis<T extends HTMLElement>(id: string, type: new () => T): T {
const element = document.getElementById(id);
if (!(element instanceof type)) {
throw new Error(`Élément #${id} absent ou de type inattendu.`);
}
return element;
}
class InscriptionManager {
private form: HTMLFormElement;
private nom: HTMLInputElement;
private courriel: HTMLInputElement;
private naissance: HTMLInputElement;
private niveau: HTMLSelectElement;
private conditions: HTMLInputElement;
private resultat: HTMLDivElement;
constructor() {
this.form = requis("inscription", HTMLFormElement);
this.nom = requis("nom", HTMLInputElement);
this.courriel = requis("courriel", HTMLInputElement);
this.naissance = requis("naissance", HTMLInputElement);
this.niveau = requis("niveau", HTMLSelectElement);
this.conditions = requis("conditions", HTMLInputElement);
this.resultat = requis("resultat", HTMLDivElement);
this.form.addEventListener("submit", (event) => this.handleSubmit(event));
}
// Toute la logique de validation ici, aucun affichage
private valider(): string[] {
const erreurs: string[] = [];
if (this.nom.value.trim().length < 2) {
erreurs.push("Le nom doit contenir au moins 2 caractères.");
}
if (!this.courriel.value.includes("@")) {
erreurs.push("Le courriel doit contenir un @.");
}
const naissance = new Date(this.naissance.value);
if (this.naissance.value === "" || Number.isNaN(naissance.getTime())) {
erreurs.push("La date de naissance est absente ou invalide.");
} else if (naissance.getTime() >= Date.now()) {
erreurs.push("La date de naissance doit être dans le passé.");
}
if (this.niveau.value === "") {
erreurs.push("Choisissez un niveau d'études.");
}
if (!this.conditions.checked) {
erreurs.push("Vous devez accepter les conditions.");
}
return erreurs;
}
private handleSubmit(event: Event): void {
event.preventDefault();
this.resultat.textContent = ""; // vider l'affichage précédent
const erreurs: string[] = this.valider();
if (erreurs.length === 0) {
this.resultat.style.color = "green";
this.resultat.textContent =
`Inscription enregistrée : ${this.nom.value.trim()}, ${this.courriel.value}, ` +
`né(e) le ${this.naissance.value}, niveau ${this.niveau.value}.`;
this.form.reset();
return;
}
this.resultat.style.color = "red";
const ul: HTMLUListElement = document.createElement("ul");
erreurs.forEach((message) => {
const li: HTMLLIElement = document.createElement("li");
li.textContent = message;
ul.appendChild(li);
});
this.resultat.appendChild(ul);
}
}
window.onload = () => {
new InscriptionManager();
};Le point clé. Séparer valider() de handleSubmit() n’est pas une coquetterie. La première ne connaît que des données et retourne un tableau : elle est testable et réutilisable. La seconde ne fait que de l’affichage. C’est aussi ce qui permet de répondre au point 2 de l’énoncé : comme valider() accumule dans erreurs au lieu de retourner dès le premier problème, l’utilisateur voit d’un coup tout ce qu’il doit corriger, au lieu de découvrir les fautes une par une à chaque soumission.
Deux détails :
this.resultat.textContent = ""avant de réafficher : sans cela les listes d’erreurs s’empilent à chaque clic ;- les messages sont insérés avec
textContent. Ils contiennent ici des constantes, mais si vous citiez la saisie de l’utilisateur dans un message («<script>n’est pas un nom valide »),innerHTMLexécuterait cette saisie. C’est un XSS auto-infligé, très courant dans les formulaires.
Rappel : les attributs required, minlength et type="email" du HTML font déjà une partie du travail et donnent un retour accessible. Ils ne dispensent pas de la validation TypeScript, et aucun des deux ne protège le serveur.
Exercice 7.8 : Formulaire et API, la qualité de l’air
approfondissement
Application côté serveur, car l’API OpenAQ bloque les appels directs depuis le navigateur (voir 6.3 · CORS). Créez un compte sur explore.openaq.org pour obtenir une clé d’API : elle apparaît dans les réglages de votre compte et s’envoie dans l’en-tête X-API-Key.
L’application doit :
- afficher un formulaire demandant le nom d’un pays ;
- à la soumission, interroger l’API pour lister les stations de mesure de ce pays, et les présenter dans un
<select>; - à la sélection d’une station, récupérer ses mesures et les afficher dans un tableau donnant le nom du polluant, la valeur et l’unité ;
- rafraîchir les données à intervalle régulier avec
setInterval; - signaler clairement à l’utilisateur si le pays saisi est inconnu de l’API, et si une requête échoue.
Elle reste côté serveur, dans une variable d’environnement. Si vous partagez ou publiez votre projet, vérifiez que le fichier .env n’en fait pas partie. Relisez l’encadré du chapitre 7.1 : une clé qui a fuité est une clé à révoquer.
Critères de réussite : la clé n’apparaît ni dans le code source servi au navigateur ni dans l’onglet Réseau ; un pays inexistant produit un message et non une page vide ; le rafraîchissement s’arrête proprement quand l’utilisateur change de station.
Comme l’interface doit rester interactive après le chargement, il ne suffit pas d’appeler OpenAQ dans le frontmatter : le navigateur doit pouvoir redemander des données. On expose donc deux routes d’API Astro, qui sont de la même origine que la page et qui seules détiennent la clé.
.env
OPENAQ_API_KEY=votre_cle_icisrc/pages/api/stations.ts
import type { APIRoute } from "astro";
export const prerender = false; // route calculée à chaque requête
const CLE: string = import.meta.env.OPENAQ_API_KEY;
interface Reponse<T> { results: T[]; }
interface Country { id: number; name: string; }
interface Station { id: number; name: string; locality?: string; }
function json(corps: unknown, status: number): Response {
return new Response(JSON.stringify(corps), {
status,
headers: { "Content-Type": "application/json" },
});
}
async function openaq<T>(url: string): Promise<T> {
const response = await fetch(url, { headers: { "X-API-Key": CLE } });
if (!response.ok) {
throw new Error(`OpenAQ a répondu ${response.status}`);
}
return (await response.json()) as T;
}
export const GET: APIRoute = async ({ url }) => {
const pays: string = (url.searchParams.get("pays") ?? "").trim();
if (pays === "") {
return json({ erreur: "Indiquez un nom de pays." }, 400);
}
try {
const pays_api = await openaq<Reponse<Country>>(
"https://api.openaq.org/v3/countries?limit=300"
);
const trouve = pays_api.results.find(
(country) => country.name.toLowerCase() === pays.toLowerCase()
);
if (trouve === undefined) {
return json({ erreur: `Le pays « ${pays} » est inconnu d'OpenAQ.` }, 404);
}
const stations = await openaq<Reponse<Station>>(
`https://api.openaq.org/v3/locations?countries_id=${trouve.id}&limit=100`
);
return json({ stations: stations.results }, 200);
} catch (error) {
console.error(error); // le détail reste dans les logs du serveur
return json({ erreur: "Service OpenAQ indisponible." }, 502);
}
};src/pages/api/mesures.ts
import type { APIRoute } from "astro";
export const prerender = false;
const CLE: string = import.meta.env.OPENAQ_API_KEY;
interface Reponse<T> { results: T[]; }
interface Capteur {
id: number;
parameter: { name: string; displayName: string | null; units: string };
}
interface DetailStation { id: number; name: string; sensors: Capteur[]; }
interface Derniere { sensorsId: number; value: number; datetime: { local: string } }
// ... json() et openaq() identiques au fichier précédent
export const GET: APIRoute = async ({ url }) => {
const station: string = url.searchParams.get("station") ?? "";
if (!/^\d+$/.test(station)) {
return json({ erreur: "Identifiant de station invalide." }, 400);
}
try {
const detail = await openaq<Reponse<DetailStation>>(
`https://api.openaq.org/v3/locations/${station}`
);
const dernieres = await openaq<Reponse<Derniere>>(
`https://api.openaq.org/v3/locations/${station}/latest?limit=100`
);
const capteurs: Capteur[] = detail.results[0]?.sensors ?? [];
// L'endpoint "latest" ne donne qu'un sensorsId : on croise avec les
// capteurs de la station pour retrouver le polluant et son unité
const mesures = dernieres.results.map((derniere) => {
const capteur = capteurs.find((item) => item.id === derniere.sensorsId);
return {
polluant: capteur?.parameter.displayName ?? capteur?.parameter.name ?? "inconnu",
valeur: derniere.value,
unite: capteur?.parameter.units ?? "?",
date: derniere.datetime.local,
};
});
return json({ mesures }, 200);
} catch (error) {
console.error(error);
return json({ erreur: "Mesures indisponibles." }, 502);
}
};src/pages/index.astro
<form id="recherche">
<label for="pays">Pays :</label>
<input type="text" id="pays" name="pays" required placeholder="France" />
<button type="submit">Chercher les stations</button>
</form>
<p id="message"></p>
<label for="station">Station :</label>
<select id="station"></select>
<table>
<thead>
<tr><th>Polluant</th><th>Valeur</th><th>Unité</th><th>Date locale</th></tr>
</thead>
<tbody id="mesures"></tbody>
</table>
<script src="../scripts/app.ts"></script>src/scripts/app.ts
interface Station { id: number; name: string; locality?: string; }
interface Mesure { polluant: string; valeur: number; unite: string; date: string; }
// On réutilise la fonction requis() écrite à l'exercice 7.7
class AirManager {
private form: HTMLFormElement;
private pays: HTMLInputElement;
private station: HTMLSelectElement;
private mesures: HTMLTableSectionElement;
private message: HTMLParagraphElement;
private minuterie: number | null = null;
constructor() {
this.form = requis("recherche", HTMLFormElement);
this.pays = requis("pays", HTMLInputElement);
this.station = requis("station", HTMLSelectElement);
this.mesures = requis("mesures", HTMLTableSectionElement);
this.message = requis("message", HTMLParagraphElement);
this.form.addEventListener("submit", (event) => this.chercherStations(event));
this.station.addEventListener("change", () => this.suivreStation());
}
private async chercherStations(event: Event): Promise<void> {
event.preventDefault();
this.arreterMinuterie();
this.message.textContent = "Recherche en cours…";
const url = new URL("/api/stations", window.location.origin);
url.searchParams.set("pays", this.pays.value);
try {
const response = await fetch(url);
const corps = await response.json();
if (!response.ok) {
this.message.textContent = corps.erreur ?? `Erreur ${response.status}.`;
this.station.replaceChildren();
this.mesures.replaceChildren();
return;
}
const stations: Station[] = corps.stations;
this.station.replaceChildren();
stations.forEach((item) => {
const option: HTMLOptionElement = document.createElement("option");
option.value = String(item.id);
option.textContent = item.locality ? `${item.name} (${item.locality})` : item.name;
this.station.appendChild(option);
});
if (stations.length === 0) {
this.message.textContent = "Aucune station de mesure pour ce pays.";
return;
}
this.message.textContent = `${stations.length} station(s) trouvée(s).`;
this.suivreStation();
} catch (error) {
this.message.textContent = "Serveur injoignable.";
}
}
private suivreStation(): void {
this.arreterMinuterie(); // une seule minuterie à la fois
void this.chargerMesures();
this.minuterie = window.setInterval(() => void this.chargerMesures(), 60_000);
}
private arreterMinuterie(): void {
if (this.minuterie !== null) {
clearInterval(this.minuterie);
this.minuterie = null;
}
}
private async chargerMesures(): Promise<void> {
const url = new URL("/api/mesures", window.location.origin);
url.searchParams.set("station", this.station.value);
try {
const response = await fetch(url);
const corps = await response.json();
if (!response.ok) {
this.message.textContent = corps.erreur ?? `Erreur ${response.status}.`;
return;
}
this.mesures.replaceChildren();
(corps.mesures as Mesure[]).forEach((mesure) => {
const tr: HTMLTableRowElement = document.createElement("tr");
[mesure.polluant, String(mesure.valeur), mesure.unite, mesure.date].forEach((texte) => {
const td: HTMLTableCellElement = document.createElement("td");
td.textContent = texte; // données venues du réseau : jamais innerHTML
tr.appendChild(td);
});
this.mesures.appendChild(tr);
});
} catch (error) {
this.message.textContent = "Rafraîchissement impossible.";
}
}
}
window.onload = () => {
new AirManager();
};Le point clé. Le navigateur ne parle qu’à localhost:4321. La clé vit dans .env, lu par import.meta.env uniquement côté serveur : dans Astro, seules les variables préfixées PUBLIC_ sont injectées dans le bundle client. Nommer la vôtre PUBLIC_OPENAQ_API_KEY la publierait à tout l’Internet. Vérifiez-le vous-même : cherchez votre clé dans les sources et dans l’onglet Réseau, vous ne devez pas la trouver.
Deuxième point, souvent raté : arreterMinuterie() est appelé avant chaque nouveau setInterval. Sans cela, chaque changement de station ajouterait une minuterie sans supprimer la précédente : au bout de cinq sélections, cinq requêtes par minute continueraient d’arriver, dont quatre pour des stations que l’utilisateur ne regarde plus. C’est une fuite de ressources classique, et ici elle consomme aussi votre quota d’API.
Enfin, la route serveur ne renvoie jamais le message d’erreur brut d’OpenAQ au client : elle le journalise et retourne un message neutre. Un message d’erreur détaillé peut révéler l’URL interrogée, voire la clé si elle figure en paramètre.
Pour approfondir, la documentation MDN sur les formulaires est excellente : Votre premier formulaire et les types de champs HTML5.
À retenir
- Un
<label for="...">doit pointer vers l’iddu champ : accessibilité et confort de clic getElementByIdretourneHTMLElement | null: préférezinstanceofà l’assertionas- Manipulez le DOM seulement après
window.onload - Écoutez
submitsur le formulaire et appelezevent.preventDefault(), sinon la page se recharge .valueest toujours une chaîne ;.checkedpour les cases à cocher- La validation côté client est un confort : seule la validation côté serveur protège