Symfony 4 / Sonata : Créer une interface CRUD imbriquée (child 1->N) avec l’ordre en drag’n Drop

En gros on souhaites faire une interface CRUD, 1N, avec laquelle, lorsque l’on est sur l’édition d’un élément, on ajoute un panel pour gérer tous les éléments fils.

Ici nous avons une table wtype, avec une table wconf qui contient une série d’enregistrement reliés a un item wtype.

Sélection_081
Sélection_082

Tout comme pour l’exemple d’implémentation du sortable avec drag’ndrop ( disponible ici ) nous allons utiliser les composants suivants :
pixassociates/sortable-behavior-bundle et stof/doctrine-extensions-bundle
Vous devrez donc avoir préalablement créé une entités qui contient une liaison 1N avec un deuxième entité.

La manipulation consiste uniquement à injecter la deuxième entités dans le call de la déclaration du service de la première avec l’argument « addChild » et la référence du service parent.

Sélection_083

Lorsque l’on souhaites faire référence a un service on utilise la chaîne utilisée pour la déclaration (qui est libre), et on ajoute « @ » pour dire que c’est une référence.

#config/services.yaml
services:
     admin.wconf:
        class: App\Admin\WconfAdmin
        tags:
            - { name: sonata.admin, manager_type: orm, label: "Configuration des types" }
        public: true
        arguments: [~, App\Entity\Wconf, 'PixSortableBehaviorBundle:SortableAdmin']
        calls:
            - [setPositionService, ["@pix_sortable_behavior.position"]]
        
    admin.wtype:
        class: App\Admin\WtypeAdmin
        arguments: [~, App\Entity\Wtype, ~]
        calls:
            - [addChild, ["@admin.wconf"]] 
        tags:
            - { name: sonata.admin, manager_type: orm, label: "Types de contrats" }
        public: true
 

Dans les 2 controller admin, il faudra rajouter les références au librairies suivantes :

use Knp\Menu\ItemInterface as MenuItemInterface;
use Sonata\AdminBundle\Admin\AdminInterface;
use Sonata\AdminBundle\Route\RouteCollection;
use Pix\SortableBehaviorBundle\Services\PositionORMHandler as PositionHandler;

Ensuite dans l’interface Parent, on ajoute les boutons.
Pour l’url de l’interface fils, il faut lui donner la référence du service pour qu’il créé les routes. Comme notre service est « admin.wconf », la référence pour la liste sera donc ‘admin.wconf.list’

    protected function configureSideMenu(MenuItemInterface $menu, $action, AdminInterface $childAdmin = null): void
    {
        if (!$childAdmin && !\in_array($action, ['edit'], true)) {
            return;
        }
               
        $admin = $this->isChild() ? $this->getParent() : $this;
        $id = $admin->getRequest()->get('id');
        $label=$this->hasSubject() && null !== $this->getSubject()->getLabel() ? $this->getSubject()->getLabel():null;
        
        $menu->addChild(
            'Configuration du contrat '.$label,
            $admin->generateMenuUrl('edit', ['id' => $id])
           
            );
        
        $menu->addChild( 'Configuration des interfaces '.$label, 
            [
                'uri' => $admin->generateUrl('admin.wconf.list', ['id' => $id]) 
            ]);
        
    }

Puisque maintenant notre interface CRUD fils est restreinte au scope de notre sélection parent, et que nous souhaitons gérer l’ordre en drag’n drop, nous allons supprimer la possibilité de trier la liste.
Il suffit juste d’ajouter l’argument sortable=false (default true), lors de la définition des champs de la liste.

    protected function configureListFields(ListMapper $listMapper)
    {
        $listMapper->add('_action', null, array(
            'actions' => array(
                'move' => array(
                    'template' => '@PixSortableBehavior/Default/_sort_drag_drop.html.twig',
                    'enable_top_bottom_buttons' => false, //optional
                ),
            ),
        ))
        ;
        
        $listMapper->addIdentifier('etape', null, ['label' => 'Etape','sortable'=>false]);
        $listMapper->addIdentifier('position', null, ['label' => 'position','sortable'=>false]);
        //$listMapper->addIdentifier('id', null, ['label' => 'id','sortable'=>false]);
        $listMapper->addIdentifier('label', null, ['label' => 'Label','sortable'=>false]);
        
        
        $listMapper->addIdentifier('synchro_field', null, ['label' => 'Destination du champs','sortable'=>false]);
        $listMapper->add('actif', null, ['editable' => true,'sortable'=>false]);
        
    }

On souhaites aussi conserver l’ordre par défaut, pour que l’utilisateur retrouve toujours le même ordre à chaque rechargement.
Il y a deux manières.


La première, consiste à surcharger la configuration par défaut :

public function __construct( $code, $class, $baseControllerName ) {
        parent::__construct( $code, $class, $baseControllerName );

}
 protected $datagridValues = array(
        '_page' => 1,
        '_sort_by' => 'position',
        '_sort_order' => 'ASC',
    );

La seconde méthode, permet de faire un trie plus fin, par exemple sur 2 champs en surchargeant la requête utilisée pour la liste.

    public function createQuery($context = 'list')
    {
        $proxyQuery = parent::createQuery('list');
        $proxyQuery->addOrderBy($proxyQuery->getRootAlias().'.etape', 'ASC');
        $proxyQuery->addOrderBy($proxyQuery->getRootAlias().'.position', 'ASC');
    
        return $proxyQuery;
    }
    

Et la on se rends compte que si on désactive l’ordre des colonnes, notre ordre configuré dans notre requête ne fonctionne plus.
Du coup, ce que l’on va faire, c’est surcharger le template pour cette liste uniquement, et virer le code qui gère les entête de colonnes, mais garder tout le reste.

La liste se trouve dans vendor/sonata-project/admin-bundle/src/Resources/views/CRUD/base_list.html.twig et notre block concerné est « table_header »

Sélection_088

Nous allons ajouter notre template dans la déclaration de notre service.
Pour cela nous devons prendre la référence du template, décrit dans cette page : https://symfony.com/doc/master/bundles/SonataAdminBundle/reference/templates.html#global-templates
Dans notre cas c’est

Sélection_089

Et on vas juste ajouter un call de type setTemplate avec notre configuration

    admin.wconf:
        class: App\Admin\WconfAdmin
        tags:
            - { name: sonata.admin, manager_type: orm, label: "Configuration des types" }
        public: true
        arguments: [~, App\Entity\Wconf, 'PixSortableBehaviorBundle:SortableAdmin']
        calls:
            - [setPositionService, ["@pix_sortable_behavior.position"]]
            - [ setTemplate, [list, "Admin/wconf-list.html.twig"]]

On a mis : « Admin/wconf-list.html.twig »
Ce qui veux dire que le système vas aller chercher le fichier /templates/Admin/wconf-list.html.twig
Ce fichier doit étendre la vue initiale de la liste, et redéfinir le block qui gère l’entête uniquement. On souhaites vraiment garder tout le reste.
Du coup, on démarre notre fichier en donnant la référence du template maître, et on redéfinis ensuite notre block

#/templates/Admin/wconf-list.html.twig
{% extends '@SonataAdmin/CRUD/base_list.html.twig' %}

        {% block table_header %}
        
                            <thead>
                                <tr class="sonata-ba-list-field-header">
                                    {% for field_description in admin.list.elements %}
                                        {% if admin.hasRoute('batch') and field_description.getOption('code') == '_batch' and batchactions|length > 0 %}
                                            <th class="sonata-ba-list-field-header sonata-ba-list-field-header-batch">
                                              <input type="checkbox" id="list_batch_checkbox">
                                            </th>
                                        {% elseif field_description.getOption('code') == '_select' %}
                                            <th class="sonata-ba-list-field-header sonata-ba-list-field-header-select"></th>
                                        {% elseif field_description.name == '_action' and app.request.isXmlHttpRequest %}
                                            {# Action buttons disabled in ajax view! #}
                                        {% elseif field_description.getOption('ajax_hidden') == true and app.request.isXmlHttpRequest %}
                                            {# Disable fields with 'ajax_hidden' option set to true #}
                                        {% else %}
                                            {% set sortable = false %}
                                            {% apply spaceless %}
                                                <th class="sonata-ba-list-field-header-{{ field_description.type }}{% if sortable %} sonata-ba-list-field-header-order-{{ sort_by|lower }} {{ sort_active_class }}{% endif %}{% if field_description.options.header_class is defined %} {{ field_description.options.header_class }}{% endif %}"{% if field_description.options.header_style is defined %} style="{{ field_description.options.header_style }}"{% endif %}>
                                                    {% if field_description.getOption('label_icon') %}
                                                        <i class="sonata-ba-list-field-header-label-icon {{ field_description.getOption('label_icon') }}" aria-hidden="true"></i>
                                                    {% endif %}
                                                    {{ field_description.label|trans({}, field_description.translationDomain) }}
                                                   
                                                </th>
                                            {% endapply %}
                                        {% endif %}
                                    {% endfor %}
                                </tr>
                            </thead>
            {% endblock %}

On a ensuite notre entête sans les liens pour pouvoir trier le contenus.
On est sûr que l’utilisateur ne vas pas gérer l’ordre en drag’n drop avec un trie mal configuré et nous appeler toutes les 5 minutes pour nous dire que ça ne fonctionne pas.