Skip to content

Commit ae07fdc

Browse files
committed
bug #2841 [Autocomplete] Ensure default plugins are nicely merged with user-defined plugins (Kocal)
This PR was merged into the 2.x branch. Discussion ---------- [Autocomplete] Ensure default plugins are nicely merged with user-defined plugins | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no <!-- please update src/**/CHANGELOG.md files --> | Docs? | no <!-- required for new features --> | Issues | Fix #1128, Fix #2002 <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead --> | License | MIT The issue happens because when merging the default configuration and the configuration defined by the user, plugins defined by the users erase plugins defined in the default configuration: At the moment, when the user didn't define `tom_select_options.plugins`, everything works fine: <details> <summary>Screenshot</summary> <img width="1800" alt="Capture d’écran 2025-06-13 à 14 22 37" src="https://github.com/user-attachments/assets/744ebb1c-b006-4e73-b90a-af08c32cea3c" /> </details> But when configuring `tom_select_options.plugins`, some necessary plugins are missing: <details> <summary>Screenshot</summary> <img width="1800" alt="Capture d’écran 2025-06-13 à 14 22 55" src="https://github.com/user-attachments/assets/a9731b2d-5c5c-40f1-a19b-e24524765c05" /> </details> With this PR, `tom_select_options.plugins` from the default configuration and the configuration defined by the user are nicely merged: <details> <summary>Screenshots</summary> <img width="1800" alt="Capture d’écran 2025-06-13 à 14 23 30" src="https://github.com/user-attachments/assets/a07f47a9-1acb-44a7-90b6-45fcc42c8da4" /> <img width="1800" alt="Capture d’écran 2025-06-13 à 14 23 53" src="https://github.com/user-attachments/assets/1e1d5064-2425-400c-95f3-3ca24f7246ed" /> </details> ## TODO: - [x] Update JS tests to prevent regressions Commits ------- 9da1c09 [Autocomplete] Ensure default plugins are nicely merged with user-defined plugins
2 parents 00e013e + 9da1c09 commit ae07fdc

File tree

3 files changed

+125
-14
lines changed

3 files changed

+125
-14
lines changed

src/Autocomplete/assets/dist/controller.js

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,28 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
2929
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
3030
};
3131

32-
var _default_1_instances, _default_1_getCommonConfig, _default_1_createAutocomplete, _default_1_createAutocompleteWithHtmlContents, _default_1_createAutocompleteWithRemoteData, _default_1_stripTags, _default_1_mergeObjects, _default_1_createTomSelect;
32+
var _default_1_instances, _default_1_getCommonConfig, _default_1_createAutocomplete, _default_1_createAutocompleteWithHtmlContents, _default_1_createAutocompleteWithRemoteData, _default_1_stripTags, _default_1_mergeConfigs, _default_1_normalizePluginsToHash, _default_1_createTomSelect;
3333
class default_1 extends Controller {
3434
constructor() {
3535
super(...arguments);
3636
_default_1_instances.add(this);
3737
this.isObserving = false;
3838
this.hasLoadedChoicesPreviously = false;
3939
this.originalOptions = [];
40+
_default_1_normalizePluginsToHash.set(this, (plugins) => {
41+
if (Array.isArray(plugins)) {
42+
return plugins.reduce((acc, plugin) => {
43+
if (typeof plugin === 'string') {
44+
acc[plugin] = {};
45+
}
46+
if (typeof plugin === 'object' && plugin.name) {
47+
acc[plugin.name] = plugin.options || {};
48+
}
49+
return acc;
50+
}, {});
51+
}
52+
return plugins;
53+
});
4054
}
4155
initialize() {
4256
if (!this.mutationObserver) {
@@ -223,7 +237,7 @@ class default_1 extends Controller {
223237
[...originalOptionsSet].every((option) => newOptionsSet.has(option)));
224238
}
225239
}
226-
_default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _default_1_getCommonConfig() {
240+
_default_1_normalizePluginsToHash = new WeakMap(), _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _default_1_getCommonConfig() {
227241
const plugins = {};
228242
const isMultiple = !this.selectElement || this.selectElement.multiple;
229243
if (!this.formElement.disabled && !isMultiple) {
@@ -288,16 +302,16 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def
288302
if (!this.selectElement && !this.urlValue) {
289303
config.shouldLoad = () => false;
290304
}
291-
return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, config, this.tomSelectOptionsValue);
305+
return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeConfigs).call(this, config, this.tomSelectOptionsValue);
292306
}, _default_1_createAutocomplete = function _default_1_createAutocomplete() {
293-
const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this), {
307+
const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeConfigs).call(this, __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this), {
294308
maxOptions: this.getMaxOptions(),
295309
});
296310
return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createTomSelect).call(this, config);
297311
}, _default_1_createAutocompleteWithHtmlContents = function _default_1_createAutocompleteWithHtmlContents() {
298312
const commonConfig = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this);
299313
const labelField = commonConfig.labelField ?? 'text';
300-
const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, commonConfig, {
314+
const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeConfigs).call(this, commonConfig, {
301315
maxOptions: this.getMaxOptions(),
302316
score: (search) => {
303317
const scoringFunction = this.tomSelect.getScoreFunction(search);
@@ -314,7 +328,7 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def
314328
}, _default_1_createAutocompleteWithRemoteData = function _default_1_createAutocompleteWithRemoteData(autocompleteEndpointUrl, minCharacterLength) {
315329
const commonConfig = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this);
316330
const labelField = commonConfig.labelField ?? 'text';
317-
const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, commonConfig, {
331+
const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeConfigs).call(this, commonConfig, {
318332
firstUrl: (query) => {
319333
const separator = autocompleteEndpointUrl.includes('?') ? '&' : '?';
320334
return `${autocompleteEndpointUrl}${separator}query=${encodeURIComponent(query)}`;
@@ -364,8 +378,15 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def
364378
return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createTomSelect).call(this, config);
365379
}, _default_1_stripTags = function _default_1_stripTags(string) {
366380
return string.replace(/(<([^>]+)>)/gi, '');
367-
}, _default_1_mergeObjects = function _default_1_mergeObjects(object1, object2) {
368-
return { ...object1, ...object2 };
381+
}, _default_1_mergeConfigs = function _default_1_mergeConfigs(config1, config2) {
382+
return {
383+
...config1,
384+
...config2,
385+
plugins: {
386+
...__classPrivateFieldGet(this, _default_1_normalizePluginsToHash, "f").call(this, config1.plugins || {}),
387+
...__classPrivateFieldGet(this, _default_1_normalizePluginsToHash, "f").call(this, config2.plugins || {}),
388+
},
389+
};
369390
}, _default_1_createTomSelect = function _default_1_createTomSelect(options) {
370391
const preConnectPayload = { options };
371392
this.dispatchEvent('pre-connect', preConnectPayload);

src/Autocomplete/assets/src/controller.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,11 @@ export default class extends Controller {
214214
config.shouldLoad = () => false;
215215
}
216216

217-
return this.#mergeObjects(config, this.tomSelectOptionsValue);
217+
return this.#mergeConfigs(config, this.tomSelectOptionsValue);
218218
}
219219

220220
#createAutocomplete(): TomSelect {
221-
const config = this.#mergeObjects(this.#getCommonConfig(), {
221+
const config = this.#mergeConfigs(this.#getCommonConfig(), {
222222
maxOptions: this.getMaxOptions(),
223223
});
224224

@@ -229,7 +229,7 @@ export default class extends Controller {
229229
const commonConfig = this.#getCommonConfig();
230230
const labelField = commonConfig.labelField ?? 'text';
231231

232-
const config = this.#mergeObjects(commonConfig, {
232+
const config = this.#mergeConfigs(commonConfig, {
233233
maxOptions: this.getMaxOptions(),
234234
score: (search: string) => {
235235
const scoringFunction = this.tomSelect.getScoreFunction(search);
@@ -251,7 +251,7 @@ export default class extends Controller {
251251
const commonConfig = this.#getCommonConfig();
252252
const labelField = commonConfig.labelField ?? 'text';
253253

254-
const config: RecursivePartial<TomSettings> = this.#mergeObjects(commonConfig, {
254+
const config: RecursivePartial<TomSettings> = this.#mergeConfigs(commonConfig, {
255255
firstUrl: (query: string) => {
256256
const separator = autocompleteEndpointUrl.includes('?') ? '&' : '?';
257257

@@ -325,10 +325,39 @@ export default class extends Controller {
325325
return string.replace(/(<([^>]+)>)/gi, '');
326326
}
327327

328-
#mergeObjects(object1: any, object2: any): any {
329-
return { ...object1, ...object2 };
328+
#mergeConfigs(config1: any, config2: any): any {
329+
return {
330+
...config1,
331+
...config2,
332+
// Plugins from both configs should be merged together.
333+
plugins: {
334+
...this.#normalizePluginsToHash(config1.plugins || {}),
335+
...this.#normalizePluginsToHash(config2.plugins || {}),
336+
},
337+
};
330338
}
331339

340+
/**
341+
* Normalizes the plugins to a hash, so that we can merge them easily.
342+
*/
343+
#normalizePluginsToHash = (plugins: TomSettings['plugins']): TPluginHash => {
344+
if (Array.isArray(plugins)) {
345+
return plugins.reduce((acc, plugin) => {
346+
if (typeof plugin === 'string') {
347+
acc[plugin] = {};
348+
}
349+
350+
if (typeof plugin === 'object' && plugin.name) {
351+
acc[plugin.name] = plugin.options || {};
352+
}
353+
354+
return acc;
355+
}, {} as TPluginHash);
356+
}
357+
358+
return plugins;
359+
};
360+
332361
/**
333362
* Returns the element, but only if it's a select element.
334363
*/

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,67 @@ describe('AutocompleteController', () => {
138138
expect(fetchMock.requests()[1].url).toEqual('/path/to/autocomplete?query=foo');
139139
});
140140

141+
it('connect with ajax URL on a select element, even when the user define custom TomSelect plugins', async () => {
142+
const { container, tomSelect } = await startAutocompleteTest(`
143+
<label for="the-select">Items</label>
144+
<select
145+
id="the-select"
146+
data-testid="main-element"
147+
data-controller="autocomplete"
148+
data-autocomplete-url-value="/path/to/autocomplete"
149+
data-autocomplete-tom-select-options-value="{&quot;plugins&quot;:[&quot;input_autogrow&quot;]}"
150+
></select>
151+
`);
152+
153+
// initial Ajax request on focus
154+
fetchMock.mockResponseOnce(
155+
JSON.stringify({
156+
results: [
157+
{
158+
value: 3,
159+
text: 'salad',
160+
},
161+
],
162+
})
163+
);
164+
165+
fetchMock.mockResponseOnce(
166+
JSON.stringify({
167+
results: [
168+
{
169+
value: 1,
170+
text: 'pizza',
171+
},
172+
{
173+
value: 2,
174+
text: 'popcorn',
175+
},
176+
],
177+
})
178+
);
179+
180+
const controlInput = tomSelect.control_input;
181+
182+
// wait for the initial Ajax request to finish
183+
userEvent.click(controlInput);
184+
await waitFor(() => {
185+
expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(1);
186+
});
187+
188+
// typing was not properly triggering, for some reason
189+
//userEvent.type(controlInput, 'foo');
190+
controlInput.value = 'foo';
191+
controlInput.dispatchEvent(new Event('input'));
192+
193+
await waitFor(() => {
194+
expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(2);
195+
});
196+
197+
expect(fetchMock.requests().length).toEqual(2);
198+
expect(fetchMock.requests()[0].url).toEqual('/path/to/autocomplete?query=');
199+
expect(fetchMock.requests()[1].url).toEqual('/path/to/autocomplete?query=foo');
200+
});
201+
141202
it('resets when ajax URL attribute on a select element changes', async () => {
142203
const { container, tomSelect } = await startAutocompleteTest(`
143204
<label for="the-select">Items</label>

0 commit comments

Comments
 (0)