Découverte de Symfony UX et composants Twig

Qui suis-je pour parler en ces lieux ?

Je m'appelle Kévin Nadin, je travaille chez Darkmira en tant que développeur PHP

 

Vous pourrez retrouver ces slides et mes anciennes conférences sur:  https://slides.com/kevinjhappy

Symfony UX, quésaco ?

Implémentation avec Twig et PHP d'outils Javascript classes ;)

Beaucoup d'applications possibles :

  1. Champ AutoComplete

  2. Infinite scroll 

  3. Formulaire dans une modal

  4. Navigation sur une map

  5. Système de votes

                     ...

Pour résumer, cela permet de créer des effets stylés en front… sans être expert en front !

Je vais vous présenter quelques applications que j'ai eu l'occasion d'utiliser sur des projets

Twig Components

Pour l'exemple, nous allons implémenter une page permettant de donner les détails d'un utilisateur

<div class="...">
  {{ component('ShowUser', {
      user: app.user
  }) }}
</div>

Coté Twig l'appel au composant se fait comme ceci

<?php

namespace App\Application\Twig\Components;

...
use App\Entity\User;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
// ou on précise le nom du composant si le nom de la classe est différent
#[AsTwigComponent('ShowUser')]
class ShowUser
{

    // ici la valeur est rempli lors de l'appel au composant
    public User $user;
    
    ...
    
}

Ce qui va appeler la classe PHP suivante 

<div class="...">
  <a href="{{ path('user_profile', {'id': this.getId}) }}">
     <img src="{{ asset(this.getAvatar) }}" alt="user" />
  </a>
  <p>
  	{{ this.getName }}
  </p>
</div>

Avec le template correspondant ShowUser.html.twig

Ici on peut voir que l'on appelle this.getId, this.getAvatar et this.getName

Ces fonctions sont codé dans la partie PHP

<?php
...

#[AsTwigComponent('ShowUser')]
class ShowUser
{
    public User $user;
    
    public function getAvatar(): string
    {
    	 $avatarDir = './assets/images/avatars/';
    	 if (null !== $this->user->getAvatar()) {
         	return $avatarDir.$this->user->getAvatar();
         }
         
         return $avatarDir.'default.png';
    }
    
    public function getId(): int
    {
    	return $this->user->getId()
    }
    
    public function getName(): string
    {
    	return ucfirst($this->user->getFirstname()).' '.ucfirst($this->user->getLastname());
    }
}

Dans notre classe les fonctions rajoutés seront disponible directement coté Twig

On peut aussi rajouter des variables inclus à l'appel du component sans les inclure en PHP

<div class="...">
  {{ component('ShowUser', {
      user: app.user,
      showAvatar : true
  }) }}
</div>
<div class="...">
  <a href="{{ path('user_profile', {'id': this.getId}) }}">
     {% if showAvatar is defined and showAvatar %}
        <img src="{{ asset(this.getAvatar) }}" alt="user" />
     {% endif %}  
  </a>
  <p>
  	{{ this.getName }}
  </p>
</div>

Pour la config du dossier des composants Twig:

twig_components.yaml

twig_component:
    anonymous_template_directory: 'app/components/'
    defaults:
        # Namespace & directory for components
        App\Application\Twig\Components\: 'app/components/'

Live Components

Dans cet exemple, nous allons afficher une liste d'utilisateur avec un scroll infini en fonction de leur pays

L'appel au composant

<div class="...">
	{{ component('UserListGrid', {countryCode: 'FR'}) }}
</div>
<?php 

namespace App\Application\Twig\Components;

...
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;

#[AsLiveComponent('UserListGrid')]
class UserListGrid {
   ...
}

Qui appellera la classe

La classe va utiliser des propriétés live LiveProps

class UserListGrid
{
    use ComponentToolsTrait;
    use DefaultActionTrait;
    
    #[LiveProp(writable: true)]
    public string $countryCode;

    private Paginator $items;

    private const PER_PAGE = 10;

    #[LiveProp]
    public int $page = 1;
    
    #[ExposeInTemplate('per_page')]
    public function getPerPage(): int
    {
        return self::PER_PAGE;
    }
    
    ...
}
    

Et implémenter plusieurs fonctions 

class UserListGrid
{
    ...
	public function __construct(private UserRepository $userRepository) {}
   
    public function getItems(): array|\Traversable
    {
        $this->items = new Paginator(
            $this->userRepository->getUsersByCountry(
                $this->countryCode, $this->page, self::PER_PAGE
            )
        );

        return $this->items->getIterator();
    }
    
    #[LiveAction]
    public function more(): void
    {
        ++$this->page;
    }
    
    // hasMore permet de vérifier si la fin des résultats a été atteinte 
    public function hasMore(): bool
    {
        return \count($this->items) > ($this->page * self::PER_PAGE);
    }
}

On utilise Doctrine pour l'exemple, la fonction doit renvoyer un QueryBuilder pour pouvoir utiliser new Paginator()

class UserRepository extends ServiceEntityRepository {
...
	public function getUsersByCountry(
		string $countryCode, $page = 0, $perPage = 0
	): QueryBuilder {

    	$queryBuilder = $this->createQueryBuilder('user');
     		->where('user.countryCode = :countryCode');
     		->setParameter('countryCode', $countryCode);
     	if ($page > 0 && $perPage > 0) {
       		$queryBuilder->setFirstResult(($page - 1) * $perPage)
       			->setMaxResults($perPage);
     	}

     	return $queryBuilder;
	}
}

Coté Twig il faut une certaine rigueur dans les class et id :

  • La classe doit rester cohérente entre la page courante et les pages précédentes
  • Les id doivent garder la rigueur de l'incrémentation de chaque page pour chaque élément
{# attributes ici est géré par symfony, il donnera un id et d'autres infos #}
<div class="UserListGrid" {{ attributes }}>

	{# cette étape permet de garder en mémoire les résultats précédents #}
    {# data-live-ignore true empèche les méthode LiveAction de se déclencher #}
    {% if page > 1 %}
         <div class="UserListGrid_page" id="items--{{ page - 1 }}-{{ per_page }}" 
         data-live-ignore="true"></div>
    {% endif %}

	{# page courrante #}
	{% for item in this.items %}
    	<div class="UserListGrid_page" id="items--{{ page }}-{{ loop.index }}">
            {{ component('ShowUser', { user: item }) }}
        </div>
    {% endfor %}
    ...

Il faut implémenter le passage à la page suivante, la méthode LiveAction vu côté PHP entre en jeu

<div class="UserListGrid" {{ attributes }}>

    {% if page > 1 %}
       <div class="UserListGrid_page" id="items--{{ page - 1 }}" data-live-ignore="true">
       </div>
    {% endif %}

	{% for item in this.items %}
    	<div class="UserListGrid_page" id="items--{{ page }}">
            {{ component('ShowUser', { user: item }) }}
        </div>
    {% endfor %}
    
     {% if this.hasMore %}
         {# ici nous allons appeler une méthode dans le component 
            avec le #LiveAction appelé "more" #}
         <button data-action="live#action" data-live-action-param="more">
            Charger la suite
         </button>
     {% endif %}
</div>

Cliquer sur "Page suivante" va appeler la méthode PHP dans le LiveComponent

class UserListGrid
{
    #[LiveProp]
    public int $page = 1;

    #[LiveAction]
    public function more(): void
    {
        ++$this->page;
    }

}

Avec $page qui est une propriété Live, alors Symfony peut la mettre à jour dynamiquement et charger la nouvelle page, en prenant l'itération suivante de getItems

Avec cette implémentation, nous avons une page en scroll infini avec un bouton chargeant les éléments sans changer de page

Aller plus loin en rajoutant du JS

En reprenant notre exemple on peut supprimer le bouton "Page suivante" pour charger automatiquement la suite une fois arrivé en bas des éléments chargés

Coté Twig on peut inclure des controllers JS Stimulus facilement

{# va charger le fichier appears-controller.js dans le namespace défini #}
<div class="UserListGrid" {{ attributes.defaults(stimulus_controller('appear') }}>

	{# cette étape permet de garder en mémoire les résultats précédents #}
    {# data-live-ignore true empèche le chargement de nouvelles données car fait dans la boucle #}
    {% if page > 1 %}
         <div class="UserListGrid_page" id="items--{{ page - 1 }}-{{ per_page }}" 
         data-live-ignore="true"></div>
    {% endif %}

	{# page courrante #}
	{% for item in this.items %}
    	<div class="UserListGrid_page" id="items--{{ page }}-{{ loop.index }}">
            {{ component('ShowUser', { user: item }) }}
        </div>
    {% endfor %}
    ...

Certaines parties vont de ce fait changer pour le "this.hasMore"

{# va charger le fichier appears-controller.js dans le namespace défini #}
<div class="UserListGrid" {{ attributes.defaults(stimulus_controller('appear') }}>
    
    ... 
	{% if this.hasMore %}
    <div
    	class="nice-loading"
        {# charge la target coté appear-controller #}
    	data-appear-target="loader"
        {# passe par appear controller au déclenchement de l'event LiveAction #}
    	data-action="appear->live#action"
        {# attends 750ms et appelle la méthode more coté PHP #}
    	data-live-action-param="debounce(750)|more"
    >
	</div>
{% endif %}
import { Controller } from '@hotwired/stimulus';

/* stimulusFetch: 'lazy' */
export default class extends Controller {
    static targets = ['loader'];

    loaderTargetConnected(element) {
        this.observer ??= new IntersectionObserver((entries) => {
            entries.forEach((entry) => {
                if (entry.isIntersecting) {
                    entry.target.dispatchEvent(new CustomEvent('appear', {detail: {entry}}));
                }
            });
        });
        this.observer?.observe(element);
    }

    loaderTargetDisconnected(element) {
        this.observer?.unobserve(element);
    }
}

Le Javascript ressemblera à cela (pris de la démo Symfony UX)

Avec cette implémentation, nous avons une page en scroll infini automatique

D'autres possibilités avec les LiveComponents et autres implémentations

<div {{ attributes.defaults(stimulus_controller('video-modal')) }} >
	<div class="modal fade" 
        tabindex="-1" 
        aria-hidden="true" 
        id="modal-content" 
        data-action="hidden.bs.modal->video-modal#stopVideo 
        shown.bs.modal->video-modal#reloadVideo"
     > 
        ...
        {# implémentation lecteur vidéo #}
        ...

        <button type="button" class="btn-close"
        	data-bs-dismiss="modal"
        	data-action="click->video-modal#stopVideo"
        	aria-label="Close">
        </button>
    </div>
</div>
    

Il est tout a fait possible d'appeler des fonctions stimulus spécifiques

Exemple: modal Bootstrap qui charge un lecteur vidéo

import { Controller } from '@hotwired/stimulus';
import { Modal } from 'bootstrap';

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

    connect() {
        console.log("video modal Connected");
    }

    disconnect() {
        console.log("video modal Disconnected");
    }
    
    stopVideo() {
    	this.videoTargets.forEach(video => {
            video.pause();
            video.currentTime = 0;
            video.removeAttribute('src');
            console.log("Stop de la vidéo :", video);
        })
    }
    
    reloadVideo() {
        this.videoTargets.forEach(video => {
            console.log("♻️ Rechargement de la vidéo :", video);
            video.load();
        });
    }
}

Appelant video_modal_controller.js

Déclencher un LiveListener

<?php

...

class ComponentOne
{
  
	#[LiveAction]
    public function launchSomething(#[LiveArg] int $id)
    {
        $this->emit('OpenSpecificModal', [
            'id' => $id,
        ]);
    }
}

Et écouter dans un autre component

<?php

...

class ComponentTwo extends AbstractController
{
    use DefaultActionTrait;
    use ComponentToolsTrait;

	#[LiveProp]
    public string $html = '';
  
	#[LiveListener('OpenSpecificModal')]
    public function openModal(#[LiveArg] int $id): void
    {
        $this->html = $this->renderBlock(
            '@app/components/OpenSpecificModal.html.twig',
            'modal_body',
            ['id' => $id]
        )->getContent();
    }
}

Traiter des formulaires

<?php
...
use Symfony\UX\LiveComponent\ComponentWithFormTrait;

#[AsLiveComponent]
class ContactUsForm extends AbstractController
{
    use DefaultActionTrait;
    use ComponentWithFormTrait;

    #[LiveProp]
    public $initialFormData = null;

    protected function instantiateForm(): FormInterface
    {
        return $this->createForm(ContactUsType::class, $this->initialFormData);
    }
    
    #[LiveAction]
    public function send()
    {
        $this->submitForm();
        $contactData = $this->getForm()->getData();
        
        // Do something with $contactData
        
        return $this->redirectToRoute(...);
	}

Et l'implémenter en Twig

<div {{ attributes }} class="...">
    {# live#action:prevent même chose que live#action, 
    mais avec un preventDefault() intégré #}
    {{ form_start(form, {
        attr: {
            'data-action': 'live#action:prevent',
            'data-live-action-param': 'send'
        }
    }) }}
    
    {{ form_widget(form.email) }}
   	{{ form_widget(form.message) }}
    
    <button data-loading="addAttribute(disabled)">Envoyer</button>
    
    {{ form_end(form) }}
</div>

Attention !

  • Les formulaires de ce type ne peuvent pas traiter de pièce jointes
  • Il faut passer par des LiveAction dédié à cela pour récupérer des fichiers uploadés (voir les démos de la documentation officielle)

Préparer un champ AutoComplete pour un formulaire :

<?php
...
use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType;

#[AsEntityAutocompleteField]
class UserAutocompleteField extends AbstractType
{
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'class' => User::class,
            'searchable_fields' => ['firstname', 'lastname', 'email'],
            'max_results' => 20,
            'query_builder' => function (UserRepository $userRepository) {
                return $userRepository->createQueryBuilder('user')
                    ->where(...);
            }
        ]);
    }

    public function getParent(): string
    {
        return BaseEntityAutocompleteType::class;
    }
}

Appel dans le FormType

<?php
...

class SomethingFormType extends AbstractType
{
	public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('user', UserAutocompleteField::class, [
            	'autocomplete' => true,
                'multiple' => true,
                'required' => false,
                'max_results' => 20,
            ]
        );
    }

	public function configureOptions(OptionsResolver $resolver): void
    {...}
}

Tout cela n’était qu’un aperçu !
Il existe encore beaucoup d’autres possibilités.

Allez voir la documentation et les démos associées.

Le code source des démos :

Et croyez moi, c'est très utile :)

Il ne vous reste plus qu'à vous laisser tenter !

Des questions ?

Découverte de Symfony UX et composants Twig

By Kevin JHappy

Découverte de Symfony UX et composants Twig

  • 7