Sonata custom actions

Pour faire suite à l’article écrit par Thomas Bourdin SYMFONY / SONATA : AJOUTER UNE FONCTION CLONE DANS UN CRUD, nous allons montrer comment, de manière très simple, nous pouvons ajouter des actions personnalisées dans une interface.

Sur le Dashboard :

image-23

Mais nous allons également voir comment personnaliser simplement et de manière générique les actions personnalisées du listing, de l’entête de votre CRUD et pour finir comment ajouter des actions pour un traitement par lot.

Pour mon exemple je suis parti d’une table toute simple appelée Title dont voici l’entité. Aucun intérêt pour vous de lancer le projet avec ces fichiers mais cela pose le contexte de l’article. On a donc des champs id, title, slug qui nous permettent d’avoir du contenu.

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use App\Repository\TitlesRepository;
/**
 * Projets
 *
 * @ORM\Table(name="titles")
 * @ORM\Entity(repositoryClass=TitlesRepository::class)
 */
class SlugTitles
{
    /**
     * @ORM\Column(name="id", type="integer", nullable=false)
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private ?int $id = null;


    /**
     * @ORM\Column(name="title", type="string", length=350, nullable=true)
     */
    private ?string $title = null;

    /**
     * @ORM\Column(name="slug", type="string", length=350, nullable=true)
     */
    private ?string $slug = null;

    /**
     * @return ?int
     */
    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @param int $id
     * @return $this
     */
    public function setId(int $id): self
    {
        $this->id = $id;
        return $this;
    }

    /**
     * @return string|null
     */
    public function getTitle(): ?string
    {
        return $this->title;
    }

    /**
     * @param string|null $title
     * @return $this
     */
    public function setTitle(?string $title): self
    {
        $this->title = $title;
        return $this;
    }

    /**
     * @return string|null
     */
    public function getSlug(): ?string
    {
        return $this->slug;
    }

    /**
     * @param string|null $slug
     * @return $this
     */
    public function setSlug(?string $slug): self
    {
        $this->slug = $slug;
        return $this;
    }

    public function __toString(): string
    {
        return (string) $this->title;
    }

}

Côté config nous déclarons notre admin (config/packages/sonata_admin.yaml:29,34) :

sonata_admin:
    title: 'Sonata Admin'
    title_logo: /bundles/sonataadmin/images/logo_title.png
    show_mosaic_button: false
    security:
        handler: sonata.admin.security.handler.role
    options:
        default_admin_route: edit
        html5_validate: false
    global_search:
        admin_route: edit
    breadcrumbs:
        child_admin_route: edit
    dashboard:
        groups:
            runroom:
            users:
                label: Users
                icon: <i class="fa fa-users"></i>
                on_top: true
                items:
                    - sonata.user.admin.user
            media:
                label: Media
                icon: <i class="fa fa-photo"></i>
                on_top: true
                items:
                    - sonata.media.admin.media
            slug_titles:
                label: "Titles"
                icon: <i class="fa fa-users"></i>
                on_top: true
                items:
                    - admin.titles
        blocks:
            - { type: sonata.admin.block.admin_list, position: left })

sonata_block:
    blocks:
        sonata.admin.block.admin_list:
            contexts: [admin]

et on le déclare comme Service afin de pouvoir injecter des paramètres. Ici le point important est que nous y associons un Controller en plus des paramètres habituels (config/services.yaml:20,24) :

parameters:

services:
    # default configuration for services in *this* file
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.

    # makes classes in src/ available to be used as services
    # this creates a service per class whose id is the fully-qualified class name
    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'
            - '../src/Kernel.php'

    # add more service definitions when explicit configuration is needed
    # please note that last definitions always *replace* previous ones
    admin.titles:
        class: App\Admin\SlugTitlesAdmin
        tags:
            - { name: sonata.admin, model_class: App\Entity\SlugTitles, controller: App\Controller\SlugTitlesAdminController, manager_type: orm, group: admin , label: "Administrations de vos titres de pages" }
        arguments: [ ~, ~, ~]

Vous remarquerez que nous associons automatiquement à notre déclaration de page d’administration un Controller \App\Controller\SlugTitlesAdminController. Cette phase est essentielle dans la mise en place d’une page d’admin. Elle va nous permettre de jouer avec la magie de Sonata. En effet Sonata va associer une action personnalisée directement avec le Controller,
Controller qui sera chargé de dispatcher nos actions entre le model et différents services dont nous pourrions avoir besoin.

<?php

declare(strict_types=1);

namespace App\Controller;

use Sonata\AdminBundle\Admin\AdminInterface;
use Sonata\AdminBundle\Controller\CRUDController;
use Sonata\AdminBundle\Datagrid\ProxyQueryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

final class SlugTitlesAdminController extends CRUDController
{
    public function batchActionClone( ProxyQueryInterface $selectedModelQuery, AdminInterface $admin, Request $request): RedirectResponse
    {
        return new RedirectResponse(
            $admin->generateUrl('list', [
                 'filter' => $this->admin->getFilterParameters()
            ])
        );
    }

    /**
     * @param $id
     */
    public function cloneAction($id): Response
    {
        return new RedirectResponse(
            $admin->generateUrl('list', [
                 'filter' => $this->admin->getFilterParameters()
            ])
        );
    }

}

Pour finir voici la coquille un peu vide de notre page d’admin.

<?php
declare(strict_types=1);

namespace App\Admin;

use Sonata\AdminBundle\Admin\AbstractAdmin;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Form\FormMapper;
use Sonata\AdminBundle\Route\RouteCollectionInterface;
use Sonata\AdminBundle\Show\ShowMapper;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\TextType;

final class SlugTitlesAdmin extends AbstractAdmin
{
    /**
     * @param string|null $code
     * @param string|null $class
     * @param string|null $baseControllerName
     */
    public function __construct(?string $code, ?string $class,?string  $baseControllerName)
    {
        parent::__construct($code, $class, $baseControllerName);
    }

    /**
     * @param DatagridMapper $filter
     * @return void
     */
    protected function configureDatagridFilters(DatagridMapper $filter): void
    {
        $filter
            ->add('id')
            ->add('title')
            ->add('slug')
        ;
    }

    /**
     * @param ListMapper $list
     * @return void
     */
    protected function configureListFields(ListMapper $list): void
    {
        $list
            ->add('title')
            ->add('slug')
            ->add(ListMapper::NAME_ACTIONS, null, [
                'actions' => [
                    'show' => [],
                    'edit' => [],
                    'delete' => []
                 ]
            ]);
        ;
    }

    /**
     * @param FormMapper $form
     * @return void
     */
    protected function configureFormFields(FormMapper $form): void
    {
        $form
            ->add('title', TextType::class, ['help' => 'Renseignez le titre de votre article'])
            ->add('slug', HiddenType::class)
        ;
    }

    /**
     * @param ShowMapper $show
     * @return void
     */
    protected function configureShowFields(ShowMapper $show): void
    {
        $show
            ->add('id')
            ->add('title')
            ->add('slug')
        ;
    }

}

Voilà qui pose le contexte. On va pouvoir ajouter à présent nos différents éléments de notre interface.

Action customisée sur le Dashboard

Mais le Dashboard c’est quoi ? C’est notre page d’accueil. Pour être exacte, ici :

image-26

On va ajouter un petit bouton d’import. Il faut bien se mettre en situation :).

Je n’aime pas trop répéter mon code, encore plus quand il s’agit de Template. Alors pour ce tuto j’ai préparé un petit bouton générique que l’on peut utiliser à chaque ajout d’action sur cette page.

<a class="btn btn-link btn-flat" href="{{ admin.generateUrl(action.route) }}">
    <i class="fas {{ action.icon | default('')}}"></i>
    {{ action.label | default('') | trans({}, 'default') }}
</a>

Rien de bien étonnant dans ce template si ce n’est une information intéressante. Au niveau du TWIG nous avons accès à $context[‘actions’] qui va nous permettre de passer des informations facilement depuis notre page src/Admin/SlugTitlesAdmin.php vers notre template templates/Sonata/default_dashboard_button.html.twig

Voici une capture du debugger lorsque l’on pose un point d’arrêt dans le fichier templates/Sonata/default_dashboard_button.html.twig

image-27

De retour dans notre fichier d’admin src/Admin/SlugTitlesAdmin.php, nous allons ajouter la méthode configureRoutes()

En effet il va falloir expliquer à Sonata et donc à Symfony quelles sont les actions et les routes associées que nous souhaitons configurer. C’est important si vous souhaitez customiser l’URL.

    /**
     * @param RouteCollectionInterface $collection
     * @return void
     */
    protected function configureRoutes(RouteCollectionInterface $collection): void
    {
        $collection
            ->add('clone', $this->getRouterIdParameter().'/clone')
            ->add('import');
    }

Comme vous le voyez nous avons deux routes. Un petit aperçu de l’interface et vous comprenez rapidement que nous avons accès à à peu prêt tout sur notre Route.

namespace Sonata\AdminBundle\Route;

use Symfony\Component\Routing\Route;

/**
 * @author Jordi Sala <jordism91@gmail.com>
 */
interface RouteCollectionInterface
{
    /**
     * @param array<string, mixed>  $defaults
     * @param array<string, string> $requirements
     * @param array<string, mixed>  $options
     * @param string[]              $schemes
     * @param string[]              $methods
     *
     * @return static
     */
    public function add(
        string $name,
        ?string $pattern = null,
        array $defaults = [],
        array $requirements = [],
        array $options = [],
        string $host = '',
        array $schemes = [],
        array $methods = [],
        string $condition = ''
    ): self;

Vous voyez l’action import est laissée vide. Sonata et Symfony font le job pour nous et c’est tres bien comme cela.

Toujours dans notre fichier de configuration de page d’administration, nous allons ajouter la méthode configureDashboardActions(). Rien de bien compliqué ici. On passe le Template qui va se charger de tout. Pour ma part j’y ajoute les informations comme label, icones et la route associée.

    /**
     * @param array $actions
     * @return array|\mixed[][]
     */
    protected function configureDashboardActions(array $actions): array
    {
        $actions['import'] = [
            'template' => 'Sonata/default_dashboard_button.html.twig',
            'label'    => 'Import',
            'icon'     => 'fa-level-up-alt',
            'route'    => 'import'
        ];


        return $actions;
    }

Ce qui visuellement nous donne ceci :

image-28

Une fois que l’on clique que se passe-t-il ? C’est là que la magie opère. Pour l’article, ce que nous envisageons c’est d’arriver dans un Controller, utiliser un Service pour récupérer des données, les insérer dans la base de données et aller directement sur notre page avec la liste de nos titres.

Dans la vraie vie vous allez vouloir passer par une page intermédiaire avec certainement une confirmation, un formulaire avec des options, etc. Là nous ne sommes pas dans la vie réelle. On veut juste montrer que notre action va automatiquement aller sur un Controller, que dans ce Controller vous pouvez récupérer un Service (merci l’autowiring !) et accéder au repository de votre entité via le modelManager.

Côté code j’ai juste ajouté un fichier ImportSevice sans configuration YAML. Ça se fait tout seul. Rien de folichon dans la méthode getDatas() on retourne juste un array avec 1 seule donnée.

<?php

namespace App\Service;

use App\Repository\TitlesRepository;

class ImportService
{

    /**
     * @return array
     */
    public function getDatas(): array
    {
        $data = [
            'title' => 'titre factice : ' . (new \DateTime())->format(\DateTimeInterface::RSS),
            'slug' => null
        ];
        return [$data];
    }
}

Dans notre Controller il n y a pas grand chose non plus :

    /**
     * @param ImportService $importService
     * @return RedirectResponse
     * @throws \Sonata\AdminBundle\Exception\ModelManagerThrowable
     */
    public function importAction(ImportService $importService): RedirectResponse
    {
        $datas = $importService->getDatas();
        foreach($datas as $data){
            $newEntity = new SlugTitles();
            $newEntity->setTitle($data['title']);
            $newEntity->setSlug($data['slug']);
            $this->admin->create($newEntity);
        }

        return new RedirectResponse($this->admin->generateUrl('list'));
    }

Comme vous le voyez nous avons automatiquement accès au service ImportService sans rien faire. Comme notre Controller étend CRUDController nous avons accès directement à $this->admin qui n’est autre qu’une instance de \Sonata\AdminBundle\Admin\AdminInterface qui lui même étend \Sonata\AdminBundle\Admin\LifecycleHookProviderInterface nous donnant accès au méthodes update(), create(), delete().

<?php

declare(strict_types=1);

/*
 * This file is part of the Sonata Project package.
 *
 * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Sonata\AdminBundle\Admin;

use Sonata\AdminBundle\Exception\LockException;
use Sonata\AdminBundle\Exception\ModelManagerThrowable;

/**
 * This interface can be implemented to provide hooks that will be called
 * during the lifecycle of the object.
 *
 * @author Thomas Rabaix <thomas.rabaix@sonata-project.org>
 *
 * @phpstan-template T of object
 */
interface LifecycleHookProviderInterface
{
    /**
     * @throws ModelManagerThrowable
     * @throws LockException
     *
     * @phpstan-param T $object
     * @phpstan-return T $object
     */
    public function update(object $object): object;

    /**
     * @throws ModelManagerThrowable
     *
     * @phpstan-param T $object
     * @phpstan-return T $object
     */
    public function create(object $object): object;

    /**
     * @throws ModelManagerThrowable
     *
     * @phpstan-param T $object
     */
    public function delete(object $object): void;
}

Donc si vous chercher à déplacer une certaine logique dans un Repository ou un Service il faudra jouer avec $this->admin.

Ce qu’il y a de super c’est qu’à présent nous avons toute notre logique. Et notre Controller va nous servir pour les actions que nous allons ajouter dans l’entête et sur chacune des lignes de notre tableau.

Action customisée sur l’entête du CRUD

image-29

En fait rien de plus simple.

    protected function configureActionButtons(array $buttonList, string $action, ?object $object = null): array
    {
        $buttonList['import'] = [
            'template' => 'Sonata/default_header_action.html.twig',
            'label'    => 'Import',
            'icon'     => 'fa-level-up-alt',
            'route'    => 'import'
        ];
        return $buttonList;
    }
<li>
    <a class="sonata-action-element" href="{{ admin.generateUrl(item.route) }}">
        <i class="fas {{ item.icon | default('')}}"></i>
        {{ item.label | default('') | trans({}, 'default') }}
    </a>
</li>

Vous verrez que dans notre template ce n’est plus action auquel nous avons accès mais item.

image-30

On clique. Ça va directement envoyer l’action à notre méthode \App\Controller\SlugTitlesAdminController::importAction.

Action customisée sur chacune des lignes du tableau

Concernant ce sujet je vous recommande la lecture de l’article écrit par Thomas Bourdin SYMFONY / SONATA : AJOUTER UNE FONCTION CLONE DANS UN CRUD qui vous apportera des informations complémentaires.

Le code est tout aussi simple que les autres.

    /**
     * @param ListMapper $list
     * @return void
     */
    protected function configureListFields(ListMapper $list): void
    {
        $list
            ->add('title')
            ->add('slug')
            ->add(ListMapper::NAME_ACTIONS, null, [
                'actions' => [
                    'show' => [],
                    'edit' => [],
                    'delete' => [],
                    'clone' => [
                        'template' => 'Sonata/default_filed_button_action.html.twig',
                        'label' => 'Clone',
                        'route' => 'clone',
                        'icon_class' => 'fas fa-clone',
                        'ask_confirmation' => true,
                        'confirmation_message' => 'êtes vous sûr de blahblah cette action ????? '
                    ],
                    'import' => ['template' => 'Sonata/custom_field_button.html.twig']
                ],
            ]);
        ;
    }

Voici les deux templates utilisés.

<a href="{{ admin.generateObjectUrl(actions.route, object) }}"
   class="btn btn-sm btn-default"
   {% if actions.ask_confirmation is defined and actions.ask_confirmation == true %}
        data-confirm="{{ actions.confirmation_message }}"
    {% endif %}
    >
    <i class="{{ actions.icon_class | default('') }} " aria-hidden="true"></i>
    {{ actions.label | default('') | trans({}, 'default') }}
</a>
<a class="btn btn-sm btn-default" href="{{ admin.generateUrl('import') }}">
    <i class="fas fa-level-up-alt"></i> {{ 'Import'|trans({}, 'SonataAdminBundle') }}
</a>

Finalement rien de neuf dans ces deux actions. L’une diffère de l’autre dans le sens ou j’ai ajouté deux paramètres au Template : ask_confirmation, confirmation_message. Non, ce ne sont pas des arguments natifs de Sonata. J’ai juste ajouté un javascript pour provoquer un confirm. Rien de bien compliqué. Voici le JavaScript qui va s’occuper de faire les choses toutes seules.

sonata_admin:
    assets:
        extra_javascripts:
            - '/Sonata/custom_field_action_ask_confirmation.js'
$(document).ready(function() {
    $('a[data-confirm]').click(function(ev) {
        var href = $(this).attr('href');

        if (!$('#dataConfirmModal').length) {
            $('body').append('' +
                '<div id="dataConfirmModal" class="modal fade" role="dialog" aria-labelledby="dataConfirmLabel" aria-hidden="true">' +
                '<div class="modal-dialog modal-lg">' +
                '<div class="modal-content">' +
                '<div class="modal-header">' +
                '<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>' +
                '<h4 class="modal-title"></h4>' +
                '</div>' +
                '' +
                '<div class="modal-body">' +
                '</div>' +
                '<div class="modal-footer">' +
                '<button class="btn" data-dismiss="modal" aria-hidden="true">Cancel</button>' +
                '<a class="btn btn-primary" id="dataConfirmOK">OK</a>' +
                '</div>' +
                '</div>' +
                '</div>' +
                '</div>');
        }
        $('#dataConfirmModal').find('.modal-body').text($(this).attr('data-confirm'));
        $('#dataConfirmOK').attr('href', href);
        $('#dataConfirmModal').modal({show:true});
        return false;
    });
});

Tout ça pour dire qu’une confirmation automatique n’existe pas pour les action customisées au niveau des entités du CRUD. Vous aurez, soit le choix de faire directement la manipulation dans votre Controller, soit bidouiller en Javascript comme montré en exemple.

Voici la methode cloneAction()

    /**
     * @param $id
     * @param CloneService $cloneService
     * @return Response
     */
    public function cloneAction($id, CloneService $cloneService): Response
    {
        $object = $this->admin->getSubject();

        if (!$object) {
            throw new NotFoundHttpException(sprintf('unable to find the object with id: %s', $id));
        }
        $clonedResult = $cloneService->clone($this->admin->getModelManager(), $object);

        if($clonedResult){
            $this->addFlash('sonata_flash_success', 'Cloned successfully');
        }else{
            $this->addFlash('sonata_flash_error', 'Clone Error');
        }

        return new RedirectResponse($this->admin->generateUrl('list', ['filter' => $this->admin->getFilterParameters()]));
    }

Le Controller prendra en argument $id qui est automatiquement passé par Sonata. Il correspond à l’id de votre entité. Le second argument est le Service qu’éventuellement vous souhaitez utiliser. Il est autowiré via Symfony. Vous pouvez donc ajouter autant de Services que vous souhaitez dans votre Controller.

$this->admin->getSubject() vous permet d’accèder directement à l’entity en question. Pratique !

image-31

Voici le service à titre d’information.

<?php

namespace App\Service;

use App\Entity\SlugTitles;
use Sonata\DoctrineORMAdminBundle\Model\ModelManager;

Class CloneService {

    /**
     * @param CRUDController $crudController
     * @param SlugTitles $slugTitle
     * @return bool
     */
    public function clone(ModelManager $modelManager, SlugTitles $slugTitle): bool
    {
        try{
            $clonedObject = clone $slugTitle;
            $clonedObject->setTitle($slugTitle->getTitle().' (Clone)');
            $modelManager->create($clonedObject);
        }catch (\Exception $e){
            return new \Exception('Erreur lors du clonage : ' . $e->getMessage());
        }

        return true;
    }
}

Comme vous pouvez le voir via $this->admin->getModelManager() nous récupèrons l’équivalent de l’entity repository qui va nous permettre d’enregistrer directement dans notre service.

Dernière étape, l’action en mode batch.

Action customisée en batch

image-32

Toujours aussi simple que les autres avec Sonata et la magie qui va avec.

    /**
     * @param array $actions
     * @return array|\mixed[][]
     */
    protected function configureBatchActions(array $actions): array
    {
        if (
            $this->hasRoute('edit') && $this->hasAccess('edit') &&
            $this->hasRoute('delete') && $this->hasAccess('delete')
        ) {
            $actions['clone'] = [
                'ask_confirmation' => true,
            ];
        }

        return $actions;
    }

Cette fois-ci la confirmation est native. Parfait !

Côté Controller on va attendre une méthode de type batchActionClone() pour une action nommée Clone.

A l’image de l’action simple qui prend en premier paramètre un int $id, batchActionClone’ActionName'() attend une instance de ProxyQueryInterface contenant une instance de QueryBuilder. Finalement un simple ProxyQueryInterface->execute() suffit pour avoir accès à l’ensemble des entités sélectionnées.

image-33
    public function batchActionClone(ProxyQueryInterface $selectedModelQuery, CloneService $cloneService): RedirectResponse
    {
        $entityList = $selectedModelQuery->execute();
        
        try {
            foreach ($entityList->getIterator() as $entity) {
                $clonedResult = $cloneService->clone($this->admin->getModelManager(), $entity);
                if($clonedResult){
                    $this->addFlash('sonata_flash_success', 'Cloned successfully: ' . $entity->getTitle());
                }else{
                    $this->addFlash('sonata_flash_error', 'Clone Error' . $entity->getTitle());
                }
            }

        } catch (\Exception $e) {
            $this->addFlash('sonata_flash_error', 'Quelques erreurs sont survenues lors de l\'execution des taches groupés');
        } finally {
            return new RedirectResponse(
                $this->admin->generateUrl('list', [
                    'filter' => $this->admin->getFilterParameters()
                ])
            );
        }
    }

Le code est plutôt simple et montre bien la logique derrière cela.

image-34
image-35

Voilà. Vous avez à présent un bon aperçu des différentes actions possibles depuis votre page d’administration de votre entité.