Skip to content

Commit 13804e6

Browse files
committed
feature #113 [LiveComponent] Smooth out parent-child component relationship (weaverryan)
This PR was squashed before being merged into the main branch. Discussion ---------- [LiveComponent] Smooth out parent-child component relationship | Q | A | ------------- | --- | Bug fix? | yes | New feature? | yes | Tickets | #102 addresses **Bug C** and **Bug F** | License | MIT This PR is to address a few outstanding issues related to nested or "parent/child" components: * [X] #102 Bug C: When a model is updated in a child component, that parent component does not receive that component update (e.g. imagine a form that renders a component... and one field is rendered inside another component - when the child component's model is updated, it should also update that same model in the parent component [the form]). * [X] Change `data-model` to take priority over `name` so that child components can use `data-model` and allow parent components to still use the `name` attribute to match up with their model. * [X] Change the new `live:update-model` event to also (when possible) pass the relevant element so that the listener can look at both the `name` and `data-model` attribute to find a match. Or find an alternative solution. * [X] Document how parent/child components work (e.g. that when a parent re-renders, the child does not re-render) * [X] #102 Bug F: Add a way to "force" a child component to re-render * [x] Document a known edge case: if a child component re-renders, and the shared model value (shared with a parent component) CHANGES in the AJAX response (e.g. `content=foo` is sent to the server but `content=FOO` is returned, and so `FOO` is now the new value of the `content` model), a parent will not be aware of this change. * [x] Add way to "map" a child model onto a parent model so that you can "map" a private `LiveProp` from a child onto a parent so that it's not lost if the parent renders over the child. * [x] Standardize / cleanup more tests with `mockRerender()`. Commits ------- a80e101 [LiveComponent] Smooth out parent-child component relationship
2 parents 1132685 + a80e101 commit 13804e6

14 files changed

+904
-192
lines changed

src/LiveComponent/README.md

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,9 @@ code works identically to the previous example:
361361
</div>
362362
```
363363

364+
If an element has _both_ `data-model` and `name` attributes, the
365+
`data-model` attribute takes precedence.
366+
364367
## Loading States
365368

366369
Often, you'll want to show (or hide) an element while a component is
@@ -1135,3 +1138,226 @@ You can also trigger a specific "action" instead of a normal re-render:
11351138
#}
11361139
>
11371140
```
1141+
1142+
## Embedded Components
1143+
1144+
Need to embed one live component inside another one? No problem! As a rule
1145+
of thumb, **each component exists in its own, isolated universe**. This
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.
1149+
1150+
Here are a few helpful things to know:
1151+
1152+
### Each component re-renders independent of one another
1153+
1154+
If a parent component re-renders, the child component will _not_ (most
1155+
of the time) be updated, even though it lives inside the parent. Each
1156+
component is its own, isolated universe.
1157+
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:
1163+
1164+
```twig
1165+
{# templates/components/post_form.html.twig #}
1166+
1167+
{{ component('textarea_field', {
1168+
value: this.content,
1169+
error: this.getError('content')
1170+
}) }}
1171+
```
1172+
1173+
In this situation, when the parent component re-renders after clicking
1174+
"Save", you _do_ want the updated child component (with the validation
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**.
1178+
1179+
This may not always be perfect, and if your child component has its own
1180+
`LiveProp` that has changed since it was first rendered, that value will
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.
1185+
1186+
### Actions, methods and model updates in a child do not affect the parent
1187+
1188+
Again, each component is its own, isolated universe! For example, suppose
1189+
your child component has:
1190+
1191+
```html
1192+
<button data-action="live#action" data-action-name="save">Save</button>
1193+
```
1194+
1195+
When the user clicks that button, it will attempt to call the `save` action
1196+
in the _child_ component only, even if the `save` action actually only
1197+
exists in the parent. The same is true for `data-model`, though there is
1198+
some special handling for this case (see next point).
1199+
1200+
### If a child model updates, it will attempt to update the parent model
1201+
1202+
Suppose a child component has a:
1203+
1204+
```html
1205+
<textarea data-model="markdown_value" data-action="live#update">
1206+
```
1207+
1208+
When the user changes this field, this will _only_ update the `markdown_value`
1209+
field in the _child_ component... because (yup, we're saying it again):
1210+
each component is its own, isolated universe.
1211+
1212+
However, sometimes this isn't what you want! Sometimes, in addition
1213+
to updating the child component's model, you _also_ want to update a
1214+
model on the _parent_ component.
1215+
1216+
To help with this, whenever a model updates, a `live:update-model` event
1217+
is dispatched. All components automatically listen to this event. This
1218+
means that, when the `markdown_value` model is updated in the child
1219+
component, _if_ the parent component _also_ has a model called `markdown_value`
1220+
it will _also_ be updated. This is done as a "deferred" update
1221+
(i.e. [updateDefer()](#deferring-a-re-render-until-later)).
1222+
1223+
If the model name in your child component (e.g. `markdown_value`) is
1224+
_different_ than the model name in your parent component (e.g. `post.content`),
1225+
you have two options. First, you can make sure both are set by
1226+
leveraging both the `data-model` and `name` attributes:
1227+
1228+
```twig
1229+
<textarea
1230+
data-model="markdown_value"
1231+
name="post[content]"
1232+
data-action="live#update"
1233+
>
1234+
```
1235+
1236+
In this situation, the `markdown_value` model will be updated on the child
1237+
component (because `data-model` takes precedence over `name`). But if
1238+
any parent components have a `markdown_value` model _or_ a `post.content`
1239+
model (normalized from `post[content`]`), their model will also be updated.
1240+
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
1260+
(e.g. during re-rendering or via an action), that change will _not_ be
1261+
reflected on any parent components that share that model.
1262+
1263+
### Full Embedded Component Example
1264+
1265+
Let's look at a full, complex example of an embedded component. Suppose
1266+
you have an `EditPostComponent`:
1267+
1268+
```php
1269+
<?php
1270+
1271+
namespace App\Twig\Components;
1272+
1273+
use App\Entity\Post;
1274+
use Doctrine\ORM\EntityManagerInterface;
1275+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
1276+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
1277+
use Symfony\UX\LiveComponent\Attribute\LiveAction;
1278+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
1279+
1280+
#[AsLiveComponent('edit_post')]
1281+
final class EditPostComponent extends AbstractController
1282+
{
1283+
#[LiveProp(exposed: ['title', 'content'])]
1284+
public Post $post;
1285+
1286+
#[LiveAction]
1287+
public function save(EntityManagerInterface $entityManager)
1288+
{
1289+
$entityManager->flush();
1290+
1291+
return $this->redirectToRoute('some_route');
1292+
}
1293+
}
1294+
```
1295+
1296+
And a `MarkdownTextareaComponent`:
1297+
1298+
```php
1299+
<?php
1300+
1301+
namespace App\Twig\Components;
1302+
1303+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
1304+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
1305+
1306+
#[AsLiveComponent('markdown_textarea')]
1307+
final class MarkdownTextareaComponent
1308+
{
1309+
#[LiveProp]
1310+
public string $label;
1311+
1312+
#[LiveProp]
1313+
public string $name;
1314+
1315+
#[LiveProp(writable: true)]
1316+
public string $value = '';
1317+
}
1318+
```
1319+
1320+
In the `EditPostComponent` template, you render the `MarkdownTextareaComponent`:
1321+
1322+
```twig
1323+
{# templates/components/edit_post.html.twig #}
1324+
<div {{ init_live_component(this) }}>
1325+
<input
1326+
type="text"
1327+
name="post[title]"
1328+
data-action="live#update"
1329+
value="{{ this.post.title }}"
1330+
>
1331+
1332+
{{ component('markdown_textarea', {
1333+
name: 'post[content]',
1334+
label: 'Content',
1335+
value: this.post.content
1336+
}) }}
1337+
1338+
<button
1339+
data-action="live#action"
1340+
data-action-name="save"
1341+
>Save</button>
1342+
</div>
1343+
```
1344+
1345+
```twig
1346+
<div {{ init_live_component(this) }} class="mb-3">
1347+
<textarea
1348+
name="{{ this.name }}"
1349+
data-model="value"
1350+
data-action="live#update"
1351+
>{{ this.value }}</textarea>
1352+
1353+
<div class="markdown-preview">
1354+
{{ this.value|markdown_to_html }}
1355+
</div>
1356+
</div>
1357+
```
1358+
1359+
Notice that `MarkdownTextareaComponent` allows a dynamic `name` attribute to
1360+
be passed in. This makes that component re-usable in any form. But it
1361+
also makes sure that when the `textarea` changes, both the `value` model
1362+
in `MarkdownTextareaComponent` _and_ the `post.content` model in
1363+
`EditPostcomponent` will be updated.

0 commit comments

Comments
 (0)