Skip to content

Commit 71dad52

Browse files
committed
Adding a data-model-map to map child models to the parent
1 parent a178f0d commit 71dad52

File tree

3 files changed

+128
-23
lines changed

3 files changed

+128
-23
lines changed

src/LiveComponent/README.md

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,11 +1141,11 @@ You can also trigger a specific "action" instead of a normal re-render:
11411141

11421142
## Embedded Components
11431143

1144-
Can you embed one live component inside another one? Absolutely! As a rule
1144+
Need to embed one live component inside another one? No problem! As a rule
11451145
of thumb, **each component exists in its own, isolated universe**. This
1146-
means that embedding one component inside another might be really simple...
1147-
or a bit more complex if your components share "model" data or are
1148-
"connected" in other ways.
1146+
means that embedding one component inside another could be really simple
1147+
or a bit more complex, depending on how inter-connected you want your components
1148+
to be.
11491149

11501150
Here are a few helpful things to know:
11511151

@@ -1155,11 +1155,11 @@ If a parent component re-renders, the child component will _not_ (most
11551155
of the time) be updated, even though it lives inside the parent. Each
11561156
component is its own, isolated universe.
11571157

1158-
But... this is not always what you want. For example, suppose a
1159-
parent component holds a form and a child component holds one field.
1160-
When you click a "Save" button on the parent component, it validates
1161-
the form and re-renders with errors - including a new `error` value
1162-
that it passes into the child:
1158+
But this is not always what you want. For example, suppose you have a
1159+
parent component that renders a form and a child component that renders
1160+
one field in that form. When you click a "Save" button on the parent
1161+
component, that validates the form and re-renders with errors - including
1162+
a new `error` value that it passes into the child:
11631163

11641164
```twig
11651165
{# templates/components/post_form.html.twig #}
@@ -1172,20 +1172,24 @@ that it passes into the child:
11721172

11731173
In this situation, when the parent component re-renders after clicking
11741174
"Save", you _do_ want the updated child component (with the validation
1175-
error) to be rendered. And, this _will_ happen because the system detects
1176-
that the parent component has _changed_ how it's rendering the child.
1175+
error) to be rendered. And this _will_ happen automatically. Why? because
1176+
the live component system detects that the **parent component has
1177+
_changed_ how it's rendering the child**.
11771178

11781179
This may not always be perfect, and if your child component has its own
11791180
`LiveProp` that has changed since it was first rendered, that value will
1180-
be lost when the parent component causes the child to re-render.
1181+
be lost when the parent component causes the child to re-render. If you
1182+
have this situation, use `data-model-map` to map that child `LiveProp` to
1183+
a `LiveProp` in the parent component, and pass it into the child when
1184+
rendering.
11811185

11821186
### Actions, methods and model updates in a child do not affect the parent
11831187

11841188
Again, each component is its own, isolated universe! For example, suppose
11851189
your child component has:
11861190

11871191
```html
1188-
<button data-action="live#action" data-action-name="save"></button>
1192+
<button data-action="live#action" data-action-name="save">Save</button>
11891193
```
11901194

11911195
When the user clicks that button, it will attempt to call the `save` action
@@ -1209,7 +1213,7 @@ However, sometimes this isn't what you want! Sometimes, in addition
12091213
to updating the child component's model, you _also_ want to update a
12101214
model on the _parent_ component.
12111215

1212-
To help with this, whenever a model updates, a `live:update-model`
1216+
To help with this, whenever a model updates, a `live:update-model` event
12131217
is dispatched. All components automatically listen to this event. This
12141218
means that, when the `markdown_value` model is updated in the child
12151219
component, _if_ the parent component _also_ has a model called `markdown_value`
@@ -1218,8 +1222,8 @@ it will _also_ be updated. This is done as a "deferred" update
12181222

12191223
If the model name in your child component (e.g. `markdown_value`) is
12201224
_different_ than the model name in your parent component (e.g. `post.content`),
1221-
you can make sure both are set by leveraging both the `data-model`
1222-
and `name` attributes:
1225+
you have two options. First, you can make sure both are set by
1226+
leveraging both the `data-model` and `name` attributes:
12231227

12241228
```twig
12251229
<textarea
@@ -1234,7 +1238,25 @@ component (because `data-model` takes precedence over `name`). But if
12341238
any parent components have a `markdown_value` model _or_ a `post.content`
12351239
model (normalized from `post[content`]`), their model will also be updated.
12361240

1237-
**NOTE**: If you _change_ a `LiveProp` ofa child component on the server
1241+
A second option is to wrap your child element in a special `data-model-map`
1242+
element:
1243+
1244+
```twig
1245+
{# templates/components/post_form.html.twig #}
1246+
1247+
<div data-model-map="from(markdown_value)|post.content">
1248+
{{ component('textarea_field', {
1249+
value: this.content,
1250+
error: this.getError('content')
1251+
}) }}
1252+
</div>
1253+
```
1254+
1255+
Thanks to the `data-model-map`, whenever the `markdown_value` model
1256+
updates in the child component, the `post.content` model will be
1257+
updated in the parent component.
1258+
1259+
**NOTE**: If you _change_ a `LiveProp` of a child component on the server
12381260
(e.g. during re-rendering or via an action), that change will _not_ be
12391261
reflected on any parent components that share that model.
12401262

src/LiveComponent/assets/src/live_controller.js

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -603,19 +603,69 @@ export default class extends Controller {
603603

604604
_handleChildComponentUpdateModel(event) {
605605
let matchingModelName = null;
606+
const mainModelName = event.detail.modelName;
607+
const potentialModelNames = [
608+
{ name: mainModelName, required: false },
609+
{ name: event.detail.extraModelName, required: false },
610+
]
606611

607-
if (doesDeepPropertyExist(this.dataValue, event.detail.modelName)) {
608-
matchingModelName = event.detail.modelName;
609-
} else if (doesDeepPropertyExist(this.dataValue, event.detail.extraModelName)) {
610-
matchingModelName = event.detail.extraModelName;
612+
const modelMapElement = event.target.closest('[data-model-map]');
613+
if (this.element.contains(modelMapElement)) {
614+
const directives = parseDirectives(modelMapElement.dataset.modelMap);
615+
616+
directives.forEach((directive) => {
617+
let from = null;
618+
directive.modifiers.forEach((modifier) => {
619+
switch (modifier.name) {
620+
case 'from':
621+
if (!modifier.value) {
622+
throw new Error(`The from() modifier requires a model name in data-model-map="${modelMapElement.dataset.modelMap}"`);
623+
}
624+
from = modifier.value;
625+
626+
break;
627+
default:
628+
console.warn(`Unknown modifier "${modifier.name}" in data-poll "${rawPollConfig}".`);
629+
}
630+
});
631+
632+
if (!from) {
633+
throw new Error(`Missing from() modifier in data-model-map="${modelMapElement.dataset.modelMap}". The format should be "from(childModelName)|parentModelName"`);
634+
}
635+
636+
// only look maps for the model currently being updated
637+
if (from !== mainModelName) {
638+
return;
639+
}
640+
641+
potentialModelNames.push({ name: directive.action, required: true });
642+
});
611643
}
612644

613-
if (matchingModelName === null) {
645+
potentialModelNames.reverse();
646+
let foundModelName = null;
647+
potentialModelNames.forEach((potentialModel) => {
648+
if (foundModelName) {
649+
return;
650+
}
651+
652+
if (doesDeepPropertyExist(this.dataValue, potentialModel.name)) {
653+
foundModelName = potentialModel.name;
654+
655+
return;
656+
}
657+
658+
if (potentialModel.required) {
659+
throw new Error(`The model name "${potentialModel.name}" does not exist! Found in data-model-map="from(${mainModelName})|${potentialModel.name}"`);
660+
}
661+
});
662+
663+
if (!foundModelName) {
614664
return;
615665
}
616666

617667
this.$updateModel(
618-
matchingModelName,
668+
foundModelName,
619669
event.detail.value,
620670
false,
621671
null,

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,39 @@ describe('LiveController parent -> child component tests', () => {
165165
expect(element).toHaveTextContent('Rows in child: not set');
166166
});
167167

168+
it('uses data-model-map to map child models to parent models', async () => {
169+
const parentTemplateDifferentModel = (data) => `
170+
<div
171+
${initLiveComponent('/_components/parent', data)}
172+
>
173+
<span>Parent textarea content: ${data.textareaContent}</span>
174+
175+
<div
176+
data-model-map="from(value)|textareaContent"
177+
>
178+
${childTemplate({ value: data.textareaContent, error: null })}
179+
</div>
180+
181+
<button
182+
data-action="live#$render"
183+
>Parent Re-render</button>
184+
</div>
185+
`;
186+
187+
const data = { textareaContent: 'Original content' };
188+
const { element, controller } = await startStimulus(parentTemplateDifferentModel(data));
189+
190+
// update & re-render the child component
191+
const inputElement = getByLabelText(element, 'Content:');
192+
await userEvent.clear(inputElement);
193+
await userEvent.type(inputElement, 'changed content');
194+
mockRerender({value: 'changed content'}, childTemplate);
195+
196+
await waitFor(() => expect(element).toHaveTextContent('Value in child: changed content'));
197+
198+
expect(controller.dataValue).toEqual({ textareaContent: 'changed content' });
199+
});
200+
168201
// TODO - what if a child component re-renders and comes down with
169202
// a changed set of data? Should that update the parent's data?
170203
});

0 commit comments

Comments
 (0)