Acciones Personalizadas de Sonata

Siguiendo el artículo escrito por Thomas Bourdin SYMFONY / SONATA: AÑADIENDO UNA FUNCIÓN DE CLONACIÓN A UN CRUD, vamos a mostrar cómo, de manera muy simple, podemos añadir acciones personalizadas a una interfaz.

En el Tablero:

image-23

Pero también vamos a ver cómo personalizar de manera simples y genérica las acciones personalizadas del listado, el encabezado de su CRUD y finalmente cómo añadir acciones para el procesamiento por lotes.

Para mi ejemplo, comencé con una tabla muy simple llamada Title que aquí es la entidad. No es de interés para ustedes lanzar el proyecto con estos archivos, pero establece el contexto del artículo. Así, tenemos campos id, title, slug que nos permiten tener contenido.

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

}

En el lado de la configuración, declaramos nuestro 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]

y lo declaramos como un Servicio para poder inyectar parámetros. Aquí el punto importante es que asociamos un Controlador además de los parámetros habituales (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: [ ~, ~, ~]

Notarán que automáticamente asociamos un Controlador \App\Controller\SlugTitlesAdminController a nuestra declaración de página de administración. Esta fase es esencial en la configuración de una página de admin. Nos permitirá jugar con la magia de Sonata. De hecho, Sonata asociará una acción personalizada directamente con el Controlador,
Controlador que estará encargado de distribuir nuestras acciones entre el modelo y varios servicios que puedan necesitarse.

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

}

Para terminar, aquí está la carcasa algo vacía de nuestra página de 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')
        ;
    }

}

Esto establece el contexto. Ahora podemos añadir varios elementos de nuestra interfaz.

Acción Personalizada en el Tablero

Pero, ¿qué es el Tablero? Es nuestra página de inicio. Para ser exactos, aquí:

image-26

Vamos a añadir un pequeño botón de importación. Es importante ponerse en situación :).

No me gusta repetir mi código, incluso más cuando se trata de Plantillas. Así que, para este tutorial, he preparado un pequeñito botón genérico que podemos utilizar para cada adición de una acción en esta página.

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

Nada sorprendente en esta plantilla excepto por una información interesante. A nivel de TWIG, tenemos acceso a $context['actions'] que nos permitirá pasar fácilmente información desde nuestra página src/Admin/SlugTitlesAdmin.php a nuestra plantilla templates/Sonata/default_dashboard_button.html.twig

Aquí hay una instantánea del depurador cuando establecemos un punto de interrupción en el archivo templates/Sonata/default_dashboard_button.html.twig

image-27

De vuelta en nuestro archivo admin src/Admin/SlugTitlesAdmin.php, vamos a añadir el método configureRoutes()

En efecto, necesitaremos explicar a Sonata y por lo tanto a Symfony cuáles son las acciones y rutas asociadas que queremos configurar. Es importante si quieres personalizar la URL.

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

Como pueden ver, tenemos dos rutas. Una rápida mirada a la interfaz y rápidamente entienden que tenemos acceso a prácticamente todo en nuestra Ruta.

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;

Ven la acción importar se deja en blanco. Sonata y Symfony hacen el trabajo por nosotros y eso está muy bien.

Todavía en nuestro archivo de configuración de la página de administración, vamos a añadir el método configureDashboardActions(). Nada complicado aquí. Pasamos la Plantilla que se ocupará de todo. Por mi parte, añado información como etiquetas, iconos y la ruta asociada.

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

Lo que visualmente nos da esto:

image-28

Una vez se hace clic, ¿qué pasa? Aquí es donde ocurre la magia. Para el artículo, lo que estamos considerando es llegar a un Controlador, usar un Servicio para recuperar datos, insertarlos en la base de datos y dirigirnos directamente a nuestra página con la lista de nuestros títulos.

En la vida real, querrás pasar por una página intermedia con ciertamente una confirmación, un formulario con opciones, etc. Ahí no estamos en la vida real. Solo queremos mostrar que nuestra acción irá automáticamente a un Controlador, que en este Controlador puedes recuperar un Servicio (¡gracias al autowiring!) y acceder al repositorio de la entidad vía el modelManager.

En el lado del código, solo he añadido un archivo ImportSevice sin configuración YAML. Sucede por sí mismo. Nada inusual en el método getDatas() simplemente devolvemos un array con solo un dato.

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

En nuestro Controlador, tampoco hay mucho:

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

Como pueden ver, tenemos acceso automáticamente al ImportService sin hacer nada. Como nuestro Controlador extiende de CRUDController tenemos acceso directo a $this->admin que no es otro que una instancia de \Sonata\AdminBundle\Admin\AdminInterface que a su vez extiende de \Sonata\AdminBundle\Admin\LifecycleHookProviderInterface dándonos acceso a los métodos actualizar(), crear(), eliminar().

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

Entonces, si estás buscando trasladar algo de lógica a un Repositorio o un Servicio necesitarás jugar con $this->admin.

Lo que es genial es que ahora tenemos toda nuestra lógica. Y nuestro Controlador se utilizará para las acciones que agregaremos en el encabezado y en cada línea de nuestra tabla.

Acción Personalizada en el Encabezado del CRUD

image-29

De hecho, nada podría ser más 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>

Verán que en nuestra plantilla ya no es acción a la que tenemos acceso sino a ítem.

image-30

Haces clic. Envía directamente la acción a nuestro método \App\Controller\SlugTitlesAdminController::importAction.

Acción Personalizada en Cada Línea de la Tabla

En este tema, recomiendo leer el artículo escrito por Thomas Bourdin SYMFONY / SONATA: AÑADIENDO UNA FUNCIÓN DE CLONACIÓN A UN CRUD que proporcionará información adicional.

El código es tan simple como los demás.

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

Estas son las dos plantillas utilizadas.

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

En última instancia, nada nuevo en estas dos acciones. Una se diferencia de la otra en que he añadido dos parámetros a la Plantilla: ask_confirmation, confirmation_message. No, estos no son argumentos nativos de Sonata. Simplemente añadí un JavaScript para activar un confirm. Nada complicado. Aquí está el JavaScript que se encargará de hacer las cosas por sí mismo.

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

Todo eso para decir que una confirmación automática no existe para acciones personalizadas al nivel de las entidades CRUD. O bien elegirás hacer directamente la manipulación en tu Controlador, o arreglártelas en JavaScript como se muestra en el ejemplo.

He aquí el método 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()]));
    }

El Controlador tomará como argumento $id que es automáticamente pasado por Sonata. Corresponde al id de tu entidad. El segundo argumento es el Servicio que desearías utilizar. Es autowired a través de Symfony. Por lo tanto, puedes añadir tantos Servicios como desees en tu Controlador.

$this->admin->getSubject() te permite acceder directamente a la entidad en cuestión. ¡Práctico!

image-31

Aquí está el servicio para su información.

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

Como pueden ver vía $this->admin->getModelManager() recuperamos el equivalente del repositorio de la entidad que nos permitirá registrar directamente en nuestro servicio.

Último paso, la acción en modo por lotes.

Acción Personalizada en Lote

image-32

Siempre tan simple como las demás con Sonata y la magia que conlleva.

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

Esta vez la confirmación es nativa. ¡Perfecto!

En el lado del Controlador, esperaremos un método de tipo batchActionClone() para una acción llamada Clone.

Como la acción simple que toma como primer parámetro un int $id, batchActionClone'ActionName'() espera una instancia de ProxyQueryInterface que contiene una instancia de QueryBuilder. Finalmente, un simple ProxyQueryInterface->execute() es suficiente para tener acceso a todas las entidades seleccionadas.

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

El código es bastante simple y muestra bien la lógica detrás de esto.

image-34
image-35

Ahí lo tienen. Ahora tienen una buena visión general de las diferentes acciones posibles desde la página de administración de su entidad.