Skip to content

Commit 59f2269

Browse files
committed
Also dispatching the name="" attribute to the live:update-model event
This adds support for an element like <input data-model="foo" name="bar">. In this situation, the "foo" model would be updated, but then both names are dispatched in the event. This allows a parent component to, for example, realize that its "bar" model should be updated. also updating test to use embedded data
1 parent aaca52b commit 59f2269

File tree

2 files changed

+83
-42
lines changed

2 files changed

+83
-42
lines changed

src/LiveComponent/assets/src/live_controller.js

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,27 @@ export default class extends Controller {
178178
throw new Error(`The update() method could not be called for "${clonedElement.outerHTML}": the element must either have a "data-model" or "name" attribute set to the model name.`);
179179
}
180180

181-
this.$updateModel(model, value, shouldRender);
181+
this.$updateModel(model, value, shouldRender, element.hasAttribute('name') ? element.getAttribute('name') : null);
182182
}
183183

184-
$updateModel(model, value, shouldRender = true) {
184+
/**
185+
* Update a model value.
186+
*
187+
* The extraModelName should be set to the "name" attribute of an element
188+
* if it has one. This is only important in a parent/child component,
189+
* where, in the child, you might be updating a "foo" model, but you
190+
* also want this update to "sync" to the parent component's "bar" model.
191+
* Typically, setup on a field like this:
192+
*
193+
* <input data-model="foo" name="bar">
194+
*
195+
* @param {string} model The model update, which could include modifiers
196+
* @param {any} value The new value
197+
* @param {boolean} shouldRender Whether a re-render should be triggered
198+
* @param {string|null} extraModelName Another model name that this might go by in a parent component.
199+
* @param {Object} options Options include: {bool} dispatch
200+
*/
201+
$updateModel(model, value, shouldRender = true, extraModelName = null, options = {}) {
185202
const directives = parseDirectives(model);
186203
if (directives.length > 1) {
187204
throw new Error(`The data-model="${model}" format is invalid: it does not support multiple directives (i.e. remove any spaces).`);
@@ -194,6 +211,7 @@ export default class extends Controller {
194211
}
195212

196213
const modelName = normalizeModelName(directive.action);
214+
const normalizedExtraModelName = extraModelName ? normalizeModelName(extraModelName) : null;
197215

198216
// if there is a "validatedFields" data, it means this component wants
199217
// to track which fields have been / should be validated.
@@ -206,15 +224,14 @@ export default class extends Controller {
206224
this.dataValue = setDeepData(this.dataValue, 'validatedFields', validatedFields);
207225
}
208226

209-
if (!doesDeepPropertyExist(this.dataValue, modelName)) {
210-
console.warn(`Model "${modelName}" is not a valid data-model value`);
227+
if (options.dispatch !== false) {
228+
this._dispatchEvent('live:update-model', {
229+
modelName,
230+
extraModelName: normalizedExtraModelName,
231+
value,
232+
});
211233
}
212234

213-
this._dispatchEvent('live:update-model', {
214-
model: modelName,
215-
value,
216-
})
217-
218235
// we do not send old and new data to the server
219236
// we merge in the new data now
220237
// TODO: handle edge case for top-level of a model with "exposed" props
@@ -577,11 +594,27 @@ export default class extends Controller {
577594
}
578595

579596
_handleChildComponentUpdateModel(event) {
580-
if (!doesDeepPropertyExist(this.dataValue, event.detail.model)) {
597+
let matchingModelName = null;
598+
599+
if (doesDeepPropertyExist(this.dataValue, event.detail.modelName)) {
600+
matchingModelName = event.detail.modelName;
601+
} else if (doesDeepPropertyExist(this.dataValue, event.detail.extraModelName)) {
602+
matchingModelName = event.detail.extraModelName;
603+
}
604+
605+
if (matchingModelName === null) {
581606
return;
582607
}
583608

584-
this.$updateModel(event.detail.model, event.detail.value, false);
609+
this.$updateModel(
610+
matchingModelName,
611+
event.detail.value,
612+
false,
613+
null,
614+
{
615+
dispatch: false
616+
}
617+
);
585618
}
586619
}
587620

src/LiveComponent/assets/test/controller/child.test.js

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,34 +22,41 @@ describe('LiveController parent -> child component tests', () => {
2222
data-controller="live"
2323
data-live-url-value="http://localhost/_components/parent"
2424
>
25-
<span>Title: ${data.title}</span>
26-
<span>Description in Parent: ${data.description}</span>
25+
<span>Title: ${data.post.title}</span>
26+
<span>Description in Parent: ${data.post.content}</span>
27+
28+
<input
29+
type="text"
30+
name="post[title]"
31+
value="${data.post.title}"
32+
>
33+
34+
${childTemplate({ value: data.post.content })}
2735
2836
<button
2937
data-action="live#$render"
3038
>Parent Re-render</button>
31-
32-
${childTemplate({ description: data.description })}
3339
</div>
3440
`
3541
}
3642

3743
const childTemplate = (data) => `
3844
<div
3945
data-controller="live"
40-
data-live-url-value="http://localhost/_components/my_component"
46+
data-live-url-value="http://localhost/_components/child"
4147
>
4248
<!-- form field not mapped with data-model -->
4349
<label>
44-
Description:
50+
Content:
4551
<input
46-
value="${data.description}"
47-
data-model="description"
52+
value="${data.value}"
53+
data-model="value"
54+
name="post[content]"
4855
data-action="live#update"
4956
>
5057
</label>
5158
52-
<div>Description in child: ${data.description}</div>
59+
<div>Value in child: ${data.value}</div>
5360
5461
<button
5562
data-action="live#$render"
@@ -75,60 +82,61 @@ describe('LiveController parent -> child component tests', () => {
7582
});
7683

7784
it('renders parent component without affecting child component', async () => {
78-
const data = { title: 'Parent component', description: 'i love components' };
85+
const data = { post: { title: 'Parent component', content: 'i love components' } };
7986
const { element } = await startStimulusForParentChild(
8087
parentTemplate(data),
8188
data,
82-
{ description: data.description }
89+
{ value: data.post.content }
8390
);
8491

85-
// on child re-render, use a new description
86-
fetchMock.get('http://localhost/_components/my_component?description=i+love+components', {
87-
html: childTemplate({ description: 'i love popcorn' }),
88-
data: { description: 'i love popcorn' }
92+
// on child re-render, use a new content
93+
fetchMock.get('http://localhost/_components/child?value=i+love+components', {
94+
html: childTemplate({ value: 'i love popcorn' }),
95+
data: { value: 'i love popcorn' }
8996
});
9097

9198
// reload the child template
9299
getByText(element, 'Child Re-render').click();
93-
await waitFor(() => expect(element).toHaveTextContent('Description in child: i love popcorn'));
100+
await waitFor(() => expect(element).toHaveTextContent('Value in child: i love popcorn'));
94101

95102
// on parent re-render, render the child template differently
96-
fetchMock.get('http://localhost/_components/parent?title=Parent+component&description=i+love+components', {
97-
html: parentTemplate({ title: 'Changed parent', description: 'changed child description' }),
98-
data: { title: 'Changed parent', description: 'changed child description' }
103+
fetchMock.get('begin:http://localhost/_components/parent', {
104+
html: parentTemplate({ post: { title: 'Changed title', content: 'changed content' } }),
105+
data: { post: { title: 'Changed title', content: 'changed content' } }
99106
});
100107
getByText(element, 'Parent Re-render').click();
101-
await waitFor(() => expect(element).toHaveTextContent('Title: Changed parent'));
108+
await waitFor(() => expect(element).toHaveTextContent('Title: Changed title'));
102109

103110
// the child component should *not* have updated
104-
expect(element).toHaveTextContent('Description in child: i love popcorn');
111+
expect(element).toHaveTextContent('Value in child: i love popcorn');
105112
});
106113

107114
it('updates child model and parent model in a deferred way', async () => {
108-
const data = { title: 'Parent component', description: 'i love' };
115+
const data = { post: { title: 'Parent component', content: 'i love' } };
109116
const { element, controller } = await startStimulusForParentChild(
110117
parentTemplate(data),
111118
data,
112-
{ description: data.description }
119+
{ value: data.post.content }
113120
);
114121

115122
// verify the child request contains the correct description & re-render
116-
fetchMock.get('http://localhost/_components/my_component?description=i+love+turtles', {
117-
html: childTemplate({ description: 'i love turtles' }),
118-
data: { description: 'i love turtles' }
123+
fetchMock.get('http://localhost/_components/child?value=i+love+turtles', {
124+
html: childTemplate({ value: 'i love turtles' }),
125+
data: { value: 'i love turtles' }
119126
});
120127

121128
// change the description in the child
122-
const inputElement = getByLabelText(element, 'Description:');
129+
const inputElement = getByLabelText(element, 'Content:');
123130
await userEvent.type(inputElement, ' turtles');
124131

125132
// wait for the render to complete
126-
await waitFor(() => expect(element).toHaveTextContent('Description in child: i love turtles'));
133+
await waitFor(() => expect(element).toHaveTextContent('Value in child: i love turtles'));
127134

128135
// the parent should not re-render
129-
expect(element).not.toHaveTextContent('Description in parent: i love turtles');
130-
// but the value DID update
131-
expect(controller.dataValue.description).toEqual('i love turtles');
136+
expect(element).not.toHaveTextContent('Content in parent: i love turtles');
137+
// but the value DID update on the parent component
138+
// this is because the name="post[content]" in the child matches the parent model
139+
expect(controller.dataValue.post.content).toEqual('i love turtles');
132140
});
133141

134142
// TODO - what if a child component re-renders and comes down with

0 commit comments

Comments
 (0)