Skip to main content

Combining Symfony live components and Mercure

score sheet

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.

Choose the dealer

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