Skip to content

Commit a5b8c55

Browse files
jvancoillieweaverryan
authored andcommitted
[Autocomplete]: implement group_by option on Entity Autocompleter results
1 parent a97dbd2 commit a5b8c55

17 files changed

+264
-13
lines changed

src/Autocomplete/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22

33
## 2.8.0
44

5+
56
- The autocomplete will now update if any of the `option` elements inside of
67
it change, including the empty / placeholder element. Additionally, if the
78
`select` or `input` element's `disabled` attribute changes, the autocomplete
89
instance will update accordingly. This makes Autocomplete work perfectly inside
910
of a LiveComponent.
1011

12+
- Added support for using [OptionGroups](https://tom-select.js.org/examples/optgroups/).
13+
14+
1115
## 2.7.0
1216

1317
- Add `assets/src` to `.gitattributes` to exclude them from the installation

src/Autocomplete/assets/dist/controller.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,13 +266,14 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def
266266
.then((response) => response.json())
267267
.then((json) => {
268268
this.setNextUrl(query, json.next_page);
269-
callback(json.results);
269+
callback(json.results.options || json.results, json.results.optgroups || []);
270270
})
271-
.catch(() => callback());
271+
.catch(() => callback([], []));
272272
},
273273
shouldLoad: function (query) {
274274
return query.length >= minCharacterLength;
275275
},
276+
optgroupField: 'group_by',
276277
score: function (search) {
277278
return function (item) {
278279
return 1;

src/Autocomplete/assets/src/controller.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Controller } from '@hotwired/stimulus';
22
import TomSelect from 'tom-select';
33
import { TPluginHash } from 'tom-select/dist/types/contrib/microplugin';
4-
import { RecursivePartial, TomSettings, TomTemplates } from 'tom-select/dist/types/types';
4+
import { RecursivePartial, TomSettings, TomTemplates, TomLoadCallback } from 'tom-select/dist/types/types';
55

66
export interface AutocompletePreConnectOptions {
77
options: any;
@@ -147,20 +147,21 @@ export default class extends Controller {
147147
// VERY IMPORTANT: use 'function (query, callback) { ... }' instead of the
148148
// '(query, callback) => { ... }' syntax because, otherwise,
149149
// the 'this.XXX' calls inside this method fail
150-
load: function (query: string, callback: (results?: any) => void) {
150+
load: function (query: string, callback: TomLoadCallback) {
151151
const url = this.getUrl(query);
152152
fetch(url)
153153
.then((response) => response.json())
154154
// important: next_url must be set before invoking callback()
155155
.then((json) => {
156156
this.setNextUrl(query, json.next_page);
157-
callback(json.results);
157+
callback(json.results.options || json.results, json.results.optgroups || []);
158158
})
159-
.catch(() => callback());
159+
.catch(() => callback([], []));
160160
},
161161
shouldLoad: function (query: string) {
162162
return query.length >= minCharacterLength;
163163
},
164+
optgroupField: 'group_by',
164165
// avoid extra filtering after results are returned
165166
score: function (search: string) {
166167
return function (item: any) {

src/Autocomplete/assets/test/controller.test.ts

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ describe('AutocompleteController', () => {
9696
value: 3,
9797
text: 'salad'
9898
},
99-
],
99+
]
100100
}),
101101
);
102102

@@ -111,8 +111,8 @@ describe('AutocompleteController', () => {
111111
{
112112
value: 2,
113113
text: 'popcorn'
114-
},
115-
],
114+
}
115+
]
116116
}),
117117
);
118118

@@ -499,4 +499,102 @@ describe('AutocompleteController', () => {
499499
await shortDelay(10);
500500
expect(tomSelect.control_input.placeholder).toBe('Select a kangaroo');
501501
});
502+
503+
it('group related options', async () => {
504+
const { container, tomSelect } = await startAutocompleteTest(`
505+
<label for="the-select">Items</label>
506+
<select
507+
id="the-select"
508+
data-testid="main-element"
509+
data-controller="check autocomplete"
510+
data-autocomplete-url-value="/path/to/autocomplete"
511+
></select>
512+
`);
513+
514+
// initial Ajax request on focus with group_by options
515+
fetchMock.mock(
516+
'/path/to/autocomplete?query=',
517+
JSON.stringify({
518+
results: {
519+
options: [
520+
{
521+
group_by: ['Meat'],
522+
value: 1,
523+
text: 'Beef'
524+
},
525+
{
526+
group_by: ['Meat'],
527+
value: 2,
528+
text: 'Mutton'
529+
},
530+
{
531+
group_by: ['starchy'],
532+
value: 3,
533+
text: 'Potatoes'
534+
},
535+
{
536+
group_by: ['starchy', 'Meat'],
537+
value: 4,
538+
text: 'chili con carne'
539+
},
540+
],
541+
optgroups: [
542+
{
543+
value: 'Meat',
544+
label: 'Meat'
545+
},
546+
{
547+
value: 'starchy',
548+
label: 'starchy'
549+
},
550+
]
551+
},
552+
}),
553+
);
554+
555+
fetchMock.mock(
556+
'/path/to/autocomplete?query=foo',
557+
JSON.stringify({
558+
results: {
559+
options: [
560+
{
561+
group_by: ['Meat'],
562+
value: 1,
563+
text: 'Beef'
564+
},
565+
{
566+
group_by: ['Meat'],
567+
value: 2,
568+
text: 'Mutton'
569+
},
570+
],
571+
optgroups: [
572+
{
573+
value: 'Meat',
574+
label: 'Meat'
575+
},
576+
]
577+
}
578+
}),
579+
);
580+
581+
const controlInput = tomSelect.control_input;
582+
583+
// wait for the initial Ajax request to finish
584+
userEvent.click(controlInput);
585+
await waitFor(() => {
586+
expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(5);
587+
expect(container.querySelectorAll('.optgroup-header')).toHaveLength(2);
588+
});
589+
590+
// typing was not properly triggering, for some reason
591+
//userEvent.type(controlInput, 'foo');
592+
controlInput.value = 'foo';
593+
controlInput.dispatchEvent(new Event('input'));
594+
595+
await waitFor(() => {
596+
expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(2);
597+
expect(container.querySelectorAll('.optgroup-header')).toHaveLength(1);
598+
});
599+
});
502600
});

src/Autocomplete/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"symfony/dependency-injection": "^5.4|^6.0",
2929
"symfony/http-foundation": "^5.4|^6.0",
3030
"symfony/http-kernel": "^5.4|^6.0",
31+
"symfony/property-access": "^5.4|^6.0",
3132
"symfony/string": "^5.4|^6.0"
3233
},
3334
"require-dev": {

src/Autocomplete/doc/index.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,20 @@ a :ref:`custom autocompleter <custom-autocompleter>`:
513513
]
514514
}
515515
516+
for using `Tom Select Option Group`_ the format is as follows
517+
518+
.. code-block:: json
519+
520+
{
521+
"results": {
522+
"options": [
523+
{ "value": "1", "text": "Pizza", "group_by": ["food"] },
524+
{ "value": "2", "text": "Banana", "group_by": ["food"] }
525+
],
526+
"optgroups": [{ "value": "food", "label": "food" }]
527+
}
528+
}
529+
516530
Once you have this, generate the URL to your controller and
517531
pass it to the ``url`` value of the ``stimulus_controller()`` Twig
518532
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
557571
.. _`Tom Select Options`: https://tom-select.js.org/docs/#general-configuration
558572
.. _`controller.ts`: https://github.com/symfony/ux/blob/2.x/src/Autocomplete/assets/src/controller.ts
559573
.. _`Tom Select Render Templates`: https://tom-select.js.org/docs/#render-templates
574+
.. _`Tom Select Option Group`: https://tom-select.js.org/examples/optgroups/

src/Autocomplete/src/AutocompleteResults.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ final class AutocompleteResults
1919
public function __construct(
2020
public array $results,
2121
public bool $hasNextPage,
22+
public array $optgroups = [],
2223
) {
2324
}
2425
}

src/Autocomplete/src/AutocompleteResultsExecutor.php

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
namespace Symfony\UX\Autocomplete;
1313

1414
use Doctrine\ORM\Tools\Pagination\Paginator;
15+
use Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException;
16+
use Symfony\Component\PropertyAccess\PropertyAccessor;
17+
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
18+
use Symfony\Component\PropertyAccess\PropertyPath;
19+
use Symfony\Component\PropertyAccess\PropertyPathInterface;
1520
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
1621
use Symfony\Component\Security\Core\Security;
1722
use Symfony\UX\Autocomplete\Doctrine\DoctrineRegistryWrapper;
@@ -21,10 +26,22 @@
2126
*/
2227
final class AutocompleteResultsExecutor
2328
{
29+
private PropertyAccessorInterface $propertyAccessor;
30+
private ?Security $security;
31+
2432
public function __construct(
2533
private DoctrineRegistryWrapper $managerRegistry,
26-
private ?Security $security = null
34+
$propertyAccessor,
35+
/* Security $security = null */
2736
) {
37+
if ($propertyAccessor instanceof Security) {
38+
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);
39+
$this->security = $propertyAccessor;
40+
$this->propertyAccessor = new PropertyAccessor();
41+
} else {
42+
$this->propertyAccessor = $propertyAccessor;
43+
$this->security = \func_num_args() >= 3 ? func_get_arg(2) : null;
44+
}
2845
}
2946

3047
public function fetchResults(EntityAutocompleterInterface $autocompleter, string $query, int $page): AutocompleteResults
@@ -50,15 +67,61 @@ public function fetchResults(EntityAutocompleterInterface $autocompleter, string
5067
$paginator = new Paginator($queryBuilder);
5168

5269
$nbPages = (int) ceil($paginator->count() / $queryBuilder->getMaxResults());
70+
$hasNextPage = $page < $nbPages;
5371

5472
$results = [];
73+
74+
if (null === $groupBy = $autocompleter->getGroupBy()) {
75+
foreach ($paginator as $entity) {
76+
$results[] = [
77+
'value' => $autocompleter->getValue($entity),
78+
'text' => $autocompleter->getLabel($entity),
79+
];
80+
}
81+
82+
return new AutocompleteResults($results, $hasNextPage);
83+
}
84+
85+
if (\is_string($groupBy)) {
86+
$groupBy = new PropertyPath($groupBy);
87+
}
88+
89+
if ($groupBy instanceof PropertyPathInterface) {
90+
$accessor = $this->propertyAccessor;
91+
$groupBy = function ($choice) use ($accessor, $groupBy) {
92+
try {
93+
return $accessor->getValue($choice, $groupBy);
94+
} catch (UnexpectedTypeException) {
95+
return null;
96+
}
97+
};
98+
}
99+
100+
if (!\is_callable($groupBy)) {
101+
throw new \InvalidArgumentException(sprintf('Option "group_by" must be callable, "%s" given.', get_debug_type($groupBy)));
102+
}
103+
104+
$optgroupLabels = [];
105+
55106
foreach ($paginator as $entity) {
56-
$results[] = [
107+
$result = [
57108
'value' => $autocompleter->getValue($entity),
58109
'text' => $autocompleter->getLabel($entity),
59110
];
111+
112+
$groupLabels = $groupBy($entity, $result['value'], $result['text']);
113+
114+
if (null !== $groupLabels) {
115+
$groupLabels = \is_array($groupLabels) ? array_map('strval', $groupLabels) : [(string) $groupLabels];
116+
$result['group_by'] = $groupLabels;
117+
$optgroupLabels = array_merge($optgroupLabels, $groupLabels);
118+
}
119+
120+
$results[] = $result;
60121
}
61122

62-
return new AutocompleteResults($results, $page < $nbPages);
123+
$optgroups = array_map(fn (string $label) => ['value' => $label, 'label' => $label], array_unique($optgroupLabels));
124+
125+
return new AutocompleteResults($results, $hasNextPage, $optgroups);
63126
}
64127
}

src/Autocomplete/src/Controller/EntityAutocompleteController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public function __invoke(string $alias, Request $request): Response
5050
}
5151

5252
return new JsonResponse([
53-
'results' => $data->results,
53+
'results' => ($data->optgroups) ? ['options' => $data->results, 'optgroups' => $data->optgroups] : $data->results,
5454
'next_page' => $nextPage,
5555
]);
5656
}

src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ private function registerBasicServices(ContainerBuilder $container): void
8181
->register('ux.autocomplete.results_executor', AutocompleteResultsExecutor::class)
8282
->setArguments([
8383
new Reference('ux.autocomplete.doctrine_registry_wrapper'),
84+
new Reference('property_accessor'),
8485
new Reference('security.helper', ContainerInterface::NULL_ON_INVALID_REFERENCE),
8586
])
8687
;

0 commit comments

Comments
 (0)