Search code examples
symfonydoctrine-ormentity-relationshipsymfony-formszikula

Symfony Form ManyToOne OneToMany


I have three entities, Block, BlockPlacement, BlockPosition:

class BlockEntity
{
    private $bid;
    /**
     * @ORM\OneToMany(
     *     targetEntity="BlockPlacementEntity",
     *     mappedBy="block",
     *     cascade={"remove"})
     */
    private $placements;
}

class BlockPlacementEntity
{
    /**
     * The id of the block postion
     *
     * @ORM\Id
     * @ORM\ManyToOne(targetEntity="BlockPositionEntity", inversedBy="placements")
     * @ORM\JoinColumn(name="pid", referencedColumnName="pid", nullable=false)
     */
    private $position;

    /**
     * The id of the block
     *
     * @var BlockEntity
     * @ORM\Id
     * @ORM\ManyToOne(targetEntity="BlockEntity", inversedBy="placements")
     * @ORM\JoinColumn(name="bid", referencedColumnName="bid", nullable=false)
     */
    private $block;

    private $sortorder;
}

class BlockPositionEntity
{
    private $pid;
    /**
     * @ORM\OneToMany(
     *     targetEntity="BlockPlacementEntity",
     *     mappedBy="position",
     *     cascade={"remove"})
     * @ORM\OrderBy({"sortorder" = "ASC"})
     */
    private $placements;
}

So, you can see the relationship: Block < OneToMany > Placement < ManyToOne > Position.

Now I am trying to construct a form to create/edit a block:

    $builder
        ->add($builder->create('placements', 'entity', [
            'class' => 'Zikula\BlocksModule\Entity\BlockPositionEntity',
            'choice_label' => 'name',
            'multiple' => true,
            'required' => false
        ]))
    ;

This gives me a good select box with multiple selections possible with a proper list of positions to choose from. But it does not show previous selections for placement (I am using existing data) e.g. marking positions as 'selected'. I have not tried creating a new Block yet, only editing existing data.

I suspect I will need to be using addModelTransformer() or addViewTransformer() but have tried some of this an cannot get it to work.

I've looked at the collection form type and I don't like that solution because it isn't a multi-select box. It requires JS and isn't as intuitive as a simple select element.

This seems like such a common issue for people. I've searched and found no common answer and nothing that helps.


Solution

  • OK - so in the end, I found a different way. @Stepan Yudin's answer worked, but is complicated (listeners, etc) and not quite like I was hoping.

    So, I have the same three entities. BlockPlacement and BlockPosition remain the same (and so aren't reposted, see above) but I have made some changes to the BlockEntity:

    class BlockEntity
    {
        private $bid;
        /**
         * @ORM\OneToMany(
         *     targetEntity="BlockPlacementEntity",
         *     mappedBy="block",
         *     cascade={"remove", "persist"},
         *     orphanRemoval=true)
         */
        private $placements;
    
        /**
         * Get an ArrayCollection of BlockPositionEntity that are assigned to this Block
         * @return ArrayCollection
         */
        public function getPositions()
        {
            $positions = new ArrayCollection();
            foreach($this->getPlacements() as $placement) {
                $positions->add($placement->getPosition());
            }
    
            return $positions;
        }
    
        /**
         * Set BlockPlacementsEntity from provided ArrayCollection of positionEntity
         * requires
         *   cascade={"remove, "persist"}
         *   orphanRemoval=true
         *   on the association of $this->placements
         * @param ArrayCollection $positions
         */
        public function setPositions(ArrayCollection $positions)
        {
            // remove placements and skip existing placements.
            foreach ($this->placements as $placement) {
                if (!$positions->contains($placement->getPosition())) {
                    $this->placements->removeElement($placement);
                } else {
                    $positions->removeElement($placement->getPosition()); // remove from positions to add.
                }
            }
    
            // add new placements
            foreach ($positions as $position) {
                $placement = new BlockPlacementEntity();
                $placement->setPosition($position);
                // sortorder is irrelevant at this stage.
                $placement->setBlock($this); // auto-adds placement
            }
        }
    }
    

    So you can see that the BlockEntity is now handling a positions parameter which doesn't exist in the entity at all. Here is the relevant form component:

    $builder
        ->add('positions', 'Symfony\Bridge\Doctrine\Form\Type\EntityType', [
            'class' => 'Zikula\BlocksModule\Entity\BlockPositionEntity',
            'choice_label' => 'name',
            'multiple' => true,
        ])
    

    note that I have changed to Symfony 2.8 form style since my first post

    This renders a multiple select element on the page which accepts any number of positions and converts them to an ArrayCollection on submit. This is then handled directly by the form's get/set position methods and these convert to/from the placement property. The cascade and orphanRemoval are important because they take care to 'clean up' the leftover entities.

    because it is references above here is the BlockPlacement setBlock($block) method:

    public function setBlock(BlockEntity $block = null)
    {
        if ($this->block !== null) {
            $this->block->removePlacement($this);
        }
    
        if ($block !== null) {
            $block->addPlacement($this);
        }
    
        $this->block = $block;
    
        return $this;
    }