Sonata Custom Actions

Following the article written by Thomas Bourdin SYMFONY / SONATA: ADDING A CLONE FUNCTION TO A CRUD, we are going to show how, in a very simple way, we can add custom actions to an interface.

On the Dashboard:

image-23

But we are also going to see how to simply and generically customize the custom actions of the listing, your CRUD’s header and finally how to add actions for batch processing.

For my example, I started with a very simple table called Title which here is the entity. No interest for you to launch the project with these files but it sets the context of the article. So, we have fields id, title, slug that allows us to have content.

<?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;
    }

}

On the config side, we declare our 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]

and we declare it as a Service to be able to inject parameters. Here the important point is that we associate a Controller in addition to the usual parameters (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: [ ~, ~, ~]

You will notice that we automatically associate a Controller \App\Controller\SlugTitlesAdminController to our administration page declaration. This phase is essential in setting up an admin page. It will allow us to play with the magic of Sonata. Indeed, Sonata will associate a custom action directly with the Controller,
Controller which will be in charge of dispatching our actions between the model and various services we may need.

<?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()
            ])
        );
    }

}

To finish, here is the somewhat empty shell of our admin page.

<?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')
        ;
    }

}

That sets the context. We can now add various elements of our interface.

Custom Action on the Dashboard

But what is the Dashboard? It's our homepage. To be exact, here:

image-26

We’re going to add a small import button. It is important to put oneself in situation :).

I do not like to repeat my code, even more so when it comes to Template. So, for this tutorial, I have prepared a generic little button that we can use for each addition of an action on this 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>

Nothing surprising in this template except for one interesting information. At the TWIG level, we have access to $context[‘actions’] which will allow us to easily pass information from our page src/Admin/SlugTitlesAdmin.php to our template templates/Sonata/default_dashboard_button.html.twig

Here is a snapshot of the debugger when we set a breakpoint in the file templates/Sonata/default_dashboard_button.html.twig

image-27

Back in our admin file src/Admin/SlugTitlesAdmin.php, we’re going to add the configureRoutes() method

Indeed, we will need to explain to Sonata and thus to Symfony what are the actions and associated routes that we want to set up. It’s important if you want to customize the URL.

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

As you can see, we have two routes. A quick look at the interface and you quickly understand that we have access to just about everything on our 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;

You see the action import is left blank. Sonata and Symfony do the job for us and that's just fine.

Still in our administration page configuration file, we're going to add the configureDashboardActions() method. Nothing complicated here. We pass the Template that will take care of everything. For my part, I add information like label, icons, and the associated route.

    /**
     * @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;
    }

Which visually gives us this:

image-28

Once clicked, what happens? That's where the magic happens. For the article, what we are considering is arriving at a Controller, using a Service to retrieve data, insert it into the database, and go directly to our page with the list of our titles.

In real life, you will want to go through an intermediate page with certainly a confirmation, a form with options, etc. There we are not in real life. We just want to show that our action will automatically go to a Controller, that in this Controller you can retrieve a Service (thanks autowiring!) and access the entity repository via the modelManager.

On the code side, I just added an ImportSevice file without YAML configuration. It happens by itself. Nothing unusual in the method getDatas() we just return an array with only one piece of data.

<?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];
    }
}

In our Controller, there is not much either:

    /**
     * @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'));
    }

As you can see, we automatically have access to the ImportService without doing anything. As our Controller extends CRUDController we have direct access to $this->admin which is none other than an instance of \Sonata\AdminBundle\Admin\AdminInterface which itself extends \Sonata\AdminBundle\Admin\LifecycleHookProviderInterface giving us access to the methods 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;
}

So, if you're looking to shift some logic into a Repository or a Service you'll need to play with $this->admin.

What’s great is that we now have our whole logic. And our Controller will be used for the actions that we will add in the header and on each line of our table.

Custom Action on the CRUD Header

image-29

In fact, nothing could be simpler.

    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>

You will see that in our template it's no longer action that we have access to but item.

image-30

You click. It directly sends the action to our method \App\Controller\SlugTitlesAdminController::importAction.

Custom Action on Each Line of the Table

On this topic, I recommend reading the article written by Thomas Bourdin SYMFONY / SONATA: ADDING A CLONE FUNCTION TO A CRUD which will provide additional information.

The code is just as simple as the others.

    /**
     * @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']
                ],
            ]);
        ;
    }

Here are the two templates used.

<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>

Ultimately, nothing new in these two actions. One differs from the other in that I've added two parameters to the Template: ask_confirmation, confirmation_message. No, these are not native arguments of Sonata. I just added a JavaScript to trigger a confirm. Nothing complicated. Here is the JavaScript that will take care of doing things all by itself.

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;
    });
});

All that to say that an automatic confirmation does not exist for custom actions at the level of the CRUD entities. You will either choose to directly do the manipulation in your Controller, or tinker in JavaScript as shown in the example.

Here is the method 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()]));
    }

The Controller will take as argument $id which is automatically passed by Sonata. It corresponds to the id of your entity. The second argument is the Service that you may wish to use. It is autowired via Symfony. You can therefore add as many Services as you want in your Controller.

$this->admin->getSubject() allows you to directly access the entity in question. Handy!

image-31

Here is the service for your 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;
    }
}

As you can see via $this->admin->getModelManager() we retrieve the equivalent of the entity repository which will allow us to record directly in our service.

Last step, the batch mode action.

Custom Batch Action

image-32

Always as simple as the others with Sonata and the magic that goes with it.

    /**
     * @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;
    }

This time the confirmation is native. Perfect!

On the Controller side, we will wait for a method of type batchActionClone() for an action named Clone.

Like the simple action which takes as a first parameter an int $id, batchActionClone'ActionName'() expects an instance of ProxyQueryInterface containing an instance of QueryBuilder. Finally, a simple ProxyQueryInterface->execute() is enough to have access to all the selected entities.

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()
                ])
            );
        }
    }

The code is rather simple and shows well the logic behind it.

image-34
image-35

There you have it. You now have a good overview of the different possible actions from your entity's administration page.