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) );