diff --git a/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php b/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php index 186c7d7e3ea..02213565be9 100644 --- a/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php +++ b/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php @@ -9,8 +9,8 @@ use Symfony\Contracts\Service\ServiceSubscriberInterface; use Symfony\UX\LiveComponent\LiveComponentHydrator; use Symfony\UX\TwigComponent\ComponentAttributes; -use Symfony\UX\TwigComponent\ComponentMetadata; use Symfony\UX\TwigComponent\EventListener\PreRenderEvent; +use Symfony\UX\TwigComponent\MountedComponent; use Twig\Environment; /** @@ -29,7 +29,7 @@ public function onPreRender(PreRenderEvent $event): void return; } - $attributes = $this->getLiveAttributes($event->getComponent(), $event->getMetadata()); + $attributes = $this->getLiveAttributes($event->getMountedComponent()); $variables = $event->getVariables(); if (isset($variables['attributes']) && $variables['attributes'] instanceof ComponentAttributes) { @@ -57,12 +57,11 @@ public static function getSubscribedServices(): array ]; } - private function getLiveAttributes(object $component, ComponentMetadata $metadata): ComponentAttributes + private function getLiveAttributes(MountedComponent $mounted): ComponentAttributes { - $url = $this->container->get(UrlGeneratorInterface::class) - ->generate('live_component', ['component' => $metadata->getName()]) - ; - $data = $this->container->get(LiveComponentHydrator::class)->dehydrate($component); + $name = $mounted->getName(); + $url = $this->container->get(UrlGeneratorInterface::class)->generate('live_component', ['component' => $name]); + $data = $this->container->get(LiveComponentHydrator::class)->dehydrate($mounted); $twig = $this->container->get(Environment::class); $attributes = [ @@ -73,7 +72,7 @@ private function getLiveAttributes(object $component, ComponentMetadata $metadat if ($this->container->has(CsrfTokenManagerInterface::class)) { $attributes['data-live-csrf-value'] = $this->container->get(CsrfTokenManagerInterface::class) - ->getToken($metadata->getName())->getValue() + ->getToken($name)->getValue() ; } diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 7b92dd26245..a89a7fcef69 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -33,6 +33,7 @@ use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentMetadata; use Symfony\UX\TwigComponent\ComponentRenderer; +use Symfony\UX\TwigComponent\MountedComponent; /** * @author Kevin Bond @@ -73,6 +74,8 @@ public function onKernelRequest(RequestEvent $event): void $action = $request->get('action', 'get'); $componentName = (string) $request->get('component'); + $request->attributes->set('_component_name', $componentName); + try { /** @var ComponentMetadata $metadata */ $metadata = $this->container->get(ComponentFactory::class)->metadataFor($componentName); @@ -84,8 +87,6 @@ public function onKernelRequest(RequestEvent $event): void throw new NotFoundHttpException(sprintf('"%s" (%s) is not a Live Component.', $metadata->getClass(), $componentName)); } - $request->attributes->set('_component_metadata', $metadata); - if ('get' === $action) { $defaultAction = trim($metadata->get('default_action', '__invoke'), '()'); @@ -144,9 +145,13 @@ public function onKernelController(ControllerEvent $event): void throw new NotFoundHttpException(sprintf('The action "%s" either doesn\'t exist or is not allowed in "%s". Make sure it exist and has the LiveAction attribute above it.', $action, \get_class($component))); } - $this->container->get(LiveComponentHydrator::class)->hydrate($component, $data); + $mounted = $this->container->get(LiveComponentHydrator::class)->hydrate( + $component, + $data, + $request->attributes->get('_component_name') + ); - $request->attributes->set('_component', $component); + $request->attributes->set('_mounted_component', $mounted); if (!\is_string($queryString = $request->query->get('args'))) { return; @@ -170,7 +175,7 @@ public function onKernelView(ViewEvent $event): void return; } - $response = $this->createResponse($request->attributes->get('_component'), $request); + $response = $this->createResponse($request->attributes->get('_mounted_component'), $request); $event->setResponse($response); } @@ -187,14 +192,14 @@ public function onKernelException(ExceptionEvent $event): void return; } - $component = $request->attributes->get('_component'); + $mounted = $request->attributes->get('_mounted_component'); // in case the exception was too early somehow - if (!$component) { + if (!$mounted) { return; } - $response = $this->createResponse($component, $request); + $response = $this->createResponse($mounted, $request); $event->setResponse($response); } @@ -232,15 +237,16 @@ public static function getSubscribedEvents(): array ]; } - private function createResponse(object $component, Request $request): Response + private function createResponse(MountedComponent $mounted, Request $request): Response { + $component = $mounted->getComponent(); + foreach (AsLiveComponent::beforeReRenderMethods($component) as $method) { $component->{$method->name}(); } $html = $this->container->get(ComponentRenderer::class)->render( - $component, - $request->attributes->get('_component_metadata') + $mounted, ); return new Response($html); diff --git a/src/LiveComponent/src/LiveComponentHydrator.php b/src/LiveComponent/src/LiveComponentHydrator.php index e79ced7a9f0..5d7e76207d0 100644 --- a/src/LiveComponent/src/LiveComponentHydrator.php +++ b/src/LiveComponent/src/LiveComponentHydrator.php @@ -17,6 +17,8 @@ use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LivePropContext; use Symfony\UX\LiveComponent\Exception\UnsupportedHydrationException; +use Symfony\UX\TwigComponent\ComponentAttributes; +use Symfony\UX\TwigComponent\MountedComponent; /** * @author Kevin Bond @@ -29,6 +31,7 @@ final class LiveComponentHydrator { private const CHECKSUM_KEY = '_checksum'; private const EXPOSED_PROP_KEY = '_id'; + private const ATTRIBUTES_KEY = '_attributes'; /** @var PropertyHydratorInterface[] */ private iterable $propertyHydrators; @@ -45,8 +48,10 @@ public function __construct(iterable $propertyHydrators, PropertyAccessorInterfa $this->secret = $secret; } - public function dehydrate(object $component): array + public function dehydrate(MountedComponent $mounted): array { + $component = $mounted->getComponent(); + foreach (AsLiveComponent::preDehydrateMethods($component) as $method) { $component->{$method->name}(); } @@ -100,15 +105,24 @@ public function dehydrate(object $component): array } } + if ($attributes = $mounted->getAttributes()->all()) { + $data[self::ATTRIBUTES_KEY] = $attributes; + $readonlyProperties[] = self::ATTRIBUTES_KEY; + } + $data[self::CHECKSUM_KEY] = $this->computeChecksum($data, $readonlyProperties); return $data; } - public function hydrate(object $component, array $data): void + public function hydrate(object $component, array $data, string $componentName): MountedComponent { $readonlyProperties = []; + if (isset($data[self::ATTRIBUTES_KEY])) { + $readonlyProperties[] = self::ATTRIBUTES_KEY; + } + /** @var LivePropContext[] $propertyContexts */ $propertyContexts = iterator_to_array(AsLiveComponent::liveProps($component)); @@ -129,7 +143,9 @@ public function hydrate(object $component, array $data): void $this->verifyChecksum($data, $readonlyProperties); - unset($data[self::CHECKSUM_KEY]); + $attributes = new ComponentAttributes($data[self::ATTRIBUTES_KEY] ?? []); + + unset($data[self::CHECKSUM_KEY], $data[self::ATTRIBUTES_KEY]); foreach ($propertyContexts as $context) { $property = $context->reflectionProperty(); @@ -187,6 +203,8 @@ public function hydrate(object $component, array $data): void foreach (AsLiveComponent::postHydrateMethods($component) as $method) { $component->{$method->name}(); } + + return new MountedComponent($componentName, $component, $attributes); } private function computeChecksum(array $data, array $readonlyProperties): string diff --git a/src/LiveComponent/src/Resources/doc/index.rst b/src/LiveComponent/src/Resources/doc/index.rst index 76a5ea3ce9b..6b161ead31a 100644 --- a/src/LiveComponent/src/Resources/doc/index.rst +++ b/src/LiveComponent/src/Resources/doc/index.rst @@ -249,30 +249,15 @@ Component Attributes .. versionadded:: 2.1 - The ``HasAttributes`` trait was added in TwigComponents 2.1. + Component attributes were added in TwigComponents 2.1. `Component attributes`_ allows you to render your components with extra props that are are converted to html attributes and made available in your component's template as an ``attributes`` variable. When used on -live components, these props are persisted between renders. You can enable -this feature by having your live component use the ``HasAttributesTrait``: +live components, these props are persisted between renders. -.. code-block:: diff - - // ... - use Symfony\UX\LiveComponent\Attribute\LiveProp; - + use Symfony\UX\TwigComponent\HasAttributesTrait; - - #[AsLiveComponent('random_number')] - class RandomNumberComponent - { - + use HasAttributesTrait; - - #[LiveProp] - public int $min = 0; - -Now, when rendering your component, you can pass html attributes -as props and these will be added to ``attributes``: +When rendering your component, you can pass html attributes as props and +these will be added to ``attributes``: .. code-block:: twig diff --git a/src/LiveComponent/src/Twig/LiveComponentRuntime.php b/src/LiveComponent/src/Twig/LiveComponentRuntime.php index 69f0d77a0f0..d843da6c2a5 100644 --- a/src/LiveComponent/src/Twig/LiveComponentRuntime.php +++ b/src/LiveComponent/src/Twig/LiveComponentRuntime.php @@ -31,8 +31,8 @@ public function __construct( public function getComponentUrl(string $name, array $props = []): string { - $component = $this->factory->create($name, $props); - $params = ['component' => $name] + $this->hydrator->dehydrate($component); + $mounted = $this->factory->create($name, $props); + $params = ['component' => $name] + $this->hydrator->dehydrate($mounted); return $this->urlGenerator->generate('live_component', $params); } diff --git a/src/LiveComponent/tests/Fixtures/Component/ComponentWithAttributes.php b/src/LiveComponent/tests/Fixtures/Component/ComponentWithAttributes.php index 16a718be4eb..535b785e6ab 100644 --- a/src/LiveComponent/tests/Fixtures/Component/ComponentWithAttributes.php +++ b/src/LiveComponent/tests/Fixtures/Component/ComponentWithAttributes.php @@ -4,7 +4,6 @@ use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\DefaultActionTrait; -use Symfony\UX\TwigComponent\HasAttributesTrait; /** * @author Kevin Bond @@ -13,5 +12,4 @@ final class ComponentWithAttributes { use DefaultActionTrait; - use HasAttributesTrait; } diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php index 710789acb26..6225d823c20 100644 --- a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php @@ -14,9 +14,6 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\UX\LiveComponent\LiveComponentHydrator; -use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component1; -use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component2; -use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component6; use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1; use Symfony\UX\TwigComponent\ComponentFactory; use Zenstruck\Browser\Response\HtmlResponse; @@ -42,7 +39,6 @@ public function testCanRenderComponentAsHtml(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); - /** @var Component1 $component */ $component = $factory->create('component1', [ 'prop1' => $entity = create(Entity1::class)->object(), 'prop2' => $date = new \DateTime('2021-03-05 9:23'), @@ -72,10 +68,7 @@ public function testCanExecuteComponentAction(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); - /** @var Component2 $component */ - $component = $factory->create('component2'); - - $dehydrated = $hydrator->dehydrate($component); + $dehydrated = $hydrator->dehydrate($factory->create('component2')); $token = null; $this->browser() @@ -160,10 +153,7 @@ public function testBeforeReRenderHookOnlyExecutedDuringAjax(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); - /** @var Component2 $component */ - $component = $factory->create('component2'); - - $dehydrated = $hydrator->dehydrate($component); + $dehydrated = $hydrator->dehydrate($factory->create('component2')); $this->browser() ->visit('/render-template/template1') @@ -183,10 +173,7 @@ public function testCanRedirectFromComponentAction(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); - /** @var Component2 $component */ - $component = $factory->create('component2'); - - $dehydrated = $hydrator->dehydrate($component); + $dehydrated = $hydrator->dehydrate($factory->create('component2')); $token = null; $this->browser() @@ -226,10 +213,7 @@ public function testInjectsLiveArgs(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); - /** @var Component6 $component */ - $component = $factory->create('component6'); - - $dehydrated = $hydrator->dehydrate($component); + $dehydrated = $hydrator->dehydrate($factory->create('component6')); $token = null; $argsQueryParams = http_build_query(['args' => http_build_query(['arg1' => 'hello', 'arg2' => 666, 'custom' => '33.3'])]); diff --git a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php index 949352393e4..7be4c894edf 100644 --- a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php +++ b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php @@ -123,15 +123,18 @@ public function testFormRemembersValidationFromInitialForm(): void $form = $formFactory->create(BlogPostFormType::class); $form->submit(['title' => '', 'content' => '']); - /** @var FormWithCollectionTypeComponent $component */ - $component = $factory->create('form_with_collection_type', [ + + $mounted = $factory->create('form_with_collection_type', [ 'form' => $form->createView(), ]); + /** @var FormWithCollectionTypeComponent $component */ + $component = $mounted->getComponent(); + // component should recognize that it is already submitted $this->assertTrue($component->isValidated); - $dehydrated = $hydrator->dehydrate($component); + $dehydrated = $hydrator->dehydrate($mounted); $dehydrated['blog_post_form']['content'] = 'changed description'; $dehydrated['validatedFields'][] = 'blog_post_form.content'; diff --git a/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php b/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php index 0ae52de620b..daae65fe3c4 100644 --- a/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php +++ b/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php @@ -17,9 +17,10 @@ use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component1; use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component2; use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component3; -use Symfony\UX\LiveComponent\Tests\Fixtures\Component\ComponentWithAttributes; use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1; +use Symfony\UX\TwigComponent\ComponentAttributes; use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\MountedComponent; use function Zenstruck\Foundry\create; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; @@ -40,20 +41,22 @@ public function testCanDehydrateAndHydrateLiveComponent(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); - /** @var Component1 $component */ - $component = $factory->create('component1', [ + $mounted = $factory->create('component1', [ 'prop1' => $prop1 = create(Entity1::class)->object(), 'prop2' => $prop2 = new \DateTime('2021-03-05 9:23'), 'prop3' => $prop3 = 'value3', 'prop4' => $prop4 = 'value4', ]); + /** @var Component1 $component */ + $component = $mounted->getComponent(); + $this->assertSame($prop1, $component->prop1); $this->assertSame($prop2, $component->prop2); $this->assertSame($prop3, $component->prop3); $this->assertSame($prop4, $component->prop4); - $dehydrated = $hydrator->dehydrate($component); + $dehydrated = $hydrator->dehydrate($mounted); $this->assertSame($prop1->id, $dehydrated['prop1']); $this->assertSame($prop2->format('c'), $dehydrated['prop2']); @@ -63,7 +66,7 @@ public function testCanDehydrateAndHydrateLiveComponent(): void $component = $factory->get('component1'); - $hydrator->hydrate($component, $dehydrated); + $hydrator->hydrate($component, $dehydrated, $mounted->getName()); $this->assertSame($prop1->id, $component->prop1->id); $this->assertSame($prop2->format('c'), $component->prop2->format('c')); @@ -79,19 +82,18 @@ public function testCanModifyWritableProps(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); - /** @var Component1 $component */ - $component = $factory->create('component1', [ + $mounted = $factory->create('component1', [ 'prop1' => create(Entity1::class)->object(), 'prop2' => new \DateTime('2021-03-05 9:23'), 'prop3' => 'value3', ]); - $dehydrated = $hydrator->dehydrate($component); + $dehydrated = $hydrator->dehydrate($mounted); $dehydrated['prop3'] = 'new value'; $component = $factory->get('component1'); - $hydrator->hydrate($component, $dehydrated); + $hydrator->hydrate($component, $dehydrated, $mounted->getName()); $this->assertSame('new value', $component->prop3); } @@ -104,20 +106,19 @@ public function testCannotModifyReadonlyProps(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); - /** @var Component1 $component */ - $component = $factory->create('component1', [ + $mounted = $factory->create('component1', [ 'prop1' => create(Entity1::class)->object(), 'prop2' => new \DateTime('2021-03-05 9:23'), 'prop3' => 'value3', ]); - $dehydrated = $hydrator->dehydrate($component); + $dehydrated = $hydrator->dehydrate($mounted); $dehydrated['prop2'] = (new \DateTime())->format('c'); $component = $factory->get('component1'); $this->expectException(\RuntimeException::class); - $hydrator->hydrate($component, $dehydrated); + $hydrator->hydrate($component, $dehydrated, $mounted->getName()); } public function testHydrationFailsIfChecksumMissing(): void @@ -129,7 +130,7 @@ public function testHydrationFailsIfChecksumMissing(): void $factory = self::getContainer()->get('ux.twig_component.component_factory'); $this->expectException(\RuntimeException::class); - $hydrator->hydrate($factory->get('component1'), []); + $hydrator->hydrate($factory->get('component1'), [], 'component1'); } public function testHydrationFailsOnChecksumMismatch(): void @@ -141,7 +142,7 @@ public function testHydrationFailsOnChecksumMismatch(): void $factory = self::getContainer()->get('ux.twig_component.component_factory'); $this->expectException(\RuntimeException::class); - $hydrator->hydrate($factory->get('component1'), ['_checksum' => 'invalid']); + $hydrator->hydrate($factory->get('component1'), ['_checksum' => 'invalid'], 'component1'); } public function testPreDehydrateAndPostHydrateHooksCalled(): void @@ -152,13 +153,15 @@ public function testPreDehydrateAndPostHydrateHooksCalled(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); + $mounted = $factory->create('component2'); + /** @var Component2 $component */ - $component = $factory->create('component2'); + $component = $mounted->getComponent(); $this->assertFalse($component->preDehydrateCalled); $this->assertFalse($component->postHydrateCalled); - $data = $hydrator->dehydrate($component); + $data = $hydrator->dehydrate($mounted); $this->assertTrue($component->preDehydrateCalled); $this->assertFalse($component->postHydrateCalled); @@ -169,7 +172,7 @@ public function testPreDehydrateAndPostHydrateHooksCalled(): void $this->assertFalse($component->preDehydrateCalled); $this->assertFalse($component->postHydrateCalled); - $hydrator->hydrate($component, $data); + $hydrator->hydrate($component, $data, $mounted->getName()); $this->assertFalse($component->preDehydrateCalled); $this->assertTrue($component->postHydrateCalled); @@ -185,15 +188,17 @@ public function testDeletingEntityBetweenDehydrationAndHydrationSetsItToNull(): $entity = create(Entity1::class); - /** @var Component1 $component */ - $component = $factory->create('component1', [ + $mounted = $factory->create('component1', [ 'prop1' => $entity->object(), 'prop2' => new \DateTime('2021-03-05 9:23'), ]); + /** @var Component1 $component */ + $component = $mounted->getComponent(); + $this->assertSame($entity->id, $component->prop1->id); - $data = $hydrator->dehydrate($component); + $data = $hydrator->dehydrate($mounted); $this->assertSame($entity->id, $data['prop1']); @@ -202,11 +207,11 @@ public function testDeletingEntityBetweenDehydrationAndHydrationSetsItToNull(): /** @var Component1 $component */ $component = $factory->get('component1'); - $hydrator->hydrate($component, $data); + $mounted = $hydrator->hydrate($component, $data, $mounted->getName()); $this->assertNull($component->prop1); - $data = $hydrator->dehydrate($component); + $data = $hydrator->dehydrate($mounted); $this->assertNull($data['prop1']); } @@ -219,10 +224,12 @@ public function testCorrectlyUsesCustomFrontendNameInDehydrateAndHydrate(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); + $mounted = $factory->create('component3', ['prop1' => 'value1', 'prop2' => 'value2']); + /** @var Component3 $component */ - $component = $factory->create('component3', ['prop1' => 'value1', 'prop2' => 'value2']); + $component = $mounted->getComponent(); - $dehydrated = $hydrator->dehydrate($component); + $dehydrated = $hydrator->dehydrate($mounted); $this->assertArrayNotHasKey('prop1', $dehydrated); $this->assertArrayNotHasKey('prop2', $dehydrated); @@ -234,7 +241,7 @@ public function testCorrectlyUsesCustomFrontendNameInDehydrateAndHydrate(): void /** @var Component3 $component */ $component = $factory->get('component3'); - $hydrator->hydrate($component, $dehydrated); + $hydrator->hydrate($component, $dehydrated, $mounted->getName()); $this->assertSame('value1', $component->prop1); $this->assertSame('value2', $component->prop2); @@ -253,14 +260,14 @@ public function testCanDehydrateAndHydrateArrays(): void $instance = clone $component; $instance->prop = ['some', 'array']; - $dehydrated = $hydrator->dehydrate($instance); + $dehydrated = $hydrator->dehydrate(new MountedComponent('my_component', $instance, new ComponentAttributes([]))); $this->assertArrayHasKey('prop', $dehydrated); $this->assertSame($instance->prop, $dehydrated['prop']); $this->assertFalse(isset($component->prop)); - $hydrator->hydrate($component, $dehydrated); + $hydrator->hydrate($component, $dehydrated, 'my_component'); $this->assertSame($instance->prop, $component->prop); } @@ -273,19 +280,18 @@ public function testCanDehydrateAndHydrateComponentsWithAttributes(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); - /** @var ComponentWithAttributes $component */ - $component = $factory->create('with_attributes', $attributes = ['class' => 'foo', 'value' => null]); + $mounted = $factory->create('with_attributes', $attributes = ['class' => 'foo', 'value' => null]); - $this->assertSame($attributes, $component->attributes->all()); + $this->assertSame($attributes, $mounted->getAttributes()->all()); - $dehydrated = $hydrator->dehydrate($component); + $dehydrated = $hydrator->dehydrate($mounted); - $this->assertArrayHasKey('attributes', $dehydrated); - $this->assertSame($attributes, $dehydrated['attributes']); + $this->assertArrayHasKey('_attributes', $dehydrated); + $this->assertSame($attributes, $dehydrated['_attributes']); - $hydrator->hydrate($component = $factory->get('with_attributes'), $dehydrated); + $mounted = $hydrator->hydrate($factory->get('with_attributes'), $dehydrated, $mounted->getName()); - $this->assertSame($attributes, $component->attributes->all()); + $this->assertSame($attributes, $mounted->getAttributes()->all()); } public function testCanDehydrateAndHydrateComponentsWithEmptyAttributes(): void @@ -296,20 +302,16 @@ public function testCanDehydrateAndHydrateComponentsWithEmptyAttributes(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); - /** @var ComponentWithAttributes $component */ - $component = $factory->create('with_attributes'); - - $this->assertSame([], $component->attributes->all()); + $mounted = $factory->create('with_attributes'); - $dehydrated = $hydrator->dehydrate($component); + $this->assertSame([], $mounted->getAttributes()->all()); - $this->assertArrayHasKey('attributes', $dehydrated); - $this->assertSame([], $dehydrated['attributes']); + $dehydrated = $hydrator->dehydrate($mounted); - $component = $factory->get('with_attributes'); + $this->assertArrayNotHasKey('_attributes', $dehydrated); - $hydrator->hydrate($component, $dehydrated); + $mounted = $hydrator->hydrate($factory->get('with_attributes'), $dehydrated, $mounted->getName()); - $this->assertSame([], $component->attributes->all()); + $this->assertSame([], $mounted->getAttributes()->all()); } } diff --git a/src/TwigComponent/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md index 5803384d707..2d4d83316a1 100644 --- a/src/TwigComponent/CHANGELOG.md +++ b/src/TwigComponent/CHANGELOG.md @@ -8,8 +8,8 @@ - Add `PostMount` hook component hook to intercept extra props. -- Add `HasAttributesTrait` for components which makes `attributes` variable available - in component templates. +- Add attributes system that takes extra props passed to `component()` and converts them + into a `ComponentAttributes` object available in your template as `attributes`. - Add `PreRenderEvent` to intercept/manipulate twig template/variables before rendering. diff --git a/src/TwigComponent/src/ComponentFactory.php b/src/TwigComponent/src/ComponentFactory.php index 4a434adcec8..479be229649 100644 --- a/src/TwigComponent/src/ComponentFactory.php +++ b/src/TwigComponent/src/ComponentFactory.php @@ -47,7 +47,7 @@ public function metadataFor(string $name): ComponentMetadata /** * Creates the component and "mounts" it with the passed data. */ - public function create(string $name, array $data = []): object + public function create(string $name, array $data = []): MountedComponent { $component = $this->getComponent($name); $data = $this->preMount($component, $data); @@ -65,11 +65,18 @@ public function create(string $name, array $data = []): object $data = $this->postMount($component, $data); - foreach ($data as $property => $value) { - throw new \LogicException(sprintf('Unable to write "%s" to component "%s". Make sure this is a writable property or create a mount() with a $%s argument.', $property, \get_class($component), $property)); + // create attributes from "attributes" key if exists + $attributes = $data['attributes'] ?? []; + unset($data['attributes']); + + // ensure remaining data is scalar + foreach ($data as $key => $value) { + if (!is_scalar($value) && null !== $value) { + throw new \LogicException(sprintf('Unable to use "%s" (%s) as an attribute. Attributes must be scalar or null. If you meant to mount this value on "%s", make sure "$%1$s" is a writable property or create a mount() method with a "$%1$s" argument.', $key, get_debug_type($value), $component::class)); + } } - return $component; + return new MountedComponent($name, $component, new ComponentAttributes(array_merge($attributes, $data))); } /** diff --git a/src/TwigComponent/src/ComponentRenderer.php b/src/TwigComponent/src/ComponentRenderer.php index 07987ada0de..d2c14107652 100644 --- a/src/TwigComponent/src/ComponentRenderer.php +++ b/src/TwigComponent/src/ComponentRenderer.php @@ -25,11 +25,14 @@ final class ComponentRenderer { private bool $safeClassesRegistered = false; - public function __construct(private Environment $twig, private EventDispatcherInterface $dispatcher) - { + public function __construct( + private Environment $twig, + private EventDispatcherInterface $dispatcher, + private ComponentFactory $factory + ) { } - public function render(object $component, ComponentMetadata $metadata): string + public function render(MountedComponent $mounted): string { if (!$this->safeClassesRegistered) { $this->twig->getExtension(EscaperExtension::class)->addSafeClass(ComponentAttributes::class, ['html']); @@ -37,11 +40,7 @@ public function render(object $component, ComponentMetadata $metadata): string $this->safeClassesRegistered = true; } - $event = new PreRenderEvent( - $component, - $metadata, - array_merge(['this' => $component], get_object_vars($component)) - ); + $event = new PreRenderEvent($mounted, $this->factory->metadataFor($mounted->getName())); $this->dispatcher->dispatch($event); diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php index 0d652a955e2..017c00513a1 100644 --- a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php +++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php @@ -56,6 +56,7 @@ class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in % ->setArguments([ new Reference('twig'), new Reference('event_dispatcher'), + new Reference('ux.twig_component.component_factory'), ]) ; diff --git a/src/TwigComponent/src/EventListener/PreRenderEvent.php b/src/TwigComponent/src/EventListener/PreRenderEvent.php index 7a6350c0b09..b058feb5c92 100644 --- a/src/TwigComponent/src/EventListener/PreRenderEvent.php +++ b/src/TwigComponent/src/EventListener/PreRenderEvent.php @@ -13,6 +13,7 @@ use Symfony\Contracts\EventDispatcher\Event; use Symfony\UX\TwigComponent\ComponentMetadata; +use Symfony\UX\TwigComponent\MountedComponent; /** * @author Kevin Bond @@ -22,16 +23,15 @@ final class PreRenderEvent extends Event { private string $template; + private array $variables; /** * @internal */ - public function __construct( - private object $component, - private ComponentMetadata $metadata, - private array $variables - ) { + public function __construct(private MountedComponent $mounted, private ComponentMetadata $metadata) + { $this->template = $this->metadata->getTemplate(); + $this->variables = $this->mounted->getVariables(); } /** @@ -54,7 +54,7 @@ public function setTemplate(string $template): self public function getComponent(): object { - return $this->component; + return $this->mounted->getComponent(); } /** @@ -79,4 +79,12 @@ public function getMetadata(): ComponentMetadata { return $this->metadata; } + + /** + * @internal + */ + public function getMountedComponent(): MountedComponent + { + return $this->mounted; + } } diff --git a/src/TwigComponent/src/HasAttributesTrait.php b/src/TwigComponent/src/HasAttributesTrait.php deleted file mode 100644 index ea7877c7688..00000000000 --- a/src/TwigComponent/src/HasAttributesTrait.php +++ /dev/null @@ -1,80 +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; - -use Symfony\UX\LiveComponent\Attribute\LiveProp; -use Symfony\UX\TwigComponent\Attribute\PostMount; - -/** - * @author Kevin Bond - * - * @experimental - */ -trait HasAttributesTrait -{ - #[LiveProp(hydrateWith: 'hydrateAttributes', dehydrateWith: 'dehydrateAttributes')] - public ComponentAttributes $attributes; - - public function setAttributes(array $attributes): void - { - $this->attributes = new ComponentAttributes($attributes); - } - - /** - * This "catches" any extra props sent to `component()` and - * makes them available as "attributes". - * - * @internal - */ - #[PostMount(priority: -1000)] - public function mountAttributes(array $data): array - { - if (isset($this->attributes)) { - // attributes might already be set if a user used an "attributes" prop - // when calling `component()` - $data = array_merge($this->attributes->all(), $data); - } - - foreach ($data as $key => $value) { - if (!is_scalar($value) && null !== $value) { - throw new \LogicException(sprintf('Unable to use "%s" (%s) as an attribute. Attributes must be scalar or null. If you meant to mount this value on your component, make sure this is a writable property.', $key, get_debug_type($value))); - } - } - - $this->attributes = new ComponentAttributes($data); - - return []; - } - - /** - * Required for dehydrating the attributes when used with live - * components. - * - * @internal - */ - public static function dehydrateAttributes(ComponentAttributes $attributes): array - { - return $attributes->all(); - } - - /** - * This stub is required to prevent Symfony's normalizer system from - * being used for $attributes when the live component is hydrated. - * - * @internal - */ - public static function hydrateAttributes(array $attributes): array - { - // todo, is there a more elegant solution? - return $attributes; - } -} diff --git a/src/TwigComponent/src/MountedComponent.php b/src/TwigComponent/src/MountedComponent.php new file mode 100644 index 00000000000..efae9e6e278 --- /dev/null +++ b/src/TwigComponent/src/MountedComponent.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent; + +/** + * @author Kevin Bond + * + * @experimental + * + * @internal + */ +final class MountedComponent +{ + public function __construct( + private string $name, + private object $component, + private ComponentAttributes $attributes + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function getComponent(): object + { + return $this->component; + } + + 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 7629815a567..5a15732bc71 100644 --- a/src/TwigComponent/src/Resources/doc/index.rst +++ b/src/TwigComponent/src/Resources/doc/index.rst @@ -413,27 +413,16 @@ Component Attributes .. versionadded:: 2.1 - The ``HasAttributes`` trait was added in TwigComponents 2.1. + Component attributes were added in TwigComponents 2.1. A common need for components is to configure/render attributes for the -root node. You can enable this feature by having your component use -the ``HasAttributesTrait``. Attributes are any data passed to ``component()`` -that cannot be mounted on the component itself. This extra data is added -to a ``ComponentAttributes`` object that lives as a public property on your -component (available as ``attributes`` in your component's template). +root node. Attributes are any data passed to ``component()`` that cannot be +mounted on the component itself. This extra data is added to a +``ComponentAttributes`` that is available as ``attributes`` in your +component's template. -To use, add the ``HasAttributesTrait`` to your component: - - use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; - use Symfony\UX\TwigComponent\HasAttributesTrait; - - #[AsTwigComponent('my_component')] - class MyComponent - { - use HasAttributesTrait; - } - -Then render the attributes on the root element: +To use, in your component's template, render the ``attributes`` variable in +the root element: .. code-block:: twig diff --git a/src/TwigComponent/src/Twig/ComponentRuntime.php b/src/TwigComponent/src/Twig/ComponentRuntime.php index fdbc1b69728..f12af5c9e53 100644 --- a/src/TwigComponent/src/Twig/ComponentRuntime.php +++ b/src/TwigComponent/src/Twig/ComponentRuntime.php @@ -32,9 +32,6 @@ public function __construct(ComponentFactory $componentFactory, ComponentRendere public function render(string $name, array $props = []): string { - return $this->componentRenderer->render( - $this->componentFactory->create($name, $props), - $this->componentFactory->metadataFor($name) - ); + return $this->componentRenderer->render($this->componentFactory->create($name, $props)); } } diff --git a/src/TwigComponent/tests/Fixtures/Component/WithAttributes.php b/src/TwigComponent/tests/Fixtures/Component/WithAttributes.php index ac1bbaadef4..464123a62f7 100644 --- a/src/TwigComponent/tests/Fixtures/Component/WithAttributes.php +++ b/src/TwigComponent/tests/Fixtures/Component/WithAttributes.php @@ -12,12 +12,9 @@ namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; -use Symfony\UX\TwigComponent\HasAttributesTrait; #[AsTwigComponent('with_attributes')] class WithAttributes { - use HasAttributesTrait; - public string $prop; } diff --git a/src/TwigComponent/tests/Integration/ComponentFactoryTest.php b/src/TwigComponent/tests/Integration/ComponentFactoryTest.php index af0015700b1..ef7ec979d26 100644 --- a/src/TwigComponent/tests/Integration/ComponentFactoryTest.php +++ b/src/TwigComponent/tests/Integration/ComponentFactoryTest.php @@ -29,10 +29,10 @@ public function testCreatedComponentsAreNotShared(): void $factory = self::getContainer()->get('ux.twig_component.component_factory'); /** @var ComponentA $componentA */ - $componentA = $factory->create('component_a', ['propA' => 'A', 'propB' => 'B']); + $componentA = $factory->create('component_a', ['propA' => 'A', 'propB' => 'B'])->getComponent(); /** @var ComponentA $componentB */ - $componentB = $factory->create('component_a', ['propA' => 'C', 'propB' => 'D']); + $componentB = $factory->create('component_a', ['propA' => 'C', 'propB' => 'D'])->getComponent(); $this->assertNotSame(spl_object_id($componentA), spl_object_id($componentB)); $this->assertSame(spl_object_id($componentA->getService()), spl_object_id($componentB->getService())); @@ -48,10 +48,10 @@ public function testNonAutoConfiguredCreatedComponentsAreNotShared(): void $factory = self::getContainer()->get('ux.twig_component.component_factory'); /** @var ComponentB $componentA */ - $componentA = $factory->create('component_b'); + $componentA = $factory->create('component_b')->getComponent(); /** @var ComponentB $componentB */ - $componentB = $factory->create('component_b'); + $componentB = $factory->create('component_b')->getComponent(); $this->assertNotSame(spl_object_id($componentA), spl_object_id($componentB)); } @@ -77,7 +77,7 @@ public function testMountCanHaveOptionalParameters(): void $component = $factory->create('component_c', [ 'propA' => 'valueA', 'propC' => 'valueC', - ]); + ])->getComponent(); $this->assertSame('valueA', $component->propA); $this->assertNull($component->propB); @@ -87,7 +87,7 @@ public function testMountCanHaveOptionalParameters(): void $component = $factory->create('component_c', [ 'propA' => 'valueA', 'propB' => 'valueB', - ]); + ])->getComponent(); $this->assertSame('valueA', $component->propA); $this->assertSame('valueB', $component->propB); @@ -105,15 +105,15 @@ public function testExceptionThrownIfRequiredMountParameterIsMissingFromPassedDa $factory->create('component_c'); } - public function testExceptionThrownIfUnableToWritePassedDataToProperty(): void + public function testExceptionThrownIfUnableToWritePassedDataToPropertyAndIsNotScalar(): void { /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Unable to write "service" to component "Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentA". Make sure this is a writable property or create a mount() with a $service argument.'); + $this->expectExceptionMessage('Unable to use "service" (stdClass) as an attribute. Attributes must be scalar or null.'); - $factory->create('component_a', ['propB' => 'B', 'service' => 'invalid']); + $factory->create('component_a', ['propB' => 'B', 'service' => new \stdClass()]); } public function testTwigComponentServiceTagMustHaveKey(): void