SF6 / Sonata 5: Gestionando una interfaz OneToMany en un CRUD con el CollectionType de Sonata

En este ejemplo, tomaremos el caso concreto de una interfaz simple que incluye múltiples elementos.
Hemos elegido usar una tabla de "Proyectos" en la que asignaremos documentos.
Por lo tanto, para un proyecto, podemos asignar varios documentos. Y para facilitar la administración, nos aseguraremos de que podamos gestionar nuestra interfaz anidada directamente dentro de la vista de edición de nuestro proyecto.

Necesitamos crear el esquema.
En primer lugar, importamos nuestro esquema actual en MysqlWorbench utilizando la función "Database/reverse engineer". Esto nos permitirá tener las tablas predeterminadas de Sonata ya que vamos a trabajar con medios.



Con MysqlWorbench, elegimos una relación 1->N entre nuestra tabla "projects" y la tabla "projects_medias" y luego una relación N->1 entre la tabla "projects_medias" y la tabla "media__media".

1->N = OneToMany
N->1 = ManyToOne

La sutileza consiste en poner un id autoincremental por defecto en nuestra tabla de enlace y no tener nuestras claves foráneas como la principal.

Esto es lo que obtenemos:

projet_id contendrá el id de la tabla de proyectos y media_id contendrá el id de la tabla de media__media.
Agregamos un campo de orden pero podemos añadir tantos campos como deseemos. Porque vamos a ver cómo gestionar todo el formulario para editar los elementos de projects_medias.

En MysqlWorbench, elegimos "Forward/reverse engineer" para empujar nuestro esquema a MySQL.

Hacemos un último truco con los comandos de Symfony para generar nuestras entidades y nuestras interfaces de administración predeterminadas.

bin/console doctrine:mapping:import "App\Entity" annotation --path=src/Entity
bin/console make:entity --regenerate App
bin/console make:sonata:admin App/Entity/Projets
bin/console make:sonata:admin App/Entity/ProjetsMedias

Es mágico pero incompleto. Necesitamos corregir las relaciones en nuestras 2 entidades.
Ya definiremos las propiedades de acceso en ambas direcciones.

Para la entidad App\Entity\Projets será la propiedad documents, con los accesorios addDocument y removeDocument


use Doctrine\Common\Collections\Collection;
...
...
private $documents;

public function getDocuments(): Collection
{

    return $this->documents;
}

public function addDocument(ProjetsMedias $document): self
{
    if (!$this->documents->contains($document)) {
        $this->documents[] = $document;
        $document->setProjets($this);
    }

    return $this;
}

public function removeDocument(ProjetsMedias $document): self
{
    if ($this->documents->removeElement($document)) {
        // set the owning side to null (unless already changed)
        if ($document->getProjets() === $this) {
            $document->setProjets(null);
        }
    }

    return $this;
}


Para la entidad App\Entity\ProjetsMedias será la propiedad projets, con los accesorios getProjets() y setProjets().

private $projets;

public function getProjets(): ?Projets
{
    return $this->projets;
}

public function setProjets(?Projets $projets): self
{
    $this->projets = $projets;
    return $this;
}

Concentrémonos en la configuración de la relación Projets->ProjetsMedias porque aquí es donde todo sucede.

/**
 * @var \Doctrine\Common\Collections\Collection
 *
 * @ORM\OneToMany(targetEntity="App\Entity\ProjetsMedias", mappedBy="projets", cascade={"persist", "remove" })
 * @ORM\JoinTable(name="projets_medias",
 *   joinColumns={
 *     @ORM\JoinColumn(name="projets_id", referencedColumnName="id")
 *   }
 * )
 */
private $documents;

La propiedad targetEntity de nuestro OneToMany debe apuntar a la entidad de nuestra tabla que contiene la lista de documentos y debemos darle la propiedad de nuestra relación ManyToOne de esta entidad.
Así que aquí es \App\Entity\ProjetsMedias que tiene una propiedad projets. Luego le damos el nombre de la tabla de MySQL a la que debe unirse: projets_media. JoinColumn define el nombre del campo en la tabla unida que contendrá nuestras claves primarias del proyecto, así que el valor de nuestra propiedad id, que debe almacenarse en el projets_id de projets_media.

Luego configuramos nuestra relación en ProjetsMedia.

/**
 * @var \Projets
 *
 * @ORM\ManyToOne(targetEntity="Projets",inversedBy="documents")
 * @ORM\JoinColumns({
 *   @ORM\JoinColumn(name="projets_id", referencedColumnName="id")
 * })
 */
private $projets;

public function getProjets(): ?Projets
{
    return $this->projets;
}

public function setProjets(?Projets $projets): self
{
    $this->projets = $projets;

    return $this;
}

La propiedad targetEntity de nuestro ManyToOne debe apuntar a la entidad de nuestra tabla que gestiona los proyectos y debemos darle la propiedad de nuestra relación OneToMany de esta entidad.
Así que aquí es Projets que tiene una propiedad documents.
Luego le damos el campo de nuestra tabla actual (ProjetsMedia) que almacena la clave primaria del proyecto. Entonces id de la tabla projets que se almacena en projets_id de projets_media

Esto no es más que el reverso de lo que configuramos para nuestra definición de \App\Entity\Projets::$documents

Último paso, como hemos elegido trabajar con medios, debemos configurar nuestra relación con la biblioteca de medios de Sonata y configurar los accesorios.

use Sonata\MediaBundle\Model\MediaInterface;
...
...
/**
 * @var \MediaMedia
 *
 * @ORM\ManyToOne(targetEntity="App\Application\Sonata\MediaBundle\Entity\Media")
 * @ORM\JoinColumns({
 *   @ORM\JoinColumn(name="media_id", referencedColumnName="id")
 * })
 */
private $mediaMedia;

public function __toString(): string
{
    return $this->getMediaMedia();
}

public function getMediaMedia(): ?MediaInterface
{

    return $this->mediaMedia;
}

public function setMediaMedia(?MediaInterface $mediaMedia): self
{
    $this->mediaMedia = $mediaMedia;

    return $this;
}

Igual que antes. Empezamos dando la entidad objetivo, aquí "App\Application\Sonata\MediaBundle\Entity\Media". Porque es la entidad que extendimos en nuestra configuración del paquete Sonata/Media.

En sonata_media.yml tenemos esto:

sonata_media:
   class:
        media: App\Application\Sonata\MediaBundle\Entity\Media
        gallery: App\Application\Sonata\MediaBundle\Entity\Gallery
        gallery_item: App\Application\Sonata\MediaBundle\Entity\GalleryItem
        category: App\Application\Sonata\ClassificationBundle\Entity\SonataClassificationCategory

Parece obvio pero si te encuentras con este artículo sin tener todos los conceptos básicos, parece prudente recordártelo. Podría desbloquear a más de una persona. 😊

Luego JoinColumn define cómo registramos la relación. Así que es el campo media_id de nuestra tabla projets_media que contendrá el campo id de nuestra tabla que gestiona los medios (media__media).

Ahora solo necesitamos configurar el campo de medios de nuestro admin que generamos automáticamente arriba (ProjetsMediasAdmin.php).
Para gestionar un campo de medios, solo necesitas usar el ModelListType de Sonata. Tienes la libertad de activar los botones opcionales de añadir/editar/listar/eliminar.

use Sonata\AdminBundle\Form\Type\ModelListType;
...
...
protected function configureFormFields(FormMapper $form): void
{
    $form
        ->add('projets')
        ->add('mediaMedia', ModelListType::class, [
            'required' => false,
            'btn_add'=>true,
            'btn_edit'=>false,
            'btn_list'=>false,
            'btn_delete'=>false,
        ])
        ->add('ordre')
    ;
}

Las relaciones ahora están completas. Podemos verificar que todo funciona activando nuestro admin de ProjetsMedia.

En nuestros servicios, la generación de nuestras interfaces ha configurado esto para nosotros:

    admin.projets:
        class: App\Admin\ProjetsAdmin
        tags:
            - { name: sonata.admin, model_class: App\Entity\Projets, controller: App\Controller\ProjetsAdminController, manager_type: orm, group: admin, label: Projets }

    admin.projets_medias:
        class: App\Admin\ProjetsMediasAdmin
        tags:
            - { name: sonata.admin, model_class: App\Entity\ProjetsMedias, controller: App\Controller\ProjetsMediasAdminController, manager_type: orm, group: admin, label: ProjetsMedias }
        arguments: [~, App\Entity\ProjetsMediasAdmin, ~]

Así que ahora podemos ir directamente a nuestra interfaz para ver si funciona.

Pero lo que nos interesa es tener esta interfaz de gestión directamente en nuestra gestión de Proyectos.

Y para eso utilizaremos el CollectionType de Sonata.
Añadimos a nuestro admin generado automáticamente, en el método que gestiona la configuración del formulario de edición, un campo documents que tendrá CollectionType como su tipo con su configuración, y lo más importante, añadimos el parámetro admin_code que apunta al nombre de nuestro servicio de admin de projets_media.

$form->add('documents', \Sonata\Form\Type\CollectionType::class,
    [
        'required' => false,
        'by_reference' => false,
        'label'=> false],
    [
        'edit' => 'inline',
        'inline' => 'table',
        'sortable' => 'position',
        'link_parameters' => [
            'context' => 'default',
            'provider' => 'sonata.media.provider.file',
            'hide_context' => true
        ],
        'admin_code' => 'admin.projets_medias'
    ]
);

De paso, en \App\Admin\ProjetsMediasAdmin, eliminamos nuestro campo projets ya que el valor se rellenará automáticamente cuando el admin esté anidado en proyectos.

protected function configureFormFields(FormMapper $form): void
{
    $form
        //->add('projets')
        ->add('mediaMedia', \Sonata\AdminBundle\Form\Type\ModelListType::class, [
            'required' => false,
            'btn_add'=>true,
            'btn_edit'=>false,
            'btn_list'=>false,
            'btn_delete'=>false,
        ])
        ->add('ordre')
    ;
}

Y la magia sucede. Ahora podemos añadir/eliminar medios directamente dentro de nuestra interfaz.

Pero aún no hemos terminado. Tenemos un campo para determinar el orden de nuestra visualización. Solo necesitamos configurar el admin de ProjetsMedia para modificar el tipo del campo order.

use Symfony\Component\Form\Extension\Core\Type\HiddenType;
...
...
protected function configureFormFields(FormMapper $form): void
{
    $form
        //->add('projets')
        ->add('mediaMedia', ModelListType::class, [
            'required' => false,
            'btn_add'=>true,
            'btn_edit'=>false,
            'btn_list'=>false,
            'btn_delete'=>false,
        ])
        ->add('ordre', HiddenType::class)
    ;
}

Luego configuramos nuestra Collection en Projets, para que el parámetro sortable apunte a nuestro campo correcto.

$form->add('documents', \Sonata\Form\Type\CollectionType::class,
    [
        'required' => false,
        'by_reference' => false,
        'label'=> false],
    [
        'edit' => 'inline',
        'inline' => 'table',
        'sortable' => 'ordre',
        'link_parameters' => [
            'context' => 'default',
            'provider' => 'sonata.media.provider.file',
            'hide_context' => true
        ],
        'admin_code' => 'admin.projets_medias'
    ]
);

Y por último, modificamos nuestra relación Projets->ProjetsMedia para configurar el orden deseado.

/**
 * @var \Doctrine\Common\Collections\Collection
 *
 * @ORM\OneToMany(targetEntity="App\Entity\ProjetsMedias", mappedBy="projets", cascade={"persist", "remove" })
 * @ORM\JoinTable(name="projets_medias",
 *   joinColumns={
 *     @ORM\JoinColumn(name="projets_id", referencedColumnName="id")
 *   }
 * )
 * @ORM\OrderBy({"ordre" = "ASC"})
 */
private Collection $documents;

Nuestra colección puede ahora ordenarse por simple arrastrar y soltar.