Skip to content

[LiveComponent][Autocomplete] Adding support for libraries to play nicely #418

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions src/Autocomplete/assets/dist/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,16 @@ class default_1 extends Controller {
super(...arguments);
_instances.add(this);
}
connect() {
if (this.tomSelect) {
return;
initialize() {
this.element.setAttribute('data-live-ignore', '');
if (this.element.id) {
const label = document.querySelector(`label[for="${this.element.id}"]`);
if (label) {
label.setAttribute('data-live-ignore', '');
}
}
}
connect() {
if (this.urlValue) {
this.tomSelect = __classPrivateFieldGet(this, _instances, "m", _createAutocompleteWithRemoteData).call(this, this.urlValue);
return;
Expand All @@ -43,6 +49,7 @@ class default_1 extends Controller {
this.tomSelect = __classPrivateFieldGet(this, _instances, "m", _createAutocomplete).call(this);
}
disconnect() {
this.tomSelect.revertSettings.innerHTML = this.element.innerHTML;
this.tomSelect.destroy();
}
get selectElement() {
Expand Down Expand Up @@ -80,6 +87,10 @@ _instances = new WeakSet(), _getCommonConfig = function _getCommonConfig() {
onItemAdd: () => {
this.tomSelect.setTextboxValue('');
},
onInitialize: function () {
const tomSelect = this;
tomSelect.wrapper.setAttribute('data-live-ignore', '');
},
closeAfterSelect: true,
};
if (!this.selectElement && !this.urlValue) {
Expand Down
23 changes: 16 additions & 7 deletions src/Autocomplete/assets/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,17 @@ export default class extends Controller {
readonly tomSelectOptionsValue: object;
tomSelect: TomSelect;

connect() {
// this avoids initializing the same field twice (TomSelect shows an error otherwise)
if (this.tomSelect) {
return;
initialize() {
this.element.setAttribute('data-live-ignore', '');
if (this.element.id) {
const label = document.querySelector(`label[for="${this.element.id}"]`);
if (label) {
label.setAttribute('data-live-ignore', '');
}
}
}

connect() {
if (this.urlValue) {
this.tomSelect = this.#createAutocompleteWithRemoteData(this.urlValue);

Expand All @@ -40,9 +45,9 @@ export default class extends Controller {
}

disconnect() {
// make sure it will "revert" to the latest innerHTML
this.tomSelect.revertSettings.innerHTML = this.element.innerHTML;
this.tomSelect.destroy();
// Fixes https://github.com/symfony/ux/issues/407
this.tomSelect = undefined;
}

#getCommonConfig(): Partial<TomSettings> {
Expand Down Expand Up @@ -73,6 +78,10 @@ export default class extends Controller {
onItemAdd: () => {
this.tomSelect.setTextboxValue('');
},
onInitialize: function() {
const tomSelect = this as any;
tomSelect.wrapper.setAttribute('data-live-ignore', '');
},
closeAfterSelect: true,
};

Expand Down Expand Up @@ -124,7 +133,7 @@ export default class extends Controller {
},
// VERY IMPORTANT: use 'function (query, callback) { ... }' instead of the
// '(query, callback) => { ... }' syntax because, otherwise,
// the 'this.XXX' calls inside of this method fail
// the 'this.XXX' calls inside this method fail
load: function (query: string, callback: (results?: any) => void) {
const url = this.getUrl(query);
fetch(url)
Expand Down
30 changes: 29 additions & 1 deletion src/Autocomplete/assets/test/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
'use strict';

import { Application, Controller } from '@hotwired/stimulus';
import { getByTestId, waitFor, getAllByLabelText } from '@testing-library/dom';
import { getByTestId, waitFor } from '@testing-library/dom';
import { clearDOM, mountDOM } from '@symfony/stimulus-testing';
import AutocompleteController from '../src/controller';
import fetchMock from 'fetch-mock-jest';
Expand Down Expand Up @@ -137,4 +137,32 @@ describe('AutocompleteController', () => {
expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(2);
});
});

it('adds live-component support', async () => {
const container = mountDOM(`
<div>
<label for="the-select" data-testid="main-element-label">Select something</label>
<select
id="the-select"
data-testid="main-element"
data-controller="check autocomplete"
></select>
</div>
`);

application = startStimulus();

await waitFor(() => {
expect(getByTestId(container, 'main-element')).toHaveClass('connected');
});

expect(getByTestId(container, 'main-element')).toHaveAttribute('data-live-ignore');
expect(getByTestId(container, 'main-element-label')).toHaveAttribute('data-live-ignore');
const tsDropdown = container.querySelector('.ts-wrapper');

await waitFor(() => {
expect(tsDropdown).not.toBeNull();
});
expect(tsDropdown).toHaveAttribute('data-live-ignore');
});
});
5 changes: 5 additions & 0 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## 2.3.1

- [BC BREAK] Previously, the `id` attribute was used with `morphdom` as the
"node id" when updating the DOM after a render. This has changed to
`data-live-id`. This is useful when maintaining the correct order of a list
of elements.

- [BEHAVIOR CHANGE] If an action Ajax call is still processing and a
model update occurs, the component will _no_ longer re-render. The
model will be updated internally, but not re-rendered (so, any
Expand Down
15 changes: 15 additions & 0 deletions src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -1599,6 +1599,12 @@ class default_1 extends Controller {
_executeMorphdom(newHtml, modifiedElements) {
const newElement = htmlToElement(newHtml);
morphdom(this.element, newElement, {
getNodeKey: (node) => {
if (!(node instanceof HTMLElement)) {
return;
}
return node.dataset.liveId;
},
onBeforeElUpdated: (fromEl, toEl) => {
if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) {
return false;
Expand Down Expand Up @@ -1626,6 +1632,15 @@ class default_1 extends Controller {
return false;
}
return true;
},
onBeforeNodeDiscarded(node) {
if (!(node instanceof HTMLElement)) {
return true;
}
if (node.hasAttribute('data-live-ignore')) {
return false;
}
return true;
}
});
this._exposeOriginalData();
Expand Down
19 changes: 19 additions & 0 deletions src/LiveComponent/assets/src/live_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,13 @@ export default class extends Controller implements LiveController {
_executeMorphdom(newHtml: string, modifiedElements: Array<HTMLElement>) {
const newElement = htmlToElement(newHtml);
morphdom(this.element, newElement, {
getNodeKey: (node: Node) => {
if (!(node instanceof HTMLElement)) {
return;
}

return node.dataset.liveId;
},
onBeforeElUpdated: (fromEl, toEl) => {
if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) {
return false;
Expand Down Expand Up @@ -735,6 +742,18 @@ export default class extends Controller implements LiveController {
return false;
}

return true;
},

onBeforeNodeDiscarded(node) {
if (!(node instanceof HTMLElement)) {
// text element
return true;
}

if (node.hasAttribute('data-live-ignore')) {
return false;
}
return true;
}
});
Expand Down
34 changes: 34 additions & 0 deletions src/LiveComponent/assets/test/controller/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { shutdownTest, createTest, initComponent } from '../tools';
import { createEvent, fireEvent, getByText, waitFor } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock-jest';
import { htmlToElement } from '../../src/dom_utils';

describe('LiveController rendering Tests', () => {
afterEach(() => {
Expand Down Expand Up @@ -166,6 +167,7 @@ describe('LiveController rendering Tests', () => {

// imitate some JavaScript changing this element
test.element.querySelector('span')?.setAttribute('data-foo', 'bar');
test.element.appendChild(htmlToElement('<div data-live-ignore>I should not be removed</div>'));

test.expectsAjaxCall('get')
.expectSentData(test.initialData)
Expand All @@ -181,6 +183,38 @@ describe('LiveController rendering Tests', () => {
const ignoreElement = test.element.querySelector('div[data-live-ignore]');
expect(ignoreElement).not.toBeNull();
expect(ignoreElement?.outerHTML).toEqual('<div data-live-ignore="">Inside Ignore Name: <span data-foo="bar">Ryan</span></div>');
expect(test.element.innerHTML).toContain('I should not be removed');
});

it('if data-live-id changes, data-live-ignore elements ARE re-rendered', async () => {
const test = await createTest({ firstName: 'Ryan', containerId: 'original' }, (data: any) => `
<div ${initComponent(data)}>
<div data-live-id="${data.containerId}">
<div data-live-ignore>Inside Ignore Name: <span>${data.firstName}</span></div>
</div>

Outside Ignore Name: ${data.firstName}

<button data-action="live#$render">Reload</button>
</div>
`);

test.expectsAjaxCall('get')
.expectSentData(test.initialData)
.serverWillChangeData((data: any) => {
// change the data on the server so the template renders differently
data.firstName = 'Kevin';
data.containerId = 'updated';
})
.init();

getByText(test.element, 'Reload').click();

await waitFor(() => expect(test.element).toHaveTextContent('Outside Ignore Name: Kevin'));
const ignoreElement = test.element.querySelector('div[data-live-ignore]');
expect(ignoreElement).not.toBeNull();
// check that even the ignored element re-rendered
expect(ignoreElement?.outerHTML).toEqual('<div data-live-ignore="">Inside Ignore Name: <span>Kevin</span></div>');
});

it('cancels a re-render if the page is navigating away', async () => {
Expand Down
8 changes: 7 additions & 1 deletion src/LiveComponent/src/Resources/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1730,7 +1730,7 @@ changes, a child component will re-render even though it was there
before *and* after the list changed. This can cause that child component
to lose some state (i.e. it re-renders with its original live props data).

To fix this, add a unique ``id`` attribute to the root component of each
To fix this, add a unique ``data-live-id`` attribute to the root component of each
child element. This will helps LiveComponent identify each item in the
list and correctly determine if a re-render is necessary, or not.

Expand All @@ -1748,6 +1748,12 @@ To handle this, add the ``data-live-ignore`` attribute to the element:

<input name="favorite_color" data-live-ignore>

.. note::

To *force* an ignored element to re-render, give its parent element a
``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``.

Backward Compatibility promise
------------------------------

Expand Down