From e6c53fd5af9baf54a34ed7cb9ff36b48553557d7 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Tue, 1 Feb 2022 15:34:18 -0500 Subject: [PATCH] [Twig] add `ExposeInTemplate` attribute --- .../src/ComponentWithFormTrait.php | 2 + src/LiveComponent/src/Resources/doc/index.rst | 29 +++++----- .../form_with_collection_type.html.twig | 2 +- src/TwigComponent/CHANGELOG.md | 3 ++ .../src/Attribute/ExposeInTemplate.php | 35 ++++++++++++ src/TwigComponent/src/ComponentRenderer.php | 39 +++++++++++++- .../TwigComponentExtension.php | 1 + .../src/EventListener/PreRenderEvent.php | 9 ++-- src/TwigComponent/src/MountedComponent.php | 8 --- src/TwigComponent/src/Resources/doc/index.rst | 54 +++++++++++++++++++ .../Component/WithExposedVariables.php | 37 +++++++++++++ src/TwigComponent/tests/Fixtures/Kernel.php | 2 + .../with_exposed_variables.html.twig | 3 ++ .../templates/exposed_variables.html.twig | 1 + .../Integration/ComponentExtensionTest.php | 9 ++++ 15 files changed, 207 insertions(+), 27 deletions(-) create mode 100644 src/TwigComponent/src/Attribute/ExposeInTemplate.php create mode 100644 src/TwigComponent/tests/Fixtures/Component/WithExposedVariables.php create mode 100644 src/TwigComponent/tests/Fixtures/templates/components/with_exposed_variables.html.twig create mode 100644 src/TwigComponent/tests/Fixtures/templates/exposed_variables.html.twig diff --git a/src/LiveComponent/src/ComponentWithFormTrait.php b/src/LiveComponent/src/ComponentWithFormTrait.php index 6403d2047f5..7dfd4d92b19 100644 --- a/src/LiveComponent/src/ComponentWithFormTrait.php +++ b/src/LiveComponent/src/ComponentWithFormTrait.php @@ -18,6 +18,7 @@ use Symfony\UX\LiveComponent\Attribute\BeforeReRender; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\Util\LiveFormUtility; +use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; use Symfony\UX\TwigComponent\Attribute\PostMount; /** @@ -27,6 +28,7 @@ */ trait ComponentWithFormTrait { + #[ExposeInTemplate(name: 'form', getter: 'getForm')] private ?FormView $formView = null; private ?FormInterface $formInstance = null; diff --git a/src/LiveComponent/src/Resources/doc/index.rst b/src/LiveComponent/src/Resources/doc/index.rst index 6b161ead31a..5b097c3d199 100644 --- a/src/LiveComponent/src/Resources/doc/index.rst +++ b/src/LiveComponent/src/Resources/doc/index.rst @@ -813,7 +813,12 @@ recreate the *same* form, we pass in the ``Post`` object and set it as a ``LiveProp``. The template for this component will render the form, which is available -as ``this.form`` thanks to the trait: +as ``form`` thanks to the trait: + +.. versionadded:: 2.1 + + The ability to access ``form`` directly in your component's template + was added in LiveComponents 2.1. Previously ``this.form`` was required. .. code-block:: twig @@ -833,13 +838,13 @@ as ``this.form`` thanks to the trait: #} data-action="change->live#update" > - {{ form_start(this.form) }} - {{ form_row(this.form.title) }} - {{ form_row(this.form.slug) }} - {{ form_row(this.form.content) }} + {{ form_start(form) }} + {{ form_row(form.title) }} + {{ form_row(form.slug) }} + {{ form_row(form.content) }} - {{ form_end(this.form) }} + {{ form_end(form) }} Mostly, this is a pretty boring template! It includes the normal @@ -1027,7 +1032,7 @@ Finally, tell the ``form`` element to use this action: {# templates/components/post_form.html.twig #} {# ... #} - {{ form_start(this.form, { + {{ form_start(form, { attr: { 'data-action': 'live#action', 'data-action-name': 'prevent|save' @@ -1148,11 +1153,11 @@ and ``removeComment()`` actions: .. code-block:: twig - {{ form_start(this.form) }} - {{ form_row(this.form.title) }} + {{ form_start(form) }} + {{ form_row(form.title) }}

Comments:

- {% for key, commentForm in this.form.comments %} + {% for key, commentForm in form.comments %} - {{ form_end(this.form) }} + {{ form_end(form) }} Done! Behind the scenes, it works like this: diff --git a/src/LiveComponent/tests/Fixtures/templates/components/form_with_collection_type.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/form_with_collection_type.html.twig index 56334201bc5..32dce1b7b1e 100644 --- a/src/LiveComponent/tests/Fixtures/templates/components/form_with_collection_type.html.twig +++ b/src/LiveComponent/tests/Fixtures/templates/components/form_with_collection_type.html.twig @@ -1,3 +1,3 @@ - {{ form(this.form) }} + {{ form(form) }} diff --git a/src/TwigComponent/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md index 2d4d83316a1..89a3dc1b4d7 100644 --- a/src/TwigComponent/CHANGELOG.md +++ b/src/TwigComponent/CHANGELOG.md @@ -13,6 +13,9 @@ - Add `PreRenderEvent` to intercept/manipulate twig template/variables before rendering. +- Add `ExposeInTemplate` attribute to make non-public properties available in component + templates directly. + ## 2.0.0 - Support for `stimulus` version 2 was removed and support for `@hotwired/stimulus` diff --git a/src/TwigComponent/src/Attribute/ExposeInTemplate.php b/src/TwigComponent/src/Attribute/ExposeInTemplate.php new file mode 100644 index 00000000000..f2819816e04 --- /dev/null +++ b/src/TwigComponent/src/Attribute/ExposeInTemplate.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Attribute; + +/** + * Use to expose private/protected properties as variables directly + * in a component template (`someProp` vs `this.someProp`). These + * properties must be "accessible" (have a getter). + * + * @author Kevin Bond + * + * @experimental + */ +#[\Attribute(\Attribute::TARGET_PROPERTY)] +final class ExposeInTemplate +{ + /** + * @param string|null $name The variable name to expose. Leave as null + * to default to property name. + * @param string|null $getter The getter method to use. Leave as null + * to default to PropertyAccessor logic. + */ + public function __construct(public ?string $name = null, public ?string $getter = null) + { + } +} diff --git a/src/TwigComponent/src/ComponentRenderer.php b/src/TwigComponent/src/ComponentRenderer.php index d2c14107652..a6a6ac58567 100644 --- a/src/TwigComponent/src/ComponentRenderer.php +++ b/src/TwigComponent/src/ComponentRenderer.php @@ -11,7 +11,9 @@ namespace Symfony\UX\TwigComponent; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; use Symfony\UX\TwigComponent\EventListener\PreRenderEvent; use Twig\Environment; use Twig\Extension\EscaperExtension; @@ -28,7 +30,8 @@ final class ComponentRenderer public function __construct( private Environment $twig, private EventDispatcherInterface $dispatcher, - private ComponentFactory $factory + private ComponentFactory $factory, + private PropertyAccessorInterface $propertyAccessor ) { } @@ -40,10 +43,42 @@ public function render(MountedComponent $mounted): string $this->safeClassesRegistered = true; } - $event = new PreRenderEvent($mounted, $this->factory->metadataFor($mounted->getName())); + $component = $mounted->getComponent(); + $variables = array_merge( + // add the component as "this" + ['this' => $component], + + // add attributes + ['attributes' => $mounted->getAttributes()], + + // expose all public properties + get_object_vars($component), + + // expose non-public properties marked with ExposeInTemplate attribute + iterator_to_array($this->exposedVariables($component)), + ); + $event = new PreRenderEvent($mounted, $this->factory->metadataFor($mounted->getName()), $variables); $this->dispatcher->dispatch($event); return $this->twig->render($event->getTemplate(), $event->getVariables()); } + + private function exposedVariables(object $component): \Iterator + { + $class = new \ReflectionClass($component); + + foreach ($class->getProperties() as $property) { + if (!$attribute = $property->getAttributes(ExposeInTemplate::class)[0] ?? null) { + continue; + } + + $attribute = $attribute->newInstance(); + + /** @var ExposeInTemplate $attribute */ + $value = $attribute->getter ? $component->{rtrim($attribute->getter, '()')}() : $this->propertyAccessor->getValue($component, $property->name); + + yield $attribute->name ?? $property->name => $value; + } + } } diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php index 017c00513a1..3c5892a4d5f 100644 --- a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php +++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php @@ -57,6 +57,7 @@ class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in % new Reference('twig'), new Reference('event_dispatcher'), new Reference('ux.twig_component.component_factory'), + new Reference('property_accessor'), ]) ; diff --git a/src/TwigComponent/src/EventListener/PreRenderEvent.php b/src/TwigComponent/src/EventListener/PreRenderEvent.php index b058feb5c92..29ec1499636 100644 --- a/src/TwigComponent/src/EventListener/PreRenderEvent.php +++ b/src/TwigComponent/src/EventListener/PreRenderEvent.php @@ -23,15 +23,16 @@ final class PreRenderEvent extends Event { private string $template; - private array $variables; /** * @internal */ - public function __construct(private MountedComponent $mounted, private ComponentMetadata $metadata) - { + public function __construct( + private MountedComponent $mounted, + private ComponentMetadata $metadata, + private array $variables + ) { $this->template = $this->metadata->getTemplate(); - $this->variables = $this->mounted->getVariables(); } /** diff --git a/src/TwigComponent/src/MountedComponent.php b/src/TwigComponent/src/MountedComponent.php index efae9e6e278..88acb34a3f6 100644 --- a/src/TwigComponent/src/MountedComponent.php +++ b/src/TwigComponent/src/MountedComponent.php @@ -41,12 +41,4 @@ public function getAttributes(): ComponentAttributes { return $this->attributes; } - - public function getVariables(): array - { - return array_merge( - ['this' => $this->component, 'attributes' => $this->attributes], - get_object_vars($this->component) - ); - } } diff --git a/src/TwigComponent/src/Resources/doc/index.rst b/src/TwigComponent/src/Resources/doc/index.rst index 5a15732bc71..ff83f638ac6 100644 --- a/src/TwigComponent/src/Resources/doc/index.rst +++ b/src/TwigComponent/src/Resources/doc/index.rst @@ -219,6 +219,60 @@ If an option name matches an argument name in ``mount()``, the option is passed as that argument and the component system will *not* try to set it directly on a property. +ExposeInTemplate Attribute +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 2.1 + + The ``ExposeInTemplate`` attribute was added in TwigComponents 2.1. + +All public component properties are available directly in your component +template. You can use the ``ExposeInTemplate`` attribute to expose +private/protected properties directly in a component template (``someProp`` +vs ``this.someProp``). These properties must be *accessible* (have a getter). + + // ... + use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; + + #[AsTwigComponent('alert')] + class AlertComponent + { + #[ExposeInTemplate] + private string $message; // available as `{{ message }}` in the template + + #[ExposeInTemplate('alert_type')] + private string $type = 'success'; // available as `{{ alert_type }}` in the template + + #[ExposeInTemplate(name: 'ico', getter: 'fetchIcon')] + private string $icon = 'ico-warning'; // available as `{{ ico }}` in the template using `fetchIcon()` as the getter + + /** + * Required to access $this->message + */ + public function getMessage(): string + { + return $this->message; + } + + /** + * Required to access $this->type + */ + public function getType(): string + { + return $this->type; + } + + /** + * Required to access $this->icon + */ + public function fetchIcon(): string + { + return $this->icon; + } + + // ... + } + PreMount Hook ~~~~~~~~~~~~~ diff --git a/src/TwigComponent/tests/Fixtures/Component/WithExposedVariables.php b/src/TwigComponent/tests/Fixtures/Component/WithExposedVariables.php new file mode 100644 index 00000000000..aeb2572a3c3 --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/Component/WithExposedVariables.php @@ -0,0 +1,37 @@ + + */ +#[AsTwigComponent('with_exposed_variables')] +final class WithExposedVariables +{ + #[ExposeInTemplate] + private string $prop1 = 'prop1 value'; + + #[ExposeInTemplate('customProp2')] + private string $prop2 = 'prop2 value'; + + #[ExposeInTemplate('customProp3', getter: 'customGetter()')] + private string $prop3 = 'prop3 value'; + + public function getProp1(): string + { + return $this->prop1; + } + + public function getProp2(): string + { + return $this->prop2; + } + + public function customGetter(): string + { + return $this->prop3; + } +} diff --git a/src/TwigComponent/tests/Fixtures/Kernel.php b/src/TwigComponent/tests/Fixtures/Kernel.php index 812506b3583..50f145ff2e1 100644 --- a/src/TwigComponent/tests/Fixtures/Kernel.php +++ b/src/TwigComponent/tests/Fixtures/Kernel.php @@ -17,6 +17,7 @@ use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Kernel as BaseKernel; +use Symfony\UX\TwigComponent\Tests\Fixtures\Component\WithExposedVariables; use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentA; use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentB; use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentC; @@ -59,6 +60,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load 'key' => 'component_d', 'template' => 'components/custom2.html.twig', ]); + $c->register(WithExposedVariables::class)->setAutoconfigured(true)->setAutowired(true); if ('missing_key' === $this->environment) { $c->register('missing_key', ComponentB::class)->setAutowired(true)->addTag('twig.component'); diff --git a/src/TwigComponent/tests/Fixtures/templates/components/with_exposed_variables.html.twig b/src/TwigComponent/tests/Fixtures/templates/components/with_exposed_variables.html.twig new file mode 100644 index 00000000000..42d4f21e4a7 --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/templates/components/with_exposed_variables.html.twig @@ -0,0 +1,3 @@ +Prop1: {{ prop1 }} +Prop2: {{ customProp2 }} +Prop3: {{ customProp3 }} diff --git a/src/TwigComponent/tests/Fixtures/templates/exposed_variables.html.twig b/src/TwigComponent/tests/Fixtures/templates/exposed_variables.html.twig new file mode 100644 index 00000000000..3ba3bc77252 --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/templates/exposed_variables.html.twig @@ -0,0 +1 @@ +{{ component('with_exposed_variables') }} diff --git a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php index ca5497e53f7..a73b467d840 100644 --- a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php +++ b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php @@ -64,4 +64,13 @@ public function testCanRenderComponentWithAttributes(): void $this->assertStringContainsString('Component Content (prop value 2)', $output); $this->assertStringContainsString('