Module 2 · Le test technique

Étude de cas : la solution disséquée

L'énoncé est lu, les données sont comprises, l'architecture est arrêtée. Reste à écrire le code — et surtout à savoir le défendre. Ce module ouvre la solution fichier par fichier, dans l'ordre où on la construirait vraiment, puis termine sur ce qui décide souvent de l'issue : les bonus qu'on choisit, la relecture des dix dernières minutes, et la présentation à l'oral.

📋 Le cas en cinq lignes — si tu arrives directement ici

L'énoncé. Construire une prise de rendez-vous pour une clinique d'imagerie. L'utilisateur choisit un service, et le formulaire qui s'affiche n'est pas écrit en dur : il est décrit dans un fichier JSON. Cinq exigences — un menu de services, l'affichage conditionnel du formulaire, des champs générés depuis la configuration, les valeurs affichées en console à l'envoi, et la réinitialisation après succès. Six bonus, annoncés par ordre de priorité.

Ce que la lecture des données a donné. Trois découvertes qui décident de tout : les type du JSON ne sont pas des types d'input HTML (il faut une traduction) ; une configuration porte le service "*", un joker qui impose de chercher la correspondance exacte d'abord ; et deux services n'ont aucune configuration propre, donc ce repli se déclenche vraiment.

L'architecture retenue. Une page serveur qui lit les données, une frontière client posée au plus bas — sur le formulaire seulement —, l'état des valeurs dans un seul objet, et un typage en union discriminée sur le type de champ pour que la branche « liste déroulante » soit la seule à posséder des options. Le détail de ces choix est dans Étude de cas : l'énoncé et les données.

La solution, morceau par morceau

Le code complet, fichier par fichier, dans l'ordre où on l'écrirait le jour du test : d'abord les types et la logique pure (qui ne dépendent de rien), puis le serveur, puis l'interface. Cet ordre n'est pas cosmétique — commencer par ce qui ne dépend de rien permet de tester tôt et de ne jamais être bloqué.

📖 La formule du module

Le traducteur unique

// UN SEUL endroit du projet traduit le vocabulaire des données
// vers celui du HTML. Partout ailleurs, on parle « config ».
{field.type === 'dropdown' ? (
  <select {...common}>…</select>
) : field.type === 'phone' ? (
  <input {...common} type="tel" />   // "phone" ici, "tel" en HTML
) : (
  <input {...common} type="text" />  // le repli, jamais absent
)}

Deux vocabulaires se rencontrent dans tout projet piloté par des données : celui de la configuration et celui de la plateforme. La formule consiste à les faire se rencontrer en un seul point, et à écrire partout ailleurs le vocabulaire des données. Quand un nouveau type de champ apparaît, tu sais exactement quel fichier ouvrir ; et l'objet common, étalé dans chaque branche, garantit que le trio d'accessibilité s'applique à tous les contrôles sans qu'on ait à y penser.

🔗 Pont — tu as déjà écrit ce traducteur

Cette silhouette t'est familière : c'est celle du composant Button de ton système de composants, décrit dans le module Composants & style. Là-bas, un objet de props communes est étalé sur un Pressable et une fonction de variantes décide de l'apparence ; ici, un objet common est étalé sur trois contrôles différents et le champ type décide de la balise. Même patron : on centralise ce qui est commun, on ne laisse varier que ce qui doit varier.

La différence tient au moment où la décision se prend. Dans ton Button, elle est écrite dans le code — tu choisis la variante en écrivant le JSX. Ici, elle vient d'un fichier JSON lu à l'exécution : tu ne sais pas, en écrivant le composant, quels champs il rendra. C'est ce déplacement — de la décision écrite à la décision reçue — qui rend l'union discriminée indispensable, alors qu'elle serait facultative dans un composant à variantes fixes.

Les types

Ce premier fichier ne fait rien : il décrit. Trois décisions de modélisation à y voir — la liste fermée des types de champ, l'union discriminée qui rend options obligatoire dans la seule branche qui en a, et l'aveu qu'on ne connaît pas les noms de champs avant l'exécution.

lib/types.ts
// Le vocabulaire de la CONFIGURATION, pas celui du HTML.
export type FieldType = 'text' | 'email' | 'phone' | 'dropdown' | 'textarea';

type CommonField = { name: string; label: string };

// Union discriminée sur `type` : la liste déroulante est la seule forme
// qui possède des options, et elle les possède OBLIGATOIREMENT.
export type FieldConfig =
  | (CommonField & { type: 'text' | 'email' | 'phone' | 'textarea' })
  | (CommonField & { type: 'dropdown'; options: string[] });

export type FormConfig = {
  title: string;
  services: string[];   // des noms de services, ou la valeur joker "*"
  fields: Field[];
};

// Les valeurs saisies : une entrée par `name` de champ. Tout est chaîne,
// parce que tout ce qui sort d'un <input> est une chaîne.
export type FormValues = Record<string, string>;

// Un dictionnaire champ → message. Absent = pas d'erreur.
export type FormErrors = Partial<Record<string, string>>;

Vingt lignes qui décrivent tout le domaine. C'est le premier fichier que l'évaluateur ouvrira, et le plus rentable à soigner.

Trois décisions à savoir défendre. FieldType est une union de chaînes littérales et non string : si demain le JSON contient "phonee", le code ne compile plus au lieu de rendre silencieusement un champ texte. L'union discriminée fait de options une propriété obligatoire dans sa branche, ce qui supprime tous les ?. en aval. Et FormValues est un Record<string, string> — un dictionnaire à clés libres — parce qu'on ne connaît pas les champs à la compilation : ils viennent d'un fichier de données. Reconnaître honnêtement cette limite, plutôt qu'inventer un typage faussement précis, est en soi un bon signal.

La recherche de configuration

La formule « Le repli qui ne ment pas » installée dans un vrai fichier, avec son type de retour complet. Regarde ce que dit la signature : elle admet qu'on puisse ne rien trouver.

lib/configuration.ts
import type { FormConfig } from './types';

export const WILDCARD_SERVICE = '*';

/**
 * Correspondance EXACTE d'abord, repli "*" ensuite.
 * Renvoie `undefined` si aucune des deux ne donne rien : c'est un cas
 * légitime (un form.json sans configuration joker), pas une anomalie.
 */
export function findFormConfig(
  configurations: FormConfig[],
  service: string,
): FormConfig | undefined {
  return (
    configurations.find((c) => c.services.includes(service)) ??
    configurations.find((c) => c.services.includes(WILDCARD_SERVICE))
  );
}

Douze lignes, zéro dépendance, et c'est la fonction la plus testable du projet : deux tableaux en entrée, un objet ou undefined en sortie.

Le type de retour est le point que l'évaluateur va sonder. FormConfig | undefined dit la vérité : la fonction peut ne rien trouver. La tentation, dans le composant, est d'écrire findFormConfig(...)! — l'assertion non nulle, qui signifie « TypeScript, tais-toi, je sais que ce n'est pas undefined ».

Mauvaise affaire, et il faut savoir dire pourquoi en une phrase : l'assertion ne vérifie rien, elle fait juste taire le compilateur. Si le cas se produit — quelqu'un retire la configuration "*" du JSON, ou ajoute un service dans un fichier sans toucher à l'autre — tu n'obtiens pas une erreur explicite mais un Cannot read properties of undefined (reading 'fields') quelque part dans le rendu, à mille lieues de la cause. Tu as échangé une erreur de compilation gratuite contre un plantage devant l'utilisateur.

Le bon réflexe est l'inverse : garder le type honnête et gérer l'absence explicitement, une seule fois, là où on peut faire quelque chose d'utile — ici, afficher un message. Le code est plus court que le débat : {service && !configuration && ...} et c'est réglé. Règle générale : un ! en TypeScript n'est jamais une solution, c'est un pari.

La lecture des données

Le fichier le plus court du projet, et le plus facile à sous-estimer. Ce qu'il faut y regarder n'est pas le code mais la frontière qu'il pose : le seul endroit où les JSON entrent dans l'application.

lib/donnees.ts
import servicesBruts from '@/data/services.json';
import configurationsBrutes from '@/data/form.json';
import type { FormConfig } from './types';

// UNE seule porte d'entrée pour les données. La page et les routes d'API
// importent d'ici : si la source change (fichier → base de données),
// un seul fichier bouge.
export function readServices(): string[] {
  return servicesBruts;
}

export function readFormConfigs(): FormConfig[] {
  // Le JSON importé est typé structurellement par TypeScript, mais il ne
  // connaît pas notre union discriminée : on affirme le contrat ici, en un
  // seul endroit identifiable, plutôt que dans dix composants.
  return configurationsBrutes as FormConfig[];
}

Next.js sait importer un fichier JSON directement — inutile de passer par fs tant que les données ne changent pas à l'exécution.

Ce fichier a l'air trivial, et c'est pour ça qu'il vaut des points : il crée un point de passage unique vers les données. Trois bénéfices concrets — la conversion de type douteuse (as FormConfig[]) est isolée sur une ligne visible plutôt que dispersée ; le jour où les données viendraient d'une vraie base, seul ce fichier changerait ; et les routes d'API du bonus 1 s'appuieront dessus sans rien dupliquer.

Un mot sur ce as, puisqu'on vient de dire du mal des assertions. Celle-ci est d'une autre nature : elle ne masque pas un cas d'absence, elle déclare un contrat sur des données externes que le compilateur ne peut pas vérifier seul — inévitable à la frontière du système. Ce qui est évitable, c'est de la répéter partout. Et si le sujet autorisait une dépendance, la réponse propre serait de valider ce JSON avec un schéma (Zod par exemple) : l'affirmation deviendrait une vérification réelle. Le dire à l'oral vaut autant que le faire.

La page serveur

Après la logique pure, l'interface. La page est le premier fichier que le navigateur demande, et elle ne fait presque rien — c'est justement ce qu'il faut remarquer.

app/page.tsx
import { PriseDeRendezVous } from '@/components/PriseDeRendezVous';
import { readServices, readFormConfigs } from '@/lib/data';

// Server Component : ce code s'exécute sur le serveur, son JS ne part
// jamais dans le navigateur. Pas de useState ici, et c'est voulu.
export default function PageAccueil() {
  const services = readServices();
  const configurations = readFormConfigs();

  return (
    <main className="page">
      <h1>Prendre rendez-vous</h1>
      <p className="page__intro">
        Choisis un service pour afficher le formulaire correspondant.
      </p>

      {/* Les données descendent en props vers la frontière client. */}
      <PriseDeRendezVous
        services={services}
        configurations={configurations}
        initialService=""
      />
    </main>
  );
}

La page ne fait que deux choses : charger, et passer. C'est exactement ce qu'on attend d'un Server Component.

Tu reconnais La page qui n'existe qu'une fois servie, du module Rendu & data fetching : pas de useState, pas de useEffect, pas de spinner — la page n'est rendue qu'une fois les données là. Une seule différence avec la formule, et elle vaut d'être dite à l'oral : il n'y a pas d'await ici, parce que readServices() lit un import local, résolu instantanément. Le patron est le même ; c'est la source de données qui est plus rapide.

Deux remarques à placer à l'oral. D'abord, toutes les configurations descendent au client, pas seulement celle du service choisi : arbitrage assumé, le fichier fait trois kilo-octets et le passer en entier rend le changement de service instantané. S'il faisait deux mégaoctets, le calcul s'inverserait et on ne descendrait que la configuration nécessaire — au prix d'un rechargement ou d'un appel à chaque changement. Savoir énoncer le seuil qui ferait basculer la décision est plus impressionnant que la décision elle-même.

Ensuite, il n'y a pas de loading.tsx, et c'est délibéré. loading.tsx et <Suspense> servent à afficher quelque chose pendant qu'une donnée lente arrive ; ici les données sont un import local, résolu instantanément. Le dire ainsi montre que tu connais l'outil et son critère d'emploi — ce qui vaut mieux que de le saupoudrer partout.

La frontière client

Le fichier central de la solution. Trois choses à y suivre du regard : l'unique 'use client', la configuration calculée et non stockée, et les trois affichages conditionnels — rien de choisi, rien de trouvé, formulaire.

components/PriseDeRendezVous.tsx
'use client';   // ← ICI commence le sous-arbre client. Une seule fois.

import { useState } from 'react';
import { ServiceSelector } from './ServiceSelector';
import { DynamicForm } from './DynamicForm';
import { findFormConfig } from '@/lib/configuration';
import type { FormConfig } from '@/lib/types';

type Props = {
  services: string[];
  configurations: FormConfig[];
  initialService: string;   // "" sur "/", le service pré-sélectionné sur "/[service]"
};

export function PriseDeRendezVous({ services, configurations, initialService }: Props) {
  // Ce composant possède le service choisi : ses DEUX enfants en dépendent.
  const [service, setService] = useState(initialService);

  // Valeur DÉRIVÉE, recalculée au rendu : pas un état, donc rien à synchroniser.
  const configuration = service ? findFormConfig(configurations, service) : undefined;

  return (
    <section className="prise-rdv">
      <ServiceSelector services={services} value={service} onChange={setService} />

      {/* Exigence 2 : le formulaire n'existe pas tant qu'aucun service n'est choisi. */}
      {!service && (
        <p className="prise-rdv__vide">Aucun service sélectionné.</p>
      )}

      {/* Le cas d'absence, traité explicitement plutôt qu'assené d'un `!`. */}
      {service && !configuration && (
        <p role="alert" className="prise-rdv__erreur">
          Aucun formulaire n’est disponible pour « {service} ».
        </p>
      )}

      {service && configuration && (
        // key={service} : changer de service DÉMONTE l'ancien formulaire et
        // en monte un neuf, avec des valeurs vierges. Voir l'explication dessous.
        <DynamicForm key={service} service={service} configuration={configuration} />
      )}
    </section>
  );
}

Quarante lignes qui contiennent les exigences 1 et 2, le cas de repli, et le piège du remontage. C'est le fichier à connaître par cœur pour l'oral.

Le point le plus intéressant est celui qu'on voit le moins : configuration n'est pas un état, c'est une valeur dérivée, recalculée à chaque rendu. On aurait pu écrire un deuxième useState et un useEffect qui le met à jour — le réflexe le plus répandu chez les débutants, et strictement pire : deux sources de vérité à synchroniser, un rendu de plus, et une fenêtre pendant laquelle l'état affiche encore l'ancienne configuration. La règle : si une valeur peut se calculer à partir de l'état existant, ce n'est pas un état.

Le second point est le key={service}, la technique du « remontage par la key » vue dans le module Formulaires 1 — contrôlé ou non. Sans elle, React voit le même composant à la même place dans l'arbre, conclut « c'est le même élément » et conserve son état : tu remplis le formulaire d'imagerie dentaire, tu passes à l'échographie, et tes anciennes valeurs sont toujours là. En changeant la key, tu dis à React « ce n'est plus le même élément » : il démonte l'ancien sous-arbre, en monte un neuf, l'état repart de zéro. Un attribut, zéro useEffect.

✍️ Exercice de lecture

Une version du composant a été livrée sans la key. Tout le reste est identique.

{service && configuration && (
  <DynamicForm service={service} configuration={configuration} />
)}

Questions : (1) Que voit exactement l'utilisateur qui remplit le formulaire « Dental Imaging » puis bascule sur « Ultrasound » ? (2) Et s'il bascule ensuite sur « Bone Scan » ? (3) Quelle est la correction, en un mot ?

Voir le corrigé

(1) Le titre change bien (« Radiology appointment »), la liste des champs aussi — « Country » disparaît, « Details » apparaît. Mais les valeurs déjà saisies restent : prénom, nom, email et téléphone conservent ce qui avait été tapé pour le rendez-vous dentaire. React a conservé l'état parce que, de son point de vue, c'est le même composant au même endroit de l'arbre — comportement documenté, pas un bug, et même souvent ce qu'on veut.

(2) Là, ça devient bancal. « Bone Scan » tombe sur le repli, qui n'a que deux champs. Les valeurs de email, phone_number et details existent toujours dans l'état, invisibles à l'écran. Si le console.log de l'exigence 4 affiche l'objet complet, on journalise des valeurs fantômes qui ne font pas partie du formulaire soumis — un bug silencieux qu'un évaluateur repère en deux secondes.

(3) key. Précisément key={service} sur <DynamicForm>. Alternative acceptable : un useEffect qui réinitialise les valeurs quand la configuration change — mais c'est plus de code, un rendu de plus, et ça ne remet pas à zéro les états internes que le formulaire pourrait avoir en plus. La key est la réponse idiomatique, et savoir dire « c'est le remontage par la clé » vaut des points à l'oral.

🧠 Quiz éclair

Quelqu'un retire la configuration "*" de form.json, puis choisit « Bone Scan ». Déroule : que voit l'utilisateur, et quelle ligne décide de ce qu'il voit ?

const configuration = service ? findFormConfig(configurations, service) : undefined;

{!service && <p>Aucun service sélectionné.</p>}

{service && !configuration && (
  <p role="alert">Aucun formulaire n’est disponible pour « {service} ».</p>
)}

{service && configuration && (
  <DynamicForm key={service} service={service} configuration={configuration} />
)}

Les deux recherches de findFormConfig échouent — aucune configuration ne nomme « Bone Scan », et il n'y a plus de joker —, donc la fonction rend undefined. Le premier bloc est écarté (un service est choisi), le troisième aussi, et c'est la deuxième condition qui l'emporte : un message nommant le service, dans un élément role="alert" donc réellement annoncé. Rien ne plante, rien n'est vide, l'utilisateur sait pourquoi.

La ligne qui décide, ce n'est pas le if — c'est la signature de findFormConfig, qui admet undefined. C'est elle qui a rendu ce troisième affichage obligatoire : sans elle, le compilateur n'aurait rien réclamé. Avec un findFormConfig(...)!, tout se serait compilé pareil et le même scénario aurait produit un Cannot read properties of undefined (reading 'fields') quelque part dans le rendu, à mille lieues de la cause.

Le raisonnement à emporter : un type honnête ne « documente » pas une limite, il force à la traiter, une fois, à l'endroit où on peut faire quelque chose d'utile.

Le sélecteur de service

Le plus simple des quatre composants. Deux détails à repérer : l'option vide en tête de liste, et la forme du onChange exposé au parent.

components/ServiceSelector.tsx
type Props = {
  services: string[];
  value: string;
  onChange: (service: string) => void;
};

// Pas de 'use client' : ce composant est importé par un composant client,
// il fait donc déjà partie du sous-arbre client.
export function ServiceSelector({ services, value, onChange }: Props) {
  return (
    <div className="field">
      {/* htmlFor ↔ id : taper sur l'étiquette ouvre la liste. */}
      <label htmlFor="service">Service</label>

      <select
        id="service"
        name="service"
        value={value}
        onChange={(e) => onChange(e.target.value)}
      >
        {/* L'option vide EST l'état « rien de choisi ». Sans elle, le premier
            service serait sélectionné d'office et l'exigence 1 tomberait. */}
        <option value="">— Choisir un service —</option>
        {services.map((s) => (
          // key = le nom du service : unique et stable, bien mieux qu'un index.
          <option key={s} value={s}>{s}</option>
        ))}
      </select>
    </div>
  );
}

Un composant contrôlé et sans état : il reçoit sa valeur, il signale les changements, il ne retient rien. C'est le composant le plus facile à tester du projet.

L'option vide est un détail que beaucoup ratent. Un <select> sans option vide affiche toujours sa première option : l'utilisateur voit « Bone Scan » sélectionné sans l'avoir choisi, et l'exigence 1 — « la liste conditionne l'accès au formulaire » — n'a plus de sens puisque le formulaire est là dès l'arrivée. L'option de valeur vide donne à l'état « rien de choisi » une existence dans l'interface : c'est le pendant visuel de la chaîne vide dans useState('').

Note aussi que onChange a la signature (service: string) => void, pas celle d'un gestionnaire d'événement React : celui qui l'utilise reçoit un nom de service, pas un ChangeEvent dont il devrait extraire e.target.value. Petite courtoisie d'API, et le genre de détail qui distingue un composant réutilisable d'un bout de JSX déplacé dans un fichier.

🧠 Quiz éclair

Deux composants du même projet exposent leur onChange de deux façons opposées. Est-ce une incohérence à corriger ?

// ServiceSelector : reçoit déjà la valeur, extraite pour lui
onChange: (service: string) => void;

// Field : reçoit l'événement brut, à charge pour l'appelant de l'ouvrir
onChange: (
  e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>,
) => void;

Non : chacun expose ce dont son appelant a besoin, et c'est ça, concevoir une API.

Le sélecteur pilote une seule valeur. Son parent veut un nom de service, pas un ChangeEvent dont il devrait extraire e.target.value : le composant fait ce petit travail une fois pour toutes et livre le résultat. Le champ, lui, est rendu en boucle sur N configurations et remonte à un gestionnaire unique — celui de la formule « un état pour tout le formulaire ». Ce gestionnaire a besoin de savoir quelle clé mettre à jour : il la lit dans e.target.name. Lui passer seulement la valeur l'obligerait à créer une fonction par champ pour se rappeler de qui elle vient.

La règle : on ne choisit pas une signature « par cohérence », on la choisit d'après ce que le consommateur doit faire du résultat. Et c'est exactement le genre de micro-décision que l'oral adore — la mauvaise réponse serait « j'ai uniformisé », qui sonne bien et dégrade les deux.

Le formulaire dynamique

Le cœur. Il assemble deux formules déjà connues : « Un état pour tout le formulaire » (Formulaires 1) et « Le validateur pur » (Formulaires 2). On ne les réexplique pas ; on les branche.

components/DynamicForm.tsx
'use client';

import { useState } from 'react';
import { Field } from './Field';
import { validate } from '@/lib/validation';
import type { FormConfig, FormErrors, FormValues } from '@/lib/types';

// L'objet initial est construit DEPUIS la configuration : toutes les clés
// existent dès le premier rendu, donc aucun champ ne bascule de contrôlé
// à non contrôlé en cours de route.
function emptyValues(config: FormConfig): FormValues {
  return Object.fromEntries(config.fields.map((c) => [c.name, '']));
}

type Etat = 'repos' | 'envoi' | 'succes' | 'echec';

export function DynamicForm({
  service,
  configuration,
}: {
  service: string;
  configuration: FormConfig;
}) {
  const [values, setValues] = useState(() => emptyValues(configuration));
  const [errors, setErrors] = useState<FormErrors>({});
  const [etat, setEtat] = useState<Etat>('repos');

  // « Un état pour tout le formulaire » : UN handler pour N champs,
  // c'est l'attribut `name` qui désigne la clé à mettre à jour.
  function handleChange(
    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>,
  ) {
    const { name, value } = e.target;
    setValues({ ...values, [name]: value });
    // On efface l'erreur du champ qu'on est en train de corriger.
    if (errors[name]) setErrors({ ...errors, [name]: undefined });
  }

  // ... handleSubmit juste en dessous
}

Rien de neuf : c'est la formule de l'état unique, appliquée à une liste de champs qu'on ne connaît qu'à l'exécution.

Deux détails valent le détour. L'initialisation paresseuse useState(() => emptyValues(configuration)) : la fonction n'est appelée qu'au premier rendu, alors que useState(emptyValues(configuration)) l'appellerait à chaque rendu pour jeter le résultat aussitôt. Sur cinq champs l'économie est ridicule ; le réflexe, lui, ne l'est pas. Et construire l'objet depuis la configuration garantit que toutes les clés existent dès le départ — l'antidote au piège du champ qui bascule de contrôlé à non contrôlé, décrit dans Formulaires 1.

La soumission, maintenant, qui porte à elle seule les exigences 4 et 5 :

components/DynamicForm.tsx (suite)
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();          // pas de rechargement de page
    if (etat === 'envoi') return; // garde anti double-envoi

    const found = validate(configuration.fields, values);
    setErrors(found);
    if (Object.keys(found).length > 0) return;

    // EXIGENCE 4 : journaliser toutes les valeurs à la soumission.
    console.log('Rendez-vous soumis', { service, titre: configuration.title, values });

    setEtat('envoi');
    try {
      const res = await fetch('/api/submissions', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ service, values }),
      });
      // fetch ne rejette PAS sur un 4xx/5xx : on vérifie res.ok nous-mêmes.
      if (!res.ok) throw new Error(`Envoi refusé (${res.status})`);

      setValues(emptyValues(configuration));  // EXIGENCE 5 : reset après succès
      setErrors({});
      setEtat('succes');
    } catch {
      setEtat('echec');
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <h2>{configuration.title}</h2>

      {configuration.fields.map((field) => (
        <Field
          key={field.name}                 // stable et unique : pas l'index
          field={field}
          value={values[field.name] ?? ''}
          error={errors[field.name]}
          onChange={handleChange}
        />
      ))}

      <button type="submit" disabled={etat === 'envoi'}>
        {etat === 'envoi' ? 'Envoi en cours…' : 'Réserver'}
      </button>

      {etat === 'succes' && <p role="status">Ta demande a bien été envoyée.</p>}
      {etat === 'echec' && <p role="alert">Envoi impossible. Réessaie dans un instant.</p>}
    </form>
  );

Chacune des lignes de ce gestionnaire se justifie en une phrase à l'oral. C'est exactement ce qu'on attend de toi.

Quelques décisions à savoir défendre. La réinitialisation passe par setValues(emptyValues(configuration)) et non par e.currentTarget.reset() : les champs sont contrôlés, c'est donc l'état qui les vide, et repartir de l'objet complet évite le piège des clés manquantes. Le noValidate sur le <form> désactive les bulles natives du navigateur : on garde les attributs sémantiques mais on affiche nos messages — l'arbitrage exposé dans Formulaires 2. Enfin, role="status" pour le succès et role="alert" pour l'échec : le premier est annoncé quand le lecteur d'écran a fini sa phrase, le second interrompt. La différence de politesse correspond à la différence d'urgence.

Le console.log de l'exigence 4 est placé après la validation et avant l'envoi, et ça s'explique : une soumission refusée par la validation n'est pas une soumission, donc journaliser avant remplirait la console de tentatives invalides ; journaliser après le fetch ferait dépendre l'exigence 4 du bonus 1 — réseau en panne, plus de journal. Là où il est, il est indépendant des bonus. Typiquement le genre de micro-décision qu'on te demandera de justifier.

🧠 Quiz éclair

Puisque changer la key vide le formulaire, pourquoi ne pas s'en servir aussi pour l'exigence 5 — la remise à zéro après un envoi réussi — au lieu de setValues(emptyValues(configuration)) ?

Parce que ça détruirait ce qu'on vient de gagner. Changer la key ne vide pas un composant : il le démonte et en monte un autre. Tout son état repart de zéro — values, oui, mais aussi errors et etat. Or c'est etat === 'succes' qui affiche « Ta demande a bien été envoyée » : le composant qui doit annoncer la réussite serait détruit à l'instant même où il l'apprend. L'utilisateur verrait un formulaire vide sans le moindre message, et se demanderait si son envoi est parti.

La distinction vaut au-delà de ce cas : la key répond à « ce n'est plus la même chose » — un autre service, donc un autre formulaire. Ici on est dans « c'est la même chose, dans un nouvel état » — même formulaire, champs remis à blanc, message de succès affiché. Deux intentions différentes, deux outils différents. Et un indice fiable : dès que tu veux garder un morceau d'état à travers l'opération, le remontage n'est pas le bon outil.

Le composant Champ : le traducteur

Dernier étage, et le seul endroit où le vocabulaire de la configuration rencontre celui du HTML : la découverte n° 1 — "phone" devient type="tel", "dropdown" devient un <select> — plus le trio d'accessibilité appliqué une fois pour tous les champs.

components/Champ.tsx
import type { FieldConfig } from '@/lib/types';

type Props = {
  field: FieldConfig;
  value: string;
  error?: string;
  onChange: (
    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>,
  ) => void;
};

export function Field({ field, value, error, onChange }: Props) {
  const errorId = `${field.name}-error`;

  // Les props partagées par TOUS les contrôles : on les écrit une fois.
  const common = {
    id: field.name,
    name: field.name,
    value,
    onChange,
    'aria-invalid': error ? true : undefined,
    'aria-describedby': error ? errorId : undefined,
  };

  return (
    <div className="field">
      <label htmlFor={field.name}>{field.label}</label>

      {/* Le SEUL endroit du projet qui traduit config → HTML. */}
      {field.type === 'dropdown' ? (
        <select {...common}>
          <option value="">—</option>
          {/* Grâce à l'union discriminée, `options` est garanti présent ICI. */}
          {field.options.map((o) => (
            <option key={o} value={o}>{o}</option>
          ))}
        </select>
      ) : field.type === 'textarea' ? (
        <textarea {...common} rows={4} />
      ) : field.type === 'phone' ? (
        // "phone" côté config, "tel" côté HTML : la traduction qui compte.
        <input {...common} type="tel" inputMode="tel" autoComplete="tel" />
      ) : field.type === 'email' ? (
        <input {...common} type="email" autoComplete="email" />
      ) : (
        <input {...common} type="text" />
      )}

      {error && (
        <p id={errorId} role="alert" className="field__error">
          <span aria-hidden="true">⚠</span> {error}
        </p>
      )}
    </div>
  );
}

Un composant, cinq types de champ, et l'accessibilité appliquée une fois pour tout le formulaire.

Le fichier le plus dense du projet. L'objet common rassemble tout ce qui ne dépend pas du type — identifiant, nom, valeur, gestionnaire, les deux attributs ARIA — et on l'étale dans chaque variante avec {...common}. Le bénéfice n'est pas la concision mais la garantie d'uniformité : impossible d'oublier aria-describedby sur le <textarea>, puisqu'un seul endroit le pose.

La cascade de ternaires est lisible ici, mais un switch dans une petite fonction renderControl(champ, common) le serait tout autant, et vieillirait mieux si le nombre de types grandissait. Les deux formes sont défendables — ce qui ne l'est pas, c'est de disperser cette traduction dans plusieurs fichiers. Le principe : une correspondance entre deux vocabulaires doit vivre à un seul endroit, sinon elle diverge.

Le trio d'accessibilité — label htmlForid, aria-invalid, aria-describedby plus role="alert" — est repris tel quel de Formulaires 2. Il coûte trois attributs et il est systématiquement noté : sur un formulaire de rendez-vous médical, dont le public compte plus de personnes âgées et en situation de handicap que la moyenne, ce n'est pas un raffinement.

✍️ Exercice de lecture

Un candidat a écrit la version courte et élégante du traducteur : puisque la configuration contient déjà un type, autant le passer directement au HTML.

<input {...common} type={field.type} />

Sur son écran, tout paraît normal. En testant l'application sur son téléphone, il remarque que le clavier ne change pas quand il tape dans le champ « Phone Number » : c'est le clavier alphabétique habituel, pas le pavé numérique.

Questions : (1) Pourquoi le clavier ne change-t-il pas ? (2) Pourquoi n'y a-t-il aucune erreur, ni dans la console, ni à la compilation ? (3) Qu'arrive-t-il aux champs dropdown et textarea ?

Voir le corrigé

(1) Parce que type="phone" n'existe pas en HTML. Le type téléphone s'appelle tel. Le navigateur reçoit une valeur qu'il ne reconnaît pas et applique la règle prévue par la spécification pour ce cas : une valeur invalide de l'attribut type retombe sur texte. Le champ se comporte donc exactement comme un <input type="text"> — clavier alphabétique compris. La correction : type="tel", et tant qu'à faire inputMode="tel" (qui pilote finement le clavier virtuel) et autoComplete="tel" (qui permet le remplissage automatique).

(2) Parce que personne ne se plaint. TypeScript : sur un <input>, l'attribut type est typé string dans les définitions de React, donc n'importe quelle chaîne passe. React : il transmet l'attribut au DOM sans le juger. Le navigateur : retomber sur text est le comportement correct selon la spécification. Trois couches qui pourraient t'avertir, trois couches qui se taisent — la définition même du bug silencieux, et la raison pour laquelle la lecture des données comptait tant.

(3) Encore pire, et cette fois visible. Ils deviennent <input type="dropdown"> et <input type="textarea">, donc par la même règle de repli deux champs texte d'une seule ligne : « Country » perd sa liste Canada/USA, « Details » perd ses quatre lignes. Les données saisies restent lisibles, donc un test rapide ne détecte rien — mais la génération fidèle des champs, exigence centrale, est ratée.

🧠 Quiz éclair

La clinique ajoute un champ à form.json. Aucune ligne de code n'est touchée. Suis-le à travers les trois fichiers concernés : où atterrit-il, et lequel de ces fichiers aurait dû crier ?

{ "name": "birth_date", "label": "Date de naissance", "type": "date" }

Lecture des données : le fichier est importé puis converti d'un trait, configurationsBrutes as FormConfig[]. Une conversion affirme, elle ne vérifie rien : "date" passe sans un mot, alors que le type FieldType est pourtant une liste fermée de cinq valeurs. C'est le seul endroit qui savait, et c'est celui qui s'est tu.

Validation : la boucle exige une valeur non vide, puis teste type === 'email' et type === 'phone'. Ni l'un ni l'autre : le champ est simplement « requis », sans aucune règle de format. On peut y écrire « demain ».

Champ : la cascade de ternaires ne reconnaît pas "date" et tombe dans son dernier else : <input type="text">. À l'écran, une jolie boîte de texte libre à la place du sélecteur de date natif — et sur mobile, pas de calendrier.

Résultat : rien ne plante, personne n'est prévenu, et le formulaire est subtilement faux. Deux corrections, dans cet ordre : valider le JSON à la frontière avec un schéma plutôt que l'affirmer, pour que la donnée inconnue soit refusée là où elle entre ; et remplacer la cascade par un switch avec une garde d'exhaustivité, pour qu'un nouveau type de champ empêche la compilation au lieu de se déguiser en texte. Le repli discret est confortable jusqu'au jour où il masque une exigence ratée.

La validation générique

L'énoncé ne demande pas de validation : elle relève du bonus 6. Elle est pourtant l'un des meilleurs investissements du test, parce que la configuration te donne gratuitement tout ce qu'il faut — un libellé pour composer le message, un type pour choisir la règle. Douze lignes, sur la formule « Le validateur pur ».

lib/validation.ts
import type { Field, FormErrors, FormValues } from './types';

// Fonction PURE : des données en entrée, des données en sortie.
// Aucune référence à React, donc testable en trois lignes avec Vitest.
export function validate(fields: Field[], values: FormValues): FormErrors {
  const errors: FormErrors = {};

  for (const champ of fields) {
    const value = (values[field.name] ?? '').trim();

    if (!value) {
      errors[field.name] = `${field.label} est requis.`;
      continue;   // un seul message par champ, le plus pertinent
    }
    if (field.type === 'email' && !/.+@.+\..+/.test(value)) {
      errors[field.name] = 'Courriel incomplet. Exemple : nom@exemple.ca';
    }
    if (field.type === 'phone' && value.replace(/\D/g, '').length < 10) {
      errors[field.name] = 'Un numéro à 10 chiffres est attendu.';
    }
  }

  return errors;   // {} = tout est valide
}

Les règles vivent dans un seul fichier, à côté du reste de la logique pure. Aucun composant ne sait ce qu'est un email valide.

Le paramètre fields vient de la configuration, donc la fonction valide n'importe quel formulaire, y compris ceux qui n'existent pas encore. La regex de email est volontairement permissive, pour la raison exposée dans Formulaires 2 : refuser une adresse valide est un bug plus grave qu'accepter une adresse douteuse. Et le continue après « requis » applique la règle du message unique par champ.

Un mot sur les tests, puisque Vitest fait partie du stack imposé. Tu n'auras pas le temps d'une suite complète, mais deux fichiers bien choisis valent beaucoup : écris-les sur findFormConfig et validate, des fonctions pures, sans React, sans DOM, sans mock. Trois cas pour la première (correspondance exacte, repli, aucun des deux), trois pour la seconde. Cinq minutes, et tu passes de « n'a pas testé » à « a testé ce qui devait l'être » — le raisonnement par le risque du module Tester le web.

Les bonus, dans l'ordre où ils rapportent

Les cinq exigences terminées et vérifiées, il te reste peut-être quarante minutes. Le réflexe est de descendre la liste dans l'ordre annoncé ; le bon calcul est de la reclasser par rapport valeur / coût.

BonusCoût réelCe qu'il démontreVerdict en 2 h
1. Routes d'API~15 minTu connais les Route Handlers et la frontière serveur/client.À faire
3. Pré-sélection par l'URL~15 minTu maîtrises le routage dynamique et tu sais factoriser deux routes.À faire
6. Validation + a11y~15 minTu penses à l'utilisateur, pas seulement au cas idéal.À faire
5. Historique des soumissions~20 minPeu de neuf si le bonus 1 est fait : le POST existe déjà, il ne reste qu'un GET et une liste.Si le temps le permet
4. Persistance du formulaire~25 minPeu, et deux pièges : localStorage n'existe pas sur le serveur (lecture en useEffect, sinon erreur d'hydratation), et la donnée persistée devient une seconde source de vérité à invalider quand la configuration change.Plutôt non
2. CRUD des services1 h et plusBeaucoup d'interface, peu de rapport avec le cœur du sujet.Piège à temps

Le CRUD est classé dernier alors que l'énoncé le place deuxième : il demande une liste, un formulaire d'ajout, une suppression avec confirmation, une édition, un état partagé, et il soulève une question désagréable — que deviennent les formulaires quand on supprime un service ? C'est une deuxième application greffée sur la première. Le classement de l'énoncé reflète l'intérêt métier, pas le coût de développement. Un candidat qui explique ce raisonnement à l'oral marque plus de points que celui qui aurait bâclé le CRUD.

Le bonus qui rapporte le plus : les Route Handlers

C'est le bonus 1 de l'énoncé, et ce n'est pas un hasard s'il est listé en premier : il transforme des données importées en dur en une vraie frontière réseau, ce qui est exactement la compétence qu'un évaluateur cherche à voir. On saute le bonus 2 (le CRUD des services), classé « plutôt non » dans le tableau valeur/coût plus haut — beaucoup de code pour une fonctionnalité que l'énoncé ne met pas en avant.

Un Route Handler est un fichier route.ts dans un dossier de app/, qui exporte des fonctions portant le nom d'une méthode HTTP. Elles reçoivent une Request standard et renvoient une Response standard — les objets du web, pas une API maison (module Route Handlers & Server Actions).

app/api/services/route.ts
import { readServices } from '@/lib/data';

// Un export nommé GET = la route répond à GET /api/services.
export async function GET() {
  // Response.json met le bon Content-Type et sérialise pour toi.
  return Response.json(readServices());
}

Six lignes : la route ne relit pas les fichiers, elle réexpose lib/data.ts.

La route des soumissions est plus intéressante, parce qu'elle reçoit quelque chose. Regarde-y deux choses : le stockage en mémoire assumé comme une limite, et le contrôle du corps de requête avant toute écriture.

app/api/submissions/route.ts
type Soumission = {
  id: string;
  receivedAt: string;
  service: string;
  values: Record<string, string>;
};

// Stockage en mémoire, comme l'autorise l'énoncé. Limite ASSUMÉE : le tableau
// repart à zéro à chaque redémarrage et n'est pas partagé entre instances.
const submissions: Soumission[] = [];

export async function GET() {
  return Response.json(submissions);
}

export async function POST(request: Request) {
  const body = await request.json();

  // Ne JAMAIS faire confiance au corps d'une requête, même à sa propre interface.
  if (typeof body?.service !== 'string' || typeof body?.values !== 'object') {
    return Response.json({ error: 'Corps de requête invalide.' }, { status: 400 });
  }

  const submission: Soumission = {
    id: crypto.randomUUID(),
    receivedAt: new Date().toISOString(),
    service: body.service,
    values: body.values,
  };
  submissions.push(submission);

  // 201 Created : la ressource a été créée. Pas 200.
  return Response.json(submission, { status: 201 });
}

Deux méthodes, un contrôle d'entrée, deux codes de statut choisis exprès. C'est tout ce qu'on attend d'une route d'API dans cet exercice.

Cette fonction POST est Le handler POST honnête du module Route Handlers & Server Actions, appliqué tel quel : on lit (await request.json()), on valide avant d'écrire (le 400), on agit (submissions.push), on répond avec le bon statut (201). Les quatre gestes, toujours dans cet ordre — et c'est dans cet ordre que tu les cherches, aussi, quand c'est le handler de quelqu'un d'autre que tu relis.

Vient la question qui sépare les candidats : faut-il que la page appelle ces routes ? Non — et savoir dire pourquoi vaut plus que le bonus lui-même. La page est un Server Component : elle s'exécute déjà sur le serveur. Lui faire appeler fetch('http://localhost:3000/api/services') reviendrait à faire sortir le serveur pour se rappeler lui-même par le réseau — sérialisation, requête HTTP, désérialisation — afin d'obtenir une donnée qu'il pouvait lire en mémoire. Aller-retour gratuit, et source d'ennuis puisqu'il faut connaître sa propre URL absolue.

La bonne lecture du bonus : la page importe lib/data.ts, et les Route Handlers exposent la même source à ceux qui ne peuvent pas l'importer — un client JavaScript, une autre application, un outil de test. lib/data.ts reste la source unique ; l'API est une façade. Si l'évaluateur insiste (« l'énoncé disait de remplacer les imports »), c'est une divergence assumée et argumentée, pas un oubli.

🧭 Et les Server Actions ?

Next.js offre une autre façon d'envoyer des données au serveur : la Server Action, une fonction marquée 'use server' qu'on branche directement sur <form action={...}> et qui reçoit un FormData. Pas de fetch, pas d'URL, pas de sérialisation manuelle.

'use server';

export async function saveAppointment(data: FormData) {
  const service = String(data.get('service') ?? '');
  // ... on enregistre, on renvoie un résultat
}

Pourquoi ne pas l'avoir utilisée ? À cause de l'exigence 4 : une Server Action s'exécute sur le serveur, donc le console.log demandé apparaîtrait dans le terminal, pas dans la console du navigateur. L'exigence oriente vers un gestionnaire côté client. Excellent sujet de discussion à l'oral : connaître l'outil et la raison précise de ne pas l'avoir choisi est plus convaincant que de l'employer par mode.

Le bonus le plus rapide : la pré-sélection depuis l'URL

C'est le bonus 3 de l'énoncé — celui qui coûte le moins pour ce qu'il démontre, d'où sa place ici juste après le premier.

Le but : que /bone-scan ouvre la page avec « Bone Scan » déjà choisi et son formulaire affiché. Deux choses à régler — la traduction entre un nom de service et un segment d'URL, puis la structure des routes.

Rappelle-toi la remarque faite en lisant services.json : les services sont des chaînes, pas des identifiants, et "Radiography (X-Ray)" ne peut pas aller telle quelle dans une URL. Il faut un slug — minuscule, sans accent, sans parenthèse, avec des tirets — et surtout la traduction inverse.

lib/configuration.ts (suite)
// "Radiography (X-Ray)" → "radiography-x-ray"
export function toSlug(service: string): string {
  return service
    .toLowerCase()
    .normalize('NFD')                    // sépare les lettres de leurs accents
    .replace(/[\u0300-\u036f]/g, '')  // supprime les accents détachés
    .replace(/[^a-z0-9]+/g, '-')         // tout le reste devient un tiret
    .replace(/^-+|-+$/g, '');            // pas de tiret au début ni à la fin
}

// On ne "dé-slugifie" PAS : on re-slugifie chaque service et on compare.
// C'est la seule manière fiable, parce que la transformation perd de l'information.
export function serviceFromSlug(services: string[], slug: string): string | undefined {
  return services.find((s) => toSlug(s) === slug);
}

La fonction inverse n'existe pas — alors on ne l'invente pas. On compare des slugs entre eux, ce qui est toujours exact.

Ce commentaire cache une idée qui vaut pour toute ta carrière : quand une transformation perd de l'information (ici les majuscules, les espaces, les parenthèses), il n'existe pas d'inverse fiable — reconstruire le nom d'origine produirait « Radiography X Ray ». La solution robuste est toujours la même : appliquer la même transformation aux deux côtés et comparer les résultats.

Reste la structure des routes, où se joue le vrai point du bonus. La tentation est de créer app/[service]/page.tsx en copiant app/page.tsx et en changeant deux lignes : la duplication la plus coûteuse possible, deux pages presque identiques qui vont diverger au premier changement, et qu'un diff suffit à révéler. Les deux routes ne sont que des portes d'entrée différentes vers le même composant — c'est précisément pour ça que PriseDeRendezVous reçoit une prop initialService.

app/[service]/page.tsx
import { notFound } from 'next/navigation';
import { PriseDeRendezVous } from '@/components/PriseDeRendezVous';
import { readServices, readFormConfigs } from '@/lib/data';
import { serviceFromSlug, toSlug } from '@/lib/configuration';

// `params` est une PROMESSE dans l'App Router moderne : on l'attend.
export default async function PageService({
  params,
}: {
  params: Promise<{ service: string }>;
}) {
  const { service: slug } = await params;

  const services = readServices();
  const initialService = serviceFromSlug(services, slug);

  // Un slug inconnu n'est pas une page vide : c'est un vrai 404.
  if (!initialService) notFound();

  return (
    <main className="page">
      <h1>Prendre rendez-vous</h1>
      <PriseDeRendezVous
        services={services}
        configurations={readFormConfigs()}
        initialService={initialService}
      />
    </main>
  );
}

// Facultatif : la liste des slugs connus, pour pré-générer les pages au build.
export async function generateStaticParams() {
  return readServices().map((s) => ({ service: toSlug(s) }));
}

Vingt-cinq lignes, et pas une seule dupliquée depuis app/page.tsx : tout le contenu vient du composant partagé.

Trois points à souligner. params est une promesse dans l'App Router moderne — const { service } = await params, et le composant est async. Changement récent détaillé dans le module Routing : l'écrire correctement montre que tu codes avec la documentation d'aujourd'hui, pas un tutoriel de 2022.

notFound() plutôt qu'un message. Un slug inexistant doit produire un vrai 404 : correct pour les moteurs de recherche, correct pour l'utilisateur, et ça évite d'inventer un troisième état d'interface. Attention, un segment dynamique à la racine est gloutonapp/[service] capture /n-importe-quoi —, donc le notFound() est ce qui empêche cette route d'avaler toutes les URL inconnues du site.

Enfin, une seule prop suffisait pour faire converger les deux routes — une décision qui paraît évidente une fois écrite mais qui se prend avant : c'est parce qu'on avait anticipé le bonus 3 en concevant PriseDeRendezVous qu'il n'y a rien eu à refactoriser. À l'oral, la phrase « j'ai prévu la prop initialService dès le départ pour que le bonus 3 ne demande qu'une page de vingt lignes » vaut de l'or.

🧠 Quiz éclair

La clinique renomme un service « CT Scan » et en garde un autre appelé « CT-Scan ». Que rend serviceFromSlug pour l'adresse /ct-scan, et qu'est-ce que ça révèle ?

export function toSlug(service: string): string {
  return service
    .toLowerCase()
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')  // supprime les accents détachés
    .replace(/[^a-z0-9]+/g, '-')       // tout le reste devient un tiret
    .replace(/^-+|-+$/g, '');
}

export function serviceFromSlug(services: string[], slug: string): string | undefined {
  return services.find((s) => toSlug(s) === slug);
}

Les deux noms produisent le même slug : l'espace et le tiret tombent tous deux dans [^a-z0-9]+. find rend donc le premier des deux dans l'ordre du fichier — et le second devient inatteignable par URL, définitivement, sans qu'aucune erreur ne soit levée. Ajoute que generateStaticParams émettrait deux fois le paramètre ct-scan.

Ce que ça révèle : une transformation qui perd de l'information ne garantit pas l'unicité de son résultat. Comparer des slugs entre eux reste la bonne technique — c'est toujours exact — mais elle repose sur une hypothèse que personne n'a vérifiée : « deux services distincts donnent deux slugs distincts ». Le jour où c'est faux, le bug est silencieux et se manifeste par une page qui montre le mauvais formulaire.

Le réflexe correspondant, et il vaut bien au-delà des URL : quand tu fabriques une clé à partir d'une donnée, demande-toi ce qui arrive en cas de collision. Ici, une boucle de contrôle au démarrage suffirait à détecter que deux services partagent un slug — ou, en amont, un vrai identifiant dans le fichier de données plutôt qu'un nom d'affichage utilisé comme clé.

Le coaching — la checklist des dix dernières minutes

Troisième et dernier genre : le coaching. On ne dissèque plus du code, on règle ta conduite le jour J. Arrête de coder dix minutes avant la fin : ces dix minutes rapportent plus que n'importe quelle fonctionnalité de plus, parce qu'elles portent sur ce que l'évaluateur regarde en premier.

  1. Fais le parcours complet, une fois, lentement. Depuis un npm run dev tout neuf : un service qui a une configuration dédiée, un service qui tombe sur le repli, remplir, soumettre, vérifier la console, vérifier que les champs se sont vidés. Si un seul geste échoue, corrige ça et rien d'autre.
  2. Ouvre la console du navigateur et regarde les avertissements. Un « each child in a list should have a unique key » ou un « changing an uncontrolled input to be controlled » qui traîne, c'est une question garantie à l'oral — et deux minutes à corriger.
  3. Rejoue les neuf points du module Lire et critiquer du code frontend sur ton propre code : res.ok, états de chargement et d'erreur, bouton désactivé, label htmlFor, div cliquables, états dérivés, effets sans effet, validation serveur, attributs d'image. Ils couvrent tout le versant « code » ; les points ci-dessous sont ceux que la remise ajoute.
  4. Uniformise les noms. Un projet où cohabitent handleSubmit, gererEnvoi et onSoumission donne une impression de patchwork. Les identifiants restent en anglais (values, errors, validate) ; tes commentaires suivent ta langue.
  5. Supprime le code mort et les console.log de débogage — sauf celui que l'exigence 4 demande. Un // TODO ou un import inutilisé coûte une remarque.
  6. Écris le README. Court : comment lancer, quelles exigences sont couvertes, quels bonus, quels choix techniques notables (une phrase chacun), et surtout ce que tu aurais fait avec plus de temps. Dix lignes, cinq minutes, et c'est le premier fichier que l'évaluateur ouvrira.

Se relire comme on relirait le code d'un autre est un exercice difficile mais très rentable — c'est exactement ce que fera l'évaluateur.

⚠️ Piège fréquent — se jeter sur les bonus

L'erreur la plus coûteuse du format, et de très loin. Le mécanisme est toujours le même : les exigences de base sont « presque finies », les bonus ont l'air plus intéressants, on bascule. Deux heures plus tard, on rend une route d'API élégante, un début de CRUD… et un formulaire qui ne se réinitialise pas après l'envoi.

Le calcul est pourtant simple : une exigence non remplie est une consigne non suivie — ça ne dit pas « il a manqué de temps », ça dit « il n'a pas lu, ou pas priorisé » — alors qu'un bonus absent, avec une ligne d'explication dans le README, ne dit rien de négatif. Tant que les cinq exigences ne sont pas terminées et vérifiées dans le navigateur, aucun bonus n'existe.

Corollaire pratique : fais un commit dès que les cinq exigences fonctionnent. Tu as un rendu valable garanti dans ta poche, et tout ce qui suit est du bonus au sens propre — y compris si tu casses tout en essayant.

Présenter son code à l'oral

Le moment qui décide : vingt à trente minutes, une ou deux personnes en face, ton écran partagé. Bonne nouvelle, l'exercice se prépare — il a une structure en cinq temps.

Cette mécanique, les questions pièges, comment parler de soi, le live coding, quoi faire après : c'est le sujet du module L'entrevue frontend, juste après celui-ci. Ce qui suit ici est son application à ce projet précis — les cinq temps remplis avec le contenu de RendezVous, tel que tu le dirais le jour J.

  1. Le problème, en une minute. Reformule l'énoncé avec tes mots : « l'idée est qu'un formulaire de clinique change complètement selon le service demandé, sans qu'un développeur touche au code : c'est une configuration JSON qui décide. » Cette minute prouve que tu as lu un problème, pas une liste de tâches.
  2. L'architecture, en une phrase. « Une page serveur charge les données, un composant client possède le service choisi, un formulaire possède ses valeurs, et un composant Champ traduit un objet de configuration en élément HTML. » Si tu ne peux pas décrire ton découpage en une phrase, c'est en général que le découpage est à revoir.
  3. Une fonctionnalité, tracée de bout en bout. Ne fais pas le tour des fichiers un par un : prends un parcours et suis-le. Le meilleur ici est le repli : « je choisis Bone Scan, ce service n'a aucune configuration dédiée — on passe donc par cette fonction, la correspondance exacte échoue, le joker prend le relais, et le formulaire générique s'affiche. » Tu montres que tu as lu les données, vu le cas limite, et traité exprès.
  4. Un choix technique assumé, avec son alternative. Présente-le avec les deux options et le critère qui a tranché : « j'ai fait descendre toutes les configurations au client plutôt que la seule du service choisi ; ça coûte trois kilo-octets et ça rend le changement instantané. Au-delà de quelques centaines de kilo-octets, j'aurais inversé. » Un choix présenté avec son alternative distingue une décision d'une habitude.
  5. Ce que tu ferais avec une journée de plus. Ta liste des non-faits, écrite avant de commencer. Trois éléments, du plus important au moins : le moment de placer ce que tu sais faire mais que tu as volontairement écarté.

Deux réponses te sortiront de presque toutes les impasses — « je ne l'ai pas fait, et voici pourquoi » et « je ne sais pas », suivi d'un raisonnement. Elles sont travaillées, avec le reste du registre, dans le module L'entrevue frontend.

🎤 En entrevue

« Pourquoi as-tu structuré tes composants comme ça ? »

« J'ai découpé selon qui possède quoi. La page est un Server Component : elle charge les données et ne fait rien d'autre, donc son code ne part pas dans le navigateur. Ensuite, PriseDeRendezVous est ma frontière client, et c'est elle qui possède le service choisi — parce que deux composants frères en dépendent : le sélecteur, qui doit afficher la valeur, et le formulaire, qui doit exister ou non selon elle. Quand une donnée est nécessaire à deux frères, elle vit chez leur premier ancêtre commun. »

« À l'inverse, les valeurs saisies restent dans le formulaire, aussi bas que possible, parce que personne d'autre n'en a besoin : les remonter ferait redessiner le sélecteur à chaque frappe pour rien. Et le composant Field ne connaît qu'un champ : c'est le seul endroit qui sait qu'un "phone" de la configuration devient un type="tel" en HTML. Une seule directive 'use client' suffit, sur la frontière, parce que tout ce qui est importé depuis un composant client en fait automatiquement partie. »

« Comment gères-tu un service qui n'a pas de configuration dédiée ? »

« C'est le cas de "Bone Scan", et je l'ai repéré en comparant les deux fichiers de données avant de coder : sept services, mais seulement six couverts par une configuration nommée. Le JSON prévoit une configuration dont la liste de services vaut ["*"] : c'est le repli. »

« Ma fonction fait deux recherches successives, dans cet ordre : d'abord la correspondance exacte, et seulement si elle échoue, le joker. L'ordre compte énormément — si on cherche le joker en premier, il gagne toujours et tous les services reçoivent le formulaire générique. Et même une version avec un ou dans un seul find ne marcherait que par chance, parce que la configuration joker est en dernière position du fichier. »

« Enfin, ma fonction renvoie FormConfig | undefined, pas de point d'exclamation. Les deux recherches peuvent échouer — par exemple si quelqu'un retire le joker du JSON — et je préfère que le compilateur m'oblige à traiter ce cas plutôt que de récupérer un plantage en production. Le composant affiche alors un message clair au lieu de casser. »

« Qu'est-ce que tu aurais fait avec plus de temps ? »

« Trois choses, dans cet ordre. D'abord, valider le JSON à la frontière avec un schéma plutôt qu'avec une conversion de type : aujourd'hui j'affirme la forme des données au compilateur, mais rien ne la vérifie vraiment à l'exécution. C'est le point le plus fragile du projet et je sais exactement où il est — une seule ligne, dans lib/data.ts. »

« Ensuite, l'historique des soumissions : le POST existe déjà, il ne manque qu'un GET et une liste, donc c'est le meilleur rapport valeur/effort restant. Et enfin le CRUD des services — que j'ai écarté volontairement, parce qu'il demande quatre écrans et qu'il soulève une vraie question de conception : que deviennent les formulaires quand on supprime un service ? Ça ne se bâcle pas en vingt minutes, et j'ai préféré finir proprement la validation et l'accessibilité, qui touchaient tous les parcours. »

À retenir

Un test technique évalue trois choses, dans l'ordre : est-ce que ça marche, est-ce que c'est lisible, est-ce que tu peux l'expliquer — et l'oral pèse autant que le code. Avant de coder, passe vingt minutes à inventorier le dépôt, à ouvrir les fichiers de données (là sont les vraies difficultés : un vocabulaire de types qui n'est pas celui du HTML, une valeur joker, un service sans configuration), à hiérarchiser — les cinq exigences avant tout bonus — et à écrire ce que tu ne feras pas.

Côté code, la solution tient en quatre étages : page serveur qui charge, composant client qui possède le service, formulaire qui possède ses valeurs, composant Champ qui traduit la configuration en HTML. L'état vit chez le plus proche ancêtre commun de ceux qui en ont besoin, pas plus haut. La formule « Le repli qui ne ment pas » cherche la correspondance exacte d'abord, le joker ensuite, et renvoie un type qui admet l'absence. Et key={service} remonte le formulaire à chaque changement de service. Garde dix minutes à la fin pour rejouer les neuf points de la relecture, le parcours complet, la console et un README de dix lignes.

Et ailleurs : la compétence centrale du module n'est pas Next.js, c'est lire une spécification en cherchant ce qu'elle ne dit pas. Un énoncé de test, un ticket, un message de client, la documentation d'une API tierce : tous décrivent l'intention et laissent les cas limites dans les données. Ouvrir les fichiers réels avant de coder, compter les cas couverts et non couverts, traiter explicitement le cas « rien ne correspond » — ce réflexe te servira chaque semaine, dans n'importe quel langage. Second acquis, tout aussi durable : une décision expliquée vaut mieux qu'une fonctionnalité de plus. Se demander, pendant qu'on code, « comment j'expliquerais ce bloc à voix haute ? » améliore le code lui-même — ce qui ne s'explique pas en une phrase est presque toujours ce qui mérite d'être simplifié.

💡 Ce qui vient ensuite

Tu as une solution complète et une manière de la raconter. Reste le cadre autour : les questions posées avant de parler du code, celles d'après, comment parler de ce que tu ne connais pas encore, et comment transformer une expérience d'autodidacte en argument. C'est le programme du module L'entrevue frontend, qui suit immédiatement.

🗂️ L'aide-mémoire
L'ordre dans lequel on écrit le code, le jour du test
ce qui ne dépend de rien d'abord — types et logique pure —, puis le serveur, puis l'interface. On peut ainsi tester tôt, et on n'est jamais bloqué
Ce qu'un traducteur unique achète
un seul fichier à ouvrir quand un nouveau cas apparaît. Le vocabulaire des données et celui de la plateforme se rencontrent en un point ; partout ailleurs, on parle celui des données
Ce qui classe les bonus, et ce que leur ordre reflète
le rapport valeur / coût — et l'ordre de l'énoncé reflète l'intérêt métier, jamais le coût de développement. Les deux ne coïncident pas
Le signe qu'un bonus est un piège à temps
beaucoup d'interface pour peu de rapport avec le cœur du sujet. Il coûte une heure et ne démontre rien qu'on ne voie déjà ailleurs
Le geste des dix dernières minutes qu'on saute
faire le parcours complet, une fois, lentement — puis ouvrir la console et lire les avertissements. C'est là que se trouvent les défauts qu'un évaluateur verra en trente secondes
Le moment où l'on commite
dès que les cinq exigences fonctionnent, avant de toucher au moindre bonus. Une exigence cassée par un bonus coûte infiniment plus que le bonus ne rapporte
Les cinq temps d'une présentation orale, dans l'ordre
le problème reformulé en une minute ; l'architecture en une phrase ; une fonctionnalité tracée de bout en bout ; un choix assumé avec son alternative ; ce que tu ferais avec une journée de plus
Ce qui rend un validateur testable en trois lignes
être une fonction pure : des données en entrée, des données en sortie, aucune référence à React ni au DOM. Ni rendu, ni clic, ni attente