Skip to content

Commit b114594

Browse files
kbondweaverryan
authored andcommitted
[Twig] Embedded components
1 parent c88dbe7 commit b114594

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 <[email protected]>
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 <[email protected]>
@@ -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 <[email protected]>
11+
* @author Kevin Bond <[email protected]>
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)