From 9da1c091d15eb67bf32a2bdaf06e71d9432b984d Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Fri, 13 Jun 2025 13:40:55 +0200 Subject: [PATCH] [Autocomplete] Ensure default plugins are nicely merged with user-defined plugins --- src/Autocomplete/assets/dist/controller.js | 37 ++++++++--- src/Autocomplete/assets/src/controller.ts | 41 +++++++++++-- .../assets/test/controller.test.ts | 61 +++++++++++++++++++ 3 files changed, 125 insertions(+), 14 deletions(-) diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js index b9712824880..7670659eaa2 100644 --- a/src/Autocomplete/assets/dist/controller.js +++ b/src/Autocomplete/assets/dist/controller.js @@ -29,7 +29,7 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }; -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; +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; class default_1 extends Controller { constructor() { super(...arguments); @@ -37,6 +37,20 @@ class default_1 extends Controller { this.isObserving = false; this.hasLoadedChoicesPreviously = false; this.originalOptions = []; + _default_1_normalizePluginsToHash.set(this, (plugins) => { + if (Array.isArray(plugins)) { + return plugins.reduce((acc, plugin) => { + if (typeof plugin === 'string') { + acc[plugin] = {}; + } + if (typeof plugin === 'object' && plugin.name) { + acc[plugin.name] = plugin.options || {}; + } + return acc; + }, {}); + } + return plugins; + }); } initialize() { if (!this.mutationObserver) { @@ -223,7 +237,7 @@ class default_1 extends Controller { [...originalOptionsSet].every((option) => newOptionsSet.has(option))); } } -_default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _default_1_getCommonConfig() { +_default_1_normalizePluginsToHash = new WeakMap(), _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _default_1_getCommonConfig() { const plugins = {}; const isMultiple = !this.selectElement || this.selectElement.multiple; if (!this.formElement.disabled && !isMultiple) { @@ -288,16 +302,16 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def if (!this.selectElement && !this.urlValue) { config.shouldLoad = () => false; } - return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, config, this.tomSelectOptionsValue); + return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeConfigs).call(this, config, this.tomSelectOptionsValue); }, _default_1_createAutocomplete = function _default_1_createAutocomplete() { - const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this), { + const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeConfigs).call(this, __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this), { maxOptions: this.getMaxOptions(), }); return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createTomSelect).call(this, config); }, _default_1_createAutocompleteWithHtmlContents = function _default_1_createAutocompleteWithHtmlContents() { const commonConfig = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this); const labelField = commonConfig.labelField ?? 'text'; - const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, commonConfig, { + const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeConfigs).call(this, commonConfig, { maxOptions: this.getMaxOptions(), score: (search) => { const scoringFunction = this.tomSelect.getScoreFunction(search); @@ -314,7 +328,7 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def }, _default_1_createAutocompleteWithRemoteData = function _default_1_createAutocompleteWithRemoteData(autocompleteEndpointUrl, minCharacterLength) { const commonConfig = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this); const labelField = commonConfig.labelField ?? 'text'; - const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, commonConfig, { + const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeConfigs).call(this, commonConfig, { firstUrl: (query) => { const separator = autocompleteEndpointUrl.includes('?') ? '&' : '?'; return `${autocompleteEndpointUrl}${separator}query=${encodeURIComponent(query)}`; @@ -364,8 +378,15 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createTomSelect).call(this, config); }, _default_1_stripTags = function _default_1_stripTags(string) { return string.replace(/(<([^>]+)>)/gi, ''); -}, _default_1_mergeObjects = function _default_1_mergeObjects(object1, object2) { - return { ...object1, ...object2 }; +}, _default_1_mergeConfigs = function _default_1_mergeConfigs(config1, config2) { + return { + ...config1, + ...config2, + plugins: { + ...__classPrivateFieldGet(this, _default_1_normalizePluginsToHash, "f").call(this, config1.plugins || {}), + ...__classPrivateFieldGet(this, _default_1_normalizePluginsToHash, "f").call(this, config2.plugins || {}), + }, + }; }, _default_1_createTomSelect = function _default_1_createTomSelect(options) { const preConnectPayload = { options }; this.dispatchEvent('pre-connect', preConnectPayload); diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts index f89b29141c4..d0b7db8282d 100644 --- a/src/Autocomplete/assets/src/controller.ts +++ b/src/Autocomplete/assets/src/controller.ts @@ -214,11 +214,11 @@ export default class extends Controller { config.shouldLoad = () => false; } - return this.#mergeObjects(config, this.tomSelectOptionsValue); + return this.#mergeConfigs(config, this.tomSelectOptionsValue); } #createAutocomplete(): TomSelect { - const config = this.#mergeObjects(this.#getCommonConfig(), { + const config = this.#mergeConfigs(this.#getCommonConfig(), { maxOptions: this.getMaxOptions(), }); @@ -229,7 +229,7 @@ export default class extends Controller { const commonConfig = this.#getCommonConfig(); const labelField = commonConfig.labelField ?? 'text'; - const config = this.#mergeObjects(commonConfig, { + const config = this.#mergeConfigs(commonConfig, { maxOptions: this.getMaxOptions(), score: (search: string) => { const scoringFunction = this.tomSelect.getScoreFunction(search); @@ -251,7 +251,7 @@ export default class extends Controller { const commonConfig = this.#getCommonConfig(); const labelField = commonConfig.labelField ?? 'text'; - const config: RecursivePartial = this.#mergeObjects(commonConfig, { + const config: RecursivePartial = this.#mergeConfigs(commonConfig, { firstUrl: (query: string) => { const separator = autocompleteEndpointUrl.includes('?') ? '&' : '?'; @@ -325,10 +325,39 @@ export default class extends Controller { return string.replace(/(<([^>]+)>)/gi, ''); } - #mergeObjects(object1: any, object2: any): any { - return { ...object1, ...object2 }; + #mergeConfigs(config1: any, config2: any): any { + return { + ...config1, + ...config2, + // Plugins from both configs should be merged together. + plugins: { + ...this.#normalizePluginsToHash(config1.plugins || {}), + ...this.#normalizePluginsToHash(config2.plugins || {}), + }, + }; } + /** + * Normalizes the plugins to a hash, so that we can merge them easily. + */ + #normalizePluginsToHash = (plugins: TomSettings['plugins']): TPluginHash => { + if (Array.isArray(plugins)) { + return plugins.reduce((acc, plugin) => { + if (typeof plugin === 'string') { + acc[plugin] = {}; + } + + if (typeof plugin === 'object' && plugin.name) { + acc[plugin.name] = plugin.options || {}; + } + + return acc; + }, {} as TPluginHash); + } + + return plugins; + }; + /** * Returns the element, but only if it's a select element. */ diff --git a/src/Autocomplete/assets/test/controller.test.ts b/src/Autocomplete/assets/test/controller.test.ts index 5b72e7fb075..e6a6d6d3cd7 100644 --- a/src/Autocomplete/assets/test/controller.test.ts +++ b/src/Autocomplete/assets/test/controller.test.ts @@ -138,6 +138,67 @@ describe('AutocompleteController', () => { expect(fetchMock.requests()[1].url).toEqual('/path/to/autocomplete?query=foo'); }); + it('connect with ajax URL on a select element, even when the user define custom TomSelect plugins', async () => { + const { container, tomSelect } = await startAutocompleteTest(` + + + `); + + // initial Ajax request on focus + fetchMock.mockResponseOnce( + JSON.stringify({ + results: [ + { + value: 3, + text: 'salad', + }, + ], + }) + ); + + fetchMock.mockResponseOnce( + JSON.stringify({ + results: [ + { + value: 1, + text: 'pizza', + }, + { + value: 2, + text: 'popcorn', + }, + ], + }) + ); + + 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(1); + }); + + // 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(fetchMock.requests().length).toEqual(2); + expect(fetchMock.requests()[0].url).toEqual('/path/to/autocomplete?query='); + expect(fetchMock.requests()[1].url).toEqual('/path/to/autocomplete?query=foo'); + }); + it('resets when ajax URL attribute on a select element changes', async () => { const { container, tomSelect } = await startAutocompleteTest(`