Skip to content

[Form] Introduce validation events for forms #38479

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from

Conversation

alcaeus
Copy link
Contributor

@alcaeus alcaeus commented Oct 8, 2020

Q A
Branch? 5.x
Bug fix? no
New feature? yes (needs docs changes)
Deprecations? yes (adds a new parameter to the ValidationListener constructor)
Tickets Related RFC: #37597
License MIT
Doc PR symfony/symfony-docs#... (yet to come)

Note: this PR was originally created as #37641 and subsequently closed. This PR contains the same changes, but opened against the 5.x branch. Discussion has not been ported over, so please raise your issues if they are still relevant.


This PR suggests the introduction of two new events related to form handling: preValidate and postValidate. These events are dispatched by the validation listener before starting validation and after validation has completed. They allow adding additional handling to the form submit process, e.g. writing changes from a DTO to a managed entity after validation succeeds. Consider the following DTO:

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Validator\FormValidationEvents;
use Symfony\Component\Form\FormEvents;

final class RenameCategory implements EventSubscriberInterface
{
    public ?string $name;

    private Category $category;

    private function __construct() {}

    public static function fromCategory(Category $category): self
    {
        $instance = new self();
        $instance->category = $category;

        return $instance;
    }

    public static function getSubscribedEvents(): array
    {
        return [
            FormEvents::PRE_SET_DATA => 'initialize',
            FormValidationEvents::POST_VALIDATE => 'apply',
    }

    public function initialize()
    {
        $this->name = $this->category->getName();
    }

    public function apply()
    {
        $this->category->rename($this->name);
    }
}

Now, a controller can create the DTO, pass it to the form, then store the managed entity:

public function renameCategory(Request $request, Category $category)
{
    $renameCategory = RenameCategory::fromCategory($category);

    $form = $this->createForm(EditCategoryForm::class, $renameCategory);
    $form->getConfig()->getEventDispatcher()->addSubscriber($renameCategory);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $entityManager->persist($category);
        $entityManager->flush();
    }

    // ...
}

Of course, this process could be further automated to fit one's needs, but I believe that for the time being, the addition of these events makes is a shortcut towards a better handling of DTOs in forms.

@d-ph
Copy link

d-ph commented Oct 9, 2020

[Note] A discussion of whether to make these form events be emitted also on children forms was started and not concluded in the previous PR: #37641 (comment)

Co-authored-by: d-ph <dominikd100@gmail.com>
@nicolas-grekas nicolas-grekas added this to the 5.x milestone Oct 12, 2020
@d-ph
Copy link

d-ph commented May 19, 2021

Hello,

Could we get an update about this PR, please? This is a fantastic feature that I believe would be highly welcome when merged. One of those things that aren't immediately obvious that are needed/useful, but that one appreciates when they discover that the feature already exists and is ready to use when they need it.

Who is able to accept/feedback the suggested addition of emitting those new form events down the entire form tree? As a compromise, only the post_validate could be emitted recursively, as I personally don't have and never had a use case for doing anything custom just before the validation is run. This would however require a notice in the docs that the pre_validate is being emitted on root forms only, so this course of action has its own downsides.

@stof, apologies for at-mentioning you, but would you be able to say how to progress the above, please? Who is able or has the authority to give a 👍 or 👎 to the above?

Thanks.

Copy link
Contributor

@maxhelias maxhelias left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could even "auto subscribe" if the data is an object and implements the right interface in FormConfigBuilder

{
$this->validator = $validator;
$this->violationMapper = $violationMapper;

if (!$eventDispatcher) {
@trigger_error(sprintf('The "$eventDispatcher" argument to the "%s" constructor will be required in Symfony 6.0.', self::class));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now it is recommended to use the trigger_deprecation function of the symfony/deprecation-contracts package

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That tells you how old this is and I'm not really staying on top of this. If someone else would like to take it over, please do!

@lyrixx
Copy link
Member

lyrixx commented May 21, 2021

Since the validation is already a form listener, you can register another listener before and after it. So I fail to see the advantage of this PR.

@alcaeus
Copy link
Contributor Author

alcaeus commented May 21, 2021

The validation happens in a listener and adds its own functionality. I think it's more explicit to do something before or after validation explicitly than when the form is submitted only knowing that validation may have happened or is about to happen.

@silasjoisten
Copy link
Contributor

silasjoisten commented May 21, 2021

This is actually a really good feature. Currently i am facing exactly that issue described in: #17918 (comment)

In my case i want to create an image type which automatically handles the upload and the removal of an image on the filesystem and in the database as well. That i only need to use my ImageType::class on different forms.
So i got my Entity

<?php

declare(strict_types=1);

namespace App\Entity;

use App\Domain\Identifier\ImageId;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;

/**
 * @ORM\Entity
 * @ORM\Table(name="images")
 */
class Image implements \Stringable
{
    /**
     * @ORM\Id
     * @ORM\Column(type="image_id", unique=true)
     */
    private ImageId $id;

    /**
     * @ORM\Column(type="string")
     */
    private string $name;

    private ?File $file = null;

    private bool $delete = false;

    public function __construct()
    {
        $this->id = new ImageId();
    }

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

    public function getId(): ImageId
    {
        return $this->id;
    }

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

    public function setName(string $name): void
    {
        $this->name = $name;
    }

    public function getFile(): ?File
    {
        return $this->file;
    }

    public function setFile(?File $file): void
    {
        $this->file = $file;
    }

    public function isDelete(): bool
    {
        return $this->delete;
    }

    public function setDelete(bool $delete): void
    {
        $this->delete = $delete;
    }
}

Then I got my Form:

<?php

declare(strict_types=1);

namespace App\Form\Image;

use App\Domain\Enum\Uploader\Context;
use App\Entity\Image;
use App\Uploader\UploaderInterface;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Image as ImageConstraint;
use function Safe\sprintf;

final class ImageType extends AbstractType
{
    public function __construct(
        private UploaderInterface $uploader,
        private FilesystemOperator $uploadStorage,
        private EntityManagerInterface $entityManager,
    ) {
    }

    public function buildView(FormView $view, FormInterface $form, array $options): void
    {
        $view->vars['context'] = (string) $options['context'];
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        /** @var Context $context */
        $context = $options['context'];

        $builder
            ->add('name', HiddenType::class)
            ->add('delete', CheckboxType::class, [
                'label' => false,
                'required' => false,
                'attr' => [
                    'class' => 'hidden',
                ],
            ])
            ->add('file', FileType::class, [
                'constraints' => [
                    new ImageConstraint([
                        'allowLandscape' => false,
                        'allowPortrait' => false,
                        'maxSize' => '2M',
                    ]),
                ],
                'label' => false,
                'required' => false,
                'attr' => [
                    'data-controller' => 'dropzone_controller',
                    'placeholder' => 'Bild suchen oder hier hinein ziehen',
                ],
                'help' => 'PNG, JPG bis zu 2MB',
            ])
            ->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($context): void {
                /** @var Image|null $object */
                $object = $event->getData();

                if (null === $object) {
                    return;
                }

                if (!$object->isDelete()) {
                    return;
                }

                $event->setData(null);
                $this->entityManager->remove($object);
                $this->uploadStorage->delete(sprintf('%s/%s', $context, $object->getName()));
            })
            ->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($context): void {
                /** @var Image|null $object */
                $object = $event->getData();

                if (!$event->getForm()->isValid()) {
                    return;            
               }

                if (null === $object) {
                    return;
                }

                if (null === $object->getFile()) {
                    return;
                }

                $filename = $this->uploader->upload($object->getFile(), $context);
                $object->setName($filename);
                $object->setFile(null);
                $event->setData($object);
            });
    }

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

And here is the part where i want to use it:

<?php

declare(strict_types=1);

namespace App\Form\User;

use App\Domain\Enum\Uploader\Context;
use App\Entity\User;
use App\Form\Image\ImageType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class UpdateAccountFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
           // more mapped fields
            ->add('profileImage', ImageType::class, [
                'label' => 'Profilbild',
                'required' => false,
                'context' => Context::PROFILE(),
            ]);
    }

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

So in my controller i create the UserType::class and it's the root form. So calling $event->getForm()->isValid() in the POST_SUBMIT listener (ImageType::class) always returns true and it will upload the image also if validation fails. So i don't have any possibility here to figure out if the form is valid or not. So these validation events would make it possible.

@lyrixx
Copy link
Member

lyrixx commented May 21, 2021

You can change the priority of your listener to pass after the validation one

@silasjoisten
Copy link
Contributor

I did this as well but it was still to early the validation failed but $event->getForm()->isValid() still returns true because only the root form triggers the validation.

@d-ph
Copy link

d-ph commented May 21, 2021

@lyrixx, the situation described by @silasjoisten is exactly the reason why I suggested that the POST_VALIDATE should be dispatched recursively through the entire form tree (child-first) -- because otherwise there is no way to have custom routines that a dev wants to execute only if the form validation has been run successfully. Listening to POST_SUBMIT on the children forms, even with appropriate priority, does not work, because the form validation is always initiated from the root form only (for a good reason), which is long after all children's POST_SUBMIT events.

@fabpot fabpot modified the milestones: 5.4, 6.1 Oct 30, 2021
@alcaeus alcaeus requested a review from yceruto as a code owner December 28, 2021 17:25
@fabpot fabpot modified the milestones: 6.1, 6.2 May 20, 2022
@nicolas-grekas
Copy link
Member

Shall we close @alcaeus? Time passing means to me this is not super desired.

@alcaeus
Copy link
Contributor Author

alcaeus commented Jul 29, 2022

Yes, there doesn't seem to be enough interest. It there is, somebody else can recreate this.

@alcaeus alcaeus deleted the form-validate-events branch July 29, 2022 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants