Skip to content

[Twig] Embedded components #317

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 18, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ public function onPreRender(PreRenderEvent $event): void
return;
}

if (method_exists($event, 'isEmbedded') && $event->isEmbedded()) {
// TODO: remove method_exists once min ux-twig-component version has this method
throw new \LogicException('Embedded components cannot be live.');
}

$metadata = $event->getMetadata();
$attributes = $this->getLiveAttributes($event->getMountedComponent(), $metadata);
$variables = $event->getVariables();
Expand Down
8 changes: 4 additions & 4 deletions src/LiveComponent/src/Resources/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1443,12 +1443,12 @@ You can also trigger a specific "action" instead of a normal re-render:
#}
>

Embedded Components
-------------------
Nested Components
-----------------

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{% component component2 %}
{% endcomponent %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace Symfony\UX\LiveComponent\Tests\Integration\EventListener;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\TwigComponent\EventListener\PreRenderEvent;
use Twig\Environment;
use Twig\Error\RuntimeError;

/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
final class AddLiveAttributesSubscriberTest extends KernelTestCase
{
public function testCannotUseEmbeddedComponentAsLive(): void
{
if (!method_exists(PreRenderEvent::class, 'isEmbedded')) {
$this->markTestSkipped('Embedded components not available.');
}

$twig = self::getContainer()->get(Environment::class);

$this->expectException(RuntimeError::class);
$this->expectExceptionMessage('Embedded components cannot be live.');

$twig->render('render_embedded.html.twig');
}
}
1 change: 1 addition & 0 deletions src/TwigComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 2.2

- Allow to pass stringable object as non mapped component attribute
- Add _embedded_ components.

## 2.1

Expand Down
24 changes: 23 additions & 1 deletion src/TwigComponent/src/ComponentRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,26 @@ public function __construct(
) {
}

public function createAndRender(string $name, array $props = []): string
{
return $this->render($this->factory->create($name, $props));
}

public function render(MountedComponent $mounted): string
{
$event = $this->preRender($mounted);

return $this->twig->render($event->getTemplate(), $event->getVariables());
}

public function embeddedContext(string $name, array $props, array $context): array
{
$context[PreRenderEvent::EMBEDDED] = true;

return $this->preRender($this->factory->create($name, $props), $context)->getVariables();
}

private function preRender(MountedComponent $mounted, array $context = []): PreRenderEvent
{
if (!$this->safeClassesRegistered) {
$this->twig->getExtension(EscaperExtension::class)->addSafeClass(ComponentAttributes::class, ['html']);
Expand All @@ -48,6 +67,9 @@ public function render(MountedComponent $mounted): string
$component = $mounted->getComponent();
$metadata = $this->factory->metadataFor($mounted->getName());
$variables = array_merge(
// first so values can be overridden
$context,

// add the component as "this"
['this' => $component],

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

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

return $this->twig->render($event->getTemplate(), $event->getVariables());
return $event;
}

private function exposedVariables(object $component, bool $exposePublicProps): \Iterator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
use Symfony\UX\TwigComponent\ComponentRenderer;
use Symfony\UX\TwigComponent\DependencyInjection\Compiler\TwigComponentPass;
use Symfony\UX\TwigComponent\Twig\ComponentExtension;
use Symfony\UX\TwigComponent\Twig\ComponentRuntime;

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

$container->register('ux.twig_component.twig.component_extension', ComponentExtension::class)
->addTag('twig.extension')
;

$container->register('ux.twig_component.twig.component_runtime', ComponentRuntime::class)
->setArguments([
new Reference('ux.twig_component.component_factory'),
new Reference('ux.twig_component.component_renderer'),
])
->addTag('twig.runtime')
->addTag('container.service_subscriber', ['key' => ComponentRenderer::class, 'id' => 'ux.twig_component.component_renderer'])
->addTag('container.service_subscriber', ['key' => ComponentFactory::class, 'id' => 'ux.twig_component.component_factory'])
;
}
}
12 changes: 12 additions & 0 deletions src/TwigComponent/src/EventListener/PreRenderEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
*/
final class PreRenderEvent extends Event
{
/** @internal */
public const EMBEDDED = '__embedded';

private string $template;

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

public function isEmbedded(): bool
{
return $this->variables[self::EMBEDDED] ?? false;
}

/**
* @return string The twig template used for the component
*/
Expand All @@ -48,6 +56,10 @@ public function getTemplate(): string
*/
public function setTemplate(string $template): self
{
if ($this->isEmbedded()) {
throw new \LogicException('Cannot modify template for embedded components.');
}

$this->template = $template;

return $this;
Expand Down
79 changes: 74 additions & 5 deletions src/TwigComponent/src/Resources/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -637,14 +637,82 @@ the twig template and twig variables before components are rendered::
}
}

Embedded Components
-------------------
Nested Components
-----------------

It's totally possible to embed one component into another. When you do
It's totally possible to nest one component into another. When you do
this, there's nothing special to know: both components render
independently. If you're using `Live Components`_, then there
*are* some guidelines related to how the re-rendering of parent and
child components works. Read `Live Embedded Components`_.
child components works. Read `Live Nested Components`_.

Embedded Components
-------------------

.. versionadded:: 2.2

Embedded components were added in TwigComponents 2.2.

You can write your component's Twig template with blocks that can be overridden
when rendering using the ``{% component %}`` syntax. These blocks can be thought of as
*slots* which you may be familiar with from Vue. The ``component`` tag is very
similar to Twig's native `embed tag`_.

Consider a data table component. You pass it headers and rows but can expose
blocks for the cells and an optional footer:

.. code-block:: twig

{# templates/components/data_table.html.twig #}

<div{{ attributes.defaults({class: 'data-table'}) }}>
<table>
<thead>
<tr>
{% for header in this.headers %}
<th class="{% block th_class %}data-table-header{% endblock %}">
{{ header }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in this.data %}
<tr>
{% for cell in row %}
<td class="{% block td_class %}data-table-cell{% endblock %}">
{{ cell }}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% block footer %}{% endblock %}
</div>

When rendering, you can override the ``th_class``, ``td_class``, and ``footer`` blocks.
The ``with`` data is what's mounted on the component object.

.. code-block:: twig

{# templates/some_page.html.twig #}

{% component table with {headers: ['key', 'value'], data: [[1, 2], [3, 4]]} %}
{% block th_class %}{{ parent() }} text-bold{% endblock %}

{% block td_class %}{{ parent() }} text-italic{% endblock %}

{% block footer %}
<div class="data-table-footer">
My footer
</div>
{% endblock %}
{% endcomponent %}

.. note::

Embedded components *cannot* currently be used with LiveComponents.

Contributing
------------
Expand All @@ -665,5 +733,6 @@ meaning it is not bound to Symfony's BC policy for the moment.
.. _`Live Components`: https://symfony.com/bundles/ux-live-component/current/index.html
.. _`live component`: https://symfony.com/bundles/ux-live-component/current/index.html
.. _`Vue`: https://v3.vuejs.org/guide/computed.html
.. _`Live Embedded Components`: https://symfony.com/bundles/ux-live-component/current/index.html#embedded-components
.. _`Live Nested Components`: https://symfony.com/bundles/ux-live-component/current/index.html#nested-components
.. _`experimental`: https://symfony.com/doc/current/contributing/code/experimental.html
.. _`embed tag`: https://twig.symfony.com/doc/3.x/tags/embed.html
37 changes: 35 additions & 2 deletions src/TwigComponent/src/Twig/ComponentExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@

namespace Symfony\UX\TwigComponent\Twig;

use Psr\Container\ContainerInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Symfony\UX\TwigComponent\ComponentFactory;
use Symfony\UX\TwigComponent\ComponentRenderer;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

Expand All @@ -21,12 +25,41 @@
*
* @internal
*/
final class ComponentExtension extends AbstractExtension
final class ComponentExtension extends AbstractExtension implements ServiceSubscriberInterface
{
public function __construct(private ContainerInterface $container)
{
}

public static function getSubscribedServices(): array
{
return [
ComponentRenderer::class,
ComponentFactory::class,
];
}

public function getFunctions(): array
{
return [
new TwigFunction('component', [ComponentRuntime::class, 'render'], ['is_safe' => ['all']]),
new TwigFunction('component', [$this, 'render'], ['is_safe' => ['all']]),
];
}

public function getTokenParsers(): array
{
return [
new ComponentTokenParser(fn () => $this->container->get(ComponentFactory::class)),
];
}

public function render(string $name, array $props = []): string
{
return $this->container->get(ComponentRenderer::class)->createAndRender($name, $props);
}

public function embeddedContext(string $name, array $props, array $context): array
{
return $this->container->get(ComponentRenderer::class)->embeddedContext($name, $props, $context);
}
}
48 changes: 48 additions & 0 deletions src/TwigComponent/src/Twig/ComponentNode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace Symfony\UX\TwigComponent\Twig;

use Twig\Compiler;
use Twig\Node\EmbedNode;
use Twig\Node\Expression\ArrayExpression;

/**
* @author Fabien Potencier <fabien@symfony.com>
* @author Kevin Bond <kevinbond@gmail.com>
*
* @experimental
*
* @internal
*/
final class ComponentNode extends EmbedNode
{
public function __construct(string $component, string $template, int $index, ArrayExpression $variables, bool $only, int $lineno, string $tag)
{
parent::__construct($template, $index, $variables, $only, false, $lineno, $tag);

$this->setAttribute('component', $component);
}

public function compile(Compiler $compiler): void
{
$compiler->addDebugInfo($this);

$compiler
->raw('$props = $this->extensions[')
->string(ComponentExtension::class)
->raw(']->embeddedContext(')
->string($this->getAttribute('component'))
->raw(', ')
->raw('twig_to_array(')
->subcompile($this->getNode('variables'))
->raw('), ')
->raw($this->getAttribute('only') ? '[]' : '$context')
->raw(");\n")
;

$this->addGetTemplate($compiler);

$compiler->raw('->display($props);');
$compiler->raw("\n");
}
}
Loading