Skip to content

Commit bb10dcf

Browse files
committed
bug #494 [Autocomplete] Fix loading autocomplete choices with ID value objects (norkunas)
This PR was merged into the 2.x branch. Discussion ---------- [Autocomplete] Fix loading autocomplete choices with ID value objects | Q | A | ------------- | --- | Bug fix? | no | New feature? | no | Tickets | Fix #493 | License | MIT Commits ------- 1136bb4 [Autocomplete] Fix loading autocomplete choices with ID value objects
2 parents 7a2eda6 + 1136bb4 commit bb10dcf

File tree

8 files changed

+236
-4
lines changed

8 files changed

+236
-4
lines changed

src/Autocomplete/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"symfony/security-bundle": "^5.4|^6.0",
4343
"symfony/security-csrf": "^5.4|^6.0",
4444
"symfony/twig-bundle": "^5.4|^6.0",
45+
"symfony/uid": "^5.4|^6.0",
4546
"zenstruck/browser": "^1.1",
4647
"zenstruck/foundry": "^1.19"
4748
},

src/Autocomplete/src/Form/AutocompleteEntityTypeSubscriber.php

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111

1212
namespace Symfony\UX\Autocomplete\Form;
1313

14+
use Doctrine\Common\Collections\ArrayCollection;
15+
use Doctrine\ORM\EntityManagerInterface;
16+
use Doctrine\ORM\Query\Parameter;
17+
use Doctrine\ORM\Utility\PersisterHelper;
1418
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
1519
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
1620
use Symfony\Component\Form\FormEvent;
@@ -53,9 +57,34 @@ public function preSubmit(FormEvent $event)
5357
if (!isset($data['autocomplete']) || '' === $data['autocomplete']) {
5458
$options['choices'] = [];
5559
} else {
56-
$options['choices'] = $options['em']->getRepository($options['class'])->findBy([
57-
$options['id_reader']->getIdField() => $data['autocomplete'],
58-
]);
60+
/** @var EntityManagerInterface $em */
61+
$em = $options['em'];
62+
$repository = $em->getRepository($options['class']);
63+
64+
$idField = $options['id_reader']->getIdField();
65+
$idType = PersisterHelper::getTypeOfField($idField, $em->getClassMetadata($options['class']), $em)[0];
66+
67+
if ($options['multiple']) {
68+
$params = [];
69+
$idx = 0;
70+
71+
foreach ($data['autocomplete'] as $id) {
72+
$params[":id_$idx"] = new Parameter("id_$idx", $id, $idType);
73+
++$idx;
74+
}
75+
76+
$options['choices'] = $repository->createQueryBuilder('o')
77+
->where(sprintf("o.$idField IN (%s)", implode(', ', array_keys($params))))
78+
->setParameters(new ArrayCollection($params))
79+
->getQuery()
80+
->getResult();
81+
} else {
82+
$options['choices'] = $repository->createQueryBuilder('o')
83+
->where("o.$idField = :id")
84+
->setParameter('id', $data['autocomplete'], $idType)
85+
->getQuery()
86+
->getResult();
87+
}
5988
}
6089

6190
// reset some critical lazy options
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace Symfony\UX\Autocomplete\Tests\Fixtures\Entity;
4+
5+
use Doctrine\ORM\Mapping as ORM;
6+
use Symfony\Component\Uid\UuidV4;
7+
8+
#[ORM\Entity()]
9+
class Ingredient
10+
{
11+
#[ORM\Id]
12+
#[ORM\GeneratedValue(strategy: 'NONE')]
13+
#[ORM\Column(type: 'uuid')]
14+
private UuidV4 $id;
15+
16+
#[ORM\Column()]
17+
private ?string $name = null;
18+
19+
#[ORM\ManyToOne(inversedBy: 'ingredients')]
20+
private Product $product;
21+
22+
public function __construct(UuidV4 $id)
23+
{
24+
$this->id = $id;
25+
}
26+
27+
public function getId(): UuidV4
28+
{
29+
return $this->id;
30+
}
31+
32+
public function getName(): ?string
33+
{
34+
return $this->name;
35+
}
36+
37+
public function setName(string $name): self
38+
{
39+
$this->name = $name;
40+
41+
return $this;
42+
}
43+
44+
public function setProduct(?Product $product): void
45+
{
46+
$this->product = $product;
47+
}
48+
49+
public function getProduct(): ?Product
50+
{
51+
return $this->product;
52+
}
53+
}

src/Autocomplete/tests/Fixtures/Entity/Product.php

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
namespace Symfony\UX\Autocomplete\Tests\Fixtures\Entity;
44

5+
use Doctrine\Common\Collections\ArrayCollection;
6+
use Doctrine\Common\Collections\Collection;
57
use Doctrine\DBAL\Types\Types;
68
use Doctrine\ORM\Mapping as ORM;
7-
use Symfony\Component\Validator\Constraints\NotBlank;
89

910
#[ORM\Entity()]
1011
class Product
@@ -30,6 +31,14 @@ class Product
3031
#[ORM\JoinColumn(nullable: false)]
3132
private ?Category $category = null;
3233

34+
#[Orm\OneToMany(targetEntity: Ingredient::class, mappedBy: 'product')]
35+
private Collection $ingredients;
36+
37+
public function __construct()
38+
{
39+
$this->ingredients = new ArrayCollection();
40+
}
41+
3342
public function getId(): ?int
3443
{
3544
return $this->id;
@@ -94,4 +103,34 @@ public function setCategory(?Category $category): self
94103

95104
return $this;
96105
}
106+
107+
/**
108+
* @return Collection<int, Ingredient>
109+
*/
110+
public function getIngredients(): Collection
111+
{
112+
return $this->ingredients;
113+
}
114+
115+
public function addIngredient(Ingredient $ingredient): self
116+
{
117+
if (!$this->ingredients->contains($ingredient)) {
118+
$this->ingredients[] = $ingredient;
119+
$ingredient->setProduct($this);
120+
}
121+
122+
return $this;
123+
}
124+
125+
public function removeIngredient(Ingredient $ingredient): self
126+
{
127+
if ($this->ingredients->removeElement($ingredient)) {
128+
// set the owning side to null (unless already changed)
129+
if ($ingredient->getProduct() === $this) {
130+
$ingredient->setProduct(null);
131+
}
132+
}
133+
134+
return $this;
135+
}
97136
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace Symfony\UX\Autocomplete\Tests\Fixtures\Factory;
4+
5+
use Doctrine\ORM\EntityRepository;
6+
use Symfony\Component\Uid\UuidV4;
7+
use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Ingredient;
8+
use Zenstruck\Foundry\RepositoryProxy;
9+
use Zenstruck\Foundry\ModelFactory;
10+
use Zenstruck\Foundry\Proxy;
11+
12+
/**
13+
* @extends ModelFactory<Ingredient>
14+
*
15+
* @method static Ingredient|Proxy createOne(array $attributes = [])
16+
* @method static Ingredient[]|Proxy[] createMany(int $number, array|callable $attributes = [])
17+
* @method static Ingredient|Proxy find(object|array|mixed $criteria)
18+
* @method static Ingredient|Proxy findOrCreate(array $attributes)
19+
* @method static Ingredient|Proxy first(string $sortedField = 'id')
20+
* @method static Ingredient|Proxy last(string $sortedField = 'id')
21+
* @method static Ingredient|Proxy random(array $attributes = [])
22+
* @method static Ingredient|Proxy randomOrCreate(array $attributes = []))
23+
* @method static Ingredient[]|Proxy[] all()
24+
* @method static Ingredient[]|Proxy[] findBy(array $attributes)
25+
* @method static Ingredient[]|Proxy[] randomSet(int $number, array $attributes = []))
26+
* @method static Ingredient[]|Proxy[] randomRange(int $min, int $max, array $attributes = []))
27+
* @method static EntityRepository|RepositoryProxy repository()
28+
* @method Ingredient|Proxy create(array|callable $attributes = [])
29+
*/
30+
final class IngredientFactory extends ModelFactory
31+
{
32+
protected function getDefaults(): array
33+
{
34+
return [
35+
'id' => new UuidV4(),
36+
'name' => self::faker()->text(),
37+
];
38+
}
39+
40+
protected function initialize(): self
41+
{
42+
return $this;
43+
}
44+
45+
protected static function getClass(): string
46+
{
47+
return Ingredient::class;
48+
}
49+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace Symfony\UX\Autocomplete\Tests\Fixtures\Form;
4+
5+
use Symfony\Component\Form\AbstractType;
6+
use Symfony\Component\OptionsResolver\OptionsResolver;
7+
use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
8+
use Symfony\UX\Autocomplete\Form\ParentEntityAutocompleteType;
9+
use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Ingredient;
10+
11+
#[AsEntityAutocompleteField]
12+
class IngredientAutocompleteType extends AbstractType
13+
{
14+
public function configureOptions(OptionsResolver $resolver)
15+
{
16+
$resolver->setDefaults([
17+
'class' => Ingredient::class,
18+
'choice_label' => function(Ingredient $ingredient) {
19+
return '<strong>'.$ingredient->getName().'</strong>';
20+
},
21+
'multiple' => true,
22+
]);
23+
}
24+
25+
public function getParent(): string
26+
{
27+
return ParentEntityAutocompleteType::class;
28+
}
29+
}

src/Autocomplete/tests/Fixtures/Form/ProductType.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
1515
{
1616
$builder
1717
->add('category', CategoryAutocompleteType::class)
18+
->add('ingredients', IngredientAutocompleteType::class)
1819
->add('portionSize', ChoiceType::class, [
1920
'choices' => [
2021
'extra small <span>🥨</span>' => 'xs',

src/Autocomplete/tests/Functional/AutocompleteFormRenderingTest.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
1515
use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\CategoryFactory;
16+
use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\IngredientFactory;
1617
use Zenstruck\Browser\Test\HasBrowser;
1718
use Zenstruck\Foundry\Test\Factories;
1819
use Zenstruck\Foundry\Test\ResetDatabase;
@@ -60,4 +61,34 @@ public function testCategoryFieldSubmitsCorrectly()
6061
->assertContains('First cat')
6162
;
6263
}
64+
65+
public function testProperlyLoadsChoicesWithIdValueObjects()
66+
{
67+
$ingredient1 = IngredientFactory::createOne(['name' => 'Flour']);
68+
$ingredient2 = IngredientFactory::createOne(['name' => 'Sugar']);
69+
70+
$this->browser()
71+
->throwExceptions()
72+
->get('/test-form')
73+
->assertElementCount('#product_ingredients_autocomplete option', 0)
74+
->assertNotContains('Flour')
75+
->assertNotContains('Sugar')
76+
->post('/test-form', [
77+
'body' => [
78+
'product' => [
79+
'ingredients' => [
80+
'autocomplete' => [
81+
(string) $ingredient1->getId(),
82+
(string) $ingredient2->getId(),
83+
],
84+
],
85+
],
86+
],
87+
])
88+
// assert that selected options are not lost
89+
->assertElementCount('#product_ingredients_autocomplete option', 2)
90+
->assertContains('Flour')
91+
->assertContains('Sugar')
92+
;
93+
}
6394
}

0 commit comments

Comments
 (0)