Repository: symfony/ux
Pull request #2541 (2025-01-31 08:40:17)

[Autocomplete] Configurable results
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| Issues        | 
| License       | MIT

TomSelect accepts by default an array of results, each item containing a "text" and a "value".
Therefore this bundle exposes results formatted like so.

However one may need to expose additional data such as a "disabled" state to customize TomSelect's rendering.

This PR aims to offer this possibility by letting `EntityAutocompleter`s drive the creation of the results array.

J-Ben87: I'm not sure why I am getting failures related to "direct deprecation notices" on [LiveComponent] (see https://github.com/symfony/ux/actions/runs/13069237861/job/36467092283?pr=2541 and https://github.com/symfony/ux/actions/runs/13069237861/job/36467096825?pr=2541) when I didn't update this component nor anything related to `zenstruck/foundry` 🤔 
daFish: Hey @J-Ben87, this is awesome. This will be very useful in the context of #2534.
J-Ben87: Thanks for your review.

I like your idea of merging extra attributes into an array containing the text and the value.

WDYT of the latest version of my branch?
smnandre: Tiny details, then lets see the CI failures and LGTM :)

Would you be ok (on another PR) to write a paragraph + example about this in the documentation ? I can help if you need / want.
J-Ben87: Ok everything should be fine now 👍🏻 

Regarding the failing tests however, I have no clue why they are failing since they are related to zenstruck/foundry deprecations in another component...
It says
```
Since zenstruck/foundry 2.4: In order to use Foundry, you must use the trait "Symfony\Bundle\FrameworkBundle\Test\KernelTestCase" in your "Symfony\UX\LiveComponent\Tests\Unit\Form\ComponentWithFormTest" tests. This will throw an exception in 3.0.
```

I will try to add the suggested trait, I just hope it won't break anything else...
J-Ben87: No worries for writing a bit of documentation, could you just point me the place and the kind of doc you expect?
J-Ben87: What is the next step to have this PR merged?
It says that it needs 2 approving reviews by reviewers with write access. Should I ping someone? Or should I just wait for one of them to notice it's ready?

Sorry if it's obvious, but like I said I'm not used to contribute so I'm confused about the next steps 😕 
smnandre: > What is the next step to have this PR merged? It says that it needs 2 approving reviews by reviewers with write access. Should I ping someone? Or should I just wait for one of them to notice it's ready?

@Kocal gave the second approval today, so we will merge it soon :) 

Often, we let some time for all maintainers to see the PR, as we are not all on the same timezone and we all need to find the good balance between day jobs, family life, and open source.

So here, the next steps are: 

**Merged:** 
someone will merge the PR (you will then be able to test it if you use the "2.x-dev" branch)

**Released:** 
Then, on the next UX release, it will be available for everyone to enjoy. We are releasing a new version every-ish month (see [the changelog](https://ux.symfony.com/changelog)). The next one will be the `2.23`, and should be released in the next few days. So you should not have to wait much here.

> Sorry if it's obvious, but like I said I'm not used to contribute so I'm confused about the next steps 😕

It's not obvious :)

And welcome aboard!

Patch:
diff --git a/src/Autocomplete/CHANGELOG.md b/src/Autocomplete/CHANGELOG.md
index 1f8cea05..8720236c 100644
--- a/src/Autocomplete/CHANGELOG.md
+++ b/src/Autocomplete/CHANGELOG.md
@@ -4,6 +4,7 @@
 
 -   Deprecate `ExtraLazyChoiceLoader` in favor of `Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader`
 -   Reset TomSelect when updating url attribute #1505
+-   Add `getAttributes()` method to define additional attributes for autocomplete results #2541
 
 ## 2.22.0
 
diff --git a/src/Autocomplete/src/AutocompleteResultsExecutor.php b/src/Autocomplete/src/AutocompleteResultsExecutor.php
index 0061ca8a..03d10e03 100644
--- a/src/Autocomplete/src/AutocompleteResultsExecutor.php
+++ b/src/Autocomplete/src/AutocompleteResultsExecutor.php
@@ -73,10 +73,7 @@ final class AutocompleteResultsExecutor
 
         if (!method_exists($autocompleter, 'getGroupBy') || null === $groupBy = $autocompleter->getGroupBy()) {
             foreach ($paginator as $entity) {
-                $results[] = [
-                    'value' => $autocompleter->getValue($entity),
-                    'text' => $autocompleter->getLabel($entity),
-                ];
+                $results[] = $this->formatResult($autocompleter, $entity);
             }
 
             return new AutocompleteResults($results, $hasNextPage);
@@ -104,10 +101,7 @@ final class AutocompleteResultsExecutor
         $optgroupLabels = [];
 
         foreach ($paginator as $entity) {
-            $result = [
-                'value' => $autocompleter->getValue($entity),
-                'text' => $autocompleter->getLabel($entity),
-            ];
+            $result = $this->formatResult($autocompleter, $entity);
 
             $groupLabels = $groupBy($entity, $result['value'], $result['text']);
 
@@ -124,4 +118,21 @@ final class AutocompleteResultsExecutor
 
         return new AutocompleteResults($results, $hasNextPage, $optgroups);
     }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function formatResult(EntityAutocompleterInterface $autocompleter, object $entity): array
+    {
+        $attributes = [];
+        if (method_exists($autocompleter, 'getAttributes')) {
+            $attributes = $autocompleter->getAttributes($entity);
+        }
+
+        return [
+            ...$attributes,
+            'value' => $autocompleter->getValue($entity),
+            'text' => $autocompleter->getLabel($entity),
+        ];
+    }
 }
diff --git a/src/Autocomplete/src/EntityAutocompleterInterface.php b/src/Autocomplete/src/EntityAutocompleterInterface.php
index df0874fd..4ad8a855 100644
--- a/src/Autocomplete/src/EntityAutocompleterInterface.php
+++ b/src/Autocomplete/src/EntityAutocompleterInterface.php
@@ -18,30 +18,50 @@ use Symfony\Bundle\SecurityBundle\Security;
 /**
  * Interface for classes that will have an "autocomplete" endpoint exposed.
  *
- * @method mixed getGroupBy() Return group_by option.
+ * @template T of object
+ *
+ * TODO Remove next lines for Symfony UX 3
+ *
+ * @method array getAttributes(object $entity) Returns extra attributes to add to the autocomplete result.
+ * @method mixed getGroupBy()                  Return group_by option.
  */
 interface EntityAutocompleterInterface
 {
     /**
      * The fully-qualified entity class this will be autocompleting.
+     *
+     * @return class-string<T>
      */
     public function getEntityClass(): string;
 
     /**
      * Create a query builder that filters for the given "query".
+     *
+     * @param EntityRepository<T> $repository
      */
     public function createFilteredQueryBuilder(EntityRepository $repository, string $query): QueryBuilder;
 
     /**
      * Returns the "choice_label" used to display this entity.
+     *
+     * @param T $entity
      */
     public function getLabel(object $entity): string;
 
     /**
      * Returns the "value" attribute for this entity, usually the id.
+     *
+     * @param T $entity
      */
     public function getValue(object $entity): mixed;
 
+    /**
+     * Returns extra attributes to add to the autocomplete result.
+     *
+     * TODO Uncomment for Symfony UX 3
+     */
+    /* public function getAttributes(object $entity): array; */
+
     /**
      * Return true if access should be granted to the autocomplete results for the current user.
      *
@@ -51,6 +71,8 @@ interface EntityAutocompleterInterface
 
     /*
      * Return group_by option.
+     *
+     * TODO Uncomment for Symfony UX 3
      */
     /* public function getGroupBy(): mixed; */
 }
diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md
index 7ba03516..a43e9518 100644
--- a/src/LiveComponent/CHANGELOG.md
+++ b/src/LiveComponent/CHANGELOG.md
@@ -3,7 +3,6 @@
 ## 2.23.0
 
 -   Allow configuring the secret used to compute fingerprints and checksums.
--   Prevent `__component` property to be serialized when called `JSON.stringify()`
 
 ## 2.22.0
 
diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js
index 41177512..375a5b15 100644
--- a/src/LiveComponent/assets/dist/live_controller.js
+++ b/src/LiveComponent/assets/dist/live_controller.js
@@ -3059,10 +3059,7 @@ class LiveControllerDefault extends Controller {
         const id = this.element.id || null;
         this.component = new Component(this.element, this.nameValue, this.propsValue, this.listenersValue, id, LiveControllerDefault.backendFactory(this), new StimulusElementDriver(this));
         this.proxiedComponent = proxifyComponent(this.component);
-        Object.defineProperty(this.element, '__component', {
-            value: this.proxiedComponent,
-            writable: true,
-        });
+        this.element.__component = this.proxiedComponent;
         if (this.hasDebounceValue) {
             this.component.defaultDebounce = this.debounceValue;
         }
diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts
index a9ea7f11..278915e0 100644
--- a/src/LiveComponent/assets/src/live_controller.ts
+++ b/src/LiveComponent/assets/src/live_controller.ts
@@ -285,10 +285,8 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
         );
         this.proxiedComponent = proxifyComponent(this.component);
 
-        Object.defineProperty(this.element, '__component', {
-            value: this.proxiedComponent,
-            writable: true,
-        });
+        // @ts-ignore Adding the dynamic property
+        this.element.__component = this.proxiedComponent;
 
         if (this.hasDebounceValue) {
             this.component.defaultDebounce = this.debounceValue;


Test patch:
diff --git a/src/Autocomplete/tests/Fixtures/Autocompleter/CustomAttributesProductAutocompleter.php b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomAttributesProductAutocompleter.php
new file mode 100644
index 00000000..7f86431a
--- /dev/null
+++ b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomAttributesProductAutocompleter.php
@@ -0,0 +1,17 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter;
+
+class CustomAttributesProductAutocompleter extends CustomProductAutocompleter
+{
+    public function getAttributes(object $entity): array
+    {
+        return [
+            'disabled' => true,
+            'value' => 'This value should be replaced with the result of getValue()',
+            'text' => 'This value should be replaced with the result of getText()',
+        ];
+    }
+}
diff --git a/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php
index 912a864c..09b5b96c 100644
--- a/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php
+++ b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php
@@ -19,6 +19,9 @@ use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil;
 use Symfony\UX\Autocomplete\EntityAutocompleterInterface;
 use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product;
 
+/**
+ * @implements EntityAutocompleterInterface<Product>
+ */
 class CustomProductAutocompleter implements EntityAutocompleterInterface
 {
     public function __construct(
@@ -58,6 +61,11 @@ class CustomProductAutocompleter implements EntityAutocompleterInterface
         return $entity->getId();
     }
 
+    public function getAttributes(object $entity): array
+    {
+        return [];
+    }
+
     public function isGranted(Security $security): bool
     {
         if ($this->requestStack->getCurrentRequest()?->query->get('enforce_test_security')) {
diff --git a/src/Autocomplete/tests/Fixtures/Kernel.php b/src/Autocomplete/tests/Fixtures/Kernel.php
index f1991a22..de2877b0 100644
--- a/src/Autocomplete/tests/Fixtures/Kernel.php
+++ b/src/Autocomplete/tests/Fixtures/Kernel.php
@@ -34,6 +34,7 @@ use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
 use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
 use Symfony\UX\Autocomplete\AutocompleteBundle;
 use Symfony\UX\Autocomplete\DependencyInjection\AutocompleteFormTypePass;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomAttributesProductAutocompleter;
 use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomGroupByProductAutocompleter;
 use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomProductAutocompleter;
 use Symfony\UX\Autocomplete\Tests\Fixtures\Form\ProductType;
@@ -176,6 +177,13 @@ final class Kernel extends BaseKernel
                 'alias' => 'custom_group_by_product',
             ]);
 
+        $services->set(CustomAttributesProductAutocompleter::class)
+            ->public()
+            ->arg(1, new Reference('ux.autocomplete.entity_search_util'))
+            ->tag(AutocompleteFormTypePass::ENTITY_AUTOCOMPLETER_TAG, [
+                'alias' => 'custom_attributes_product',
+            ]);
+
         $services->alias('public.results_executor', 'ux.autocomplete.results_executor')
             ->public();
 
diff --git a/src/Autocomplete/tests/Integration/AutocompleteResultsExecutorTest.php b/src/Autocomplete/tests/Integration/AutocompleteResultsExecutorTest.php
new file mode 100644
index 00000000..b804afd1
--- /dev/null
+++ b/src/Autocomplete/tests/Integration/AutocompleteResultsExecutorTest.php
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Tests\Integration;
+
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\UX\Autocomplete\AutocompleteResultsExecutor;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomAttributesProductAutocompleter;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\ProductFactory;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Kernel;
+use Zenstruck\Foundry\Test\Factories;
+use Zenstruck\Foundry\Test\ResetDatabase;
+
+class AutocompleteResultsExecutorTest extends KernelTestCase
+{
+    use Factories;
+    use ResetDatabase;
+
+    public function testItReturnsExtraAttributes(): void
+    {
+        $kernel = new Kernel('test', true);
+        $kernel->disableForms();
+        $kernel->boot();
+
+        $product = ProductFactory::createOne(['name' => 'Foo']);
+
+        /** @var AutocompleteResultsExecutor $executor */
+        $executor = $kernel->getContainer()->get('public.results_executor');
+        $autocompleter = $kernel->getContainer()->get(CustomAttributesProductAutocompleter::class);
+        $data = $executor->fetchResults($autocompleter, '', 1);
+        $this->assertCount(1, $data->results);
+        $this->assertSame(['disabled' => true, 'value' => $product->getId(), 'text' => 'Foo'], $data->results[0]);
+    }
+}
diff --git a/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php b/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php
index 6bae36f9..097e4d77 100644
--- a/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php
+++ b/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php
@@ -18,6 +18,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
 use Symfony\UX\LiveComponent\Test\InteractsWithLiveComponents;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component2;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Factory\CategoryFixtureEntityFactory;
+use Zenstruck\Foundry\Test\Factories;
 use Zenstruck\Foundry\Test\ResetDatabase;
 
 /**
@@ -25,6 +26,7 @@ use Zenstruck\Foundry\Test\ResetDatabase;
  */
 final class InteractsWithLiveComponentsTest extends KernelTestCase
 {
+    use Factories;
     use InteractsWithLiveComponents;
     use ResetDatabase;
 
diff --git a/src/LiveComponent/tests/Unit/Form/ComponentWithFormTest.php b/src/LiveComponent/tests/Unit/Form/ComponentWithFormTest.php
index a7062605..01941f10 100644
--- a/src/LiveComponent/tests/Unit/Form/ComponentWithFormTest.php
+++ b/src/LiveComponent/tests/Unit/Form/ComponentWithFormTest.php
@@ -14,6 +14,7 @@ namespace Symfony\UX\LiveComponent\Tests\Unit\Form;
 use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Component\FormComponentWithManyDifferentFieldsType;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Factory\CategoryFixtureEntityFactory;
+use Zenstruck\Foundry\Test\Factories;
 use Zenstruck\Foundry\Test\ResetDatabase;
 
 /**
@@ -21,6 +22,7 @@ use Zenstruck\Foundry\Test\ResetDatabase;
  */
 class ComponentWithFormTest extends KernelTestCase
 {
+    use Factories;
     use ResetDatabase;
 
     public function testFormValues(): void