Skip to content

[LiveComponent] consider data-value attribute #203

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
Jan 18, 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
21 changes: 21 additions & 0 deletions src/LiveComponent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,27 @@ code works identically to the previous example:
If an element has _both_ `data-model` and `name` attributes, the
`data-model` attribute takes precedence.

### Using data-value="" for non input elements

If you want to set the value of a model with an element that is not an input element and does not have a `value` attribute you can use `data-value`

```twig
<div {{ init_live_component(this)>

<a
data-action="live#update"
data-model="min"
data-value="10">
Set min to 10
</a>

// ...
</div>
```

If an element has _both_ `data-value` and `value` attributes, the
`data-value` attribute takes precedence.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfectly explained!

## Loading States

Often, you'll want to show (or hide) an element while a component is
Expand Down
15 changes: 13 additions & 2 deletions src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -1057,11 +1057,11 @@ class default_1 extends Controller {
window.removeEventListener('beforeunload', this.markAsWindowUnloaded);
}
update(event) {
const value = event.target.value;
const value = this._getValueFromElement(event.target);
this._updateModelFromElement(event.target, value, true);
}
updateDefer(event) {
const value = event.target.value;
const value = this._getValueFromElement(event.target);
this._updateModelFromElement(event.target, value, false);
}
action(event) {
Expand Down Expand Up @@ -1111,6 +1111,17 @@ class default_1 extends Controller {
$render() {
this._makeRequest(null);
}
_getValueFromElement(element) {
const value = element.dataset.value || element.value;
if (!value) {
const clonedElement = (element.cloneNode());
if (!(clonedElement instanceof HTMLElement)) {
throw new Error('cloneNode() produced incorrect type');
}
throw new Error(`The update() method could not be called for "${clonedElement.outerHTML}": the element must either have a "data-value" or "value" attribute set.`);
}
return value;
}
_updateModelFromElement(element, value, shouldRender) {
const model = element.dataset.model || element.getAttribute('name');
if (!model) {
Expand Down
20 changes: 18 additions & 2 deletions src/LiveComponent/assets/src/live_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,13 @@ export default class extends Controller {
* Called to update one piece of the model
*/
update(event: any) {
const value = event.target.value;
const value = this._getValueFromElement(event.target);

this._updateModelFromElement(event.target, value, true);
}

updateDefer(event: any) {
const value = event.target.value;
const value = this._getValueFromElement(event.target);

this._updateModelFromElement(event.target, value, false);
}
Expand Down Expand Up @@ -195,6 +195,22 @@ export default class extends Controller {
this._makeRequest(null);
}

_getValueFromElement(element: HTMLElement){
const value = element.dataset.value || element.value;

if (!value) {
const clonedElement = (element.cloneNode());
// helps typescript know this is an HTMLElement
if (!(clonedElement instanceof HTMLElement)) {
throw new Error('cloneNode() produced incorrect type');
}

throw new Error(`The update() method could not be called for "${clonedElement.outerHTML}": the element must either have a "data-value" or "value" attribute set.`);
}

return value;
}

_updateModelFromElement(element: HTMLElement, value: string, shouldRender: boolean) {
const model = element.dataset.model || element.getAttribute('name');

Expand Down
43 changes: 42 additions & 1 deletion src/LiveComponent/assets/test/controller/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import { clearDOM } from '@symfony/stimulus-testing';
import { initLiveComponent, mockRerender, startStimulus } from '../tools';
import { getByLabelText, waitFor } from '@testing-library/dom';
import {getByLabelText, getByText, waitFor} from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock-jest';

Expand All @@ -28,6 +28,7 @@ describe('LiveController data-model Tests', () => {
value="${data.name}"
>
</label>
<a data-action="live#update" data-model="name" data-value="Jan">Change name to Jan</a>
</div>
`;

Expand Down Expand Up @@ -61,6 +62,25 @@ describe('LiveController data-model Tests', () => {
expect(document.activeElement.dataset.model).toEqual('name');
});

it('renders correctly with data-value and live#update', async () => {
const data = { name: 'Ryan' };
const { element, controller } = await startStimulus(template(data));

fetchMock.getOnce('end:?name=Jan', {
html: template({ name: 'Jan' }),
data: { name: 'Jan' }
});

userEvent.click(getByText(element, 'Change name to Jan'));

await waitFor(() => expect(getByLabelText(element, 'Name:')).toHaveValue('Jan'));
expect(controller.dataValue).toEqual({name: 'Jan'});

// assert all calls were done the correct number of times
fetchMock.done();
});


it('correctly only uses the most recent render call results', async () => {
const data = { name: 'Ryan' };
const { element, controller } = await startStimulus(template(data));
Expand Down Expand Up @@ -148,6 +168,27 @@ describe('LiveController data-model Tests', () => {
fetchMock.done();
});

it('uses data-value when both value and data-value is present', async () => {
const data = { name: 'Ryan' };
const { element, controller } = await startStimulus(template(data));

// give element data-model="name" and name="first_name"
const inputElement = getByLabelText(element, 'Name:');
inputElement.dataset.value = 'first_name';

// ?name should be what's sent to the server
mockRerender({name: 'first_name'}, template, (data) => {
data.name = 'first_name';
})

await userEvent.type(inputElement, ' WEAVER');

await waitFor(() => expect(inputElement).toHaveValue('first_name'));
expect(controller.dataValue).toEqual({name: 'first_name'});

fetchMock.done();
});

it('standardizes user[firstName] style models into post.name', async () => {
const deeperModelTemplate = (data) => `
<div
Expand Down