Repository: symfony/ux (php)

Issue:
[Autocomplete][LiveComponent] Select box doesn't reset when used in a live component
When using the Autocomplete component in a form as part of a LiveComponent, the field is not reset when re-rendering the component (E.G when submitting the form). The previous selected values are still selected.

Here is an example using a multi-select dropdown, with a custom query builder that removes the selected items from the list:

https://github.com/symfony/ux/assets/144858/d0add8fa-9547-401d-99db-c9dabf357a3e

When changing the form to an Autocomplete type, and selecting items, it doesn't get removed from the select

https://github.com/symfony/ux/assets/144858/cb710386-64c8-4444-b480-e4ff1c6933a6

Currently in order to reset the autocomplete dropdown, I'm emitting a custom browser event with a custom controller on the select with the following which seems to work fine:


```js
import { Controller } from '@hotwired/stimulus';
import TomSelect from 'tom-select';
import { TomInput, TomSettings } from 'tom-select/dist/types/types';

/* stimulusFetch: 'lazy' */
export default class extends Controller {
    connect(): void {
        document.addEventListener('select:clear', (): void => {
            if ((this.element as TomInput).tomselect) {
                const settings: TomSettings|undefined = (this.element as TomInput).tomselect?.settings;
                (this.element as TomInput).tomselect?.destroy();
                new TomSelect(<TomInput>this.element, (settings as TomSettings));
            }
        });
    }
}
```

Pull request:
[Autocomplete] Reset TomSelect when updating controller attributes
| Q             | A
| ------------- | ---
| Bug fix?      | yes
| New feature?  | no
| Issues        | Fix #1500 
| License       | MIT

When using remote data for Autocomplete, the mutation observer is not started, so the select cannot be reset when some options change (E.G when limiting the options returned after selecting other options using extra_options from #1322).

Instead, we start the mutation observer when using remote data. When the observer runs and checks if the options have changes, we explicitly set the `areOptionsEquivalent` to `true`, since the page will just render an empty `select`. We also check if any of the `data-autocomplete` attributes changes and forces a reset (E.G using extra_options, the `data-autocomplete-url` value can be different when using extra options, so we reset the select when this attribute changes).

This fixes only one of the issues from #1500, I'll investigate the other issues and create separate PRs.

Patch:
diff --git a/src/Autocomplete/CHANGELOG.md b/src/Autocomplete/CHANGELOG.md
index d7384fee..2498c7b9 100644
--- a/src/Autocomplete/CHANGELOG.md
+++ b/src/Autocomplete/CHANGELOG.md
@@ -1,5 +1,9 @@
 # CHANGELOG
 
+## 2.23.0
+
+-   Reset TomSelect when updating url attribute #1505
+
 ## 2.22.0
 
 -   Take `labelField` TomSelect option into account #2382
diff --git a/src/Autocomplete/assets/dist/controller.d.ts b/src/Autocomplete/assets/dist/controller.d.ts
index 59938a72..d5cc6b3e 100644
--- a/src/Autocomplete/assets/dist/controller.d.ts
+++ b/src/Autocomplete/assets/dist/controller.d.ts
@@ -40,6 +40,7 @@ export default class extends Controller {
     connect(): void;
     initializeTomSelect(): void;
     disconnect(): void;
+    urlValueChanged(): void;
     private getMaxOptions;
     get selectElement(): HTMLSelectElement | null;
     get formElement(): HTMLInputElement | HTMLSelectElement;
diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js
index fb04294a..e021770d 100644
--- a/src/Autocomplete/assets/dist/controller.js
+++ b/src/Autocomplete/assets/dist/controller.js
@@ -91,6 +91,9 @@ class default_1 extends Controller {
             }
         }
     }
+    urlValueChanged() {
+        this.resetTomSelect();
+    }
     getMaxOptions() {
         return this.selectElement ? this.selectElement.options.length : 50;
     }
@@ -131,7 +134,6 @@ class default_1 extends Controller {
             this.element.innerHTML = currentHtml;
             this.initializeTomSelect();
             this.tomSelect.setValue(currentValue);
-            this.startMutationObserver();
         }
     }
     changeTomSelectDisabledState(isDisabled) {
diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts
index 38b324fc..6b68283b 100644
--- a/src/Autocomplete/assets/src/controller.ts
+++ b/src/Autocomplete/assets/src/controller.ts
@@ -128,6 +128,10 @@ export default class extends Controller {
         }
     }
 
+    urlValueChanged() {
+        this.resetTomSelect();
+    }
+
     #getCommonConfig(): Partial<TomSettings> {
         const plugins: TPluginHash = {};
 
@@ -391,8 +395,6 @@ export default class extends Controller {
             this.element.innerHTML = currentHtml;
             this.initializeTomSelect();
             this.tomSelect.setValue(currentValue);
-
-            this.startMutationObserver();
         }
     }
 


Test patch:
diff --git a/src/Autocomplete/assets/test/controller.test.ts b/src/Autocomplete/assets/test/controller.test.ts
index e17dc6ae..5b72e7fb 100644
--- a/src/Autocomplete/assets/test/controller.test.ts
+++ b/src/Autocomplete/assets/test/controller.test.ts
@@ -138,6 +138,80 @@ describe('AutocompleteController', () => {
         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(`
+            <label for="the-select">Items</label>
+            <select
+                id="the-select"
+                data-testid="main-element"
+                data-controller="autocomplete"
+                data-autocomplete-url-value="/path/to/autocomplete"
+            ></select>
+        `);
+
+        const selectElement = getByTestId(container, 'main-element') as HTMLSelectElement;
+
+        // 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);
+        });
+
+        controlInput.value = 'foo';
+        controlInput.dispatchEvent(new Event('input'));
+
+        await waitFor(() => {
+            expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(2);
+        });
+
+        expect(selectElement.value).toBe('');
+        tomSelect.addItem('2');
+        expect(selectElement.value).toBe('2');
+
+        selectElement.outerHTML = `
+            <select
+                id="the-select"
+                data-testid="main-element"
+                data-controller="autocomplete"
+                data-autocomplete-url-value="/path/to/autocomplete2"
+            ></select>
+        `;
+
+        // wait for the MutationObserver to flush these changes
+        await shortDelay(10);
+
+        expect(selectElement.value).toBe('');
+    });
+
     it('connect with ajax URL on an input element', async () => {
         const { container, tomSelect } = await startAutocompleteTest(`
             <label for="the-input">Items</label>