symfony / ux

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

[LiveComponent] Validation of URL parameter values #2024

Open MiguelAdige opened 1 month ago

MiguelAdige commented 1 month ago

Hello, I'm developing a search engine with Symfony Forms and Live components, I have a liveprop tags property with the url attribute when the parameter is empty in the url reload or directly access the url I get a violation of a constraint "The selected choice is invalid." except when the tag parameter is absent.

I think this is a bug we shouldn't do a data transformation if the parameter is empty.

PHP Version : 8.3 Symfony version : 6.4 symfony/ux-live-component : 2.18.1 symfony/form : 6.4.10

Caused by:

[Symfony\Component\Validator\ConstraintViolation](file:///home/mixta/Projets/kemit/vendor/symfony/validator/ConstraintViolation.php#L19) {[#1678 ▼](https://127.0.0.1:8000/_profiler/756c37?panel=form&type=request#sf-dump-167832670-ref21678)
  root: Symfony\Component\Form\Form {[#1509](https://127.0.0.1:8000/_profiler/756c37?panel=form&type=request#sf-dump-167832670-ref21509) …}
  path: "children[tags]"
  value: ""
}

[Symfony\Component\Form\Exception\TransformationFailedException](file:///home/mixta/Projets/kemit/vendor/symfony/form/Exception/TransformationFailedException.php#L19) {#1582 ▼
  #message: "Expected an array."
  #code: 0
  #file: "[/home/mixta/Projets/kemit/vendor/symfony/form/Extension/Core/Type/ChoiceType.php](file:///home/mixta/Projets/kemit/vendor/symfony/form/Extension/Core/Type/ChoiceType.php#L121)"
  #line: 121
  -invalidMessage: null
  -invalidMessageParameters: []
  trace: {▶}
} 

SearchLiveComponent.php

<?php

namespace App\Twig\Components;

use App\Entity\Category;
use App\Entity\SearchData;
use App\Entity\Tag;
use App\Enum\OrderByEnum;
use App\Form\SearchFormType;
use App\Repository\SearchDataRepository;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\Pagination\PaginationInterface;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentToolsTrait;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;
use Symfony\UX\TwigComponent\Attribute\PostMount;

#[AsLiveComponent(template: 'twig/components/Search.html.twig', csrf: false)]
final class SearchLiveComponent extends AbstractController
{
    use DefaultActionTrait;
    use ComponentToolsTrait;
    use ComponentWithFormTrait;
    use ValidatableComponentTrait;

    #[LiveProp(writable: true, url: true)]
    #[Assert\Type('string')]
    #[Assert\NotBlank(message: new TranslatableMessage('notBlankLocationOrQuery', domain: 'search'), groups: ['notBlankLocationOrQuery'])]
    public string $query = '';

    #[LiveProp(writable: true, url: true)]
    #[Assert\Type('string')]
    #[Assert\NotBlank(message: new TranslatableMessage('notBlankLocationOrQuery', domain: 'search'), groups: ['notBlankLocationOrQuery'])]
    public string $location = '';

    #[LiveProp(writable: true, url: true)]
    public ?Category $category = null;

    /** @var Tag[] */
    #[LiveProp(writable: true, url: true)]
    public ?array $tags = [];

    #[LiveProp(writable: true, url: true)]
    #[Assert\Sequentially([
        new Assert\Type(type: 'integer'),
        new Assert\Range(min: 1, max: 30),
    ])]
    public int $proximity = 5;

    #[LiveProp(writable: true, url: true)]
    #[Assert\Type('float', groups: ['latitude'])]
    public float $latitude = 0.0;

    #[LiveProp(writable: true, url: true)]
    #[Assert\Type('float', groups: ['longitude'])]
    public float $longitude = 0.0;

    #[LiveProp(writable: true, url: true)]
    #[Assert\Choice(callback: [OrderByEnum::class, 'cases'], groups: ['order'])]
    public ?OrderByEnum $order = OrderByEnum::NOVELTY;

    public ?SearchData $searchData = null;

    public ?PaginationInterface $items = null;

    public function __construct(
        private readonly SearchDataRepository $searchDataRepository,
        private readonly EntityManagerInterface $entityManager,
        private readonly PaginatorInterface $paginator,
        private readonly RequestStack $request,
    ) {
    }

    protected function instantiateForm(): FormInterface
    {
        return $this->createForm(SearchFormType::class, $this->searchData);
    }

    #[PostMount]
    public function postMount(): void
    {
        $this->searchData = new SearchData();
    }

    #[LiveAction]
    public function search(): PaginationInterface
    {
        $this->submitForm();
        $this->validate();

        $this->searchData->setQuery($this->query);
        $this->searchData->setLocation($this->location);
        if (!is_null($this->category) && null !== $this->category->getId()) {
            $this->searchData->setCategory($this->category);
        }
        $this->searchData->setProximity($this->proximity);
        $this->searchData->setLongitude($this->longitude);
        $this->searchData->setLatitude($this->latitude);
        $this->searchData->setOrder($this->order);

        foreach ($this->tags as $tag) {
            $this->searchData->addTag($tag);
        }

        dump($this->searchData);
        $query = $this->searchDataRepository->findSearch($this->searchData);
        $results = $this->paginator->paginate(
            $query,
            $this->request->getCurrentRequest()->query->getInt('page', 1),
            15,
        );

        $this->items = $results;
        $this->resetValidation();

        return $results;
    }

    public function getEstablishments(): ?PaginationInterface
    {
        return $this->items;
    }

    #[LiveAction]
    public function reset(): void
    {
        $this->resetValidation();
        $this->resetForm();
    }
}

Class form SearchFormType.php

<?php

namespace App\Form;

use App\Entity\Category;
use App\Entity\SearchData;
use App\Entity\Tag;
use App\Enum\OrderByEnum;
use App\Validation\SearchValidationGroupResolver;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\RangeType;
use Symfony\Component\Form\Extension\Core\Type\SearchType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Translation\TranslatableMessage;

class SearchFormType extends AbstractType
{
    public function __construct(
        private readonly SearchValidationGroupResolver $groupResolver,
    ) {
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('query', SearchType::class, [
                'label' => false,
                'attr' => [
                    'data-model' => 'query',
                    'placeholder' => new TranslatableMessage('Commerce, produit, marque...', domain: 'search'),
                ],
            ])
            ->add('location', TextType::class, [
                'label' => false,
                'attr' => [
                    'data-model' => 'location',
                    'placeholder' => new TranslatableMessage('Adresse, ville, code postal...', domain: 'search'),
                    'data-PlaceAutocompletion-target' => 'address',
                    'data-action' => 'keydown->PlaceAutocompletion#preventSubmit input->PlaceAutocompletion#updateLocation',
                ],
                'required' => false,
            ])
            ->add('latitude', HiddenType::class, [
                'attr' => [
                    'data-PlaceAutocompletion-target' => 'latitude',
                    'data-model' => 'latitude',
                ],
            ])
            ->add('longitude', HiddenType::class, [
                'attr' => [
                    'data-PlaceAutocompletion-target' => 'longitude',
                    'data-model' => 'longitude',
                ],
            ])
            ->add('proximity', RangeType::class, [
                'label' => false,
                'attr' => [
                    'min' => 1,
                    'max' => 30,
                    'step' => 1,
                    'data-model' => 'proximity',
                ],
            ])
            ->add('order', EnumType::class, [
                'label' => new TranslatableMessage('Trier par', [], 'search'),
                'class' => OrderByEnum::class,
                'attr' => ['data-model' => 'order'],
            ])
            ->add('category', EntityType::class, [
                'label' => new TranslatableMessage('Catégorie', domain: 'search'),
                'class' => Category::class,
                'placeholder' => new TranslatableMessage('Toutes les catégories', [], 'search'),
                'choice_label' => 'name',
                'autocomplete' => true,
                'required' => false,
                'attr' => ['data-model' => 'category'],
            ])
            ->add('tags', EntityType::class, [
                'label' => new TranslatableMessage('Tags', domain: 'search'),
                'class' => Tag::class,
                'autocomplete' => true,
                'multiple' => true,
                'required' => false,
                'placeholder' => $tagsPlaceholder = new TranslatableMessage('Aucun tag sélectionner', [], 'search'),
                'tom_select_options' => [
                    'hidePlaceholder' => true,
                    'placeholder' => $tagsPlaceholder->getMessage(),
                ],
                'attr' => ['data-model' => 'tags'],
            ])
        ;

    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => SearchData::class,
            'method' => 'GET',
            'csrf_protection' => false,
            'validation_groups' => $this->groupResolver,
        ]);
    }

    public function getBlockPrefix(): string
    {
        return '';
    }
}

Class entity SearchData.php

<?php

namespace App\Entity;

use App\Enum\OrderByEnum;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Component\Validator\Constraints as Assert;

class SearchData
{
    #[Assert\Type('integer', groups: ['page'])]
    private ?int $page = 1;

    #[Assert\Type('string', groups: ['query'])]
    #[Assert\NotBlank(message: new TranslatableMessage('notBlankLocationOrQuery', domain: 'search'), groups: ['notBlankLocationOrQuery'])]
    private ?string $query = null;

    #[Assert\Type('string', groups: ['location'])]
    #[Assert\NotBlank(message: new TranslatableMessage('notBlankLocationOrQuery', domain: 'search'), groups: ['notBlankLocationOrQuery'])]
    private ?string $location = null;

    private ?Category $category = null;

    /**
     * @var Collection<int, Tag>|null
     */
    private ?Collection $tags;

    #[Assert\Sequentially([
        new Assert\Type(type: 'integer'),
        new Assert\Range(min: 1, max: 30),
    ], groups: ['proximity'])]
    private ?int $proximity = 5;

    #[Assert\Choice(callback: [OrderByEnum::class, 'cases'], groups: ['order'])]
    private ?OrderByEnum $order = OrderByEnum::NOVELTY;

    #[Assert\Type('float', groups: ['latitude'])]
    private ?float $latitude = 0.0;

    #[Assert\Type('float', groups: ['longitude'])]
    private ?float $longitude = 0.0;

    public function __construct()
    {
        $this->tags = new ArrayCollection();
    }

    public function getQuery(): ?string
    {
        return $this->query;
    }

    public function setQuery(?string $query): static
    {
        $this->query = $query;

        return $this;
    }

    public function getLocation(): ?string
    {
        return $this->location;
    }

    public function setLocation(?string $location): static
    {
        $this->location = $location;

        return $this;
    }

    public function getCategory(): ?Category
    {
        return $this->category;
    }

    public function setCategory(?Category $category): static
    {
        $this->category = $category;

        return $this;
    }

    /**
     * @return Collection<int, Tag>
     */
    public function getTags(): Collection
    {
        return $this->tags;
    }

    public function addTag(Tag $tag): static
    {
        if (!$this->tags->contains($tag)) {
            $this->tags->add($tag);
        }

        return $this;
    }

    public function removeTag(Tag $tag): static
    {
        $this->tags->removeElement($tag);

        return $this;
    }

    public function getProximity(): ?int
    {
        return $this->proximity;
    }

    public function setProximity(?int $proximity): static
    {
        $this->proximity = $proximity;

        return $this;
    }

    public function getOrder(): ?OrderByEnum
    {
        return $this->order;
    }

    public function setOrder(?OrderByEnum $order): static
    {
        $this->order = $order;

        return $this;
    }

    /**
     * Get the value of page.
     */
    public function getPage(): ?int
    {
        return $this->page;
    }

    /**
     * Set the value of page.
     */
    public function setPage(?int $page): self
    {
        $this->page = $page;

        return $this;
    }

    public function getLatitude(): ?float
    {
        return $this->latitude;
    }

    public function setLatitude(?float $latitude = 0.0): self
    {
        $this->latitude = $latitude;

        return $this;
    }

    public function getLongitude(): ?float
    {
        return $this->longitude;
    }

    public function setLongitude(?float $longitude = 0.0): self
    {
        $this->longitude = $longitude;

        return $this;
    }
}

Class controller SearchController.php

<?php

namespace App\Controller;

use App\Entity\SearchData;
use App\Entity\Tag;
use App\Enum\OrderByEnum;
use App\Form\SearchFormType;
use App\Repository\SearchDataRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route;

class SearchController extends AbstractController
{
    #[Route('/search', name: 'app_search')]
    public function index(
        Request $request,
        SearchDataRepository $searchDataRepository,
        PaginatorInterface $paginator,
        #[MapQueryParameter] string $location,
        #[MapQueryParameter] ?string $category,
        #[MapQueryParameter] ?string $query = '',
        #[MapQueryParameter] ?array $tags = [],
        #[MapQueryParameter(filter: FILTER_VALIDATE_FLOAT)] ?float $latitude = 0.0,
        #[MapQueryParameter(filter: FILTER_VALIDATE_FLOAT)] ?float $longitude = 0.0,
        #[MapQueryParameter] ?OrderByEnum $order = null,
    ): Response {
        $searchData = new SearchData();

        $form = $this->createForm(SearchFormType::class, $searchData);
        $form->handleRequest($request);
        dump($searchData);
        $results = $searchDataRepository->findSearch($searchData);
        $establishment = $paginator->paginate(
            $results,
            $request->query->getInt('page', 1),
            15,
        );

        return $this->render('search/index.html.twig', [
            'form' => $form,
            'page' => $request->query->getInt('page', 1),
            'tags' => $tags,
            'establishment' => $establishment,
        ]);
    }
}
smnandre commented 1 month ago

I have a liveprop tags property with the url attribute when the parameter is empty in the url reload or directly access the url I get a violation of a constraint "The selected choice is invalid." except when the tag parameter is absent.

So this error is trigger by the controller and not the LiveComponent, right ?

MiguelAdige commented 1 month ago

Hello, this error is triggered by LiveComponent. It was later that I retrieved the url parameter to see if it wasn't there and I didn't figure out how to get around the problem.

smnandre commented 1 month ago

Could you open the smallest/simplest reproducer possible.. so we can look at this ?