Skip to content

Commit e3c248e

Browse files
committed
feature #317 [Twig] Embedded components (kbond)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [Twig] Embedded components | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Tickets | Fix #300 | License | MIT This adds a `{% component name %}` tag to allow rendering components and overriding their _slots_ (by using twig's native block system). Example: ```twig {# templates/components/alert.html.twig #} <div{{ attributes.defaults({class: 'alert alert-'~type}) }}> {% block default %}<strong>{{ type }}</strong> {{ message }}{% endblock %} </div> {# "embed" in another template: #} {% component 'alert' with {message: 'embedded message', class: 'extra-class', role: 'alert'} %} {% block default %} {# override "default slot (block)" #} {{ parent() }} {# call parent "slot" #} Custom stuff... {{ this.message }} {# access component properties/methods as normal #} {{ computed.someMethod }} {# all component features available #} {% endblock %} {% endcomponent %} {# can still use the function as normal #} {{ component('alert', {message: 'some message'}) }} ``` More Advanced Example: ```twig {# templates/components/dialog.html.twig #} <div{{ attributes }}> <h1>{% block title %}Default Title{% endblock %}</h1> <p>{% block body %}Default Body{% endblock %}</p> <div>{% block footer %}Default Footer{% endblock %}</div> </div> {# "embed" in another template: #} {% component dialog %} {# quotes can optionally be left off the component name #} {% block title %}Custom Title{% endblock %} {% block body %}Custom Body{% endblock %} {% block footer %}Custom Footer{% endblock %} {% endcomponent %} ``` Some notes: 1. Most code is copied and modified from twig's native `embed` tag 2. I really wanted to have a default slot to avoid the need for overriding the default block (see first example above) but couldn't figure out how to do this or if it's even possible. Maybe someone with a deeper knowledge of twig internals could help? 3. The `PreRender` event is still dispatched like normal but because of the nature of how this feature works, modifying the template via a listener is not supported. 4. Using with LiveComponent's is not supported and throws an exception if attempted. Perhaps some trickery could be added in the future to achieve but I think this feature adds enough value to stand on its own. 5. This with #283 could add more value I think - it would remove the need for simple components to be _backed by_ a component service. **TODO:** - [x] Update changelog - [x] Tests - [x] Docs - [x] Clarify nomenclature, _nested_ components vs _embedded_ components Commits ------- b114594 [Twig] Embedded components
2 parents c88dbe7 + b114594 commit e3c248e

File tree

17 files changed

+410
-57
lines changed

17 files changed

+410
-57
lines changed

src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ public function onPreRender(PreRenderEvent $event): void
3434
return;
3535
}
3636

37+
if (method_exists($event, 'isEmbedded') && $event->isEmbedded()) {
38+
// TODO: remove method_exists once min ux-twig-component version has this method
39+
throw new \LogicException('Embedded components cannot be live.');
40+
}
41+
3742
$metadata = $event->getMetadata();
3843
$attributes = $this->getLiveAttributes($event->getMountedComponent(), $metadata);
3944
$variables = $event->getVariables();

src/LiveComponent/src/Resources/doc/index.rst

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1443,12 +1443,12 @@ You can also trigger a specific "action" instead of a normal re-render:
14431443
#}
14441444
>
14451445
1446-
Embedded Components
1447-
-------------------
1446+
Nested Components
1447+
-----------------
14481448

1449-
Need to embed one live component inside another one? No problem! As a
1449+
Need to nest one live component inside another one? No problem! As a
14501450
rule of thumb, **each component exists in its own, isolated universe**.
1451-
This means that embedding one component inside another could be really
1451+
This means that nesting one component inside another could be really
14521452
simple or a bit more complex, depending on how inter-connected you want
14531453
your components to be.
14541454

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
{% component component2 %}
2+
{% endcomponent %}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace Symfony\UX\LiveComponent\Tests\Integration\EventListener;
4+
5+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
6+
use Symfony\UX\TwigComponent\EventListener\PreRenderEvent;
7+
use Twig\Environment;
8+
use Twig\Error\RuntimeError;
9+
10+
/**
11+
* @author Kevin Bond <kevinbond@gmail.com>
12+
*/
13+
final class AddLiveAttributesSubscriberTest extends KernelTestCase
14+
{
15+
public function testCannotUseEmbeddedComponentAsLive(): void
16+
{
17+
if (!method_exists(PreRenderEvent::class, 'isEmbedded')) {
18+
$this->markTestSkipped('Embedded components not available.');
19+
}
20+
21+
$twig = self::getContainer()->get(Environment::class);
22+
23+
$this->expectException(RuntimeError::class);
24+
$this->expectExceptionMessage('Embedded components cannot be live.');
25+
26+
$twig->render('render_embedded.html.twig');
27+
}
28+
}

src/TwigComponent/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 2.2
44

55
- Allow to pass stringable object as non mapped component attribute
6+
- Add _embedded_ components.
67

78
## 2.1
89

src/TwigComponent/src/ComponentRenderer.php

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,26 @@ public function __construct(
3737
) {
3838
}
3939

40+
public function createAndRender(string $name, array $props = []): string
41+
{
42+
return $this->render($this->factory->create($name, $props));
43+
}
44+
4045
public function render(MountedComponent $mounted): string
46+
{
47+
$event = $this->preRender($mounted);
48+
49+
return $this->twig->render($event->getTemplate(), $event->getVariables());
50+
}
51+
52+
public function embeddedContext(string $name, array $props, array $context): array
53+
{
54+
$context[PreRenderEvent::EMBEDDED] = true;
55+
56+
return $this->preRender($this->factory->create($name, $props), $context)->getVariables();
57+
}
58+
59+
private function preRender(MountedComponent $mounted, array $context = []): PreRenderEvent
4160
{
4261
if (!$this->safeClassesRegistered) {
4362
$this->twig->getExtension(EscaperExtension::class)->addSafeClass(ComponentAttributes::class, ['html']);
@@ -48,6 +67,9 @@ public function render(MountedComponent $mounted): string
4867
$component = $mounted->getComponent();
4968
$metadata = $this->factory->metadataFor($mounted->getName());
5069
$variables = array_merge(
70+
// first so values can be overridden
71+
$context,
72+
5173
// add the component as "this"
5274
['this' => $component],
5375

@@ -64,7 +86,7 @@ public function render(MountedComponent $mounted): string
6486

6587
$this->dispatcher->dispatch($event);
6688

67-
return $this->twig->render($event->getTemplate(), $event->getVariables());
89+
return $event;
6890
}
6991

7092
private function exposedVariables(object $component, bool $exposePublicProps): \Iterator

src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
use Symfony\UX\TwigComponent\ComponentRenderer;
2525
use Symfony\UX\TwigComponent\DependencyInjection\Compiler\TwigComponentPass;
2626
use Symfony\UX\TwigComponent\Twig\ComponentExtension;
27-
use Symfony\UX\TwigComponent\Twig\ComponentRuntime;
2827

2928
/**
3029
* @author Kevin Bond <kevinbond@gmail.com>
@@ -67,14 +66,8 @@ class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in %
6766

6867
$container->register('ux.twig_component.twig.component_extension', ComponentExtension::class)
6968
->addTag('twig.extension')
70-
;
71-
72-
$container->register('ux.twig_component.twig.component_runtime', ComponentRuntime::class)
73-
->setArguments([
74-
new Reference('ux.twig_component.component_factory'),
75-
new Reference('ux.twig_component.component_renderer'),
76-
])
77-
->addTag('twig.runtime')
69+
->addTag('container.service_subscriber', ['key' => ComponentRenderer::class, 'id' => 'ux.twig_component.component_renderer'])
70+
->addTag('container.service_subscriber', ['key' => ComponentFactory::class, 'id' => 'ux.twig_component.component_factory'])
7871
;
7972
}
8073
}

src/TwigComponent/src/EventListener/PreRenderEvent.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
*/
2323
final class PreRenderEvent extends Event
2424
{
25+
/** @internal */
26+
public const EMBEDDED = '__embedded';
27+
2528
private string $template;
2629

2730
/**
@@ -35,6 +38,11 @@ public function __construct(
3538
$this->template = $this->metadata->getTemplate();
3639
}
3740

41+
public function isEmbedded(): bool
42+
{
43+
return $this->variables[self::EMBEDDED] ?? false;
44+
}
45+
3846
/**
3947
* @return string The twig template used for the component
4048
*/
@@ -48,6 +56,10 @@ public function getTemplate(): string
4856
*/
4957
public function setTemplate(string $template): self
5058
{
59+
if ($this->isEmbedded()) {
60+
throw new \LogicException('Cannot modify template for embedded components.');
61+
}
62+
5163
$this->template = $template;
5264

5365
return $this;

src/TwigComponent/src/Resources/doc/index.rst

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -637,14 +637,82 @@ the twig template and twig variables before components are rendered::
637637
}
638638
}
639639

640-
Embedded Components
641-
-------------------
640+
Nested Components
641+
-----------------
642642

643-
It's totally possible to embed one component into another. When you do
643+
It's totally possible to nest one component into another. When you do
644644
this, there's nothing special to know: both components render
645645
independently. If you're using `Live Components`_, then there
646646
*are* some guidelines related to how the re-rendering of parent and
647-
child components works. Read `Live Embedded Components`_.
647+
child components works. Read `Live Nested Components`_.
648+
649+
Embedded Components
650+
-------------------
651+
652+
.. versionadded:: 2.2
653+
654+
Embedded components were added in TwigComponents 2.2.
655+
656+
You can write your component's Twig template with blocks that can be overridden
657+
when rendering using the ``{% component %}`` syntax. These blocks can be thought of as
658+
*slots* which you may be familiar with from Vue. The ``component`` tag is very
659+
similar to Twig's native `embed tag`_.
660+
661+
Consider a data table component. You pass it headers and rows but can expose
662+
blocks for the cells and an optional footer:
663+
664+
.. code-block:: twig
665+
666+
{# templates/components/data_table.html.twig #}
667+
668+
<div{{ attributes.defaults({class: 'data-table'}) }}>
669+
<table>
670+
<thead>
671+
<tr>
672+
{% for header in this.headers %}
673+
<th class="{% block th_class %}data-table-header{% endblock %}">
674+
{{ header }}
675+
</th>
676+
{% endfor %}
677+
</tr>
678+
</thead>
679+
<tbody>
680+
{% for row in this.data %}
681+
<tr>
682+
{% for cell in row %}
683+
<td class="{% block td_class %}data-table-cell{% endblock %}">
684+
{{ cell }}
685+
</td>
686+
{% endfor %}
687+
</tr>
688+
{% endfor %}
689+
</tbody>
690+
</table>
691+
{% block footer %}{% endblock %}
692+
</div>
693+
694+
When rendering, you can override the ``th_class``, ``td_class``, and ``footer`` blocks.
695+
The ``with`` data is what's mounted on the component object.
696+
697+
.. code-block:: twig
698+
699+
{# templates/some_page.html.twig #}
700+
701+
{% component table with {headers: ['key', 'value'], data: [[1, 2], [3, 4]]} %}
702+
{% block th_class %}{{ parent() }} text-bold{% endblock %}
703+
704+
{% block td_class %}{{ parent() }} text-italic{% endblock %}
705+
706+
{% block footer %}
707+
<div class="data-table-footer">
708+
My footer
709+
</div>
710+
{% endblock %}
711+
{% endcomponent %}
712+
713+
.. note::
714+
715+
Embedded components *cannot* currently be used with LiveComponents.
648716

649717
Contributing
650718
------------
@@ -665,5 +733,6 @@ meaning it is not bound to Symfony's BC policy for the moment.
665733
.. _`Live Components`: https://symfony.com/bundles/ux-live-component/current/index.html
666734
.. _`live component`: https://symfony.com/bundles/ux-live-component/current/index.html
667735
.. _`Vue`: https://v3.vuejs.org/guide/computed.html
668-
.. _`Live Embedded Components`: https://symfony.com/bundles/ux-live-component/current/index.html#embedded-components
736+
.. _`Live Nested Components`: https://symfony.com/bundles/ux-live-component/current/index.html#nested-components
669737
.. _`experimental`: https://symfony.com/doc/current/contributing/code/experimental.html
738+
.. _`embed tag`: https://twig.symfony.com/doc/3.x/tags/embed.html

src/TwigComponent/src/Twig/ComponentExtension.php

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111

1212
namespace Symfony\UX\TwigComponent\Twig;
1313

14+
use Psr\Container\ContainerInterface;
15+
use Symfony\Contracts\Service\ServiceSubscriberInterface;
16+
use Symfony\UX\TwigComponent\ComponentFactory;
17+
use Symfony\UX\TwigComponent\ComponentRenderer;
1418
use Twig\Extension\AbstractExtension;
1519
use Twig\TwigFunction;
1620

@@ -21,12 +25,41 @@
2125
*
2226
* @internal
2327
*/
24-
final class ComponentExtension extends AbstractExtension
28+
final class ComponentExtension extends AbstractExtension implements ServiceSubscriberInterface
2529
{
30+
public function __construct(private ContainerInterface $container)
31+
{
32+
}
33+
34+
public static function getSubscribedServices(): array
35+
{
36+
return [
37+
ComponentRenderer::class,
38+
ComponentFactory::class,
39+
];
40+
}
41+
2642
public function getFunctions(): array
2743
{
2844
return [
29-
new TwigFunction('component', [ComponentRuntime::class, 'render'], ['is_safe' => ['all']]),
45+
new TwigFunction('component', [$this, 'render'], ['is_safe' => ['all']]),
3046
];
3147
}
48+
49+
public function getTokenParsers(): array
50+
{
51+
return [
52+
new ComponentTokenParser(fn () => $this->container->get(ComponentFactory::class)),
53+
];
54+
}
55+
56+
public function render(string $name, array $props = []): string
57+
{
58+
return $this->container->get(ComponentRenderer::class)->createAndRender($name, $props);
59+
}
60+
61+
public function embeddedContext(string $name, array $props, array $context): array
62+
{
63+
return $this->container->get(ComponentRenderer::class)->embeddedContext($name, $props, $context);
64+
}
3265
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace Symfony\UX\TwigComponent\Twig;
4+
5+
use Twig\Compiler;
6+
use Twig\Node\EmbedNode;
7+
use Twig\Node\Expression\ArrayExpression;
8+
9+
/**
10+
* @author Fabien Potencier <fabien@symfony.com>
11+
* @author Kevin Bond <kevinbond@gmail.com>
12+
*
13+
* @experimental
14+
*
15+
* @internal
16+
*/
17+
final class ComponentNode extends EmbedNode
18+
{
19+
public function __construct(string $component, string $template, int $index, ArrayExpression $variables, bool $only, int $lineno, string $tag)
20+
{
21+
parent::__construct($template, $index, $variables, $only, false, $lineno, $tag);
22+
23+
$this->setAttribute('component', $component);
24+
}
25+
26+
public function compile(Compiler $compiler): void
27+
{
28+
$compiler->addDebugInfo($this);
29+
30+
$compiler
31+
->raw('$props = $this->extensions[')
32+
->string(ComponentExtension::class)
33+
->raw(']->embeddedContext(')
34+
->string($this->getAttribute('component'))
35+
->raw(', ')
36+
->raw('twig_to_array(')
37+
->subcompile($this->getNode('variables'))
38+
->raw('), ')
39+
->raw($this->getAttribute('only') ? '[]' : '$context')
40+
->raw(");\n")
41+
;
42+
43+
$this->addGetTemplate($compiler);
44+
45+
$compiler->raw('->display($props);');
46+
$compiler->raw("\n");
47+
}
48+
}

0 commit comments

Comments
 (0)