diff --git a/src/Autocomplete/CHANGELOG.md b/src/Autocomplete/CHANGELOG.md
index 54179e874c4..a28a67eae20 100644
--- a/src/Autocomplete/CHANGELOG.md
+++ b/src/Autocomplete/CHANGELOG.md
@@ -2,12 +2,16 @@
## 2.8.0
+
- The autocomplete will now update if any of the `option` elements inside of
it change, including the empty / placeholder element. Additionally, if the
`select` or `input` element's `disabled` attribute changes, the autocomplete
instance will update accordingly. This makes Autocomplete work perfectly inside
of a LiveComponent.
+- Added support for using [OptionGroups](https://tom-select.js.org/examples/optgroups/).
+
+
## 2.7.0
- Add `assets/src` to `.gitattributes` to exclude them from the installation
diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js
index 23e3cf7a42b..4e12d75b053 100644
--- a/src/Autocomplete/assets/dist/controller.js
+++ b/src/Autocomplete/assets/dist/controller.js
@@ -266,13 +266,14 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def
.then((response) => response.json())
.then((json) => {
this.setNextUrl(query, json.next_page);
- callback(json.results);
+ callback(json.results.options || json.results, json.results.optgroups || []);
})
- .catch(() => callback());
+ .catch(() => callback([], []));
},
shouldLoad: function (query) {
return query.length >= minCharacterLength;
},
+ optgroupField: 'group_by',
score: function (search) {
return function (item) {
return 1;
diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts
index 8275b43dbf0..a52da8e3bb3 100644
--- a/src/Autocomplete/assets/src/controller.ts
+++ b/src/Autocomplete/assets/src/controller.ts
@@ -1,7 +1,7 @@
import { Controller } from '@hotwired/stimulus';
import TomSelect from 'tom-select';
import { TPluginHash } from 'tom-select/dist/types/contrib/microplugin';
-import { RecursivePartial, TomSettings, TomTemplates } from 'tom-select/dist/types/types';
+import { RecursivePartial, TomSettings, TomTemplates, TomLoadCallback } from 'tom-select/dist/types/types';
export interface AutocompletePreConnectOptions {
options: any;
@@ -147,20 +147,21 @@ export default class extends Controller {
// VERY IMPORTANT: use 'function (query, callback) { ... }' instead of the
// '(query, callback) => { ... }' syntax because, otherwise,
// the 'this.XXX' calls inside this method fail
- load: function (query: string, callback: (results?: any) => void) {
+ load: function (query: string, callback: TomLoadCallback) {
const url = this.getUrl(query);
fetch(url)
.then((response) => response.json())
// important: next_url must be set before invoking callback()
.then((json) => {
this.setNextUrl(query, json.next_page);
- callback(json.results);
+ callback(json.results.options || json.results, json.results.optgroups || []);
})
- .catch(() => callback());
+ .catch(() => callback([], []));
},
shouldLoad: function (query: string) {
return query.length >= minCharacterLength;
},
+ optgroupField: 'group_by',
// avoid extra filtering after results are returned
score: function (search: string) {
return function (item: any) {
diff --git a/src/Autocomplete/assets/test/controller.test.ts b/src/Autocomplete/assets/test/controller.test.ts
index e5af78f0663..2085c531cd9 100644
--- a/src/Autocomplete/assets/test/controller.test.ts
+++ b/src/Autocomplete/assets/test/controller.test.ts
@@ -96,7 +96,7 @@ describe('AutocompleteController', () => {
value: 3,
text: 'salad'
},
- ],
+ ]
}),
);
@@ -111,8 +111,8 @@ describe('AutocompleteController', () => {
{
value: 2,
text: 'popcorn'
- },
- ],
+ }
+ ]
}),
);
@@ -499,4 +499,102 @@ describe('AutocompleteController', () => {
await shortDelay(10);
expect(tomSelect.control_input.placeholder).toBe('Select a kangaroo');
});
+
+ it('group related options', async () => {
+ const { container, tomSelect } = await startAutocompleteTest(`
+
+
+ `);
+
+ // initial Ajax request on focus with group_by options
+ fetchMock.mock(
+ '/path/to/autocomplete?query=',
+ JSON.stringify({
+ results: {
+ options: [
+ {
+ group_by: ['Meat'],
+ value: 1,
+ text: 'Beef'
+ },
+ {
+ group_by: ['Meat'],
+ value: 2,
+ text: 'Mutton'
+ },
+ {
+ group_by: ['starchy'],
+ value: 3,
+ text: 'Potatoes'
+ },
+ {
+ group_by: ['starchy', 'Meat'],
+ value: 4,
+ text: 'chili con carne'
+ },
+ ],
+ optgroups: [
+ {
+ value: 'Meat',
+ label: 'Meat'
+ },
+ {
+ value: 'starchy',
+ label: 'starchy'
+ },
+ ]
+ },
+ }),
+ );
+
+ fetchMock.mock(
+ '/path/to/autocomplete?query=foo',
+ JSON.stringify({
+ results: {
+ options: [
+ {
+ group_by: ['Meat'],
+ value: 1,
+ text: 'Beef'
+ },
+ {
+ group_by: ['Meat'],
+ value: 2,
+ text: 'Mutton'
+ },
+ ],
+ optgroups: [
+ {
+ value: 'Meat',
+ label: 'Meat'
+ },
+ ]
+ }
+ }),
+ );
+
+ const controlInput = tomSelect.control_input;
+
+ // wait for the initial Ajax request to finish
+ userEvent.click(controlInput);
+ await waitFor(() => {
+ expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(5);
+ expect(container.querySelectorAll('.optgroup-header')).toHaveLength(2);
+ });
+
+ // typing was not properly triggering, for some reason
+ //userEvent.type(controlInput, 'foo');
+ controlInput.value = 'foo';
+ controlInput.dispatchEvent(new Event('input'));
+
+ await waitFor(() => {
+ expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(2);
+ expect(container.querySelectorAll('.optgroup-header')).toHaveLength(1);
+ });
+ });
});
diff --git a/src/Autocomplete/composer.json b/src/Autocomplete/composer.json
index 3859a3015c3..5b8c649b7ee 100644
--- a/src/Autocomplete/composer.json
+++ b/src/Autocomplete/composer.json
@@ -28,6 +28,7 @@
"symfony/dependency-injection": "^5.4|^6.0",
"symfony/http-foundation": "^5.4|^6.0",
"symfony/http-kernel": "^5.4|^6.0",
+ "symfony/property-access": "^5.4|^6.0",
"symfony/string": "^5.4|^6.0"
},
"require-dev": {
diff --git a/src/Autocomplete/doc/index.rst b/src/Autocomplete/doc/index.rst
index 9c7992d82cc..78a18f550ec 100644
--- a/src/Autocomplete/doc/index.rst
+++ b/src/Autocomplete/doc/index.rst
@@ -513,6 +513,20 @@ a :ref:`custom autocompleter `:
]
}
+ for using `Tom Select Option Group`_ the format is as follows
+
+ .. code-block:: json
+
+ {
+ "results": {
+ "options": [
+ { "value": "1", "text": "Pizza", "group_by": ["food"] },
+ { "value": "2", "text": "Banana", "group_by": ["food"] }
+ ],
+ "optgroups": [{ "value": "food", "label": "food" }]
+ }
+ }
+
Once you have this, generate the URL to your controller and
pass it to the ``url`` value of the ``stimulus_controller()`` Twig
function, or to the ``autocomplete_url`` option of your form field.
@@ -557,3 +571,4 @@ the Symfony framework: https://symfony.com/doc/current/contributing/code/bc.html
.. _`Tom Select Options`: https://tom-select.js.org/docs/#general-configuration
.. _`controller.ts`: https://github.com/symfony/ux/blob/2.x/src/Autocomplete/assets/src/controller.ts
.. _`Tom Select Render Templates`: https://tom-select.js.org/docs/#render-templates
+.. _`Tom Select Option Group`: https://tom-select.js.org/examples/optgroups/
diff --git a/src/Autocomplete/src/AutocompleteResults.php b/src/Autocomplete/src/AutocompleteResults.php
index 02be8409471..9101487f187 100644
--- a/src/Autocomplete/src/AutocompleteResults.php
+++ b/src/Autocomplete/src/AutocompleteResults.php
@@ -19,6 +19,7 @@ final class AutocompleteResults
public function __construct(
public array $results,
public bool $hasNextPage,
+ public array $optgroups = [],
) {
}
}
diff --git a/src/Autocomplete/src/AutocompleteResultsExecutor.php b/src/Autocomplete/src/AutocompleteResultsExecutor.php
index 150db35deba..55a5e9f48cd 100644
--- a/src/Autocomplete/src/AutocompleteResultsExecutor.php
+++ b/src/Autocomplete/src/AutocompleteResultsExecutor.php
@@ -12,6 +12,11 @@
namespace Symfony\UX\Autocomplete;
use Doctrine\ORM\Tools\Pagination\Paginator;
+use Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException;
+use Symfony\Component\PropertyAccess\PropertyAccessor;
+use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
+use Symfony\Component\PropertyAccess\PropertyPath;
+use Symfony\Component\PropertyAccess\PropertyPathInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Security;
use Symfony\UX\Autocomplete\Doctrine\DoctrineRegistryWrapper;
@@ -21,10 +26,22 @@
*/
final class AutocompleteResultsExecutor
{
+ private PropertyAccessorInterface $propertyAccessor;
+ private ?Security $security;
+
public function __construct(
private DoctrineRegistryWrapper $managerRegistry,
- private ?Security $security = null
+ $propertyAccessor,
+ /* Security $security = null */
) {
+ if ($propertyAccessor instanceof Security) {
+ trigger_deprecation('symfony/ux-autocomplete', '2.8.0', 'Passing a "%s" instance as the second argument of "%s()" is deprecated, pass a "%s" instance instead.', Security::class, __METHOD__, PropertyAccessorInterface::class);
+ $this->security = $propertyAccessor;
+ $this->propertyAccessor = new PropertyAccessor();
+ } else {
+ $this->propertyAccessor = $propertyAccessor;
+ $this->security = \func_num_args() >= 3 ? func_get_arg(2) : null;
+ }
}
public function fetchResults(EntityAutocompleterInterface $autocompleter, string $query, int $page): AutocompleteResults
@@ -50,15 +67,61 @@ public function fetchResults(EntityAutocompleterInterface $autocompleter, string
$paginator = new Paginator($queryBuilder);
$nbPages = (int) ceil($paginator->count() / $queryBuilder->getMaxResults());
+ $hasNextPage = $page < $nbPages;
$results = [];
+
+ if (null === $groupBy = $autocompleter->getGroupBy()) {
+ foreach ($paginator as $entity) {
+ $results[] = [
+ 'value' => $autocompleter->getValue($entity),
+ 'text' => $autocompleter->getLabel($entity),
+ ];
+ }
+
+ return new AutocompleteResults($results, $hasNextPage);
+ }
+
+ if (\is_string($groupBy)) {
+ $groupBy = new PropertyPath($groupBy);
+ }
+
+ if ($groupBy instanceof PropertyPathInterface) {
+ $accessor = $this->propertyAccessor;
+ $groupBy = function ($choice) use ($accessor, $groupBy) {
+ try {
+ return $accessor->getValue($choice, $groupBy);
+ } catch (UnexpectedTypeException) {
+ return null;
+ }
+ };
+ }
+
+ if (!\is_callable($groupBy)) {
+ throw new \InvalidArgumentException(sprintf('Option "group_by" must be callable, "%s" given.', get_debug_type($groupBy)));
+ }
+
+ $optgroupLabels = [];
+
foreach ($paginator as $entity) {
- $results[] = [
+ $result = [
'value' => $autocompleter->getValue($entity),
'text' => $autocompleter->getLabel($entity),
];
+
+ $groupLabels = $groupBy($entity, $result['value'], $result['text']);
+
+ if (null !== $groupLabels) {
+ $groupLabels = \is_array($groupLabels) ? array_map('strval', $groupLabels) : [(string) $groupLabels];
+ $result['group_by'] = $groupLabels;
+ $optgroupLabels = array_merge($optgroupLabels, $groupLabels);
+ }
+
+ $results[] = $result;
}
- return new AutocompleteResults($results, $page < $nbPages);
+ $optgroups = array_map(fn (string $label) => ['value' => $label, 'label' => $label], array_unique($optgroupLabels));
+
+ return new AutocompleteResults($results, $hasNextPage, $optgroups);
}
}
diff --git a/src/Autocomplete/src/Controller/EntityAutocompleteController.php b/src/Autocomplete/src/Controller/EntityAutocompleteController.php
index b2ec8d90d0c..e65cb6dad12 100644
--- a/src/Autocomplete/src/Controller/EntityAutocompleteController.php
+++ b/src/Autocomplete/src/Controller/EntityAutocompleteController.php
@@ -50,7 +50,7 @@ public function __invoke(string $alias, Request $request): Response
}
return new JsonResponse([
- 'results' => $data->results,
+ 'results' => ($data->optgroups) ? ['options' => $data->results, 'optgroups' => $data->optgroups] : $data->results,
'next_page' => $nextPage,
]);
}
diff --git a/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php b/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php
index 91a3c377a20..3c8fcc4d8f0 100644
--- a/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php
+++ b/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php
@@ -81,6 +81,7 @@ private function registerBasicServices(ContainerBuilder $container): void
->register('ux.autocomplete.results_executor', AutocompleteResultsExecutor::class)
->setArguments([
new Reference('ux.autocomplete.doctrine_registry_wrapper'),
+ new Reference('property_accessor'),
new Reference('security.helper', ContainerInterface::NULL_ON_INVALID_REFERENCE),
])
;
diff --git a/src/Autocomplete/src/EntityAutocompleterInterface.php b/src/Autocomplete/src/EntityAutocompleterInterface.php
index b019aed4194..e26758843cf 100644
--- a/src/Autocomplete/src/EntityAutocompleterInterface.php
+++ b/src/Autocomplete/src/EntityAutocompleterInterface.php
@@ -46,4 +46,9 @@ public function getValue(object $entity): mixed;
* Note: if SecurityBundle is not installed, this will not be called.
*/
public function isGranted(Security $security): bool;
+
+ /**
+ * Return group_by option.
+ */
+ public function getGroupBy(): mixed;
}
diff --git a/src/Autocomplete/src/Form/WrappedEntityTypeAutocompleter.php b/src/Autocomplete/src/Form/WrappedEntityTypeAutocompleter.php
index 3b92ab7c9e9..c57e0829fef 100644
--- a/src/Autocomplete/src/Form/WrappedEntityTypeAutocompleter.php
+++ b/src/Autocomplete/src/Form/WrappedEntityTypeAutocompleter.php
@@ -112,6 +112,11 @@ public function isGranted(Security $security): bool
throw new \InvalidArgumentException('Invalid passed to the "security" option: it must be the boolean true, a string role or a callable.');
}
+ public function getGroupBy(): mixed
+ {
+ return $this->getFormOption('group_by');
+ }
+
private function getFormOption(string $name): mixed
{
$form = $this->getForm();
diff --git a/src/Autocomplete/tests/Fixtures/Autocompleter/CustomGroupByProductAutocompleter.php b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomGroupByProductAutocompleter.php
new file mode 100644
index 00000000000..d3ae879e0ee
--- /dev/null
+++ b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomGroupByProductAutocompleter.php
@@ -0,0 +1,19 @@
+ 'custom_product'
]);
+ $services->set(CustomGroupByProductAutocompleter::class)
+ ->public()
+ ->arg(1, new Reference('ux.autocomplete.entity_search_util'))
+ ->tag(AutocompleteFormTypePass::ENTITY_AUTOCOMPLETER_TAG, [
+ 'alias' => 'custom_group_by_product'
+ ]);
+
$services->alias('public.results_executor', 'ux.autocomplete.results_executor')
->public();
diff --git a/src/Autocomplete/tests/Integration/WiringTest.php b/src/Autocomplete/tests/Integration/WiringTest.php
index 5d053b92ddf..fb7813e3a58 100644
--- a/src/Autocomplete/tests/Integration/WiringTest.php
+++ b/src/Autocomplete/tests/Integration/WiringTest.php
@@ -14,7 +14,9 @@
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\UX\Autocomplete\AutocompleteResultsExecutor;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomGroupByProductAutocompleter;
use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomProductAutocompleter;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\CategoryFactory;
use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\ProductFactory;
use Symfony\UX\Autocomplete\Tests\Fixtures\Kernel;
use Zenstruck\Foundry\Test\Factories;
@@ -73,4 +75,24 @@ public function testWiringWithManyResults(): void
$this->assertCount(0, $data->results);
$this->assertFalse($data->hasNextPage);
}
+
+ public function testWiringWithoutFormAndGroupByOption(): void
+ {
+ $kernel = new Kernel('test', true);
+ $kernel->disableForms();
+ $kernel->boot();
+
+ $category1 = CategoryFactory::createOne(['name' => 'foods']);
+ $category2 = CategoryFactory::createOne(['name' => 'toys']);
+ ProductFactory::createOne(['name' => 'pizza', 'category' => $category1]);
+ ProductFactory::createOne(['name' => 'toy food', 'category' => $category2]);
+ ProductFactory::createOne(['name' => 'puzzle', 'category' => $category2]);
+
+ /** @var AutocompleteResultsExecutor $executor */
+ $executor = $kernel->getContainer()->get('public.results_executor');
+ $autocompleter = $kernel->getContainer()->get(CustomGroupByProductAutocompleter::class);
+ $data = $executor->fetchResults($autocompleter, '', 1);
+ $this->assertCount(3, $data->results);
+ $this->assertCount(2, $data->optgroups);
+ }
}
diff --git a/src/Autocomplete/tests/Unit/AutocompleteResultsExecutorTest.php b/src/Autocomplete/tests/Unit/AutocompleteResultsExecutorTest.php
index 45f685112f1..427dc05f10c 100644
--- a/src/Autocomplete/tests/Unit/AutocompleteResultsExecutorTest.php
+++ b/src/Autocomplete/tests/Unit/AutocompleteResultsExecutorTest.php
@@ -12,6 +12,7 @@
namespace Symfony\UX\Autocomplete\Tests\Unit;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Security;
use Symfony\UX\Autocomplete\AutocompleteResultsExecutor;
@@ -31,6 +32,7 @@ public function testItExecutesSecurity()
$executor = new AutocompleteResultsExecutor(
$doctrineRegistry,
+ $this->createMock(PropertyAccessorInterface::class),
$this->createMock(Security::class)
);