Azioni Personalizzate Sonata

Seguendo l'articolo scritto da Thomas Bourdin SYMFONY / SONATA: AGGIUNGERE UNA FUNZIONE DI CLONAZIONE A UN CRUD, vi mostreremo come, in modo molto semplice, possiamo aggiungere azioni personalizzate ad un'interfaccia.

Sul Dashboard:

image-23

Ma vedremo anche come personalizzare in modo semplice e generico le azioni personalizzate della lista, l'intestazione del vostro CRUD e infine come aggiungere azioni per l'elaborazione batch.

Per il mio esempio, sono partito con una tabella molto semplice chiamata Title che qui è l'entità. Non è di vostro interesse avviare il progetto con questi file ma ciò imposta il contesto dell'articolo. Quindi, abbiamo i campi id, titolo, slug che ci permettono di avere dei contenuti.

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

}

Lato configurazione, dichiariamo il nostro 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]

e lo dichiariamo come un Servizio per poter iniettare dei parametri. Qui il punto importante è che associamo un Controller in aggiunta ai parametri usuali (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: [ ~, ~, ~]

Noterete che associamo automaticamente un Controller \App\Controller\SlugTitlesAdminController alla nostra dichiarazione di pagina di amministrazione. Questa fase è fondamentale nella configurazione di una pagina admin. Ci consentirà di giocare con la magia di Sonata. Infatti, Sonata assocerà un'azione personalizzata direttamente con il Controller,
Controller che sarà incaricato di distribuire le nostre azioni tra il modello e i vari servizi di cui potremmo avere bisogno.

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

}

Per finire, ecco la struttura un po' vuota della nostra pagina di amministrazione.

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

}

Questo imposta il contesto. Ora possiamo aggiungere vari elementi della nostra interfaccia.

Azione personalizzata sul Dashboard

Ma cos'è il Dashboard? È la nostra homepage. Per essere precisi, qui:

image-26

Aggiungeremo un piccolo pulsante di importazione. È importante mettersi in situazione :).

Non mi piace ripetere il mio codice, ancora di più quando si tratta di Template. Quindi, per questo tutorial, ho preparato un piccolo pulsante generico che possiamo usare per ogni aggiunta di un'azione a questa pagina.

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

Niente di sorprendente in questo template tranne per una informazione interessante. A livello di TWIG, abbiamo accesso a $context['azioni'] che ci permetterà di passare facilmente informazioni dalla nostra pagina src/Admin/SlugTitlesAdmin.php al nostro template templates/Sonata/default_dashboard_button.html.twig

Ecco uno snapshot del debugger quando impostiamo un breakpoint nel file templates/Sonata/default_dashboard_button.html.twig

image-27

Tornando al nostro file admin src/Admin/SlugTitlesAdmin.php, aggiungeremo il metodo configureRoutes()

Infatti, avremo bisogno di spiegare a Sonata e quindi a Symfony quali sono le azioni e le rotte associate che vogliamo impostare. È importante se si desidera personalizzare l'URL.

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

Come potete vedere, abbiamo due rotte. Uno sguardo veloce all'interfaccia e si capisce rapidamente che abbiamo accesso a quasi tutto sulla nostra 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;

Vedete l'azione import è lasciata vuota. Sonata e Symfony fanno il lavoro per noi e va benissimo così.

Sempre nel nostro file di configurazione della pagina di amministrazione, aggiungeremo il metodo configureDashboardActions(). Qui niente di complicato. Si passa il Template che si occuperà di tutto. Per parte mia, aggiungo informazioni come etichetta, icone e la rotta associata.

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

Il che visivamente ci dà questo:

image-28

Una volta cliccato, cosa succede? Qui avviene la magia. Per l'articolo, quello che stiamo considerando è arrivare ad un Controller, utilizzando un Servizio per recuperare i dati, inserirli nel database e andare direttamente alla nostra pagina con l'elenco dei nostri titoli.

Nella vita reale, vorrete passare da una pagina intermedia con certamente una conferma, un modulo con opzioni, ecc. Lì non siamo nella vita reale. Vogliamo solo mostrare che la nostra azione andrà automaticamente a un Controller, che in questo Controller potete recuperare un Servizio (grazie all'autowiring!) e accedere al repository dell'entità tramite il modelManager.

Sul lato del codice, ho appena aggiunto un file ImportSevice senza configurazione YAML. Accade tutto da solo. Niente di insolito nel metodo getDatas() restituiamo solo un array con un unico 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];
    }
}

Nel nostro Controller non c'è molto neanche:

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

Come potete vedere, abbiamo automaticamente accesso al ImportService senza fare nulla. Poiché il nostro Controller estende CRUDController abbiamo accesso diretto a $this->admin che non è altro che un'istanza di \Sonata\AdminBundle\Admin\AdminInterface che a sua volta estende \Sonata\AdminBundle\Admin\LifecycleHookProviderInterface dandoci accesso ai metodi 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;
}

Quindi, se state cercando di spostare della logica in un Repository o un Servizio dovrete giocare con $this->admin.

Quello che è fantastico è che adesso abbiamo tutta la nostra logica. E il nostro Controller verrà usato per le azioni che aggiungeremo nell'intestazione e su ogni riga della nostra tabella.

Azione personalizzata sull'intestazione del CRUD

image-29

Infatti, niente potrebbe essere più semplice.

    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>

Vedrete che nel nostro template non è più azione a cui abbiamo accesso ma voce.

image-30

Si clicca. Invia direttamente l'azione al nostro metodo \App\Controller\SlugTitlesAdminController::importAction.

Azione personalizzata su Ogni Riga della Tabella

Su questo argomento, consiglio di leggere l'articolo scritto da Thomas Bourdin SYMFONY / SONATA: AGGIUNGERE UNA FUNZIONE DI CLONAZIONE A UN CRUD che fornirà informazioni aggiuntive.

Il codice è semplice tanto quanto gli altri.

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

Ecco i due template utilizzati.

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

Alla fine, niente di nuovo in queste due azioni. Una differisce dall'altra nel fatto che ho aggiunto due parametri al Template: ask_confirmation, confirmation_message. No, questi non sono argomenti nativi di Sonata. Ho appena aggiunto un JavaScript per attivare una conferma. Niente di complicato. Ecco il JavaScript che si occuperà di fare le cose da solo.

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

Questo per dire che una conferma automatica non esiste per le azioni personalizzate a livello delle entità CRUD. Dovrete scegliere di fare direttamente la manipolazione nel vostro Controller, o armeggiare in JavaScript come mostrato nell'esempio.

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

Il Controller prenderà come argomento $id che viene passato automaticamente da Sonata. Corrisponde all'id della vostra entità. Il secondo argomento è il Servizio che si potrebbe voler utilizzare. È autowired tramite Symfony. Quindi, potete aggiungere quanti Servizi desiderate nel vostro Controller.

$this->admin->getSubject() permette di accedere direttamente all'entità in questione. Comodo!

image-31

Ecco il servizio per la vostra informazione.

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

Come potete vedere tramite $this->admin->getModelManager() recuperiamo l'equivalente del repository dell'entità che ci permetterà di registrare direttamente nel nostro servizio.

Ultimo passo, l'azione in modalità batch.

Azione Batch Personalizzata

image-32

Sempre semplice come le altre con Sonata e la magia che ne consegue.

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

Questa volta la conferma è nativa. Perfetto!

Lato Controller, aspetteremo un metodo di tipo batchActionClone() per un'azione chiamata Clone.

Come per l'azione semplice che prende come primo parametro un int $id, batchActionClone'ActionName'() si aspetta un'istanza di ProxyQueryInterface contenente un'istanza di QueryBuilder. Infine, un semplice ProxyQueryInterface->execute() è sufficiente per avere accesso a tutte le entità selezionate.

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

Il codice è piuttosto semplice e mostra bene la logica dietro di esso.

image-34
image-35

Ecco fatto. Ora avete una buona panoramica delle diverse azioni possibili dalla pagina di amministrazione dell'entità.