~~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
| composer require symfony/ux-turbo
|
Désactiver turbo drive globalement
| 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)
| <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"
| <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 :
| {% block metas %}{% endblock %}
|
Et sur une page donnée :
| {% block metas %}
<meta name="turbo-cache-control" content="no-preview">
{% endblock %}
|
Empêcher un élément du DOM à entrer dans le cache
| <div id="foo" data-turbo-cache="false">...</div>
|
Désactiver turbo drive sur un lien
| <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 :
| 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
reload au cht de version
| script_attributes:
defer: true
'data-turbo-track': reload
link_attributes:
'data-turbo-track': reload
|
Naviguer en javascript avec Turbo
| import * as Turbo from '@hotwired/turbo';
Turbo.visit('/foo');
|
Turbo Frames
Installer turbo frame : comme turbo drive
| composer require symfony/ux-turbo
|
La balise turbo-frame
Balise simple
| <turbo-frame id="planet-info">
xxx
</turbo-frame>
|
avec une source
| <turbo-frame id="planet-info" src="/planets/earth">
</turbo-frame>
|
Pour une lazy turbo frame
| <turbo-frame id="planet-info" src="/planets/earth" loading="lazy">
|
Un lien qui ressort de la frame
| <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é :
| 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
| turbo-frame[busy] .frame-loading-hide, turbo-frame .frame-loading-show {
display: none;
}
turbo-frame[busy] .frame-loading-show {
display: inline-block;
}
|
On reçoit un header Turbo-Frame: planet-info
dans la réponse HTTP.
| $idTurboFrame = $request->headers->get('Turbo-Frame')
|
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).
| {{ 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) }}
|
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.
| 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 :
| <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
| <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.
| <turbo-stream action="update" targets=".css-class">
<template>
Le nouveau texte.
</template>
</turbo-stream>
|
Savoir si le navigateur accepte les Turbo Streams
| 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
| 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
| <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
| // ajouter une annotation @Broadcast
/**
* @ORM\Entity(repositoryClass=ReviewRepository::class)
* @Broadcast()
*/
class Review
{
// ...
}
|
Dans le twig
| <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
| composer require symfony/stimulus-bundle
|
Création d'un controller stimulus
assets/controllers/hello_controller.js
| 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
| <div data-controller="hello"></div>
|
en twig
| <div {{ stimulus_controller('hello') }}></div>
|
Action stimulus
| <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 :
| {{ form_row(form.password, { attr: stimulus_action('hello-controller', 'checkPasswordStrength').toArray() }) }}
|
Targets stimulus
| <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 :
| {{ form_row(form.password, { attr: stimulus_target('hello-controller', 'a-target').toArray() }) }}
|
Twig Components
Documentation des twig components
Installation
| composer require symfony/ux-twig-component
|
Exemple simple
Classe src/Twig/Components/Alert.php
| 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
| {# templates/components/Alert.html.twig #}
<div class="alert alert-{{ type }}">
{{ message }}
</div>
|
Plusieurs syntaxes possible pour le render :
| {{ 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.