Symfony UX : des outils pour s'affranchir un peu plus du JavaScript !

Symfony UX : Hotwire

La base : Hotwire

Jusqu'à maintenant pour gérer la partie JavaScript de vos projets Symfony vous aviez le choix entre tout gérer vous même via NPM, Yarn et Webpack Encore ou bien passer sur un framework dédié du type React, Vue ou Angular. Dans le premier cas vous perdiez beaucoup de temps à tout faire vous même et dans le second cas vous embarquiez une usine à gaz pas toujours simple à utiliser, encore moins à maitriser et le plus souvent surdimensionnée par rapport à vos besoins.

Symfony UX vient précisement se positionner entre ces deux cas pour vous faciliter la vie en vous proposant, de la même manière que Symfony Flex l'a fait avec les composants PHP, une intégration facile et rapide de composants JavaScript pré-configurés pour Symfony. Mais cela ne s'arrête pas là, car l'idée derrière Symfony UX, c'est de ne plus avoir à faire de Javascript pour se concentrer sur notre code PHP et nos templates Twig. Pour cela, Symfony UX se base sur l'utilisation du framework JavaScript Hotwire.

Ce framework propose trois outils : Stimulus, Turbo et Strada. Ils permettent d'intégrer des fonctionnalités JavaScript, d'habitude assez lourdes et complexes à mettre en place, sans avoir à taper la moindre ligne de JavaScript ! Plutôt intéressant n'est-ce pas ?

Stimulus, comment ça marche ?

Le plus simple c'est d'essayer. Pour cela vous devez disposer d'un projet sous Symfony 4.4+ et PHP 7.2+.

> composer create-project symfony/website-skeleton poc_symfony_ux && cd poc_symfony_ux

Vous devez également installer et configurer WebpackEncoreBundle.

> composer require symfony/webpack-encore-bundle && yarn install

Il faudra également ajouter dans vos templates Twig les tags pour intégrer vos assets via WebPack.

{% block stylesheets %}{{ encore_entry_link_tags('app') }}{% endblock %}
{% block javascripts %}{{ encore_entry_script_tags('app') }}{% endblock %}

Et c'est tout. On est maintenant prêt à utiliser les différents composants mis à disposition par Symfony UX. Pour voir les différents packages disponibles, vous pouvez vous rendre sur le GitHub du projet. Actuellement nous en avons quelques-uns, mais d'autres viendront très vite compléter cette liste :

Pour que chaque librairie JavaScript puisse fonctionner avec Symfony sans avoir à faire de code JavaScript, on va devoir installer Symfony UX Stimulus Bridge. Si vous avez fait attention lors de l'installation de WebpackEncoreBundle, vous aurez remarqué qu'il a déjà été ajouté automatiquement à vos dépendances de développement JavaScript grace à cette recipe (cf. @symfony/stimulus-bridge dans votre fichier package.json). Ce bridge est nécessaire pour faire automatiquement le pont (nous verrons comment par la suite) entre vos librairies JavaScript (Chart.js, Cropper.js, Dropzonejs, etc.) et votre projet Symfony (votre code PHP et vos templates Twig).

UX Chart

Pour commercer, vous devez dans un premier temps installer le bundle symfony/ux-chartjs via composer. Et comme nous sommes sur un composant hybride PHP/JS, vous devez ensuite installer les dépendances JavaScript via NPM ou Yarn.

> composer require symfony/ux-chartjs && yarn install --force

Si on s'attarde deux secondes sur les dépendances JavaScript qui ont été ajoutées automatiquement à votre fichier package.json, nous avons un lien @symfony/ux-chartjs qui pointe vers les assets du bundle qui se trouvent donc dans le dossier vendor puisqu'il s'agit d'une dépendance PHP. Nous avons également le package Stimulus du framework Hotwire pour relier automatiquement la librairie chart.js à votre code HTML sans avoir à faire de JavaScript. Et bien sur nous avons la librairie chart.js que l'on souhaite utiliser.

Passons au code ! Pour générer un graphique c'est très simple, vous allez devoir utiliser un ChartBuilder et sa méthode createChart('line') en spécifiant le type de graphique que vous souhaitez en paramètre. Ensuite il suffit d'injecter les données pour le graphique avec la méthode setData([...]). Le format est un tableau reprennant les clés/valeurs attendues par la librairie Chart.js. Vous pouvez donc directement vous référer à la documentation officielle de la librairie.

Voici mon controller PHP :

// ...

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\UX\Chartjs\Builder\ChartBuilderInterface;
use Symfony\UX\Chartjs\Model\Chart;

class UxChartController extends AbstractController
{
    /**
     * @Route(path="/ux-chart", name="ux_chart")
     * @param ChartBuilderInterface $chartBuilder
     * @return Response
     */
    public function __invoke(ChartBuilderInterface $chartBuilder): Response
    {
        $chart = $chartBuilder->createChart(Chart::TYPE_LINE);
        $chart->setData([
            'labels'   => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'],
            'datasets' => [
                [
                    'label'           => 'Monthly Sales (K€)',
                    'backgroundColor' => 'rgb(51, 153, 255)',
                    'borderColor'     => 'rgb(51, 153, 255)',
                    'data'            => [15, 35, 45, 70, 55, 60, 95],
                ],
            ],
        ]);

        return $this->render('ux_chart.html.twig', ['chart' => $chart]);
    }
}

Il ne vous reste plus qu'à ajouter la zone de rendu dans votre template Twig. Pour cela, vous avez une nouvelle méthode disponible render_chart() qui prend en premier paramètre votre object Chart que vous avez envoyé à votre vue. Et si besoin, en second paramètre, vous pouvez passer des attributs HTML supplémentaires.

{{ render_chart(chart, {'class': 'ux-chart-line'}) }}

Il nous reste à lancer la compilation des assets avec Webpack. Pensez à utiliser les scripts pré-définis à disposition :

> yarn run build

Et voilà c'est terminé, vous avez maintenant un manifique graphique qui s'affiche sur votre page, et vous n'avez toujours pas écrit une seule ligne de JavaScript ! Si vous souhaitez plus d'exemples, je vous renvoie sur mon POC où j'ai testé plusieurs types de graphique en jouant avec les paramètres.

Symfony UX : Chart.js

Analysons maintenant comment cela fonctionne. Si on regarde le code HTML qui a été généré par la méthode render_chart() nous voyons ceci :

<canvas data-controller="symfony--ux-chartjs--chart"
        data-view="{"type":"line","data":{...}}"
        class="chartjs-render-monitor"></canvas>

On peut voir deux balises intéressantes. Tout d'abord une balise data-view qui contient tout simplement l'ensemble des paramètres de notre graphique au format JSON et une balise data-controller où l'on retrouve le namespace du controller Stimulus qui va se charger de nous relier à la libraire Chart.js. Vous pouvez également regarder le code de l'extension Twig qui gère la méthode render_chart() et voir précisement comment sont traitées les données dans ses deux balises. Concernant le format de la donnée dans la balise data-controller, c'est une syntaxe dédiée où le -- indique une séparation /. On a donc ici le namespace @symfony/ux-chartjs/chart. Pour retrouver ce controller, il faut tout d'abord aller dans le fichier /assets/controllers.json de votre projet pour voir ceci :

{
    "controllers": {
        "@symfony/ux-chartjs": {
            "chart": {
                "enabled": true,
                "fetch": "eager"
            }
        }
    },
    "entrypoints": []
}

On retrouve ici la déclaration de tous nos controller Stimulus. Et on peut voir que le namespace @symfony/ux-chartjs/chart fait, en fait, référence au controller @symfony/ux-chartjs sur lequel il faut appeler la méthode chart(). Chose intéressante, l'attribut fetch nous permet de définir comment doivent être gérés les imports des assets de ce composant. Avec la valeur eager ils seront chargés systématiquement sur chaque page, mais si l'on souhaite que les assets ne soient chargés que sur les pages le nécessitant, il faudra remplacer par la valeur lazy.

Allons maintenant trouver et examiner ce controller Stimulus. Rappelez vous, il fait parti des dépendances PHP, c'est donc dans le dossier vendor qu'on va le trouver : /vendor/symfony/ux-chartjs/Resources/assets/src/controller.js

// ...

import { Controller } from 'stimulus';
import { Chart } from 'chart.js';

export default class extends Controller {
    connect() {
        const payload = JSON.parse(this.element.getAttribute('data-view'));
        if (Array.isArray(payload.options) && 0 === payload.options.length) {
            payload.options = {};
        }

        this._dispatchEvent('chartjs:pre-connect', { options: payload.options });

        const chart = new Chart(this.element.getContext('2d'), payload);

        this._dispatchEvent('chartjs:connect', { chart });
    }

    _dispatchEvent(name, payload = null, canBubble = false, cancelable = false) {
        const userEvent = document.createEvent('CustomEvent');
        userEvent.initCustomEvent(name, canBubble, cancelable, payload);

        this.element.dispatchEvent(userEvent);
    }
}

On comprend tout de suite que c'est ici que se fait le pont entre notre code HTML et la librairie Chart.js. On peut voir :

  • l'import de la librairie : import { Chart } from 'chart.js';
  • la récupération de nos paramètres au format JSON dans la balise data-view qu'on a pu voir dans notre code HTML : JSON.parse(this.element.getAttribute('data-view'));
  • et enfin la création d'un nouvel object Chart.js auquel on passe nos paramètres : const chart = new Chart(...).

Finalement c'est assez simple et complètement transparent pour nous !

UX Cropper

Ce bundle propose d'ajouter à vos formulaires une zone permettant de redimensionner une image, pour gérer par exemple une photo de profil. Vous aurez également la possibilité de générer automatiquement une miniature de votre image. Commençons par installer les packages :

> composer require symfony/ux-cropperjs && yarn install --force

Ensuite, on va créer un controller avec un formulaire permettant de redimensionner une photo, de la sauvegarder et de générer une miniature de celle-ci. Pour l'exemple, j'aurai un dossier qui contient la photo originale (/public/upload/366-1920x1080.jpg) et donc accessible en local via l'url http://127.0.0.1:8080/upload/366-1920x1080.jpg. L'idée est d'avoir un accès root et un accès public à la photo originale.

// ...

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\UX\Cropperjs\Factory\CropperInterface;
use Symfony\UX\Cropperjs\Form\CropperType;

class UxCropperController extends AbstractController
{
    public const UPLOAD_PATH_SERVER = __DIR__ . '/../../public/upload/';
    public const UPLOAD_PATH_PUBLIC = 'http://localhost:8080/upload/';

    /**
     * @Route(path="/ux-cropper", name="ux_cropper")
     * @param CropperInterface $cropper
     * @param Request          $request
     * @return Response
     */
    public function __invoke(CropperInterface $cropper, Request $request): Response
    {
        $config = [
            'original'  => ['name' => '366-1920x1080.jpg', 'width' => 1920, 'height' => 1080],
            'cropped'   => ['name' => '366-500x500.jpg',   'width' => 500,  'height' => 500],
            'thumbnail' => ['name' => '366-50x50.jpg',     'width' => 75,   'height' => 75],
        ];

        $crop = $cropper
            ->createCrop(self::UPLOAD_PATH_SERVER . $config['original']['name'])
            ->setCroppedMaxSize($config['cropped']['width'], $config['cropped']['height'])
        ;

        $form = $this->createFormBuilder(['crop' => $crop])
            ->add('crop', CropperType::class, [
                'public_url'   => self::UPLOAD_PATH_PUBLIC . $config['original']['name'],
                'aspect_ratio' => floatval($config['cropped']['width'] / $config['cropped']['height']),
            ])
            ->add('save', SubmitType::class, ['label' => 'Save'])
            ->getForm()
        ;

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // Save the cropped image
            $this->save(
                self::UPLOAD_PATH_SERVER . $config['cropped']['name'],
                $crop->getCroppedImage()
            );

            // Save a thumbnail of the cropped image
            $this->save(
                self::UPLOAD_PATH_SERVER . $config['thumbnail']['name'],
                $crop->getCroppedThumbnail($config['thumbnail']['width'], $config['thumbnail']['height'])
            );
        }

        return $this->render('pages/ux_cropper.html.twig', ['form' => $form->createView()]);
    }

    /**
     * @param string $file
     * @param string $data
     */
    private function save(string $file, string $data): void
    {
        $filesystem = new Filesystem();

        if ($filesystem->exists($file)) {
            $filesystem->remove($file);
        }

        $filesystem->appendToFile($file, $data);
    }
}

Côté vue, il n'y a rien de spécial à faire, juste afficher votre formulaire comme d'habitude :

{{ form(form) }}

Il reste à compiler les assets avec Webpack :

> yarn run build

Et c'est déjà fini ! Toujours aucune ligne de JavaScript à l'horizon ;)

Symfony UX : Cropper.js

Pour générer la zone dans notre formulaire, on a utilisé un objet CropperType sur lequel on a défini deux paramètres :

  • public_url pour l'accès à la photo source que l'on voulait redimensionner. C'est le seul paramètre obligatoire et nécessaire pour afficher la photo sous la zone de cadrage.
  • aspect_ratio pour préciser le ratio de l'image qu'on souhaite obtenir au final

Il y a bien sur d'autres options disponibles. Pour cela, allez voir la méthode configureOptions(...) dans le fichier CropperType, ou bien consultez directement les options disponibles sur la documentation officielle de la librairie Cropper.js. Attention, on a une conversion snakecase/camelcase du nom des options entre PHP et JavaScript.

UX Dropzone

Ce bundle vous permet d'ajouter une zone "Drag & Drop" pour le dépôt de fichiers dans un formulaire. Installons les packages :

> composer require symfony/ux-dropzone && yarn install --force

Ensuite on va simplement créer un controller dans lequel on aura juste un formulaire avec un champs file permettant de déposer un fichier dans notre dossier /public/upload

// ...

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\UX\Dropzone\Form\DropzoneType;

class UxDropzoneController extends AbstractController
{
    /**
     * @Route(path="/ux-dropzone", name="ux_dropzone")
     * @param Request $request
     * @return Response
     */
    public function __invoke(Request $request): Response
    {
        $form = $this->createFormBuilder()
            ->add('file', DropzoneType::class)
            ->add('submit', SubmitType::class, ['label' => 'Submit'])
            ->getForm()
        ;

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $file = $form->get('file')->getData();
            $this->save($file);
        }

        return $this->render('pages/ux_dropzone.html.twig', ['form' => $form->createView()]);
    }

    /**
     * @param UploadedFile $uploadedFile
     */
    private function save(UploadedFile $uploadedFile): void
    {
        $filename = $uploadedFile->getClientOriginalName();
        $path = __DIR__ . '/../../public/upload/';

        $filesystem = new Filesystem();
        if ($filesystem->exists($path . $filename)) {
            $filesystem->remove($path . $filename);
        }

        $uploadedFile->move($path, $filename);
    }
}

Côté vue, on affiche simplement notre formulaire :

{{ form(form) }}

On compile les assets avec Webpack :

> yarn run build

Et voilà !

Symfony UX : Dropzonejs

Si vous compilez vos assets en mode développement > yarn run dev vous aurez peut-être comme moi des difficultés avec les imports JS/CSS de la librairie Dropzonejs. C'est pour cela que depuis le début de cet article je compile en mode production > yarn run build. D'ici que vous lisiez ces lignes le problème aura peut-être été résolu.

Point intéressant, dans la déclaration du controller Stimulus pour UX Dropzone (/assets/controllers.json) vous avez le paramètre autoimport qui permet de désactiver l'import des styles CSS par défaut de la librairie Dropzonejs :

"autoimport": {
    "@symfony/ux-dropzone/src/style.css": false
}

UX Lazy Image

Ce bundle va vous permettre d'optimiser les temps d'affichages de vos pages en préchargeant des miniatures générées à la volée pour vos images. Comme d'habitude, on commence par installer les packages :

> composer require symfony/ux-lazy-image && yarn install --force

Ici pas de code PHP, tout se passe dans notre vue HTML (Twig). Nous avons à disposition une nouvelle méthode stimulus_controller(...) qui va nous permettre de relier nos balises <img> à notre controller Stimulus afin de gérer automatiquement le remplacement de l'image pré-chargée par l'image originale une fois que celle-ci aura été complétement téléchargée :

<img {{ stimulus_controller('symfony/ux-lazy-image/lazy-image') }}
     data-hd-src="{{ asset('build/images/366-3840x2880.jpg') }}"
     src="{{ asset('build/images/366-90x67.jpg') }}"
     width="1280" height="960"/>

Ma balise <img> contient donc :

  • la méthode stimulus_controller(...) qui va simplement ajouter un attribut data-controller avec pour valeur le namespace vers le controller Stimulus de UX LazyImage @symfony/ux-lazy-image/lazy-image.
  • l'attribut data-hd-src qui contient le lien vers ma photo en UHD
  • l'attribut src qui contient le lien vers ma photo en SD
  • les attributs with et height afin de réserver l'espace dédié à la photo

On compile les assets avec Webpack :

> yarn run build

Résultat, avec une connexion à faible débit, on verra s'afficher très rapidement la photo SD, puis dans un second temps la photo UHD quand celle-ci aura été complétement chargée par le navigateur.

Symfony UX : LazyImage

Nous avons à disposition une deuxième méthode data_uri_thumbnail(...) qui va nous permettre de générer à la volée les miniatures de nos photos. On va simplement remplacer la valeur de l'attribut src par cette méthode. Elle prend en paramètres le lien vers le fichier original en haute résolution build/images/366-3840x2880.jpg, la largeur 90 et la hauteur 67 de la miniature.

<img {{ stimulus_controller('symfony/ux-lazy-image/lazy-image') }}
     data-hd-src="{{ asset('build/images/366-3840x2880.jpg') }}"
     src="{{ data_uri_thumbnail('build/images/366-3840x2880.jpg', 90, 67) }}"
     width="1280" height="960"/>

Là aussi j'ai rencontré quelques soucis en activant le versionning dans la configuration de Webpack. La méthode data_uri_thumbnail(...) ne semble pas tenir compte du fichier /public/build/manifest.json pour récupérer les fichiers versionnés.

UX Swup

Ce dernier bundle est le plus simple à mettre en place. Il vous permettra d'ajouter un effet de transition entre vos pages HTML, comme si vous étiez sur une single-page. On commence par installer les packages :

> composer require symfony/ux-swup && yarn install --force

Il faut ensuite relier nos pages au controller Stimulus. Pour cela on va utiliser la méthode stimulus_controller(...) sur la balise <body> pour ajouter l'attribut data-controller avec le namespace du controller Stimulus @symfony/ux-swup/swup, comme on a pu le faire précédement avec UX LazyImage. Pour finir, on ajoute l'identifiant swup sur la balise ayant le contenu sur lequel on souhaite appliquer l'effet de transition.

<!DOCTYPE html>
<html>
    <head>
        ...
        {{ encore_entry_script_tags('app') }}
    </head>
    <body {{ stimulus_controller('symfony/ux-swup/swup') }}>
        <main id="swup">...</main>
    </body>
</html>

On compile les assets avec Webpack :

> yarn run build

Et c'est tout. Lorsque vous cliquerez sur vos liens, la page cible sera pré-chargée pour ajouter un effet de transition sur le changement de page. L'identifiant swup est celui par défaut, mais vous pouvez ajouter vos propres identifiants. Pour cela vous devez ajouter l'attribut data-containers sur la balise <body> et renseigner les identifiants à prendre en compte :

<!DOCTYPE html>
<html>
    <head>
        ...
        {{ encore_entry_script_tags('app') }}
    </head>
    <body {{ stimulus_controller('symfony/ux-swup/swup') }} data-containers="#headers #content #footers">
        <header  id="headers">...</main>
        <section id="content">...</section>
        <footer  id="footers">...</footer>
    </body>
</html>

Enfin pour gérer le type d'animation, vous pouvez ajouter l'attribut data-theme sur la balise <body> et lui donner en valeur le nom du thème Swup.js à utiliser : fade ou slide

<!DOCTYPE html>
<html>
    <head>
        ...
        {{ encore_entry_script_tags('app') }}
    </head>
    <body {{ stimulus_controller('symfony/ux-swup/swup') }} data-theme="slide">
        <main id="swup">...</main>
    </body>
</html>

Premier bilan

On vient de faire le tour des différents packages disponibles à ce jour avec Symfony UX Stimulus et pour moi le contrat est bien rempli ! Je n'ai pas écrit une seule ligne de code JavaScript, ni géré de dépendances JavaScript, tout s'est fait de manière complétement transparente. Un gain de temps qui pourra être investi ailleurs c'est toujours bon à prendre :D

Vous trouverez ci-dessous un lien vers mon POC sur GitHub, le lien vers le GitHub du projet Symfony UX et le lien vers l'article officiel « Symfony UX Initiative ».

Accéder à mon POC Accéder au GitHub du projet Lire l'article officiel sur Symfony UX Initiative

J'espère que je vous aurai donné envie d'essayer Symfony UX et je vous donne rendez-vous pour un prochain article sur Symfony UX Turbo.