Combining Symfony live components and Mercure
Here's the next blog post of my little series about Symfony live components. Last week, I wrote my first Symfony live component, and today I will use Symfony Turbo Streams (with mercure) to update my components.
As you might remember from the previous post, I am refactoring the frontend of dikdikdik, a web app to keep track of the scores when playing Solo Whist, a card game.
This web app uses a command bus, which is nice, because when a user interacts with one of my live components, I can just send a command to the command bus, and the application will do whatever is needed.
Let me make this clear by an example. Below you see an excerpt from the
PlayerSwitchCommand
I created last week, that enables you to disable or re-enable a player. (So that
you can keep on playing when a player leaves, and you still have enough players
at the table. If the player comes back later, you can switch them on again.)
#[LiveAction]
public function kick(): void
{
$this->commandBus->dispatch(
new KickPlayer(
$this->tableIdentifier,
$this->playerIdentifier,
$this->gameNumber
)
);
}
#[LiveAction]
public function join(): void
{
$this->commandBus->dispatch(
new JoinPlayer(
$this->tableIdentifier,
$this->playerIdentifier,
$this->gameNumber,
null
)
);
}
As you can see, the live actions just push commands to the command bus, which I find very readable.
Now I recently created another live component, allowing the user to indicate which player
has dealt the cards: the
DealerSelectComponent
.
This component exposes a list of active players, which will be used by the twig file to render a drop down box with the player's names:
public function getActivePlayers(): PlayerDetailsSet
{
return $this->knownPlayerDetails->getKnownPlayerDetails(
ScoreSheetIdentifier::forTable($this->tableIdentifier)
)->getActive();
}
What I want to achieve, is that the DealerSelectComponent
will be rerendered whenever
a player leaves or joins.
Live components have a polling attribute, that can be used to periodically rerender the component, but this would generate a lot of requests to my humble server. Moreover, the current version of my score sheet app (with vue, not with live components) uses mercure to send server events back to the browser of my visitors, so that the browser only refetches information from the server when that's needed. And once you've used mercure, you don't want to go back to polling 😉.
So I looked at Symfony turbo streams. I am already using turbo streams to render the actual score sheet. (This is already merged in the develop-branch, but not yet in production.) And after some fiddling, I can now update symfony components using turbo streams and mercure events.
This is how it works:
The file
write.html.twig
file, the twig file that renders the score sheet and everything you need to
write down the scores, includes another twig file _manage.players.html.twig
, and this one is wrapped
in a div
that will be updated by a turbo stream:
<div id="manage-players" class="col-sm-6" {{ turbo_stream_listen(constant('\\App\\Publishing\\PlayerManagementPublisher::TOPIC')|format(tableIdentifier.toString)) }} >
{{ include('_manage.players.html.twig', {
permissions: permissions,
tableIdentifier: tableIdentifier,
}) }}
</div>
The file _manage.players.html.twig
contains two components that need to be updated when the player situation is
changed: the DealerSelectComponent
, which I already mentioned, and also
the NewPlayerComponent
, because you can't add a new player if there's already
six players sitting at your table.
This is how that looks:
<turbo-stream action="update" target="manage-players">
<template>
{% if permissions.canAddPlayer or permissions.canAnnounceDealer %}
<h2>{{ 'table.managePlayers'|trans }}</h2>
<div class="card">
<div class="card-body">
{{ component('newPlayer', {
tableIdentifier: tableIdentifier,
permissions: permissions
}) }}
{{ component('dealerSelect', {
tableIdentifier: tableIdentifier,
permissions: permissions
}) }}
</div>
</div>
{% endif %}
</template>
</turbo-stream>
Then I still need a piece of php code that sends the updated html for
_manage.players.html.twig
to mercure whenever the player situation changed.
That piece of code is in the
PlayerManagementPublisher
class:
public function __invoke(PlayersSituationChanged $event): void
{
$tableIdentifier = $event->getTableIdentifier();
$topic = sprintf(self::TOPIC, $tableIdentifier->toString());
$permissions = $this->authorization->getTablePermissions($tableIdentifier);
$this->publisher->__invoke(
new Update(
$topic,
$this->twigEnvironment->render(
'_manage.players.html.twig',
[
'permissions' => $permissions,
'tableIdentifier' => $tableIdentifier,
],
),
),
);
}
So this seems to work:
There are some shortcomings, though:
- Technically the components don't re-render on a mercure event, but the
containing twig re-renders. It would be nice if a mercure event could
trigger the
$render
action of the component itself. But I think this is not possible with live components, at least in the current version. - I use translations in my twigs, but when the publisher renders the twig, it doesn't know which language to use. I think I'll solve this by publishing with a topic per language. The browser should then listen to the correct one. (Update: this was fixed in 0618183a)
- When you type a name in the
NewPlayerComponent
, and press enter, a new player is added, and the input is cleared. I would like this input to keep the focus, but I'm not sure yet how I'll achieve this. (Update: this was fixed in 2924642e)
You can find the code in the feature/167 branch of dikdikdik, but you should be aware that changing the complete front-end takes a lot of time. So the code in this branch is rather broken. Once I've figured out how I'll organize the Symfony Components and Symfony Turbo things, I will fix the application by running the web tests, until they all run again. Once that's ok, I'll be able to remove the vue components.
Update 2021-11-07: This branch was merged this week, so you can just check out the current source code.
Comments
Comments powered by Disqus