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
...
<div class="...">
{{ component('ShowUser', {
user: app.user
}) }}
</div><?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;
...
}<div class="...">
<a href="{{ path('user_profile', {'id': this.getId}) }}">
<img src="{{ asset(this.getAvatar) }}" alt="user" />
</a>
<p>
{{ this.getName }}
</p>
</div>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());
}
}<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>twig_component:
anonymous_template_directory: 'app/components/'
defaults:
# Namespace & directory for components
App\Application\Twig\Components\: 'app/components/'
<div class="...">
{{ component('UserListGrid', {countryCode: 'FR'}) }}
</div><?php
namespace App\Application\Twig\Components;
...
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
#[AsLiveComponent('UserListGrid')]
class UserListGrid {
...
}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;
}
...
}
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);
}
}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;
}
}{# 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 %}
...<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>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
{# 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 %}
...{# 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);
}
}<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>
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();
});
}
}<?php
...
class ComponentOne
{
#[LiveAction]
public function launchSomething(#[LiveArg] int $id)
{
$this->emit('OpenSpecificModal', [
'id' => $id,
]);
}
}<?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();
}
}<?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(...);
}<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><?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;
}
}<?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
{...}
}Le code source des démos :