Skip to content

Commit 94cc1b5

Browse files
committed
feat(Autocomplete): implement group_by option on Entity Autocompleter result
1 parent eebc6db commit 94cc1b5

14 files changed

+270
-50
lines changed

src/Autocomplete/assets/dist/controller.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,13 +131,14 @@ _instances = new WeakSet(), _getCommonConfig = function _getCommonConfig() {
131131
const url = this.getUrl(query);
132132
fetch(url)
133133
.then(response => response.json())
134-
.then(json => { this.setNextUrl(query, json.next_page); callback(json.results); })
134+
.then(json => { this.setNextUrl(query, json.next_page); callback(json.results.options, json.results.optgroups || []); })
135135
.catch(() => callback());
136136
},
137137
shouldLoad: function (query) {
138138
const minLength = minCharacterLength || 3;
139139
return query.length >= minLength;
140140
},
141+
optgroupField: 'group_by',
141142
score: function (search) {
142143
return function (item) {
143144
return 1;

src/Autocomplete/assets/src/controller.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,19 +136,20 @@ export default class extends Controller {
136136
// VERY IMPORTANT: use 'function (query, callback) { ... }' instead of the
137137
// '(query, callback) => { ... }' syntax because, otherwise,
138138
// the 'this.XXX' calls inside this method fail
139-
load: function (query: string, callback: (results?: any) => void) {
139+
load: function (query: string, callback: (options?: any, optgroups?: any) => void) {
140140
const url = this.getUrl(query);
141141
fetch(url)
142142
.then(response => response.json())
143143
// important: next_url must be set before invoking callback()
144-
.then(json => { this.setNextUrl(query, json.next_page); callback(json.results) })
144+
.then(json => { this.setNextUrl(query, json.next_page); callback(json.results.options, json.results.optgroups || []) })
145145
.catch(() => callback());
146146
},
147147
shouldLoad: function (query: string) {
148148
const minLength = minCharacterLength || 3;
149149

150150
return query.length >= minLength;
151151
},
152+
optgroupField: 'group_by',
152153
// avoid extra filtering after results are returned
153154
score: function(search: string) {
154155
return function(item: any) {

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

Lines changed: 125 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -108,28 +108,32 @@ describe('AutocompleteController', () => {
108108
fetchMock.mock(
109109
'/path/to/autocomplete?query=',
110110
JSON.stringify({
111-
results: [
112-
{
113-
value: 3,
114-
text: 'salad'
115-
},
116-
],
111+
results: {
112+
options: [
113+
{
114+
value: 3,
115+
text: 'salad'
116+
},
117+
]
118+
},
117119
}),
118120
);
119121

120122
fetchMock.mock(
121123
'/path/to/autocomplete?query=foo',
122124
JSON.stringify({
123-
results: [
124-
{
125-
value: 1,
126-
text: 'pizza'
127-
},
128-
{
129-
value: 2,
130-
text: 'popcorn'
131-
},
132-
],
125+
results: {
126+
options: [
127+
{
128+
value: 1,
129+
text: 'pizza'
130+
},
131+
{
132+
value: 2,
133+
text: 'popcorn'
134+
}
135+
]
136+
},
133137
}),
134138
);
135139

@@ -181,6 +185,111 @@ describe('AutocompleteController', () => {
181185
});
182186
});
183187

188+
it('connect with ajax URL and group_by option', async () => {
189+
const container = mountDOM(`
190+
<label for="the-select">Items</label>
191+
<select
192+
id="the-select"
193+
data-testid="main-element"
194+
data-controller="check autocomplete"
195+
data-autocomplete-url-value="/path/to/autocomplete"
196+
></select>
197+
`);
198+
199+
application = startStimulus();
200+
201+
await waitFor(() => {
202+
expect(getByTestId(container, 'main-element')).toHaveClass('connected');
203+
});
204+
205+
// initial Ajax request on focus with group_by options
206+
fetchMock.mock(
207+
'/path/to/autocomplete?query=',
208+
JSON.stringify({
209+
results: {
210+
options: [
211+
{
212+
group_by: ['Meat'],
213+
value: 1,
214+
text: 'Beef'
215+
},
216+
{
217+
group_by: ['Meat'],
218+
value: 2,
219+
text: 'Mutton'
220+
},
221+
{
222+
group_by: ['starchy'],
223+
value: 3,
224+
text: 'Potatoes'
225+
},
226+
{
227+
group_by: ['starchy', 'Meat'],
228+
value: 4,
229+
text: 'chili con carne'
230+
},
231+
],
232+
optgroups: [
233+
{
234+
value: 'Meat',
235+
label: 'Meat'
236+
},
237+
{
238+
value: 'starchy',
239+
label: 'starchy'
240+
},
241+
]
242+
},
243+
}),
244+
);
245+
246+
fetchMock.mock(
247+
'/path/to/autocomplete?query=foo',
248+
JSON.stringify({
249+
results: {
250+
options: [
251+
{
252+
group_by: ['Meat'],
253+
value: 1,
254+
text: 'Beef'
255+
},
256+
{
257+
group_by: ['Meat'],
258+
value: 2,
259+
text: 'Mutton'
260+
},
261+
],
262+
optgroups: [
263+
{
264+
value: 'Meat',
265+
label: 'Meat'
266+
},
267+
]
268+
}
269+
}),
270+
);
271+
272+
const tomSelect = getByTestId(container, 'main-element').tomSelect;
273+
const controlInput = tomSelect.control_input;
274+
275+
// wait for the initial Ajax request to finish
276+
userEvent.click(controlInput);
277+
await waitFor(() => {
278+
expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(5);
279+
expect(container.querySelectorAll('.optgroup-header')).toHaveLength(2);
280+
});
281+
282+
// typing was not properly triggering, for some reason
283+
//userEvent.type(controlInput, 'foo');
284+
controlInput.value = 'foo';
285+
controlInput.dispatchEvent(new Event('input'));
286+
287+
await waitFor(() => {
288+
expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(2);
289+
expect(container.querySelectorAll('.optgroup-header')).toHaveLength(1);
290+
});
291+
});
292+
184293
it('adds live-component support', async () => {
185294
const container = mountDOM(`
186295
<div>

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/src/AutocompleteResultsExecutor.php

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
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\PropertyAccessorInterface;
17+
use Symfony\Component\PropertyAccess\PropertyPath;
18+
use Symfony\Component\PropertyAccess\PropertyPathInterface;
1519
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
1620
use Symfony\Component\Security\Core\Security;
1721
use Symfony\UX\Autocomplete\Doctrine\DoctrineRegistryWrapper;
@@ -25,7 +29,8 @@ final class AutocompleteResultsExecutor
2529
{
2630
public function __construct(
2731
private DoctrineRegistryWrapper $managerRegistry,
28-
private ?Security $security = null
32+
private PropertyAccessorInterface $propertyAccessor,
33+
private ?Security $security = null,
2934
) {
3035
}
3136

@@ -53,14 +58,56 @@ public function fetchResults(EntityAutocompleterInterface $autocompleter, string
5358

5459
$nbPages = (int) ceil($paginator->count() / $queryBuilder->getMaxResults());
5560

56-
$results = [];
57-
foreach ($paginator as $entity) {
58-
$results[] = [
59-
'value' => $autocompleter->getValue($entity),
60-
'text' => $autocompleter->getLabel($entity),
61-
];
61+
$options = [];
62+
$optgroups = [];
63+
64+
if (null !== $groupBy = $autocompleter->getGroupBy()) {
65+
if (\is_string($groupBy)) {
66+
$groupBy = new PropertyPath($groupBy);
67+
}
68+
69+
if ($groupBy instanceof PropertyPathInterface) {
70+
$accessor = $this->propertyAccessor;
71+
$groupBy = function ($choice) use ($accessor, $groupBy) {
72+
try {
73+
return $accessor->getValue($choice, $groupBy);
74+
} catch (UnexpectedTypeException) {
75+
return null;
76+
}
77+
};
78+
}
79+
}
80+
81+
if (\is_callable($groupBy)) {
82+
$optgroupLabels = [];
83+
84+
foreach ($paginator as $entity) {
85+
$option = [
86+
'value' => $autocompleter->getValue($entity),
87+
'text' => $autocompleter->getLabel($entity),
88+
];
89+
90+
$groupLabels = $groupBy($entity, $option['value'], $option['text']);
91+
92+
if (null !== $groupLabels) {
93+
$groupLabels = \is_array($groupLabels) ? array_map('strval', $groupLabels) : [(string) $groupLabels];
94+
$option['group_by'] = $groupLabels;
95+
$optgroupLabels = array_merge($optgroupLabels, $groupLabels);
96+
}
97+
98+
$options[] = $option;
99+
}
100+
101+
$optgroups = array_map(fn (string $label) => ['value' => $label, 'label' => $label], array_unique($optgroupLabels));
102+
} else {
103+
foreach ($paginator as $entity) {
104+
$options[] = [
105+
'value' => $autocompleter->getValue($entity),
106+
'text' => $autocompleter->getLabel($entity),
107+
];
108+
}
62109
}
63110

64-
return new AutocompleteResults($results, $page < $nbPages);
111+
return new AutocompleteResults($options, $optgroups, $page < $nbPages);
65112
}
66113
}

src/Autocomplete/src/EntityAutocompleterInterface.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,9 @@ public function getValue(object $entity): mixed;
4646
* Note: if SecurityBundle is not installed, this will not be called.
4747
*/
4848
public function isGranted(Security $security): bool;
49+
50+
/**
51+
* Return Form option by name.
52+
*/
53+
public function getFormOption(string $name): mixed;
4954
}

src/Autocomplete/src/Form/WrappedEntityTypeAutocompleter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ public function isGranted(Security $security): bool
112112
throw new \InvalidArgumentException('Invalid passed to the "security" option: it must be the boolean true, a string role or a callable.');
113113
}
114114

115-
private function getFormOption(string $name): mixed
115+
public function getFormOption(string $name): mixed
116116
{
117117
$form = $this->getForm();
118118
$formOptions = $form['autocomplete']->getConfig()->getOptions();
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter;
4+
5+
use Doctrine\ORM\EntityRepository;
6+
use Doctrine\ORM\QueryBuilder;
7+
use Symfony\Component\HttpFoundation\RequestStack;
8+
use Symfony\Component\Security\Core\Security;
9+
use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil;
10+
use Symfony\UX\Autocomplete\EntityAutocompleterInterface;
11+
use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product;
12+
13+
class CustomGroupByProductAutocompleter extends CustomProductAutocompleter
14+
{
15+
public function getFormOption(string $name): mixed
16+
{
17+
return 'group_by' === $name ? 'category.name' : null;
18+
}
19+
}

src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,9 @@ public function isGranted(Security $security): bool
5858

5959
return true;
6060
}
61+
62+
public function getFormOption(string $name): mixed
63+
{
64+
return null;
65+
}
6166
}

src/Autocomplete/tests/Fixtures/Kernel.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
2929
use Symfony\UX\Autocomplete\AutocompleteBundle;
3030
use Symfony\UX\Autocomplete\DependencyInjection\AutocompleteFormTypePass;
31+
use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomGroupByProductAutocompleter;
3132
use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomProductAutocompleter;
3233
use Symfony\UX\Autocomplete\Tests\Fixtures\Form\ProductType;
3334
use Twig\Environment;
@@ -135,6 +136,13 @@ protected function configureContainer(ContainerConfigurator $c): void
135136
'alias' => 'custom_product'
136137
]);
137138

139+
$services->set(CustomGroupByProductAutocompleter::class)
140+
->public()
141+
->arg(1, new Reference('ux.autocomplete.entity_search_util'))
142+
->tag(AutocompleteFormTypePass::ENTITY_AUTOCOMPLETER_TAG, [
143+
'alias' => 'custom_group_by_product'
144+
]);
145+
138146
$services->alias('public.results_executor', 'ux.autocomplete.results_executor')
139147
->public();
140148

0 commit comments

Comments
 (0)