Symfony 4 / Sonata: Crear una interfaz CRUD anidada (hijo 1->N) con ordenación por arrastrar y soltar

En resumen, queremos crear una interfaz CRUD, 1N, con la cual, cuando estamos editando un elemento, agregamos un panel para gestionar todos los elementos hijos.

Aquí tenemos una tabla wtype, con una tabla wconf que contiene una serie de registros vinculados a un elemento wtype.

Sélection_081
Sélection_082

Al igual que para el ejemplo de implementación de ordenable con arrastrar y soltar ( disponible aquí ) vamos a usar los siguientes componentes:
pixassociates/sortable-behavior-bundle y stof/doctrine-extensions-bundle
Por lo tanto, deberás haber creado previamente una entidad que contenga una relación 1N con una segunda entidad.

La manipulación consiste únicamente en inyectar la segunda entidad en la declaración de llamada del servicio de la primera con el argumento "addChild" y la referencia al servicio padre.

Sélection_083

Cuando queremos referirnos a un servicio, usamos la cadena utilizada para la declaración (que es libre), y añadimos "@" para indicar que es una referencia.

#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
 

En los 2 controladores de administración, necesitarás añadir referencias a las siguientes librerías:

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

Luego en la interfaz del Padre, agregamos los botones.
Para la url de la interfaz hija, tenemos que darle la referencia del servicio para que cree las rutas. Dado que nuestro servicio es "admin.wconf", la referencia para la lista será entonces '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]) 
            ]);
        
    }

Ahora que nuestra interfaz CRUD hija está restringida al alcance de nuestra selección de padres, y ya que queremos gestionar el orden por arrastrar y soltar, vamos a quitar la posibilidad de ordenar la lista.
Simplemente implica añadir el argumento sortable=false (por defecto true), al definir los campos de la lista.

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

También queremos preservar el orden por defecto, para que el usuario siempre encuentre el mismo orden con cada recarga.
Hay dos maneras.


La primera consiste en sobrecargar la configuración predeterminada:

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

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

El segundo método permite un ordenamiento más fino, por ejemplo en 2 campos sobrescribiendo la consulta utilizada para la lista.

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

Y ahí nos damos cuenta que si desactivamos el ordenamiento de las columnas, nuestro orden configurado en nuestra consulta ya no funciona.
Entonces, lo que vamos a hacer es sobreescribir la plantilla para esta lista solamente, y eliminar el código que gestiona los encabezados de las columnas, pero mantener todo lo demás.

La lista se encuentra en vendor/sonata-project/admin-bundle/src/Resources/views/CRUD/base_list.html.twig y nuestro bloque en cuestión es "table_header"

Sélection_088

Vamos a añadir nuestra plantilla en la declaración de nuestro servicio.
Para eso debemos tomar la referencia de la plantilla, descrita en esta página: https://symfony.com/doc/master/bundles/SonataAdminBundle/reference/templates.html#global-templates
En nuestro caso es

Sélection_089

Y simplemente haremos una llamada de tipo setTemplate con nuestra configuración

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

Ponemos: “Admin/wconf-list.html.twig”
Esto significa que el sistema buscará el archivo /templates/Admin/wconf-list.html.twig
Este archivo debe extender la vista inicial de la lista, y luego redefinir el bloque que gestiona el encabezado solamente. Realmente queremos mantener todo lo demás.
Así, empezamos nuestro archivo dando la referencia de la plantilla maestra, y luego redefinimos nuestro bloque

#/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 %}

Entonces tendremos nuestro encabezado sin los enlaces para ordenar el contenido.
Nos aseguramos de que el usuario no va a gestionar el orden por arrastrar y soltar con un ordenamiento mal configurado y nos llame cada 5 minutos para decirnos que no funciona.