diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts index 93d77ee51ca..7e9d912ac50 100644 --- a/src/Autocomplete/assets/src/controller.ts +++ b/src/Autocomplete/assets/src/controller.ts @@ -38,6 +38,7 @@ export default class extends Controller { private mutationObserver: MutationObserver; private isObserving = false; private hasLoadedChoicesPreviously = false; + private originalOptions: Array<{ value: string; text: string; group: string | null }> = []; initialize() { if (this.requiresLiveIgnore()) { @@ -65,10 +66,20 @@ export default class extends Controller { } connect() { + if (this.selectElement) { + this.originalOptions = this.createOptionsDataStructure(this.selectElement); + } + this.initializeTomSelect(); } initializeTomSelect() { + // live components support: morphing the options causes issues, due + // to the fact that TomSelect reorders the options when you select them + if (this.selectElement) { + this.selectElement.setAttribute('data-skip-morph', ''); + } + if (this.urlValue) { this.tomSelect = this.#createAutocompleteWithRemoteData( this.urlValue, @@ -313,8 +324,17 @@ export default class extends Controller { private resetTomSelect(): void { if (this.tomSelect) { this.stopMutationObserver(); + + // Grab the current HTML then restore it after destroying TomSelect + // This is needed because TomSelect's destroy revert the element to + // its original HTML. + const currentHtml = this.element.innerHTML; + const currentValue: any = this.tomSelect.getValue(); this.tomSelect.destroy(); + this.element.innerHTML = currentHtml; this.initializeTomSelect(); + this.tomSelect.setValue(currentValue); + this.startMutationObserver(); } } @@ -329,33 +349,6 @@ export default class extends Controller { this.startMutationObserver(); } - /** - * TomSelect doesn't give us a way to update the placeholder, so most of - * this code is copied from TomSelect's source code. - * - * @private - */ - private updateTomSelectPlaceholder(): void { - const input = this.element; - let placeholder = input.getAttribute('placeholder') || input.getAttribute('data-placeholder'); - if (!placeholder && !this.tomSelect.allowEmptyOption) { - const option = input.querySelector('option[value=""]'); - - if (option) { - placeholder = option.textContent; - } - } - - if (placeholder) { - this.stopMutationObserver(); - // override settings so it's used again later - this.tomSelect.settings.placeholder = placeholder; - // and set it right now - this.tomSelect.control_input.setAttribute('placeholder', placeholder); - this.startMutationObserver(); - } - } - private startMutationObserver(): void { if (!this.isObserving && this.mutationObserver) { this.mutationObserver.observe(this.element, { @@ -376,58 +369,11 @@ export default class extends Controller { } private onMutations(mutations: MutationRecord[]): void { - const addedOptionElements: HTMLOptionElement[] = []; - const removedOptionElements: HTMLOptionElement[] = []; - let hasAnOptionChanged = false; let changeDisabledState = false; - let changePlaceholder = false; mutations.forEach((mutation) => { switch (mutation.type) { - case 'childList': - // look for changes to any + + + + `; // wait for the MutationObserver to flush these changes await shortDelay(10); @@ -488,26 +488,20 @@ describe('AutocompleteController', () => { await shortDelay(10); // TomSelect will move the "2" option out of its optgroup and onto the bottom - // let's imitate an Ajax call reversing that - const selectedOption2 = selectElement.children[3]; - if (!(selectedOption2 instanceof HTMLOptionElement)) { - throw new Error('cannot find option 3'); - } - const smallDogGroup = selectElement.children[1]; - if (!(smallDogGroup instanceof HTMLOptGroupElement)) { - throw new Error('cannot find small dog group'); - } - - // add a new element, which is really just the old dog2 - const newOption2 = document.createElement('option'); - newOption2.setAttribute('value', '2'); - newOption2.innerHTML = 'dog2'; - // but the new HTML will correctly mark this as selected - newOption2.setAttribute('selected', ''); - smallDogGroup.appendChild(newOption2); - - // remove the dog2 element from the bottom - selectElement.removeChild(selectedOption2); + // let's imitate an Ajax call reversing that order + selectElement.innerHTML = ` + + + + + + + + + + + + `; // TomSelect will still have the correct value expect(tomSelect.getValue()).toEqual('2'); @@ -529,44 +523,62 @@ describe('AutocompleteController', () => { `); + // select 3 to start + tomSelect.addItem('3'); const selectElement = getByTestId(container, 'main-element') as HTMLSelectElement; + expect(selectElement.value).toBe('3'); // something external changes the set of options, including add a new one - selectElement.children[1].setAttribute('value', '4'); - selectElement.children[1].innerHTML = 'dog4'; - selectElement.children[2].setAttribute('value', '5'); - selectElement.children[2].innerHTML = 'dog5'; - selectElement.children[3].setAttribute('value', '6'); - selectElement.children[3].innerHTML = 'dog6'; - const newOption7 = document.createElement('option'); - newOption7.setAttribute('value', '7'); - newOption7.innerHTML = 'dog7'; - selectElement.appendChild(newOption7); - const newOption8 = document.createElement('option'); - newOption8.setAttribute('value', '8'); - newOption8.innerHTML = 'dog8'; - selectElement.appendChild(newOption8); + selectElement.innerHTML = ` + + + + + + + `; + + let newTomSelect: TomSelect|null = null; + container.addEventListener('autocomplete:connect', (event: any) => { + newTomSelect = (event.detail as AutocompleteConnectOptions).tomSelect; + }); // wait for the MutationObserver to flush these changes await shortDelay(10); - const controlInput = tomSelect.control_input; - userEvent.click(controlInput); + // the previously selected option is no longer there + expect(selectElement.value).toBe(''); + userEvent.click(container.querySelector('.ts-control') as HTMLElement); await waitFor(() => { // make sure all 5 new options are there expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(5); }); - tomSelect.addItem('7'); + if (null === newTomSelect) { + throw new Error('Missing TomSelect instance'); + } + // @ts-ignore + newTomSelect.addItem('7'); expect(selectElement.value).toBe('7'); // remove an element, the control should update selectElement.removeChild(selectElement.children[1]); await shortDelay(10); - userEvent.click(controlInput); + userEvent.click(container.querySelector('.ts-control') as HTMLElement); await waitFor(() => { expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(4); }); + + // change again, but the selected value is still there + selectElement.innerHTML = ` + + + + + + `; + await shortDelay(10); + expect(selectElement.value).toBe('7'); }); it('toggles correctly between disabled and enabled', async () => { @@ -616,15 +628,25 @@ describe('AutocompleteController', () => { const selectElement = getByTestId(container, 'main-element') as HTMLSelectElement; expect(tomSelect.control_input.placeholder).toBe('Select a dog'); - selectElement.children[0].innerHTML = 'Select a cat'; - // wait for the MutationObserver - await shortDelay(10); - expect(tomSelect.control_input.placeholder).toBe('Select a cat'); + let newTomSelect: TomSelect|null = null; + container.addEventListener('autocomplete:connect', (event: any) => { + newTomSelect = (event.detail as AutocompleteConnectOptions).tomSelect; + }); + + selectElement.innerHTML = ` + + + + + `; - // a different way to change the placeholder - selectElement.children[0].childNodes[0].nodeValue = 'Select a kangaroo'; + // wait for the MutationObserver await shortDelay(10); - expect(tomSelect.control_input.placeholder).toBe('Select a kangaroo'); + if (null === newTomSelect) { + throw new Error('Missing TomSelect instance'); + } + // @ts-ignore + expect(newTomSelect.control_input.placeholder).toBe('Select a cat'); }); it('group related options', async () => { diff --git a/src/LiveComponent/assets/src/morphdom.ts b/src/LiveComponent/assets/src/morphdom.ts index f28db968981..523aa32ea30 100644 --- a/src/LiveComponent/assets/src/morphdom.ts +++ b/src/LiveComponent/assets/src/morphdom.ts @@ -112,11 +112,17 @@ export function executeMorphdom( return true; } + if (fromEl.hasAttribute('data-skip-morph')) { + fromEl.innerHTML = toEl.innerHTML; + + return false; + } + // look for data-live-ignore, and don't update return !fromEl.hasAttribute('data-live-ignore'); }, - beforeNodeRemoved(node) { + beforeNodeRemoved(node: Node) { if (!(node instanceof HTMLElement)) { // text element return true; diff --git a/src/LiveComponent/assets/test/controller/render.test.ts b/src/LiveComponent/assets/test/controller/render.test.ts index cb93c578189..08844178269 100644 --- a/src/LiveComponent/assets/test/controller/render.test.ts +++ b/src/LiveComponent/assets/test/controller/render.test.ts @@ -10,7 +10,7 @@ 'use strict'; import { shutdownTests, createTest, initComponent } from '../tools'; -import { getByText, waitFor } from '@testing-library/dom'; +import { getByTestId, getByText, waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import { htmlToElement } from '../../src/dom_utils'; @@ -208,6 +208,35 @@ describe('LiveController rendering Tests', () => { expect(ignoreElement?.outerHTML).toEqual('
Inside Ignore Name: Kevin
'); }); + it('overwrites HTML instead of morph with data-skip-morph', async () => { + const test = await createTest({ firstName: 'Ryan' }, (data: any) => ` +
+
Inside Skip Name: ${data.firstName}
+ + Outside Skip Name: ${data.firstName} + + +
+ `); + + const spanBefore = getByTestId(test.element, 'inside-skip-morph'); + expect(spanBefore).toHaveTextContent('Ryan'); + + test.expectsAjaxCall() + .serverWillChangeProps((data: any) => { + // change the data on the server so the template renders differently + data.firstName = 'Kevin'; + }); + + getByText(test.element, 'Reload').click(); + + await waitFor(() => expect(test.element).toHaveTextContent('Outside Skip Name: Kevin')); + const spanAfter = getByTestId(test.element, 'inside-skip-morph'); + expect(spanAfter).toHaveTextContent('Kevin'); + // but it is not just a mutation of the original element + expect(spanAfter).not.toBe(spanBefore); + }); + it('cancels a re-render if the page is navigating away', async () => { const test = await createTest({greeting: 'aloha!'}, (data: any) => `
${data.greeting}
diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index 4900cf23bb2..c37f7586efa 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -3243,6 +3243,20 @@ an element, that changes is preserved (see :ref:`smart-rerender-algorithm`). ``data-live-id`` attribute. During a re-render, if this value changes, all of the children of the element will be re-rendered, even those with ``data-live-ignore``. +Overwrite HTML Instead of Morphing +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Normally, when a component re-renders, the new HTML is "morphed" onto the existing +elements on the page. In some rare cases, you may want to simply overwrite the existing +HTML with the new HTML instead of morphing it. This can be done by adding a +``data-skip-morph`` attribute: + +.. code-block:: html + + + Define another route for your Component ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~