diff --git a/src/Autocomplete/composer.json b/src/Autocomplete/composer.json index df4032610c8..24f88bafabf 100644 --- a/src/Autocomplete/composer.json +++ b/src/Autocomplete/composer.json @@ -42,6 +42,7 @@ "symfony/security-bundle": "^5.4|^6.0", "symfony/security-csrf": "^5.4|^6.0", "symfony/twig-bundle": "^5.4|^6.0", + "symfony/uid": "^5.4|^6.0", "zenstruck/browser": "^1.1", "zenstruck/foundry": "^1.19" }, diff --git a/src/Autocomplete/src/Form/AutocompleteEntityTypeSubscriber.php b/src/Autocomplete/src/Form/AutocompleteEntityTypeSubscriber.php index 6e0eee7d6e1..94dd289531d 100644 --- a/src/Autocomplete/src/Form/AutocompleteEntityTypeSubscriber.php +++ b/src/Autocomplete/src/Form/AutocompleteEntityTypeSubscriber.php @@ -11,6 +11,10 @@ namespace Symfony\UX\Autocomplete\Form; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Query\Parameter; +use Doctrine\ORM\Utility\PersisterHelper; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Form\FormEvent; @@ -53,9 +57,34 @@ public function preSubmit(FormEvent $event) if (!isset($data['autocomplete']) || '' === $data['autocomplete']) { $options['choices'] = []; } else { - $options['choices'] = $options['em']->getRepository($options['class'])->findBy([ - $options['id_reader']->getIdField() => $data['autocomplete'], - ]); + /** @var EntityManagerInterface $em */ + $em = $options['em']; + $repository = $em->getRepository($options['class']); + + $idField = $options['id_reader']->getIdField(); + $idType = PersisterHelper::getTypeOfField($idField, $em->getClassMetadata($options['class']), $em)[0]; + + if ($options['multiple']) { + $params = []; + $idx = 0; + + foreach ($data['autocomplete'] as $id) { + $params[":id_$idx"] = new Parameter("id_$idx", $id, $idType); + ++$idx; + } + + $options['choices'] = $repository->createQueryBuilder('o') + ->where(sprintf("o.$idField IN (%s)", implode(', ', array_keys($params)))) + ->setParameters(new ArrayCollection($params)) + ->getQuery() + ->getResult(); + } else { + $options['choices'] = $repository->createQueryBuilder('o') + ->where("o.$idField = :id") + ->setParameter('id', $data['autocomplete'], $idType) + ->getQuery() + ->getResult(); + } } // reset some critical lazy options diff --git a/src/Autocomplete/tests/Fixtures/Entity/Ingredient.php b/src/Autocomplete/tests/Fixtures/Entity/Ingredient.php new file mode 100644 index 00000000000..bc810d53513 --- /dev/null +++ b/src/Autocomplete/tests/Fixtures/Entity/Ingredient.php @@ -0,0 +1,53 @@ +id = $id; + } + + public function getId(): UuidV4 + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function setProduct(?Product $product): void + { + $this->product = $product; + } + + public function getProduct(): ?Product + { + return $this->product; + } +} diff --git a/src/Autocomplete/tests/Fixtures/Entity/Product.php b/src/Autocomplete/tests/Fixtures/Entity/Product.php index 966ae0ece5e..8640290b986 100644 --- a/src/Autocomplete/tests/Fixtures/Entity/Product.php +++ b/src/Autocomplete/tests/Fixtures/Entity/Product.php @@ -2,9 +2,10 @@ namespace Symfony\UX\Autocomplete\Tests\Fixtures\Entity; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Validator\Constraints\NotBlank; #[ORM\Entity()] class Product @@ -30,6 +31,14 @@ class Product #[ORM\JoinColumn(nullable: false)] private ?Category $category = null; + #[Orm\OneToMany(targetEntity: Ingredient::class, mappedBy: 'product')] + private Collection $ingredients; + + public function __construct() + { + $this->ingredients = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; @@ -94,4 +103,34 @@ public function setCategory(?Category $category): self return $this; } + + /** + * @return Collection + */ + public function getIngredients(): Collection + { + return $this->ingredients; + } + + public function addIngredient(Ingredient $ingredient): self + { + if (!$this->ingredients->contains($ingredient)) { + $this->ingredients[] = $ingredient; + $ingredient->setProduct($this); + } + + return $this; + } + + public function removeIngredient(Ingredient $ingredient): self + { + if ($this->ingredients->removeElement($ingredient)) { + // set the owning side to null (unless already changed) + if ($ingredient->getProduct() === $this) { + $ingredient->setProduct(null); + } + } + + return $this; + } } diff --git a/src/Autocomplete/tests/Fixtures/Factory/IngredientFactory.php b/src/Autocomplete/tests/Fixtures/Factory/IngredientFactory.php new file mode 100644 index 00000000000..ee238ce1e10 --- /dev/null +++ b/src/Autocomplete/tests/Fixtures/Factory/IngredientFactory.php @@ -0,0 +1,49 @@ + + * + * @method static Ingredient|Proxy createOne(array $attributes = []) + * @method static Ingredient[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static Ingredient|Proxy find(object|array|mixed $criteria) + * @method static Ingredient|Proxy findOrCreate(array $attributes) + * @method static Ingredient|Proxy first(string $sortedField = 'id') + * @method static Ingredient|Proxy last(string $sortedField = 'id') + * @method static Ingredient|Proxy random(array $attributes = []) + * @method static Ingredient|Proxy randomOrCreate(array $attributes = [])) + * @method static Ingredient[]|Proxy[] all() + * @method static Ingredient[]|Proxy[] findBy(array $attributes) + * @method static Ingredient[]|Proxy[] randomSet(int $number, array $attributes = [])) + * @method static Ingredient[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])) + * @method static EntityRepository|RepositoryProxy repository() + * @method Ingredient|Proxy create(array|callable $attributes = []) + */ +final class IngredientFactory extends ModelFactory +{ + protected function getDefaults(): array + { + return [ + 'id' => new UuidV4(), + 'name' => self::faker()->text(), + ]; + } + + protected function initialize(): self + { + return $this; + } + + protected static function getClass(): string + { + return Ingredient::class; + } +} diff --git a/src/Autocomplete/tests/Fixtures/Form/IngredientAutocompleteType.php b/src/Autocomplete/tests/Fixtures/Form/IngredientAutocompleteType.php new file mode 100644 index 00000000000..5a3696a39a2 --- /dev/null +++ b/src/Autocomplete/tests/Fixtures/Form/IngredientAutocompleteType.php @@ -0,0 +1,29 @@ +setDefaults([ + 'class' => Ingredient::class, + 'choice_label' => function(Ingredient $ingredient) { + return ''.$ingredient->getName().''; + }, + 'multiple' => true, + ]); + } + + public function getParent(): string + { + return ParentEntityAutocompleteType::class; + } +} diff --git a/src/Autocomplete/tests/Fixtures/Form/ProductType.php b/src/Autocomplete/tests/Fixtures/Form/ProductType.php index 9261087fd8f..ce99325d058 100644 --- a/src/Autocomplete/tests/Fixtures/Form/ProductType.php +++ b/src/Autocomplete/tests/Fixtures/Form/ProductType.php @@ -15,6 +15,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder ->add('category', CategoryAutocompleteType::class) + ->add('ingredients', IngredientAutocompleteType::class) ->add('portionSize', ChoiceType::class, [ 'choices' => [ 'extra small 🥨' => 'xs', diff --git a/src/Autocomplete/tests/Functional/AutocompleteFormRenderingTest.php b/src/Autocomplete/tests/Functional/AutocompleteFormRenderingTest.php index f03e749537b..738530ad6f1 100644 --- a/src/Autocomplete/tests/Functional/AutocompleteFormRenderingTest.php +++ b/src/Autocomplete/tests/Functional/AutocompleteFormRenderingTest.php @@ -13,6 +13,7 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\CategoryFactory; +use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\IngredientFactory; use Zenstruck\Browser\Test\HasBrowser; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; @@ -60,4 +61,34 @@ public function testCategoryFieldSubmitsCorrectly() ->assertContains('First cat') ; } + + public function testProperlyLoadsChoicesWithIdValueObjects() + { + $ingredient1 = IngredientFactory::createOne(['name' => 'Flour']); + $ingredient2 = IngredientFactory::createOne(['name' => 'Sugar']); + + $this->browser() + ->throwExceptions() + ->get('/test-form') + ->assertElementCount('#product_ingredients_autocomplete option', 0) + ->assertNotContains('Flour') + ->assertNotContains('Sugar') + ->post('/test-form', [ + 'body' => [ + 'product' => [ + 'ingredients' => [ + 'autocomplete' => [ + (string) $ingredient1->getId(), + (string) $ingredient2->getId(), + ], + ], + ], + ], + ]) + // assert that selected options are not lost + ->assertElementCount('#product_ingredients_autocomplete option', 2) + ->assertContains('Flour') + ->assertContains('Sugar') + ; + } }