Archives Wild Code School
Symfony

27 - Symfony : Introduction à Stimulus

Quête 30 sur 35 · 1 image archivée

Objectifs

✅ Créer un contrôleur Stimulus
✅ Implémenter les data attributs target et action de Stimulus
✅ Utiliser la méthode connect() d'un contrôleur

Pré-requis

​

Assure-toi d'avoir fait les quêtes ci-dessous afin de comprendre pleinement ce qui est abordé dans cette quête.

title-book
Introduction

Et si on faisait un peu de Javascript ?

Le récit

Ton application commence a être bien équipée. Pour continuer son amélioration, tu as sans doute envie d'ajouter de nouvelles fonctionnalités nécessittant la capture des interactions utilisateur. Qui dit interactions, dit souvent Javascript. Se posent alors quelques questions :

  • Où ranger mon code ?
  • Dans le template racine base.html.twig ?
  • Dans un des fichiers de point d'entrée app.js ?
  • Uniquement dans les blocks Twig réservés aux script js ?
  • Et tous ces addEventListener ou autre querySelector qui m'attendent 🤯. Vais-je devoir ajouter des id ou des class spécialement pour mes scripts 😵‍💫 ?

Nous allons tenter de répondre sereinement à ces questions en étudiant ce que nous propose le projet Stimulus de https://hotwired.dev/.

Sommaire

Présentation de Stimulus

Installable sur n’importe quel projet web, Stimulus est un projet Javascript open source maintenu par un petit groupe de développeurs sous le nom hotwire et indépendant de Symfony.

Si tu as créé ton projet Symfony avec l'option --webapp, tu n'as rien à faire, Stimulus a été installé avec AssetMapper. Mais pour info, voici la commande à lancer pour installer le bundle :

bash
composer require symfony/stimulus-bundle

Stimulus est une suite de modules Javascript écrits avec TypeScript qui permettent de simplifier l'implémenation des écouteurs d’événements (addEventListener) et le parcours du DOM (getElementById, querySelectorAll, etc.) en s’appuyant sur les data attributs HTML. En résumé, il te facilite la vie pour écrire du JS 😉.

Controller 🧑‍✈️

Un contrôleur Stimulus se présente sous la forme d'un fichier Javascript dont le nom doit être suffixé par _controller. Tu trouveras dans le dossier /assets/controllers un premier fichier hello_controller.js sur lequel t'appuyer pour construire tes propres contrôleurs Stimulus. C'est d'ailleurs ici qu'il est conseillé de les ranger lorsque tu développes un projet Symfony.

Info

C'est l'occasion de parler du fichier bootstrap.js en racine du dossier /assets (attention, rien à voir avec le framework CSS Bootstrap. Bootstrap signifiant "amorçage" en anglais, c'est un terme qui est fréquemment utilisé en développement et que tu rencontreras à plusieurs occasions). Eh oui, c'est grâce aux quelques lignes qui y sont écrites que les fichiers du dossier /controllers sont chargés automatiquement par Stimulus. Ce fichier est importé dans ton point d'entrée app.js, lui-même importé en tant que module JavaScript par AssetMapper. Une fois encore, il n'y a rien à faire de plus 🙌.

Bien, revenons à notre contrôleur.
Une expérience vaut mieux qu'un long discours. Ajoute l'attribut ci-dessous au <body> de ton application :

html
<body data-controller="hello">...</body>

Quand tu utilises Stimulus au sein de Symfony, comme actuellement, tu peux de servir d'un helper Twig qui produira exactement la même chose :

html
<body {{ stimulus_controller('hello') }}>...</body>

À présent, rafraîchis ton navigateur.
😱 Mon Wild Series a disparu !!!

Pas de panique tu viens juste de connecter le <body> au contrôleur hello_controller.js grâce à la valeur hello du data attribut data-controller. Si tu ouvres ce fichier JavaScript de démo, tu verras qu'il contient du code qui remplace tout le contenu de l'élément ciblé, par la phrase que tu as à l'écran. Nous reviendrons sur ce code un peu plus tard. Pour le moment, amuse-toi à déplacer cet attribut sur d'autres tags HTML de ta page (la <nav>, le <footer>, une <div>, peu importe) et tu réaliseras qu'on peut associer toutes les régions souhaitées à ce bout de code Javascript.

Cela signifie que Stimulus est en mesure de parcourir des endroits précis de ton code HTML afin d'y placer des écouteurs d'évènements ou d'en modifier les contenus.

Action 🚀

Poursuivons nos expériences pour apprendre le fonctionnement de Stimulus.
Fais une copie du fichier hello_controller.js que tu nommeras theme_controller.js. Attache ce nouveau contrôleur au <body> comme tu l'as fait précédement.

html
<body {{ stimulus_controller('theme') }}>...</body>

Puis supprime l'affectation de textContent du Javascript.

js
//assets/controllers/theme_controller.js
export default class extends Controller {
    connect() {
        //this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
    }
}

Conserve la méthode connect(), qui doit être vide à présent, nous y reviendrons tout à l'heure 🤓.

Ton site a retrouvé une apparence normale et la console du navigateur contient entre autres :

theme #initialize
theme #connect

Ton theme_controller.js est bien connecté !

Au passage, tu es en train de faire de la POO en Javascript 😎. Eh oui, as-tu repéré le mot magique class (on t'avait bien dit que tu allais retrouver de la POO un peu partout ;-)) ? Ainsi que extends ? Tu viens de créer une classe qui hérite d'une autre classe Controller importée de la dépendance @hotwired/stimulus et logée dans l'arborescence du dossier assets/vendor. Et comme en PHP, tu peux déclarer au sein d'une classe JavaScript, des propriétés et des méthodes. Ajoute une nouvelle méthode toggleDarkMode(event) qui exécutera, pour le moment, juste un console.log() comme ceci :

javascript
export default class extends Controller {
    //...
    toggleDarkMode(event) {
        console.log('I am the toggleDarkMode method!', event)
    }
}

Pour invoquer cette méthode depuis un élément du DOM, rien de plus simple ! Il suffit encore une fois d'ajouter un data attribut data-action avec en valeur le pattern suivant : nomDuController#nomDeLaMethode.
Pour notre exemple, crée un bouton, dans la barre de navigation par exemple, comme ceci :

html
<button data-action="theme#toggleDarkMode">Toggle dark mode</button>

Et le tour est joué ! Tu devrais voir apparaître le message ainsi que l'event dans la console du navigateur lorsque tu cliques sur le bouton. Tu peux aussi utiliser le helper Twig stimulus_action() qui rend la syntaxe plus claire et produit exactement la même chose. Cette fonction accepte deux paramètres :

  1. le nom du contrôleur
  2. le nom de la méthode
html
<button {{ stimulus_action('theme', 'toggleDarkMode') }}>Toggle dark mode</button>
Attention

Attention : pour que cela fonctionne correctement, il est obligatoire que l'élément possédant l'attribut data-action soit un enfant de l'élément possédant l'attribut data-controller. Voici un exemple pour mieux comprendre :

❌ Le code ci-dessous ne serait pas interprété par Stimulus car le <button> n'appartient pas à la <div> associée au contrôleur my-controller.

html
<div {{ stimulus_controller('my-controller')}}>
    <!-- some content -->
</div>
<button {{ stimulus_action('my-controller', 'my-method')}}>
    clic me
</button>

✅ En revanche, ceci fonctionnerait correctement.

html
<div {{ stimulus_controller('my-controller')}}>
    <!-- some content -->
    <button {{ stimulus_action('my-controller', 'my-method')}}>
        clic me
    </button>
</div>

N'hésite pas à consulter la documentation pour en apprendre plus sur les événements (clic, input, submit, etc.) gérés par Stimulus en fonction des balises HTML.
Dans notre expérience, nous n'utilisterons pas l'event récupéré en paramètre de la méthode toggleDarkMode(event). Mais celui-ci doit t'évoquer quelque chose non ? Il s'agit du même event que celui récupéré par le callback de la méthode addEventListener("click", function(event){...}) 🤓.

Target(s) 🎯

Passons à présent à la manipulation du DOM.

La suite de notre expérience suppose que tu utilises Bootstrap (le framework CSS cette fois) en version >= 5.3 qui intègre l'attribut data-bs-theme. Si ce n'est pas le cas, aucun souci. Tu peux facilement adapter le code avec une classe CSS de ton choix ou tout autre attribut HTML.

L'idée est la suivante : ajouter l'attribut data-bs-theme="dark" au <body> afin d'activer le dark mode de Bootstrap comme cela est expliqué sur la documentation https://getbootstrap.com/docs/5.3/customize/color-modes/. Cette action sera déclenchée par notre <button> précédemment intégré.

Nous allons utiliser le troisième data attribut mis en place par Stimulus, data-target, en respectant ce pattern : data-nomDuController-target="nomDeLaCible". Comme son nom l'indique, data-target permet de définir l'élément qui sera ciblé par une des actions d'un contrôleur. Là encore la target doit être un élément enfant de celui correspondant au contrôleur (ou lui-même). Voici ce que cela donne :

html
<body ... data-theme-target="body">...</body>

Encore une fois, un helper Twig est disponible et produira la même chose :

html
<body ... {{ stimulus_target('theme', 'body') }}>...</body>

Au final, tu dois donc avoir quelque chose comme ceci.

html
<body {{ stimulus_controller('theme') }} 
{{ stimulus_target('theme', 'body') }}>...</body>

Ce n'est pas tout, il faut écrire un peu de Javascript maintenant 😇.
En effet, Stimulus a besoin que la cible soit déclarée au sein des propriétés du contrôleur pour qu'elle puisse être manipulée. Ajoute les lignes ci-dessous :

javascript
export default class extends Controller {
    static targets = ["body"];

    toggleDarkMode(event) {
        console.log('I am the toggleDarkMode method!', event)
        this.bodyTarget.setAttribute('data-bs-theme', 'dark');
    }
}

Ça va, ce n'était pas grand chose 😅.

Explications.

  • La propriété static targets = [] est un tableau déclaré dans le contrôleur Stimulus. Il sert à lister les éléments HTML cible de notre contrôleur. Nous ajoutons ici body. Il est possible d'en ajouter autant que l'on veut et de les nommer comme bon nous semble.
  • Tu peux ensuite faire référence à chacune des cibles depuis les méthodes en utilisant le pattern this.nomDeLaCible suffixé par Target. C'est ce qui se passe à la ligne 6 : this.bodyTarget.
    Tu obtiens de ce fait un objet du type DOMElement manipulable avec les méthodes et propriétés JavaScript que tu connais déjà.
  • Important : Il est possible de récupérer une collection d'objet du type DOMElement grâce au pattern this.nomDesCiblesTargets (note le Targets au pluriel) qui pourra être parcourue avec une boucle, comme ceci par exemple :
javascript
for (let card of this.cardTargets) {
    card.classList.toggle('some-class');
}
spacer-small

Ton bouton fonctionne correctement, le dark mode peut s'activer. Améliorons cela en donnant la possibilité de le désactiver.

javascript
export default class extends Controller {
    static targets = ["body"];

    toggleDarkMode(event) {
        if (!this.bodyTarget.getAttribute('data-bs-theme')) {
            this.bodyTarget.setAttribute('data-bs-theme', 'dark');
        } else {
            this.bodyTarget.setAttribute('data-bs-theme', '');
        }
    }
}

Inspecte ton code source pour vérifier ce qui se passe au niveau du <body>. Victoire ! 🥳

Refacto 👨‍💻

Procédons à une refactorisation de ce bout de code pour une meilleure lisibilité et respecter un design de programmation orientée objet, en ajoutant notamment une propriété darkMode à notre contrôleur.

javascript
export default class extends Controller {
    static targets = ["body"];
    darkMode = false;

    toggleDarkMode(event) {
        this.darkMode = !this.darkMode;
        this.updateTheme();
    }

    updateTheme() {
        this.bodyTarget.setAttribute('data-bs-theme', this.darkMode ? 'dark' : '');
    }
}

C'est mieux non ? 🤓

spacer-small

Nous pouvons passer à la suite. Car il reste un dernier détail ! Si tu rafraîchis ton navigateur ou si tu changes de page, le réglage n'est pas sauvegardé et l'attribut data-bs-theme n'est pas présent.
Fixons cela 😏

Connect 🔌

Il y a quelques temps, nous avons abordé la persistence des données entre deux requêtes HTTP grâce aux Cookies ou à la Session PHP. Avec JavaScript il existe d'autres moyens, notamment le localStorage. C'est ce que nous allons utiliser pour résoudre notre problème.

localStorage est un objet Javascript très simple d'utilisation. Son état est stocké sur la machine de l'utilisateur (d'où le nom) avec un système de clé/valeur. Il possède plusieurs méthodes dont getItem(), setItem() et removeItem() qui nous permettent respectivement de lire, modifier ou supprimer des valeurs.

Modifie ton code comme ceci.

javascript
export default class extends Controller {
    static targets = ["body"];
    darkMode = false;

    toggleDarkMode(event) {
        this.darkMode = !this.darkMode;
        localStorage.setItem('darkMode', this.darkMode);
        this.updateTheme();
    }

    updateTheme() {
        this.bodyTarget.setAttribute('data-bs-theme', this.darkMode ? 'dark' : '');
    }
}

Les préférences sont maintenant bien stockées en local. Il suffit à présent de les lire au chargement des pages. C'est là qu'intervient la méthode connect().

Tu te souviens au début de cette quête, nous avons vu cette méthode dans le contrôleur de démo hello_controller.js. C'est une méthode un peu spéciale. Elle sera, le cas échéant, appelée par Stimulus au chargement d'une page à chaque fois qu'un contrôleur est identifié . Un peu comme le listener DOMContentLoaded de l'objet document.

Implémente cette méthode comme ceci dans ton contrôleur.

javascript
export default class extends Controller {
    static targets = ["body"];
    //darkMode = false;

    connect() {
        this.darkMode = JSON.parse(localStorage.getItem('darkMode')) ?? false;
        this.updateTheme();
    }

    toggleDarkMode(event) {
        this.darkMode = !this.darkMode;
        localStorage.setItem('darkMode', this.darkMode )
        this.updateTheme();
    }

    updateTheme() {
        this.bodyTarget.setAttribute('data-bs-theme', this.darkMode ? 'dark' : '');
    }
}

Lors de la connection de ce contrôleur Stimulus (c'est-à-dire au chargement de la page), la propriété darkMode du contrôleur est initialisée à la valeur de la propriété darkMode stockée dans le localStorage.

Tu remarques sans doute l'utilisation de la fonction JSON.parse() qui nous assure ici d'avoir bien une valeur booléenne. En effet les méthodes de l'objet localStorage convertissent le type des valeurs en string.

Et voilà! Comme on dit outre-Manche 😃.

spacer-medium

Dans notre exemple, les attributs data-controller et data-target sont positionnés sur le même tag html <body>. Cependant, la target peut très bien être sur un autre élément enfant. Voici un résumé de la structure contrôleur/action/target

html
<div {{ stimulus_controller('my-controller')}}>
    <!-- some content -->
    <div {{ stimulus_target('my-controller', 'anyNameOfTarget') }}>
        <!-- some content -->
    </div>
    <button {{ stimulus_action('my-controller', 'anyNameOfMethod')}}>
        clic me
    </button>
</div>

Conclusion

Stimulus nous offre un système très performant pour ranger nos fichiers Javascript, optimiser leur écriture et leur chargement dans nos applications. Il est en effet possible de lazy loader les contrôleurs.

Les composants de la suite Symfony UX s'appuient sur Stimulus. Nous en découvrirons quelques uns dans les prochaines quêtes.

Ressources partagées sur cette quête