SF6 / Sonata 5 : Gérer une interface OneToMany dans un CRUD avec les CollectionType de sonata

Dans cet exemple nous allons prendre le cas concret d’une interface simple qui comprend des éléments multiples.
Nous avons choisis d’utiliser une table « Projets » dans laquelle nous allons affecter des documents.
Donc pour un projet, nous pouvons affecter plusieurs documents. Et pour faciliter l’administration, nous allons faire en sorte de pouvoir gérer notre interface imbriquée directement dans la vue d’édition de notre projet.

Il nous faut créer le schéma.
Au préalable on importe notre schéma actuel dans MysqlWorbench avec la fonction « Database/reverse engineer ». Ce qui nous permettra d’avoir les tables par défaut de Sonata puisque nous allons jouer avec les médias.



Avec MysqlWorbench on choisit une relation 1->N entre notre table « projets » et la table « projets_medias » et ensuite une relation N->1 entre la table « projets_medias » et la table « media__media« .

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

La subtilité consiste à mettre un id auto incrément par défaut dans notre table de liaison et de ne pas mettre nos clés étrangères en primary.

Ça nous donne ceci :

projet_id contiendra l’id de la table projets et media_id contiendra l’id de la table media__media.
On rajoute un champ ordre mais on peut rajouter autant de champs que l’on souhaite. Car nous allons voir comment gérer l’ensemble du formulaire d’édition des éléments de projets_medias.

Dans MysqlWorbench, on choisit « Forward/reverse engineer » pour pousser notre schéma dans mysql.

On fait un dernier tour de passe/passe avec les commandes de Symfony pour générer nos entités et nos interfaces d’admin par défaut.

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

C’est magique mais incomplet. Il nous faut corriger les relations dans nos 2 entités.
Nous allons déjà définir les propriétés d’accès dans les deux sens.

Pour l’entité App\Entity\Projets ce sera la propriété documents, avec les accesseurs addDocument et 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;
}


Pour l’entité App\Entity\ProjetsMedias ce sera la propriété projets, avec les accesseurs getProjets() et setProjets().

private $projets;

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

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

Concentrons nous sur la configuration de la relation Projets->ProjetsMedias car c’est là que tout se joue.

/**
 * @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 propriété targetEntity de notre OnToMany doit pointer sur l’entité de notre table qui contient la liste des documents et on doit lui donner la propriété de notre relation ManyToOne de cette entité.
Donc ici c’est \App\Entity\ProjetsMedias qui a une propriété projets. On lui donne ensuite le nom de la table mysql qu’il doit joindre : projets_media. JoinColumn définit le nom du champ de la table jointe qui contiendra nos clés primaires de projets, soit la valeur de notre propriété id, qui doit être stockée dans la table projets_id de projets_media.

Ensuite nous configurons notre relation dans 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 propriété targetEntity de notre ManyToOne doit pointer sur l’entité de notre table qui gère les projets et on doit lui donner la propriété de notre relation OneToMany de cette entité.
Donc ici c’est Projets qui a une propriété documents.
On lui donne ensuite le champ de notre table courante (ProjetsMedia) qui stocke la clef primaire du projet. Donc id de la table projets qui est stockée dans projets_id de projets_media

C’est ni plus ni moins que l’inverse de ce que l’on a configuré pour notre définition de \App\Entity\Projets::$documents

Dernière étape, comme on a choisit de jouer avec des médias, on doit configurer notre relation avec la médiathèque de Sonata et configurer les accesseurs.

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

Idem que plus haut. On commence par donner l’entité cible, ici « App\Application\Sonata\MediaBundle\Entity\Media ». Car c’est l’entité que nous avons étendue dans notre configuration du bundle Sonata/Media.

Dans sonata_media.yml nous avons ceci :

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

Ça semble évident mais si vous tombez sur cet article sans avoir toutes les bases il me parait judicieux de le rappeler. Ça pourrait en débloquer plus d’un 😉

Ensuite JoinColumn définit comment on enregistre la relation. Donc c’est le champ media_id de notre table projets_media qui contiendra le champ id de notre table qui gère les médias (media__media).

Il nous reste à configurer le champs média de notre admin que nous avons généré automatiquement plus haut (ProjetsMediasAdmin.php).
Pour gérer un champ média il faut juste utiliser le ModelListType de Sonata. Libre à vous d’activer les boutons optionnels ajout/edition/liste/delete.

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

Les relations sont maintenant complètes. On peut vérifier que tout fonctionne en activant notre admin de ProjetsMedia.

Dans nos services la génération de nos interfaces nous a configuré ceci :

    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, ~]

On peut donc aller directement sur notre interface pour voir si ça fonctionne.

Mais ce qui nous intéresse c’est d’avoir cette interface de gestion directement dans notre gestion de Projets.

Et pour cela nous allons utiliser les CollectionType de Sonata.
Nous ajoutons à notre admin généré automatiquement, dans la méthode qui gère la configuration de formulaire d’édition (configureFormFields), un champ documents qui aura comme type CollectionType avec sa configuration, et chose la plus importante, on ajoute le paramètre admin_code qui pointe sur le nom du service de notre 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'
    ]
);

Accessoirement, dans \App\Admin\ProjetsMediasAdmin, on supprime notre champ projets puisque la valeur sera renseignée automatiquement lorsque l’admin sera imbriqué dans projets.

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

Et la magie opère. On peut maintenant ajouter/supprimer des médias directement dans notre interface.

Mais ce n’est pas tout à fait terminé. On a un champ pour déterminer l’ordre de notre affichage. Il nous faut juste configurer l’admin de ProjetsMedia pour modifier le type du champs ordre.

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

Ensuite on configure notre Collection dans Projets, pour que le paramètre sortable pointe sur notre bon champ.

$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'
    ]
);

Et dernier point, on modifie notre relation Projets->ProjetsMedia pour donner l’ordre souhaité.

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

Notre collection peut maintenant être ordonnée par simple drag’n drop.