Skip to content

~~Aide mémoire pour Symfony UX

Auteur : Philippe Le Van - @plv@framapiaf.org

Date : 14 décembre 2023

Introduction

Symfony UX est un ensemble de composants qui permettent de faire des "ergonomies javascript" avec Symfony et très peu de Javascript.

Cette page est juste un aide mémoire pour retrouver rapidement les commandes utiles.

Un grand merci à Ryan Weaver pour ses vidéos sur Symfony UX sur le site Symfonycasts ainsi qu'à tous les contributeurs de Symfony UX.

Turbo Drive

Installer turbo drive

1
composer require symfony/ux-turbo

Désactiver turbo drive globalement

1
2
import * as Turbo from '@hotwired/turbo';
Turbo.session.drive = false;

Préchargement d'une page

Preload a page (si on pense qu'un lien va être cliqué juste après)

1
<a href="{{ path('xxx') }}" data-turbo-preload>Foo</a>

Désactiver/réactiver turbo drive sur une page ou une portion de page

Sur n'importe quel élément HTML, on peut utiliser l'attribut data-turbo="true/false"

1
<body data-turbo="false">

désactiver la mise en cache d'une page

Désactiver la preview de turbo drive (mise en cache d'une page) sur une page :

dans le base.html.twig, on peut avoir :

1
{% block metas %}{% endblock %}

Et sur une page donnée :

1
2
3
{% block metas %}
    <meta name="turbo-cache-control" content="no-preview">
{% endblock %}

Empêcher un élément du DOM à entrer dans le cache

1
<div id="foo" data-turbo-cache="false">...</div>

Désactiver turbo drive sur un lien

1
<a href="{{ path('xxx') }}" data-turbo="false">Foo</a>

Evènements sur turbo drive

Les évènements turbo sont décrits ici : https://turbo.hotwired.dev/reference/events

Exemple d'utilisation pour éviter qu'une modale arrive dans un cache de turbo drive :

1
2
3
4
5
6
7
import { Modal } from 'bootstrap';
document.addEventListener('turbo:before-cache', () => {
    if (document.body.classList.contains('modal-open')) {
        const modal = Modal.getInstance(document.querySelector('.modal'));
        modal.hide();
    }
});

Organisation conseillée des listeners Turbo

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { Modal } from 'bootstrap';
const TurboHelper = class {
    constructor() {
        document.addEventListener('turbo:before-cache', () => {
            this.doSomething1();
            this.doSomething2();
        });
        // lancé uniquement quand une page est rendue par 
        // turbo et avant que le body soit mis à jour.
        // warning : appelé 2x quand preview + rendering
        document.addEventListener('turbo:before-render', () => {
            this.xxx();
        });
        // lancé uniquement quand une page est rendue par 
        // turbo et après la mise à jour du body
        document.addEventListener('turbo:render', () => {
            this.xxx();
        });
        // lancé quand on arrive sur une page, dès la 1ère
        // fois puis à chaque visite (=> analytics)
        document.addEventListener('turbo:load', () => {
            this.xxx();
        });
    }
    doSomething1() {
        // do whatever
    }
    doSomething2() {
        // do whatever
    }
}
export default new TurboHelper();

Gérer les rechargement d'assets qui changent avec webpack encore

Dans webpack_encore.yaml

Enable le versionning

1
    .enableVersioning()

reload au cht de version

1
2
3
4
5
    script_attributes:
        defer: true
        'data-turbo-track': reload
    link_attributes:
        'data-turbo-track': reload
1
2
import * as Turbo from '@hotwired/turbo';
Turbo.visit('/foo');

Turbo Frames

Installer turbo frame : comme turbo drive

1
composer require symfony/ux-turbo

La balise turbo-frame

Balise simple

1
2
3
<turbo-frame id="planet-info">
    xxx
</turbo-frame>

avec une source

1
2
<turbo-frame id="planet-info" src="/planets/earth">
</turbo-frame>

Pour une lazy turbo frame

1
<turbo-frame id="planet-info" src="/planets/earth" loading="lazy">

Un lien qui ressort de la frame

1
<a href="{{ path('xxx') }}" data-turbo-frame="_top">Foo</a>

Ajouter un spinner (indicateur de chargement)

Quand une turbo-frame est en cours de chargement, l'attribut busy est ajouté à la balise <turbo-frame busy>

Exemple en jouant avec l'opacité :

1
2
3
4
5
6
7
8
turbo-frame {
    /* pour que l'opacity fonctionne */
    display: block;
}
turbo-frame[busy] {
    opacity: 0.5;
    pointer-events: none;
}

Exemple avec un spinner :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<turbo-frame id="toto">
    {% if showDescription %}
        {{ featuredProduct.description }}
    {% else %}
        {{ featuredProduct.description|u.truncate(25)|trim }}...
        <a
            class="frame-loading-hide"
            href="{{ path('_app_cart_product_featured', {
                description: true,
            }) }}">(read more)</a>
        <span class="frame-loading-show fas fa-spinner fa-spin"></span>
    {% endif %}
</turbo-frame>

et la CSS associée

1
2
3
4
5
6
turbo-frame[busy] .frame-loading-hide, turbo-frame .frame-loading-show {
    display: none;
}
turbo-frame[busy] .frame-loading-show {
    display: inline-block;
}

Header qui indique qu'on est dans une turbo-frame

On reçoit un header Turbo-Frame: planet-info dans la réponse HTTP.

1
$idTurboFrame = $request->headers->get('Turbo-Frame')

data-turbo-frame attribute sur un form

L'attribut data-turbo-frame permet de spécifier la frame dans laquelle le formulaire doit être affiché (un id de turbo frame ou _top).

1
2
3
4
5
6
{{ form_start(form, {
    attr: { 'data-turbo-frame': formTarget|default('_top') }
}) }}
    {{ form_widget(form) }}
    <button class="btn btn-primary" formnovalidate>{{ button_label|default('Save') }}</button>
{{ form_end(form) }}

Le controller stimulus pour gérer un formulaire dans une modal bootstrap

On prend l'exemple d'une page de liste avec un formulaire de new :

Affichage du bouton et récupération de la modal

Extrait du Fichier : templates/admin/product/index.html.twig

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    <div
        {{ stimulus_controller('modal-form') }}
    >
        <button
            class="btn btn-primary btn-sm"
            data-action="modal-form#openModal"
        >+ Add new product</button>
        {{ include('_modal.html.twig', {
            modalTitle: 'Add a new Product',
            modalSrc: path('product_admin_new'),
            frameId: 'product-info',
        }) }}
    </div>

Le contenu de la modal

Fichier : templates/_modal.html.twig

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<div
    class="modal fade"
    tabindex="-1"
    aria-hidden="true"
    data-modal-form-target="modal"
>
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">{{ modalTitle }}</h5>
                <button type="button" class="btn-close"
                        data-bs-dismiss="modal"
                        aria-label="Close"></button>
            </div>
            <turbo-frame
                class="modal-body"
                src="{{ modalSrc }}"
                id="{{ frameId }}"
            >
                {{ modalContent|default('Loading...') }}
            </turbo-frame>
        </div>
    </div>
</div>

Controller Stimulus

Fichier : assets/controllers/modal-form_controller.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { Controller } from 'stimulus';
import { Modal } from 'bootstrap';
import * as Turbo from '@hotwired/turbo';
export default class extends Controller {
    static targets = ['modal'];
    modal = null;
    connect() {
        this.boundBeforeFetchResponse = this.beforeFetchResponse.bind(this);
        document.addEventListener('turbo:before-fetch-response', this.boundBeforeFetchResponse);
    }
    disconnect() {
        document.removeEventListener('turbo:before-fetch-response', this.boundBeforeFetchResponse);
    }
    async openModal(event) {
        this.modal = new Modal(this.modalTarget);
        this.modal.show();
    }

    // appelé par un event turbo avant que la réponse du submit ne soit traitée
    beforeFetchResponse(event) {
        // si pas de modal, on ne fait rien
        if (!this.modal || !this.modal._isShown) {
            return;
        }
        // si la requête est un succès et qu'elle est redirigée,
        // on bloque le rechargement pour éviter de remplir la modal
        // et on redirige (avec turbo) l'utilisateur vers la redirection
        // => on sort de la modal et on recharge la page totale.
        const fetchResponse = event.detail.fetchResponse;
        if (fetchResponse.succeeded && fetchResponse.redirected) {
            event.preventDefault();
            Turbo.visit(fetchResponse.location);
        }
    }
}

Turbo stream

Basic usage

PHP renvoie d'une réponse de type Turbo Stream

La réponse PHP comporte un 3e paramètre qui est une instance de TurboStreamResponse.

1
2
3
4
5
return $this->render(
    'product/reviews.stream.html.twig',
    [],
    new TurboStreamResponse()
);

Cette réponse renvoie un content-Type: text/vnd.turbo-stream.html

Le twig avec la réponse de turbo stream

Le twig associé contient les éléments suivants :

1
2
3
4
5
<turbo-stream action="update" target="content-div">
    <template>
        Le nouveau texte.
    </template>
</turbo-stream>

L'HTML qui contient le texte qui sera remplacé

L'HTML où va s'insérer le contenu

1
2
3
<div id="content-div">
    Contenu qui sera remplacé
</div>

Autres actions de turbo stream

Dans le paragraphe précédent, on a vu une action de type update.

Il existe d'autres actions :

  • append
  • prepend
  • replace
  • update
  • remove
  • before
  • after

Target une classe CSS au lieu d'un id

On peut remplacer l'attribut target par un attribut targets, on peut mettre une classe CSS au lieu d'un id.

1
2
3
4
5
<turbo-stream action="update" targets=".css-class">
    <template>
        Le nouveau texte.
    </template>
</turbo-stream>

Savoir si le navigateur accepte les Turbo Streams

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
    $request->setRequestFormat(TurboBundle::STREAM_FORMAT);

    return $this->render('product/reviews.stream.html.twig', [
        'product' => $product,
    ]);
}
$this->addFlash('review_success', 'Thanks for your review! I like you!');
return $this->redirectToRoute('app_product_reviews', [
    'id' => $product->getId(),
]);

Activer un Turbo Stream en Javascript

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Controller } from 'stimulus';
import { visit, renderStreamMessage } from '@hotwired/turbo';
export default class extends Controller {
    count = 0;
    static targets = ['count'];
    increment() {
        this.count++;
        this.countTarget.innerText = this.count;
        const streamMessage = `
<turbo-stream action="update" target="flash-container">
    <template>
        <div class="alert alert-success">
            Thanks for clicking ${this.count} times!
        </div>
    </template>
</turbo-stream>
        `;
        renderStreamMessage(streamMessage);
        if (this.count === 10) {
            visit('/you-won');
        }
    }
}

Turbo Stream et Mercure

Installation

1
composer require symfony/mercure-bundle

Activer Mercure

Dans assets/controllers.json, pensez à bien activer mercure-turbo-streams

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
    "controllers": {
        "@symfony/ux-turbo": {
            "turbo-core": {
                "enabled": true,
                "fetch": "eager"
            },
            "mercure-turbo-stream": {
                "enabled": true,
                "fetch": "eager"
            }
        }
    },
    "entrypoints": []
}

Listener dans l'HTML

1
<div {{ turbo_stream_listen('product-reviews') }}></div>

Publier un message en PHP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Mercure\UpdateBuilder;

class ProductController extends AbstractController
{
    public function review(Request $request, Product $product, HubInterface $hub): Response
    {
        // ...
        $update = new Update(
            'product-reviews',
                '
                <turbo-stream action="update" target="product-quick-stats">
                    <template>QUICK STATS CHANGED!</template>
                </turbo-stream>
                '
        );
        $hub->publish($update);
        // ...
    }
}

Publication automatique d'une entité : Broadcast

Dans l'entité PHP

1
2
3
4
5
6
7
8
9
// ajouter une annotation @Broadcast
/**
 * @ORM\Entity(repositoryClass=ReviewRepository::class)
 * @Broadcast()
 */
class Review
{
    // ...
}

Dans le twig

1
<div {{ turbo_stream_listen('App\\Entity\\Review') }}></div>

le turbo-stream envoyé (par twig) sur une modification de l'entité

dans broadcast/Review.stream.html.twig

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{% block create %}
<turbo-stream action="update" target="product-{{ entity.product.id }}-quick-stats">
    <template>
        {{ include('product/_quickStats.html.twig') }}
    </template>
</turbo-stream>
<turbo-stream action="append" target="product-{{ entity.product.id }}-review-list">
    <template>
        {{ include('product/_review.html.twig', {
        review: entity,
        isNew: true
        }) }}
    </template>
</turbo-stream>
{% endblock %}
{% block update %}
    UPDATE!
{% endblock %}
{% block remove %}
    REMOVE!
{% endblock %}

Stimulus

Installation

1
composer require symfony/stimulus-bundle

Création d'un controller stimulus

assets/controllers/hello_controller.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = [ "name", "output" ]
    connect() {
        this.element.textContent = 'Hello Stimulus!';
    }
    greet() {
        this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!`
    }
}

Initialisation d'un controller stimulus

en html simple

1
<div data-controller="hello"></div>

en twig

1
<div {{ stimulus_controller('hello') }}></div>

Action stimulus

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<div {{ stimulus_action('controller', 'method') }}>Hello</div>
<div {{ stimulus_action('controller', 'method', 'click') }}>Hello</div>
<div {{ stimulus_action('controller', 'method')|stimulus_action('other-controller', 'test') }}>Hello</div>
<div {{ stimulus_action('hello-controller', 'method', 'click', { 'count': 3 }) }}>Hello</div>

<!-- would render -->
<div data-action="controller#method">Hello</div>
<div data-action="click->controller#method">Hello</div>
<div data-action="controller#method other-controller#test">Hello</div>
<div data-action="click->hello-controller#method" data-hello-controller-count-param="3">Hello</div>

Pour un form :

1
{{ form_row(form.password, { attr: stimulus_action('hello-controller', 'checkPasswordStrength').toArray() }) }}

Targets stimulus

1
2
3
4
5
6
7
8
<div {{ stimulus_target('controller', 'a-target') }}>Hello</div>
<div {{ stimulus_target('controller', 'a-target second-target') }}>Hello</div>
<div {{ stimulus_target('controller', 'a-target')|stimulus_target('other-controller', 'another-target') }}>Hello</div>

<!-- would render -->
<div data-controller-target="a-target">Hello</div>
<div data-controller-target="a-target second-target">Hello</div>
<div data-controller-target="a-target" data-other-controller-target="another-target">Hello</div>

Pour un form :

1
{{ form_row(form.password, { attr: stimulus_target('hello-controller', 'a-target').toArray() }) }}

Twig Components

Documentation des twig components

Installation

1
composer require symfony/ux-twig-component

Exemple simple

Classe src/Twig/Components/Alert.php

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
namespace App\Twig\Components;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
class Alert
{
    public string $type = 'success';
    public string $message;
}

Template templates/components/Alert.html.twig

1
2
3
4
{# templates/components/Alert.html.twig #}
<div class="alert alert-{{ type }}">
    {{ message }}
</div>

Plusieurs syntaxes possible pour le render :

1
2
3
{{ component('Alert', { message: 'Hello Twig Components!' }) }}

<twig:Alert message="Or use the fun HTML syntax!" />

Live Components

Documentation des live components

Un live component, c'est un twig component qui se met à jour tout seul.