symfony / ux

Symfony UX initiative: a JavaScript ecosystem for Symfony
https://ux.symfony.com/
MIT License
817 stars 298 forks source link

Issue with Nested LiveCollectionType and Non-Null Entity Values Not Being Respected #1700

Open BriceFab opened 5 months ago

BriceFab commented 5 months ago

I am currently working with the Symfony UX Live Component, specifically using nested LiveCollectionType::class instances. My setup involves three levels of LiveCollectionType nested within each other.

I have encountered an issue where an entity, which requires non-null values for certain properties, is not behaving as expected. When attempting to add an element to one of the collections, I am faced with an error from the property accessor indicating that the property is being set to null.

This behavior is unexpected because the entity has default values defined for its properties, which should prevent null values. However, it seems that these default values are not being acknowledged or applied when the collection element is added.

Environment Symfony version: symfony/ux-live-component ^2.16 Issue Detail The specific error message I am encountering is related to the property accessor and suggests that a property, which should not be null based on my entity definition, is indeed being passed or interpreted as null during the process of adding a new element to a LiveCollectionType nested collection.

The complexity and dependencies of the entity in question make it impractical to consider custom hydration as a solution to this issue. I am seeking advice or a workaround that would allow me to maintain the integrity of my non-null entity properties without resorting to custom solutions that could complicate the system further.

Steps to Reproduce Define an entity with non-null properties and default values. Set up a form with nested LiveCollectionType::class, at least three levels deep. Attempt to add an element to the collection dynamically using the UX Live Component. Observe the error related to property accessor indicating a null value assignment contrary to the entity's definition. Expected Behavior The addition of a new element to a collection should respect the entity's non-null property requirements and default values, without resulting in errors related to null value assignments.

Actual Behavior An error is thrown indicating that a non-null property is being set to null when adding a new element to the collection, despite the entity having defined default values for such properties.

image

class MyElementClass
{
    public const UNIT_REPEAT = 'unit_repeat';
    public const UNIT_TIME = 'unit_time';

    #[ORM\Column(length: 255)]
    private string $unit = self::UNIT_REPEAT;
}

// sample form type
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('my_element_class', LiveCollectionType::class, [
                'entry_type' => MyElementType::class,
                'entry_options' => [
                    'label' => false,
                ],
                'label' => false,
                'allow_add' => true,
                'allow_delete' => true,
                'by_reference' => false,
            ]);
    }
smnandre commented 5 months ago

Does your problem occur on "level one" or does it need the 3 nested levels ?

Also, could you set a minimal reproducer ? It really does help for others to digg in and find what's not working as expected :)

BriceFab commented 5 months ago

@smnandre Sure :)

And same with form type with required => true not apply by default if not selected by the user first..

Video of the issue (expire in 30 days): https://www.swisstransfer.com/d/8ffd72c5-c957-4270-8b9b-b8f64d962dfc

Controller:

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Training\Program\Program;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class TestLiveController extends AbstractController
{
    #[Route('/test-live-form/{id}', name: 'app_test_live_form', defaults: ['id' => null],)]
    public function index(?Program $program = null): Response
    {
        return $this->render('test/live-form.html.twig', [
            'program' => $program,
        ]);
    }
}

Template: test/live-form.html.twig

{% extends 'base.html.twig' %}

{% block content %}
    {{ component('test:live-form', {
        program,
        loading: 'defer',
    }) }}
{% endblock %}

LiveComponent: test:live-form

<?php

declare(strict_types=1);

namespace App\Twig\Components\Test;

use App\Entity\Training\Program\Program;
use App\Entity\Training\Program\ProgramSession;
use App\Form\Training\Program\ProgramType;
use App\Twig\Components\App\Notification;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\LiveCollectionTrait;

#[AsLiveComponent(
    name: 'test:live-form',
    template: 'components/test/live-form.html.twig',
)]
class LiveForm extends AbstractController
{
    use DefaultActionTrait;
    use LiveCollectionTrait;

    #[LiveProp(fieldName: 'formData')]
    public ?Program $program = null;

    protected function instantiateForm(): FormInterface
    {
        if (null === $this->program) {
            $this->program = (new Program())
                ->setName('Program ' . date('d.m.Y'))
                ->setUser($this->getUser())
                ->addSession(new ProgramSession());
        }

        return $this->createForm(
            ProgramType::class,
            $this->program,
        );
    }

    #[LiveAction]
    public function save(EntityManagerInterface $em): Response
    {
        $this->submitForm();

        /** @var Program $program */
        $program = $this->getForm()->getData();

        $this->addFlash(Notification::SUCCESS, 'Enregistré avec succès!');

        $em->persist($program);
        $em->flush();

        return $this->redirectToRoute('app_test_live_form', [
            'id' => $program->getId(),
        ]);
    }
}

Template: components/test/live-form.html.twig

<div {{ attributes.defaults({
    class: 'relative',
}) }}>
    {{ form_start(form) }}

    {{ form_row(form.name) }}

    {% for key, itemForm in form.tests %}
        <div class="text-red-500">
            {{ form_errors(itemForm) }}
        </div>

        {{ form_row(itemForm) }}
    {% endfor %}

    {{ form_widget(form.tests.vars.button_add, {label: '+ Add test', attr: {
        class: 'btn btn-primary',
    }}) }}

    <button class="btn btn-primary mt-4 flex items-center justify-center"
            type="submit"
            data-loading="addAttribute(disabled)">
        Save
    </button>

    {{ form_end(form) }}
</div>

Form: ProgramType

<?php

declare(strict_types=1);

namespace App\Form\Training\Program;

use App\Entity\Training\Program\Program;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Count;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType;

class ProgramType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', TextType::class, [
                'constraints' => [
                    new NotBlank(),
                    new Length(min: 3),
                ],
            ])

            ->add('tests', LiveCollectionType::class, [
                'entry_type' => ProgramSerieType::class,
                'entry_options' => [
                    'label' => false,
                ],
                'label' => false,
                'allow_add' => true,
                'allow_delete' => true,
                'by_reference' => false,
                'constraints' => [
                    new Count(min: 1),
                ],
                'mapped' => false, // for the sample. In case it's a propery on my program entity
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Program::class,
        ]);
    }
}

Form: ProgramSerieType

<?php

declare(strict_types=1);

namespace App\Form\Training\Program;

use App\BusinessLogic\Training\Program\SerieTempo;
use App\BusinessLogic\Training\Program\SerieUnit;
use App\Entity\Training\Program\ProgramSerie;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TimeType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class ProgramSerieType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('unit', ChoiceType::class, [
                'choices' => [
'test1' => 'test1',
'test2' => 'test2',
],
                'required' => true,
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => ProgramSerie::class,
        ]);
    }
}

Entity: ProgramSerie

#[ORM\Entity(repositoryClass: ProgramSerieRepository::class)]
class ProgramSerie
{
    use IdTrait;

    #[ORM\Column(length: 255)]
    private string $unit = 'default_value_to_be_defined_by_default_with_live_component_form';

    public function getUnit(): string
    {
        return $this->unit;
    }

    public function setUnit(string $unit): static
    {
        $this->unit = $unit;

        return $this;
    }
}

Entity: ProgramSerie

#[ORM\Entity(repositoryClass: ProgramRepository::class)]
class Program
{
    use IdTrait;

    #[ORM\Column(length: 255, nullable: false)]
    private ?string $name = null;

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(?string $name): self
    {
        $this->name = $name;

        return $this;
    }
}

I hope it will help :)

smnandre commented 5 months ago

Thanks a lot for all this details.. I watched the video and looked at the code.

I must admit this is a bit far from what i meant by reproducer 😅 ... (see https://symfony.com/doc/current/contributing/code/reproducer.html)

The idea is more to open a repository where i can clone the code, run "composer install" and see per myself what's happening :)

It's a bit hard there for me with the pasted code :)

And for now i'm still unable to tell you if this is just a minor bug something, a small configuration fix, or something deeper :)

juuuuln commented 1 week ago

I ran into the same issue today, tried to reproduce it. But no matter the depth I configured, the error did not reappear in my reproducer.

In the app where the error did occur I managed to prevent it by setting the empty_data to new ArrayCollection() on the embedded form that was ultimately causing the error upon adding a new collection, e.g.:

    $builder
       ->add('multipleOption', LiveCollectionType::class, [
            'entry_type' => MultipleOptionsEmbeddedType::class,
            'empty_data' => new ArrayCollection(), // Setting the empty_data to ArrayCollection prevents the 'null' error upon adding a new entry
            'allow_add' => true,
            'allow_delete' => true,
            'by_reference' => false,
        ])
    ;
}`