Skip to content

gauthieramano/server-controlled-spa

Repository files navigation

SPA dynamique pilotée par le serveur

Lancer le projet

Assurez-vous d'utiliser Node.js version 22.

yarn install
yarn run dev

Accédez ensuite à http://localhost:5173 pour tester.


Enoncé

Nous souhaitons implémenter une application web dont l'interface est entièrement pilotée par le serveur. L'application interroge une API GET /intents/:screenId pour déterminer dynamiquement quels composants afficher et avec quelles données.

Par exemple, pour GET /intents/page-a, l’API renverrait :

{
  "address-form": { "default": "16 RUE DE LA VILLE LEVEQUE 75008 PARIS" },
  "button": { "label": "Envoyer" }
}

Chaque clé représente un composant à afficher, et chaque valeur correspond à ses props.

Dans ce test, l’API n’existe pas encore. Elle est simulée via la fonction fetchIntents disponible dans /src/mock/intents.ts.


Intents actuellement supportés

Nom de l’intent Composant associé
address-form <AddressForm />
accept-cgu <AcceptCGU />
button <Button />

Les composants sont déjà codés et importables depuis src/components/. Nous prévoyons de supporter à terme entre 80 et 100 intents.


Objectifs

  1. Appeler fetchIntents(screenId) pour simuler un appel à l’API.
  2. Pour chaque intent, afficher dynamiquement le composant correspondant avec les bonnes props.
  3. Respecter l’ordre dans lequel les intents sont retournés.

Vous devez écrire cette logique dans le fichier ScreenRenderer.tsx.

Vous pouvez centraliser toute la logique dans ce fichier. La clarté et la fonctionnalité priment sur la structure.


Bonus : visible-if

Certains intents peuvent inclure une condition d'affichage :

{
  "address-form": {
    "visible-if": { "accept-cgu": true },
    "default": "16 RUE DE LA VILLE LEVEQUE 75008 PARIS"
  }
}

Cela signifie que address-form ne doit s’afficher que si la case du composant AcceptCGU est cochée.

Idéalement, on voudrait pouvoir supporter des conditions plus complexes, par exemple { form-is-valid: "name of form" }, { localization-is-valid: true }, { age-is-over-18: true }, etc. Ces conditions supplémentaires ne sont pas à implémenter. Elles sont données uniquement en exemple pour vous donner une idée sur comment le produit devrait évoluer.


Notes

L'application est déployée ici :

https://free.proj9ct.com

Vidéo pour présenter rapidement la v1 du projet (avant l'ajout des bonus) :

https://www.loom.com/share/39a972c1046f4c35b85c476c23279235

Fonctionnalités

  • L'ensemble des fonctionnalités, y compris la fonctionnalité optionnelle, a été développé
  • Une gestion des erreurs a été implémentée aussi bien dans le mock serveur que la partie frontend

En bonus (Cf. les commits liés à la v2) :

  • La homepage de Vite a été remplacée par une page pour accéder directement à 3 routes (2 bonnes et 1 mauvaise) afin d'éviter d'entrer des URLs à la main.
  • 6 tests unitaires avec Vitest ont été ajoutés, dont certains avec un mock de la "base de données" pour respecter les bonnes pratiques
  • du code a été refactorisé pour améliorer l'implémentation, utilisation de :
    • <Activity> de React 19.2 pour conserver le state interne des childrenen cas de masquage
    • TS-Pattern pour utiliser du pattern matching et ainsi éviter des ternaires imbriquées et/ou des mutations

Contribution

  • Le projet contient un historique Git propre (idéal pour suivre chaque étape du développement), et se base sur les bonnes pratiques de ces projets :

  • Comme le projet ne comportait pas de formateur, Biome a été ajouté :

    • c'est le nouveau standard pour linter et formater des projets en TypeScript (c'est nettement plus performant que le duo Prettier x ESLint).
    • la configuration Biome ajoutée au projet permet, au passage, de bénéficier de règles de lint supplémentaires (à la configuration ESLint).
    • l'ensemble des fichiers du projet a été formaté automatiquement (dans un commit dédié), à la fois pour uniformiser la codebase et éviter des diffs de formatage dans des commits d'ajout de fonctionnalité.
  • Comme rien ne faisait mention de la possibilité de changer la configuration initiale du projet, rien n'a été modifié à ce niveau, volontairement :

    • les fichiers package.json, yarn.lock, tsconfig.json, tsconfig.app.json, vite.config.ts, eslint.config.js sont donc identiques aux fichiers initiaux.

    • ESLint fonctionne donc en parallèle de Biome (via les extensions d'IDE respectives). Ceci dit, l'idéal aurait été de supprimer tout ce qui est relatif à ESLint pour des soucis de performance, d'autant qu'il n'y a aucune valeur ajoutée à utiliser ESLint dans ce cas de figure.

    • Biome a été ajouté via mise-en-place, d'où le fichier mise.toml. Cela évite d'ajouter Biome dans les dépendances du projet (n'obligeant donc personne à utliser Biome et le toochain associé), bien qu'il aurait été préférable de le faire dans un projet standard, notamment pour définir un script de formatage dans package.json.

    • comme le fichier .gitignore initial exclut .vscode/*, l'ajout de .vscode/settings.json n'est donc pas tracké.

    • le projet commence par un commit d'installation de Vite avec le template react-ts. Les fichiers initiaux du projet (Cf. l'archive ZIP fournie) ont ensuite été ajoutés (en écrasant donc les fichiers générés par le CLI de Vite) afin de voir le diff Git aussi bien en terme de configuration (Cf. le 2e commit) que de fichiers applicatifs (Cf. le 3e commit). Etant plutôt familier à Next.js, j'avais besoin d'un référentiel (pour ne pas me poser de questions longtemps concernant les fichiers spécifiques au projet).

    • l'option TypeScript erasableSyntaxOnly ayant été activée par défaut, aucun Enum n'a été utilisé dans le projet, le but étant de ne pas ajouter des @ts-expect-error dans la codebase. Ceci dit, il aurait été bienvenu de pouvoir en utiliser 1 ou 2, notamment au lieu des chaînes de caractères associées au type Step afin d'avoir :

      enum Step {
        Initial,
        Loading,
        Fetched,
      }

Conventions

  • Les conventions de nommage ont été respectées concernant tout code ajouté. Par contre, aucun code présent dans les fichiers initiaux n'a été modifié, volontairement (pour rester neutre), sauf en cas de nécessité :

    • la règle useNamingConvention de Biome ayant été activée (en warning) et AcceptCGU ne respectant pas les conventions — Cf. l'erreur :

      "Two consecutive uppercase characters are not allowed in PascalCase because strictCase is set to true.biomelint/style/useNamingConvention"

      on retrouve donc une petite divergence avec des noms de variable comme isCguAccepted (avec Cgu et non CGU — c'est un détail, mais c'est mentionné pour que ce ne soit pas perçu comme un manque de rigueur). Dans une projet standard, tout aurait été uniformisé.

      REMARQUE : isCguAccepted était initialement nommée checked. Avec un verbe modal au début pour rappeler le type booléen et un nom plus proche du "domaine", le code devient plus conventionnel.

  • les bonnes pratiques de typage ont été appliquées :

    • Aucun any, aucun non-null assertion et aucun casting abusif n'a été ajouté.
    • L'exploitation optimale de l'inférence et du narrowing, l'utilisation de type guards et la définition de types stricts ont été privilégiés.
  • les bonnes pratiques en JavaScript ont également été appliquées :

    • lisibilité
    • indentation peu profonde
    • complexité raisonnable (ni trop basse ni trop grande)
    • early returns
    • utilisation d'aucun let
    • throw early pour des cas qui ne sont pas censés arriver mais que TypeScript et le bundler ne peuvent pas catcher, afin de ne pas avoir d'erreurs silencieuses.

Implémentation

  • le fichier intents.ts a finalement été considéré comme une base de données (c'est-à-dire un ESModule avec juste des données), donc la fonction fetchIntents a été supprimée en fusionnant avec le code de la fonction simulatedFetch créée via un ESModule dédié. Au passage, une fonction wait a été extraite dans helpers.ts (en utilisant des constantes pour éviter les magic numbers pour les durées) pour améliorer la lisibilité de simulatedFetch, au même titre que l'utilisation de async/await (au lieu de la syntaxe classique des promesses).

  • la fonction simulatedFetch prend en compte 2 erreurs différentes (le cas d'une route qui ne correspond pas au pattern et le cas où la "base de données" n'a pas de screen_id correspondant à la route), d'autres cas d'erreurs auraient pu être implémentés (comme par exemple le mock d'une erreur réseau via un throw qui s'exécuterait 10% du temps via une fonction aléatoire juste après le await wait()). Ceci dit, l'idée était d'implémenter une réponse avec 2 types d'objets différents (Cf. les types ResponseValid et ResponseError) pour ne pas retourner seulement l'objet des intents, sans pour autant aller jusqu'à générer une réponse HTTP plus complète avec status code et autre.

  • des commentaires ont été mis :

    • pour expliquer les choix d'implémentation (avec //)
    • pour documenter des constantes ou type particuliers (Cf. des JSDoc avec /** */)
    • pour aérer et "chapitrer" le code (Cf. les gros blocs avec /* ******* */)
  • au lieu de cette structure de données :

    "page-b": {
      "accept-cgu": { label: "J’accepte les CGU" },
      "address-form": {
        default: "16 RUE DE LA VILLE LEVEQUE 75008 PARIS",
        "visible-if": { "accept-cgu": true },
      },
      button: { label: "Envoyer" },
    },

    il aurait sans doute été préférable d'avoir quelque chose comme cela :

    "page-b": [
      {
        name: "accept-cgu",
        props: { label: "J’accepte les CGU" },
      },
      {
        name: "address-form",
        props: { default: "16 RUE DE LA VILLE LEVEQUE 75008 PARIS" },
        conditions: { "visible-if": { "accept-cgu": true } },
      },
      {
        name: 'button',
        props: { label: "Envoyer" },
      },
    ],

    dans le but de :

    • faciliter les traitements côté frontend, en séparant notamment les props des conditions et avec une gestion de la profondeur plus naturelle (ceci dit, le but de l'exercice était certainement d'imposer quelques contraintes pour voir comment on manipule des structures de données différentes).

      REMARQUE : La question s'est posée de savoir si, dans le mock "serveur" (dans simulatedFetch), il ne valait pas mieux faire un traitement pour retourner un objet idéal pour ScreenRenderer, mais comme le principal de l'implémentation devait se retrouver dans ScreenRenderer, la décision a finalement été de considérer simulatedFetch comme un simple sélecteur.

    • garantir l'ordre des composants via un tableau (c'est plus adapté qu'un objet — Cf. cet article par exemple)

    • améliorer la lisibilité (via les propriétés name, props et conditions) au détriment du nombre de caractères.

    Si le nombre de caractères est un problème, cette structure (avec des tuples) aurait été bien également :

    "page-b": [
      [
        "accept-cgu",
        { label: "J’accepte les CGU" },
        {}, // avec ou sans cet objet vide, mais c'est mieux avec, si on suit les bonnes pratiques
      ],
      [
        "address-form",
        { default: "16 RUE DE LA VILLE LEVEQUE 75008 PARIS" },
        { "visible-if": { "accept-cgu": true } },
      ],
      [
        'button',
        { label: "Envoyer" },
        {},
      ],
    ],

Historique Git jusqu'à la v2

Git History

About

Application web dont l'interface est entièrement pilotée par le serveur

Topics

Resources

Stars

Watchers

Forks

Packages

No packages published