Skip to content

Commit 320ea74

Browse files
committed
feature #919 [Autocomplete] When min chars is not set, keep loading after initial load (weaverryan)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [Autocomplete] When min chars is not set, keep loading after initial load | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Tickets | From conversation on Slack! | License | MIT This helps with the case where you type 3 characters, get a load, then backspace (or clear all the characters) and loading stops and you can't get new options. You can see the current behavior by going to https://ux.symfony.com/autocomplete, typing "ban", then backspacing. You will only see "banana" as an option, even if you clear it, unless you guess the first 3 chars of some other option. Cheers! Commits ------- 30a1e7d [Autocomplete] When min chars is not set, keep loading after initial load
2 parents 6d46f90 + 30a1e7d commit 320ea74

File tree

10 files changed

+176
-86
lines changed

10 files changed

+176
-86
lines changed

src/Autocomplete/assets/dist/controller.d.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,7 @@ export default class extends Controller {
1414
optionsAsHtml: BooleanConstructor;
1515
noResultsFoundText: StringConstructor;
1616
noMoreResultsText: StringConstructor;
17-
minCharacters: {
18-
type: NumberConstructor;
19-
default: number;
20-
};
17+
minCharacters: NumberConstructor;
2118
tomSelectOptions: ObjectConstructor;
2219
preload: StringConstructor;
2320
};
@@ -26,12 +23,14 @@ export default class extends Controller {
2623
readonly noMoreResultsTextValue: string;
2724
readonly noResultsFoundTextValue: string;
2825
readonly minCharactersValue: number;
26+
readonly hasMinCharactersValue: boolean;
2927
readonly tomSelectOptionsValue: object;
3028
readonly hasPreloadValue: boolean;
3129
readonly preloadValue: string;
3230
tomSelect: TomSelect;
3331
private mutationObserver;
3432
private isObserving;
33+
private hasLoadedChoicesPreviously;
3534
initialize(): void;
3635
connect(): void;
3736
disconnect(): void;

src/Autocomplete/assets/dist/controller.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class default_1 extends Controller {
2828
super(...arguments);
2929
_default_1_instances.add(this);
3030
this.isObserving = false;
31+
this.hasLoadedChoicesPreviously = false;
3132
}
3233
initialize() {
3334
if (this.requiresLiveIgnore()) {
@@ -49,7 +50,7 @@ class default_1 extends Controller {
4950
}
5051
connect() {
5152
if (this.urlValue) {
52-
this.tomSelect = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createAutocompleteWithRemoteData).call(this, this.urlValue, this.minCharactersValue);
53+
this.tomSelect = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createAutocompleteWithRemoteData).call(this, this.urlValue, this.hasMinCharactersValue ? this.minCharactersValue : null);
5354
return;
5455
}
5556
if (this.optionsAsHtmlValue) {
@@ -291,8 +292,17 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def
291292
})
292293
.catch(() => callback([], []));
293294
},
294-
shouldLoad: function (query) {
295-
return query.length >= minCharacterLength;
295+
shouldLoad: (query) => {
296+
if (null !== minCharacterLength) {
297+
return query.length >= minCharacterLength;
298+
}
299+
if (this.hasLoadedChoicesPreviously) {
300+
return true;
301+
}
302+
if (query.length > 0) {
303+
this.hasLoadedChoicesPreviously = true;
304+
}
305+
return query.length >= 3;
296306
},
297307
optgroupField: 'group_by',
298308
score: function (search) {
@@ -334,7 +344,7 @@ default_1.values = {
334344
optionsAsHtml: Boolean,
335345
noResultsFoundText: String,
336346
noMoreResultsText: String,
337-
minCharacters: { type: Number, default: 3 },
347+
minCharacters: Number,
338348
tomSelectOptions: Object,
339349
preload: String,
340350
};

src/Autocomplete/assets/src/controller.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export default class extends Controller {
1717
optionsAsHtml: Boolean,
1818
noResultsFoundText: String,
1919
noMoreResultsText: String,
20-
minCharacters: { type: Number, default: 3 },
20+
minCharacters: Number,
2121
tomSelectOptions: Object,
2222
preload: String,
2323
};
@@ -27,13 +27,15 @@ export default class extends Controller {
2727
declare readonly noMoreResultsTextValue: string;
2828
declare readonly noResultsFoundTextValue: string;
2929
declare readonly minCharactersValue: number;
30+
declare readonly hasMinCharactersValue: boolean;
3031
declare readonly tomSelectOptionsValue: object;
3132
declare readonly hasPreloadValue: boolean;
3233
declare readonly preloadValue: string;
3334
tomSelect: TomSelect;
3435

3536
private mutationObserver: MutationObserver;
3637
private isObserving = false;
38+
private hasLoadedChoicesPreviously = false;
3739

3840
initialize() {
3941
if (this.requiresLiveIgnore()) {
@@ -62,7 +64,10 @@ export default class extends Controller {
6264

6365
connect() {
6466
if (this.urlValue) {
65-
this.tomSelect = this.#createAutocompleteWithRemoteData(this.urlValue, this.minCharactersValue);
67+
this.tomSelect = this.#createAutocompleteWithRemoteData(
68+
this.urlValue,
69+
this.hasMinCharactersValue ? this.minCharactersValue : null
70+
);
6671

6772
return;
6873
}
@@ -163,7 +168,7 @@ export default class extends Controller {
163168
return this.#createTomSelect(config);
164169
}
165170

166-
#createAutocompleteWithRemoteData(autocompleteEndpointUrl: string, minCharacterLength: number): TomSelect {
171+
#createAutocompleteWithRemoteData(autocompleteEndpointUrl: string, minCharacterLength: number | null): TomSelect {
167172
const config: RecursivePartial<TomSettings> = this.#mergeObjects(this.#getCommonConfig(), {
168173
firstUrl: (query: string) => {
169174
const separator = autocompleteEndpointUrl.includes('?') ? '&' : '?';
@@ -184,8 +189,26 @@ export default class extends Controller {
184189
})
185190
.catch(() => callback([], []));
186191
},
187-
shouldLoad: function (query: string) {
188-
return query.length >= minCharacterLength;
192+
shouldLoad: (query: string) => {
193+
// if min length is specified, always use it
194+
if (null !== minCharacterLength) {
195+
return query.length >= minCharacterLength;
196+
}
197+
198+
// otherwise, default to 3, but always load after the first request
199+
// this gives nice behavior when the user deletes characters and
200+
// goes below the minimum length, it will still load fresh choices
201+
202+
if (this.hasLoadedChoicesPreviously) {
203+
return true;
204+
}
205+
206+
// mark that the choices have loaded (but avoid initial load)
207+
if (query.length > 0) {
208+
this.hasLoadedChoicesPreviously = true;
209+
}
210+
211+
return query.length >= 3;
189212
},
190213
optgroupField: 'group_by',
191214
// avoid extra filtering after results are returned

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,96 @@ describe('AutocompleteController', () => {
227227
expect(tomSelect.settings.shouldLoad('')).toBeTruthy()
228228
})
229229

230+
it('default min-characters will always load after first load', async () => {
231+
const { container, tomSelect } = await startAutocompleteTest(`
232+
<label for="the-select">Items</label>
233+
<select
234+
id="the-select"
235+
data-testid="main-element"
236+
data-controller="autocomplete"
237+
data-autocomplete-url-value="/path/to/autocomplete"
238+
></select>
239+
`);
240+
241+
const controlInput = tomSelect.control_input;
242+
243+
// ajax call from initial focus
244+
fetchMock.mock(
245+
'/path/to/autocomplete?query=',
246+
JSON.stringify({
247+
results: [
248+
{
249+
value: 1,
250+
text: 'pizza'
251+
},
252+
],
253+
}),
254+
);
255+
// wait for the initial Ajax request to finish
256+
userEvent.click(controlInput);
257+
await waitFor(() => {
258+
expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(1);
259+
});
260+
261+
// min length will default to 3, so this is too short
262+
controlInput.value = 'fo';
263+
controlInput.dispatchEvent(new Event('input'));
264+
// should still have just 1 option
265+
await waitFor(() => {
266+
expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(1);
267+
});
268+
269+
// now trigger a load
270+
fetchMock.mock(
271+
'/path/to/autocomplete?query=foo',
272+
JSON.stringify({
273+
results: [
274+
{
275+
value: 1,
276+
text: 'pizza'
277+
},
278+
{
279+
value: 2,
280+
text: 'popcorn'
281+
},
282+
],
283+
}),
284+
);
285+
controlInput.value = 'foo';
286+
controlInput.dispatchEvent(new Event('input'));
287+
288+
await waitFor(() => {
289+
expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(2);
290+
});
291+
292+
// now go below the min characters, but it should still load
293+
fetchMock.mock(
294+
'/path/to/autocomplete?query=fo',
295+
JSON.stringify({
296+
results: [
297+
{
298+
value: 1,
299+
text: 'pizza'
300+
},
301+
{
302+
value: 2,
303+
text: 'popcorn'
304+
},
305+
{
306+
value: 3,
307+
text: 'apples'
308+
},
309+
],
310+
}),
311+
);
312+
controlInput.value = 'fo';
313+
controlInput.dispatchEvent(new Event('input'));
314+
315+
await waitFor(() => {
316+
expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(3);
317+
});
318+
});
319+
230320
it('adds work-around for live-component & multiple select', async () => {
231321
const { container } = await startAutocompleteTest(`
232322
<div>

src/Autocomplete/src/Doctrine/SearchEscaper.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@ private static function isDqlReservedKeyword(string $string): bool
4848
$lexer->moveNext();
4949
$token = $lexer->lookahead;
5050

51-
if (200 <= $token['type']) {
51+
// backwards compat for when $token changed from array to object
52+
// https://github.com/doctrine/lexer/pull/79
53+
$type = \is_array($token) ? $token['type'] : $token->type;
54+
55+
if (200 <= $type) {
5256
return true;
5357
}
5458

src/Autocomplete/src/Form/AutocompleteChoiceTypeExtension.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public function configureOptions(OptionsResolver $resolver): void
9999
'allow_options_create' => false,
100100
'no_results_found_text' => 'No results found',
101101
'no_more_results_text' => 'No more results',
102-
'min_characters' => 3,
102+
'min_characters' => null,
103103
'max_results' => 10,
104104
'preload' => 'focus',
105105
]);

src/Turbo/tests/app/Entity/Artist.php

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,41 +11,28 @@
1111

1212
namespace App\Entity;
1313

14+
use Doctrine\Common\Collections\Collection;
1415
use Doctrine\ORM\Mapping as ORM;
1516
use Symfony\UX\Turbo\Attribute\Broadcast;
1617

1718
/**
18-
* @ORM\Entity
19-
*
20-
* @Broadcast
21-
*
2219
* @author Rick Kuipers <[email protected]>
2320
*/
2421
#[Broadcast]
22+
#[ORM\Entity]
2523
class Artist
2624
{
27-
/**
28-
* @ORM\Column(type="integer")
29-
*
30-
* @ORM\Id
31-
*
32-
* @ORM\GeneratedValue(strategy="AUTO")
33-
*
34-
* @var int|null
35-
*/
36-
public $id;
25+
#[ORM\Id]
26+
#[ORM\GeneratedValue(strategy: 'AUTO')]
27+
#[ORM\Column(type: 'integer')]
28+
public ?int $id = null;
3729

38-
/**
39-
* @ORM\Column
40-
*
41-
* @var string
42-
*/
43-
public $name = '';
30+
#[ORM\Column]
31+
public string $name = '';
4432

4533
/**
46-
* @ORM\OneToMany(targetEntity="App\Entity\Song", mappedBy="artist")
47-
*
48-
* @var Song[]
34+
* @var Collection<int, Song>
4935
*/
50-
public $songs;
36+
#[ORM\OneToMany(targetEntity: Song::class, mappedBy: 'artist')]
37+
public Collection $songs;
5138
}

src/Turbo/tests/app/Entity/Book.php

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,30 +15,17 @@
1515
use Symfony\UX\Turbo\Attribute\Broadcast;
1616

1717
/**
18-
* @ORM\Entity
19-
*
20-
* @Broadcast
21-
*
2218
* @author Kévin Dunglas <[email protected]>
2319
*/
2420
#[Broadcast]
21+
#[ORM\Entity]
2522
class Book
2623
{
27-
/**
28-
* @ORM\Column(type="integer")
29-
*
30-
* @ORM\Id
31-
*
32-
* @ORM\GeneratedValue(strategy="AUTO")
33-
*
34-
* @var int|null
35-
*/
36-
public $id;
24+
#[ORM\Id]
25+
#[ORM\GeneratedValue(strategy: 'AUTO')]
26+
#[ORM\Column(type: 'integer')]
27+
public ?int $id = null;
3728

38-
/**
39-
* @ORM\Column
40-
*
41-
* @var string
42-
*/
43-
public $title = '';
29+
#[ORM\Column]
30+
public string $title = '';
4431
}

0 commit comments

Comments
 (0)