Open
Description
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,
]);
}
}