Description
Symfony version(s) affected
6.4
Description
When working with a dynamic add/delete OneToMany collection on the frontend, I take the approach of incrementing a unique index value to replace the prototype.vars.name
string. As opposed to trying to ensure a 0, 1, 2, 3, etc. order which can be a pain, especially with nested collections.
The user may submit things in an order like 1, 3, 4, 7, etc. depending how they added/removed things. This works if there are no form errors.
The bug happens when the form errors kick in. If the user submits collection children with indices 1, 3, 4, 7, they render fine with said indices, but the errors are trying to map to 1, 2, 3, 4, etc. It seems like the errors get the first index and just start incrementing by 1 instead of using the actual indices.
How to reproduce
The following code is a trimmed down and renamed version of my code, so no promises that the following 100% works.
Parent entity:
<?php
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
class Stuff
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 25, nullable: true)]
#[Assert\Length(max: 25)]
#[Assert\NotBlank]
private ?string $name = null;
/**
* @var Collection<int, Thing>
*/
#[ORM\OneToMany(targetEntity: Thing::class, mappedBy: 'stuff', orphanRemoval: true, cascade: ['persist', 'remove'])]
#[Assert\Valid]
#[Assert\Count(min: 0, max: 4)]
private Collection $things;
public function __construct()
{
$this->things = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): static
{
$this->name = $name;
return $this;
}
/**
* @return Collection<int, Thing>
*/
public function getThings(): Collection
{
return $this->things;
}
public function addThing(Thing $thing): static
{
if (!$this->things->contains($thing)) {
$this->things->add($thing);
$thing->setStuff($this);
}
return $this;
}
public function removeThing(Thing $thing): static
{
if ($this->things->removeElement($thing)) {
// set the owning side to null (unless already changed)
if ($thing->getThing() === $this) {
$thing->setStuff(null);
}
}
return $this;
}
}
Child entity:
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
class Thing
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 25, nullable: true)]
#[Assert\Length(max: 25)]
#[Assert\NotBlank]
private ?string $name = null;
#[ORM\ManyToOne(inversedBy: 'things')]
#[ORM\JoinColumn(nullable: false)]
private ?Stuff $stuff = null;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): static
{
$this->name = $name;
return $this;
}
public function getStuff(): ?Stuff
{
return $this->stuff;
}
public function setStuff(?Stuff $stuff): static
{
$this->stuff = $stuff;
return $this;
}
}
Parent form:
<?php
namespace App\Form;
use App\Entity\Stuff;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class StuffType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('name', TextType::class);
$builder->add('things', CollectionType::class, [
'entry_type' => ThingType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'error_bubbling' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Stuff::class,
]);
}
}
Child form:
<?php
namespace App\Form;
use App\Entity\Thing;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ThingType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('name', TextType::class);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Thing::class,
]);
}
}
Main form template:
{{ form_start(form) }}
{% include 'form/collection.html.twig' with {
collection: form.things,
noun: 'Thing',
max: 4,
} %}
{{ form_end(form) }}
Collection helper template:
{# there was a custom Twig function here to generate an actual unique value #}
{% set uniq_id = 'abc123' %}
{% macro render_item(item, noun) %}
<fieldset class="mb-3">
<legend class="col-form-label required">
{{ noun }} #<span data-number></span>
</legend>
<div id="{{ item.vars.id }}">
{{ form_widget(item) }}
<a class="btn btn-danger btn-sm" href="#">
Delete {{ noun }}
</a>
</div>
</fieldset>
{% endmacro %}
{% do collection.setRendered %}
<div id="container_{{ uniq_id }}">
{% for child in collection.children %}
{{ _self.render_item(child, noun) }}
{% endfor %}
</div>
{{ form_errors(collection) }}
<div class="mb-3">
<button type="button" id="add_{{ uniq_id }}" class="btn btn-primary btn-sm">
Add {{ noun }}
</button>
</div>
<template id="template_{{ uniq_id }}">{{ _self.render_item(collection.vars.prototype, noun) }}</template>
<script>
document.addEventListener('DOMContentLoaded', function() {
const max = {{ max|json_encode|raw }};
const template = document.getElementById('template_{{ uniq_id }}');
const name = '{{ collection.vars.prototype.vars.name }}';
const addButton = document.getElementById('add_{{ uniq_id }}');
const container = document.getElementById('container_{{ uniq_id }}');
{#
previously I wrongly assumed items were always rendered 0, 1, 2, 3
and initialized this value based on the length of collection.children
#}
let index = {{ collection.children ? max(collection.children|keys) : -1 }};
function updateNumbers() {
for (let i = 0; i < container.children.length; i++) {
const el = container.children.item(i);
el.querySelector('[data-number]').textContent = i + 1;
}
}
async function initEl(el) {
const deleteButton = el.querySelector('a.btn-danger');
deleteButton.addEventListener('click', async function(e) {
e.preventDefault();
if (confirm('Are you sure you want to delete this {{ noun }}?')) {
el.remove();
if (container.children.length < max) {
addButton.disabled = false;
}
updateNumbers();
}
});
if (container.children.length >= max) {
addButton.disabled = true;
}
updateNumbers();
}
function addEl() {
if (container.children.length >= max) {
return;
}
index++;
const el = template.content.firstElementChild.cloneNode(true);
el.innerHTML = el.innerHTML.replaceAll(name, index);
container.append(el);
initEl(el);
}
addButton.addEventListener('click', async function(e) {
e.preventDefault();
addEl();
});
for (let i = 0; i < container.children.length; i++) {
initEl(container.children.item(i));
}
});
</script>
In the controller, I'm not doing anything out of the ordinary:
#[Route('/stuff/{id}', name: 'stuff_edit', methods: ['GET', 'POST'])]
public function edit(
EntityManagerInterface $em,
Request $request,
#[MapEntity(id: 'id')] Stuff $stuff,
): Response {
// access checking
$form = $this->createForm(StuffType::class, $stuff);
$form->add('submit', SubmitType::class);
$form->handleRequest($request);
if ($form->isSubmitted()) {
if ($form->isValid()) {
$em->persist($stuff);
$em->flush();
$this->addFlash('notice', 'Stuff updated successfully.');
return $this->redirectToRoute('stuff_edit', [
'id' => $stuff->getId(),
]);
}
$this->addFlash('error', 'There are some errors in the form below.');
}
return $this->render('form/stuff_form.html.twig', [
'form' => $form->createView(),
'stuff' => $stuff,
]);
}
Possible Solution
If possible, the form errors should not increment based on the first index but follow the indices of the actual form elements.
Additional Context
This is from the debugger category for Forms. You can see the Forms are indexed 1, 3, 4, 7.
This is from the debugger category for Validator. You can see it is trying to map errors to 1, 2, 3, 4.
What happens is the form renders:
- index 1 as the 1st item which gets error 1
- index 3 as the 2nd item which gets error 3
- index 4 as the 3rd item which gets error 4
- index 7 as the 4th item which gets no error
- then the unmapped error 2 is bubbled to
{{ form_errors(collection) }}