diff --git a/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php b/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php index 0ac343dba4e..5c7f629bc3e 100644 --- a/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php +++ b/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php @@ -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(); diff --git a/src/LiveComponent/src/Resources/doc/index.rst b/src/LiveComponent/src/Resources/doc/index.rst index de9f51b2b3b..57fc50b35b5 100644 --- a/src/LiveComponent/src/Resources/doc/index.rst +++ b/src/LiveComponent/src/Resources/doc/index.rst @@ -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. diff --git a/src/LiveComponent/tests/Fixtures/templates/render_embedded.html.twig b/src/LiveComponent/tests/Fixtures/templates/render_embedded.html.twig new file mode 100644 index 00000000000..236d84ad4ba --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/templates/render_embedded.html.twig @@ -0,0 +1,2 @@ +{% component component2 %} +{% endcomponent %} diff --git a/src/LiveComponent/tests/Integration/EventListener/AddLiveAttributesSubscriberTest.php b/src/LiveComponent/tests/Integration/EventListener/AddLiveAttributesSubscriberTest.php new file mode 100644 index 00000000000..7bef8f358b1 --- /dev/null +++ b/src/LiveComponent/tests/Integration/EventListener/AddLiveAttributesSubscriberTest.php @@ -0,0 +1,28 @@ + + */ +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'); + } +} diff --git a/src/TwigComponent/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md index ce8d31943bb..37933cc4e51 100644 --- a/src/TwigComponent/CHANGELOG.md +++ b/src/TwigComponent/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2.2 - Allow to pass stringable object as non mapped component attribute +- Add _embedded_ components. ## 2.1 diff --git a/src/TwigComponent/src/ComponentRenderer.php b/src/TwigComponent/src/ComponentRenderer.php index d2efd9523bf..a633ec48e49 100644 --- a/src/TwigComponent/src/ComponentRenderer.php +++ b/src/TwigComponent/src/ComponentRenderer.php @@ -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']); @@ -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], @@ -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 diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php index 0a70852aa63..b8e7a902ab3 100644 --- a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php +++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php @@ -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 @@ -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']) ; } } diff --git a/src/TwigComponent/src/EventListener/PreRenderEvent.php b/src/TwigComponent/src/EventListener/PreRenderEvent.php index 29ec1499636..54956167a49 100644 --- a/src/TwigComponent/src/EventListener/PreRenderEvent.php +++ b/src/TwigComponent/src/EventListener/PreRenderEvent.php @@ -22,6 +22,9 @@ */ final class PreRenderEvent extends Event { + /** @internal */ + public const EMBEDDED = '__embedded'; + private string $template; /** @@ -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 */ @@ -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; diff --git a/src/TwigComponent/src/Resources/doc/index.rst b/src/TwigComponent/src/Resources/doc/index.rst index e438c642fec..d153769871c 100644 --- a/src/TwigComponent/src/Resources/doc/index.rst +++ b/src/TwigComponent/src/Resources/doc/index.rst @@ -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 #} + + + + + + {% for header in this.headers %} + + {% endfor %} + + + + {% for row in this.data %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} + +
+ {{ header }} +
+ {{ cell }} +
+ {% block footer %}{% endblock %} + + +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 %} + + {% endblock %} + {% endcomponent %} + +.. note:: + + Embedded components *cannot* currently be used with LiveComponents. Contributing ------------ @@ -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 diff --git a/src/TwigComponent/src/Twig/ComponentExtension.php b/src/TwigComponent/src/Twig/ComponentExtension.php index 3356457fe99..e06122bb017 100644 --- a/src/TwigComponent/src/Twig/ComponentExtension.php +++ b/src/TwigComponent/src/Twig/ComponentExtension.php @@ -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; @@ -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); + } } diff --git a/src/TwigComponent/src/Twig/ComponentNode.php b/src/TwigComponent/src/Twig/ComponentNode.php new file mode 100644 index 00000000000..c77c14c267d --- /dev/null +++ b/src/TwigComponent/src/Twig/ComponentNode.php @@ -0,0 +1,48 @@ + + * @author Kevin Bond + * + * @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"); + } +} diff --git a/src/TwigComponent/src/Twig/ComponentRuntime.php b/src/TwigComponent/src/Twig/ComponentRuntime.php deleted file mode 100644 index 395f3d33876..00000000000 --- a/src/TwigComponent/src/Twig/ComponentRuntime.php +++ /dev/null @@ -1,36 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\TwigComponent\Twig; - -use Symfony\UX\TwigComponent\ComponentFactory; -use Symfony\UX\TwigComponent\ComponentRenderer; - -/** - * @author Kevin Bond - * - * @experimental - * - * @internal - */ -final class ComponentRuntime -{ - public function __construct( - private ComponentFactory $componentFactory, - private ComponentRenderer $componentRenderer - ) { - } - - public function render(string $name, array $props = []): string - { - return $this->componentRenderer->render($this->componentFactory->create($name, $props)); - } -} diff --git a/src/TwigComponent/src/Twig/ComponentTokenParser.php b/src/TwigComponent/src/Twig/ComponentTokenParser.php new file mode 100644 index 00000000000..a67333611e2 --- /dev/null +++ b/src/TwigComponent/src/Twig/ComponentTokenParser.php @@ -0,0 +1,120 @@ + + * @author Kevin Bond + * + * @experimental + * + * @internal + */ +final class ComponentTokenParser extends AbstractTokenParser +{ + /** @var ComponentFactory|callable():ComponentFactory */ + private $factory; + + /** + * @param callable():ComponentFactory $factory + */ + public function __construct(callable $factory) + { + $this->factory = $factory; + } + + public function parse(Token $token): Node + { + $stream = $this->parser->getStream(); + $parent = $this->parser->getExpressionParser()->parseExpression(); + $componentName = $this->componentName($parent); + $componentMetadata = $this->factory()->metadataFor($componentName); + + [$variables, $only] = $this->parseArguments(); + + if (null === $variables) { + $variables = new ArrayExpression([], $parent->getTemplateLine()); + } + + $parentToken = new Token(Token::STRING_TYPE, $componentMetadata->getTemplate(), $token->getLine()); + $fakeParentToken = new Token(Token::STRING_TYPE, '__parent__', $token->getLine()); + + // inject a fake parent to make the parent() function work + $stream->injectTokens([ + new Token(Token::BLOCK_START_TYPE, '', $token->getLine()), + new Token(Token::NAME_TYPE, 'extends', $token->getLine()), + $parentToken, + new Token(Token::BLOCK_END_TYPE, '', $token->getLine()), + ]); + + $module = $this->parser->parse($stream, fn (Token $token) => $token->test("end{$this->getTag()}"), true); + + // override the parent with the correct one + if ($fakeParentToken === $parentToken) { + $module->setNode('parent', $parent); + } + + $this->parser->embedTemplate($module); + + $stream->expect(Token::BLOCK_END_TYPE); + + return new ComponentNode($componentName, $module->getTemplateName(), $module->getAttribute('index'), $variables, $only, $token->getLine(), $this->getTag()); + } + + public function getTag(): string + { + return 'component'; + } + + private function componentName(AbstractExpression $expression): string + { + if ($expression instanceof ConstantExpression) { // using {% component 'name' %} + return $expression->getAttribute('value'); + } + + if ($expression instanceof NameExpression) { // using {% component name %} + return $expression->getAttribute('name'); + } + + throw new \LogicException('Could not parse component name.'); + } + + private function factory(): ComponentFactory + { + if (\is_callable($this->factory)) { + $this->factory = ($this->factory)(); + } + + return $this->factory; + } + + private function parseArguments(): array + { + $stream = $this->parser->getStream(); + + $variables = null; + + if ($stream->nextIf(Token::NAME_TYPE, 'with')) { + $variables = $this->parser->getExpressionParser()->parseExpression(); + } + + $only = false; + + if ($stream->nextIf(Token::NAME_TYPE, 'only')) { + $only = true; + } + + $stream->expect(Token::BLOCK_END_TYPE); + + return [$variables, $only]; + } +} diff --git a/src/TwigComponent/tests/Fixtures/Component/Table.php b/src/TwigComponent/tests/Fixtures/Component/Table.php new file mode 100644 index 00000000000..cfdc151182f --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/Component/Table.php @@ -0,0 +1,13 @@ + + {% if this.caption %} + {{ this.caption }} + {% endif %} + + + {% for header in this.headers %} + + {% block th %}{{ header }}{% endblock %} + + {% endfor %} + + + + {% for row in this.data %} + + {% for cell in row %} + + {% block td %}{{ cell }}{% endblock %} + + {% endfor %} + + {% endfor %} + + +{% if block('footer') is defined %} + {{ block('footer') }} +{% endif %} diff --git a/src/TwigComponent/tests/Fixtures/templates/embedded_component.html.twig b/src/TwigComponent/tests/Fixtures/templates/embedded_component.html.twig new file mode 100644 index 00000000000..ea4ebb5c91c --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/templates/embedded_component.html.twig @@ -0,0 +1,6 @@ +{% component table with { caption: 'data table', headers: ['key', 'value'], data: [[1, 2], [3, 4]] } %} + {% block th %}custom th ({{ parent() }}){% endblock %} + {% block td %}custom td ({{ parent() }}){% endblock %} + + {% block footer %}My footer{% endblock %} +{% endcomponent %} diff --git a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php index bb1bb4cbf55..687dd7a66a7 100644 --- a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php +++ b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php @@ -118,6 +118,15 @@ public function testCanDisableExposingPublicProps(): void $this->assertStringContainsString('NoPublicProp1: default', $output); } + public function testCanRenderEmbeddedComponent(): void + { + $output = self::getContainer()->get(Environment::class)->render('embedded_component.html.twig'); + + $this->assertStringContainsString('data table', $output); + $this->assertStringContainsString('custom th (key)', $output); + $this->assertStringContainsString('custom td (1)', $output); + } + private function renderComponent(string $name, array $data = []): string { return self::getContainer()->get(Environment::class)->render('render_component.html.twig', [