EasyAdmin and Mercure: real use case

EasyAdmin is one of the most popular admin panel generators available for symfony-applications. Since it uses the standard Symfony security component to authenticate users, it allows many users to log in and change data at the same time.

But there is one problem…

Let’s say you are editing an entity, for example, some product with a certain article. You start to change some property, for example, its quantity in stock. In the meantime, another administrator also decided to edit the same entity. He changes another property, say the price of a product, and commits his changes before you do. In the meantime, you are on an edit page with an already outdated field and are about to enter your own version of the fields without suspecting anything. Everything looks quite normal.

Now you click the “Save” button. What happens in this case?

You don’t see any errors, but you just reverted your colleague’s changes. One solution to this problem is to implement a locking mechanism such as described in Doctrine documentation. This will require you to add a version field to the entity whose data integrity you want to enforce.

Below is a code snippet straight from the Doctrine documentation that demonstrates how this works:

<?php

use Doctrine\DBAL\LockMode;
use Doctrine\ORM\OptimisticLockException;

$theEntityId = 1;
$expectedVersion = 184;

try {
   $entity = $em->find('Article', $theEntityId, LockMode::OPTIMISTIC, $expectedVersion);
   // делаем что-то
   $em->flush();
} catch(OptimisticLockException $e) {
   echo "Sorry, but someone else has already changed this entity. Please apply the changes again!";
}

If someone changes the essence,

will not match and you will get an error. While this strategy resolves value conflicts, it comes at the cost of user experience (UX). What about a user notification that would warn him before how he will try to save his changes?

Mercure

Mercure is an open source solution for fast and reliable real-time messaging. This is a modern and convenient replacement for both the basic WebSocket API and the high-level libraries and services based on it.

Instead of using Doctrine’s blocking mechanism, let’s use Mercure to send real-time notifications that will alert all users on the same page that someone else has just changed the object and therefore the form must be reloaded before the changes can be saved. editing. But this is the standard implementation.

When you’re working with a list, you can potentially see out-of-date data, but it’s not as critical as the data won’t be lost in that case.

Demonstration

An administrator named Semyon edits the first article in order to reduce its quantity (quantity) from 10 to 9:

At the same time, an administrator named Leonid edits the same article in order to increase the price (price) to 51:

Administrator Semyon confirms his changes by clicking one of the save buttons.

Administrator Leonid receives a real-time notification, and JavaScript processes the information from the received message to warn the user that this article has already been changed by someone else, but he edited it. He is prompted to reload the page before making his own changes:

Administrator Leonid refreshes the page and now sees the current price.

Administrator Leonid reduces the number to 9 and saves.

Final state of the item:

Without real-time notification, its status would be:

And Semyon’s modification would have been lost.

How to implement it?

This feature is optional, which means that it will only be activated if you take care of it yourself. As the very first step, you will need to install the Mercure bundle:

composer require mercure-bundle

This bundle includes a Docker recipe for adding a Mercure container. You may need to change the Mercure options a bit if you’re not going to use the recipe for Docker.

But it will run on default settings, so you should see notifications without any additional configuration. Of course, in order for Mercure to work, you will need to update your project’s Docker containers.

docker compose up –wait

You should now see the Mercure container.

docker ps

CONTAINER ID   IMAGE         	COMMAND              	CREATED     	STATUS     	PORTS                                  	NAMES
cb37e7a21a64   dunglas/mercure   "/usr/bin/caddy run …"   8 seconds ago   Up 7 seconds   443/tcp, 2019/tcp, 0.0.0.0:50943->80/tcp   easyadmin-mercure-demo-mercure-1

In the following output, we can see that the open port is 50943. You can also use the command docker portto check local ports:

docker port cb37e7a21a64
80/tcp -> 0.0.0.0:50943

If you are using the command line in Symfony, the Mercure container will be automatically detected and display the MERCURE_PUBLIC_URL and MERCURY_URL environment variables (only works with the dunglas/mercure Docker image). (documentation)

Implementation

Technically, when Mercure is running, the data-ea-mercure-url HTML attribute is created containing the topic the user needs to subscribe to in order to receive notifications:

data-ea-mercure-url="{{ ea_call_function_if_exists('mercure', mercure_topic, {'hub' : hub_name}) }}"

We then subscribe to this Mercure topic using the object EventSource:

const mercureURL = document.body.getAttribute('data-ea-mercure-url');
if (! mercureURL) {
   return;
}


const eventSource = new EventSource(mercureURL);

When a notification is received, the related entity IDs are extracted from it and the HTML reacts accordingly to notify the user:

eventSource.onmessage = event => {
   const data = JSON.parse(event.data);
   const action = data.action;
   const id = Object.values(data.id)[0];
   const bodyId = document.body.getAttribute('id');
   const row = document.querySelector('tr[data-id="'+id+'"]');

You can see the full version of the code Here.

Demo Application

There is a small demo application where you can easily test this functionality. You can find it at the following link: https://github.com/coopTilleuls/easyadmin-mercure-demo.

README explains how to run an application using Docker and the Symfony command line. You can return to the default behavior by simply deleting mercur-bundle:

composer remove mercure-bundle
   docker compose down --remove-orphans
   docker compose up --wait

Return to the EasyAdmin interface. Now you should not see any notifications and no errors in the logs either on the server side or on the client side. Don’t forget to update your browser!

Testing with Panther

This mechanism is a good use case not only for Mercure, but also for panther. Indeed, we cannot cover it with standard Symfony functional tests (by extending WebTestCase). But we can use Panther which works with JavaScript because it uses a real headless browser (Chrome or Firefox). In this case, the task becomes a little more difficult because we have to use two completely isolated browser instances (administrator 1 and administrator 2).

It provides a special test case Symfony\Component\Panther\PantherTestCase which contains several useful methods and assertions. Let’s see what code we can write to test the scenario we discussed above:

<?php

declare(strict_types=1);

namespace App\Tests\E2E\Controller\Admin;

use Facebook\WebDriver\Exception\NoSuchElementException;
use Facebook\WebDriver\Exception\TimeoutException;
use Symfony\Component\Panther\PantherTestCase as E2ETestCase;

/**
* @see https://github.com/symfony/panther#creating-isolated-browsers-to-test-apps-using-mercure-or-websockets
*/
final class ArticleCrudControllerTest extends E2ETestCase
{
   private const SYMFONY_SERVER_URL = 'http://127.0.0.1:8000'; // используем локальный веб-сервер Symfony CLI

   private const ARTICLE_LIST_URL = '/admin?crudAction=index&crudControllerFqcn=App\Controller\Admin\ArticleCrudController';

   // это второй артикул, так как мы создаем первый в тесте AdminCrud
   private const ARTICLE_EDIT_URL = '/admin?crudAction=edit&crudControllerFqcn=App\Controller\Admin\ArticleCrudController&entityId=2';

   private const ARTICLE_NEW_URL = '/admin?crudAction=new&crudControllerFqcn=App\Controller\Admin\ArticleCrudController';

   private const NOTIFICATION_SELECTOR = '#conflict_notification';

   /**
    * @throws NoSuchElementException
    * @throws TimeoutException
    */
   public function testMercureNotification(): void
   {
       $this->takeScreenshotIfTestFailed();

       // подключается первый администратор 
       $client = self::createPantherClient([
           'external_base_uri' => self::SYMFONY_SERVER_URL,
       ]);

       $client->request('GET', self::ARTICLE_LIST_URL);
       self::assertSelectorTextContains('body', 'Article');
       self::assertSelectorTextContains('body', 'Add Article');

       // первый администратор создает артикул
       $client->request('GET', self::ARTICLE_NEW_URL);
       $client->submitForm('Create', [
           'Article[code]' => 'CDB142',
           'ArticleEasyAdmin и Mercure: реальный юзкейс' => 'Chaise de bureau 2',
           'Article[quantity]' => '10',
           'Article[price]' => '50',
       ]);

       // первый администратор получает доступ к странице редактирования артикула, которую он только что создал
       $client->request('GET', self::ARTICLE_EDIT_URL);

       self::assertSelectorTextContains('body', 'Save changes');

       // второй администратор получает доступ к странице редактирования того же артикула и изменяет количество
       $client2 = self::createAdditionalPantherClient();
       $client2->request('GET', self::ARTICLE_EDIT_URL);
       $client2->submitForm('Save changes', [
           'Article[quantity]' => '9',
       ]);

       // первый администратор получил уведомление благодаря Mercure, и ему предлагается перезагрузить страницу
       $client->waitForVisibility(self::NOTIFICATION_SELECTOR);

       self::assertSelectorIsVisible(self::NOTIFICATION_SELECTOR);
       self::assertSelectorTextContains(self::NOTIFICATION_SELECTOR, 'The data displayed is outdated');
       self::assertSelectorTextContains(self::NOTIFICATION_SELECTOR, 'Reload');
   }
}

The most interesting you can see here:

$client2 = self::createAdditionalPantherClient();

This line allows you to use a second test client completely isolated from the first. This will help us initiate a message from Mercure that the first customer will receive. We simply edit a specific entity and then check if the first client received the Mercure message correctly: whether a notification with a reload button is displayed.

Notifications from other sources

Your entities can of course also be modified by other sources, not just EasyAdmin.

In this case, you can send notifications manually, for example, to listeners on specific domains. Given that this is a Doctrine entity that you receive in the listener, the code would look like this:

// use Symfony\Component\Mercure\Update;
// use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
// use Symfony\Component\Mercure\HubInterface;
$topic = $this->adminUrlGenerator->setController(Article::class)
   ->unsetAllExcept('crudControllerFqcn')->generateUrl();
$update = new Update(
   $topic,
   (string) json_encode(['id' => 1]),
);


$this->hub->publish($update);

Conclusion

This was a specific use case showing how Mercure can help improve the user experience.

Of course, using a Doctrine lock would be a more robust solution. We recommend consolidating data state using both approaches. Mercure’s upsides are that the database doesn’t need to be changed, and you avoid Doctrine blocking errors (and user frustration) as the entity will be updated with notifications.

Do you know any other good use cases for Mercure — feel free to write about them in the comments!

The material was prepared as part of the imminent launch new course flow “Symfony Framework”.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *