diff --git a/src/TwigComponent/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md index 91659369299..5813bd612e5 100644 --- a/src/TwigComponent/CHANGELOG.md +++ b/src/TwigComponent/CHANGELOG.md @@ -10,8 +10,9 @@ - Add new HTML syntax for rendering components: `` - `true` attribute values now render just the attribute name, `false` excludes it entirely. - +- Add helpers for testing components. - The first argument to `AsTwigComponent` is now optional and defaults to the class name. +- Allow passing a FQCN to `ComponentFactory` methods. ## 2.7.0 diff --git a/src/TwigComponent/doc/index.rst b/src/TwigComponent/doc/index.rst index 3448d603088..b5ef128c6d9 100644 --- a/src/TwigComponent/doc/index.rst +++ b/src/TwigComponent/doc/index.rst @@ -922,6 +922,61 @@ And in your component template you can access your embedded block {% block footer %}{% endblock %} +Test Helpers +------------ + +You can test how your component is mounted and rendered using the +``InteractsWithTwigComponents`` trait:: + + use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + use Symfony\UX\TwigComponent\Test\InteractsWithTwigComponents; + + class MyComponentTest extends KernelTestCase + { + use InteractsWithTwigComponents; + + public function testComponentMount(): void + { + $component = $this->mountTwigComponent( + name: 'MyComponent', // can also use FQCN (MyComponent::class) + data: ['foo' => 'bar'], + ); + + $this->assertInstanceOf(MyComponent::class, $component); + $this->assertSame('bar', $component->foo); + } + + public function testComponentRenders(): void + { + $rendered = $this->renderTwigComponent( + name: 'MyComponent', // can also use FQCN (MyComponent::class) + data: ['foo' => 'bar'], + ); + + $this->assertStringContainsString('bar', $rendered); + } + + public function testEmbeddedComponentRenders(): void + { + $rendered = $this->renderTwigComponent( + name: 'MyComponent', // can also use FQCN (MyComponent::class) + data: ['foo' => 'bar'], + content: '
My content
', // "content" (default) block + blocks: [ + 'header' => '
My header
', + 'menu' => $this->renderTwigComponent('Menu'), // can embed other components + ], + ); + + $this->assertStringContainsString('bar', $rendered); + } + } + +.. note:: + + The ``InteractsWithTwigComponents`` trait can only be used in tests that extend + ``Symfony\Bundle\FrameworkBundle\Test\KernelTestCase``. + Contributing ------------ diff --git a/src/TwigComponent/src/ComponentFactory.php b/src/TwigComponent/src/ComponentFactory.php index f45ebc3f5a7..8e0ccc3f860 100644 --- a/src/TwigComponent/src/ComponentFactory.php +++ b/src/TwigComponent/src/ComponentFactory.php @@ -26,20 +26,24 @@ final class ComponentFactory { /** - * @param array $config + * @param array $config + * @param array $classMap */ public function __construct( private ServiceLocator $components, private PropertyAccessorInterface $propertyAccessor, private EventDispatcherInterface $eventDispatcher, - private array $config + private array $config, + private array $classMap, ) { } public function metadataFor(string $name): ComponentMetadata { + $name = $this->classMap[$name] ?? $name; + if (!$config = $this->config[$name] ?? null) { - throw new \InvalidArgumentException(sprintf('Unknown component "%s". The registered components are: %s', $name, implode(', ', array_keys($this->config)))); + $this->throwUnknownComponentException($name); } return new ComponentMetadata($config); @@ -142,8 +146,10 @@ private function mount(object $component, array &$data): void private function getComponent(string $name): object { + $name = $this->classMap[$name] ?? $name; + if (!$this->components->has($name)) { - throw new \InvalidArgumentException(sprintf('Unknown component "%s". The registered components are: %s', $name, implode(', ', array_keys($this->components->getProvidedServices())))); + $this->throwUnknownComponentException($name); } return $this->components->get($name); @@ -182,4 +188,12 @@ private function postMount(object $component, array $data): array return $data; } + + /** + * @return never + */ + private function throwUnknownComponentException(string $name): void + { + throw new \InvalidArgumentException(sprintf('Unknown component "%s". The registered components are: %s', $name, implode(', ', array_keys($this->config)))); + } } diff --git a/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php b/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php index b2bb184f90f..8be8c553b6b 100644 --- a/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php +++ b/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php @@ -29,6 +29,7 @@ public function process(ContainerBuilder $container): void $componentConfig = []; $componentReferences = []; + $componentClassMap = []; $componentNames = []; foreach ($container->findTaggedServiceIds('twig.component') as $id => $tags) { $definition = $container->findDefinition($id); @@ -52,11 +53,13 @@ public function process(ContainerBuilder $container): void $componentConfig[$tag['key']] = $tag; $componentReferences[$tag['key']] = new Reference($id); $componentNames[] = $tag['key']; + $componentClassMap[$tag['class']] = $tag['key']; } } $factoryDefinition = $container->findDefinition('ux.twig_component.component_factory'); $factoryDefinition->setArgument(0, ServiceLocatorTagPass::register($container, $componentReferences)); $factoryDefinition->setArgument(3, $componentConfig); + $factoryDefinition->setArgument(4, $componentClassMap); } } diff --git a/src/TwigComponent/src/Test/InteractsWithTwigComponents.php b/src/TwigComponent/src/Test/InteractsWithTwigComponents.php new file mode 100644 index 00000000000..810e332c1fa --- /dev/null +++ b/src/TwigComponent/src/Test/InteractsWithTwigComponents.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Test; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +/** + * @author Kevin Bond + */ +trait InteractsWithTwigComponents +{ + protected function mountTwigComponent(string $name, array $data = []): object + { + if (!$this instanceof KernelTestCase) { + throw new \LogicException(sprintf('The "%s" trait can only be used on "%s" classes.', __TRAIT__, KernelTestCase::class)); + } + + return static::getContainer()->get('ux.twig_component.component_factory')->create($name, $data)->getComponent(); + } + + /** + * @param array $blocks + */ + protected function renderTwigComponent(string $name, array $data = [], ?string $content = null, array $blocks = []): RenderedComponent + { + if (!$this instanceof KernelTestCase) { + throw new \LogicException(sprintf('The "%s" trait can only be used on "%s" classes.', __TRAIT__, KernelTestCase::class)); + } + + $blocks = array_filter(array_merge($blocks, ['content' => $content])); + + if (!$blocks) { + return new RenderedComponent(self::getContainer()->get('twig') + ->createTemplate('{{ component(name, data) }}') + ->render([ + 'name' => $name, + 'data' => $data, + ]) + ); + } + + $template = sprintf('{%% component "%s" with data %%}', addslashes($name)); + + foreach (array_keys($blocks) as $blockName) { + $template .= sprintf('{%% block %1$s %%}{{ blocks.%1$s|raw }}{%% endblock %%}', $blockName); + } + + $template .= '{% endcomponent %}'; + + return new RenderedComponent(self::getContainer()->get('twig') + ->createTemplate($template) + ->render([ + 'data' => $data, + 'blocks' => $blocks, + ]) + ); + } +} diff --git a/src/TwigComponent/src/Test/RenderedComponent.php b/src/TwigComponent/src/Test/RenderedComponent.php new file mode 100644 index 00000000000..54cc27f79df --- /dev/null +++ b/src/TwigComponent/src/Test/RenderedComponent.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Test; + +/** + * @author Kevin Bond + */ +final class RenderedComponent implements \Stringable +{ + /** + * @internal + */ + public function __construct(private string $html) + { + } + + public function __toString(): string + { + return $this->html; + } +} diff --git a/src/TwigComponent/tests/Fixtures/Component/WithSlots.php b/src/TwigComponent/tests/Fixtures/Component/WithSlots.php new file mode 100644 index 00000000000..d026635ae8a --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/Component/WithSlots.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component; + +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; + +/** + * @author Kevin Bond + */ +#[AsTwigComponent] +final class WithSlots +{ +} diff --git a/src/TwigComponent/tests/Fixtures/templates/components/WithSlots.html.twig b/src/TwigComponent/tests/Fixtures/templates/components/WithSlots.html.twig new file mode 100644 index 00000000000..2470d632698 --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/templates/components/WithSlots.html.twig @@ -0,0 +1,5 @@ +
+ {% block content %}{% endblock %} + {% block slot1 %}{% endblock %} + {% block slot2 %}{% endblock %} +
diff --git a/src/TwigComponent/tests/Integration/ComponentFactoryTest.php b/src/TwigComponent/tests/Integration/ComponentFactoryTest.php index 6320bff4cf8..edbab16788a 100644 --- a/src/TwigComponent/tests/Integration/ComponentFactoryTest.php +++ b/src/TwigComponent/tests/Integration/ComponentFactoryTest.php @@ -17,6 +17,7 @@ use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentA; use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentB; use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentC; +use Symfony\UX\TwigComponent\Tests\Fixtures\Component\WithSlots; /** * @author Kevin Bond @@ -170,6 +171,18 @@ public function testInputPropsStoredOnMountedComponent(): void $this->assertSame(['propA' => 'A', 'propB' => 'B'], $mountedComponent->getInputProps()); } + /** + * @doesNotPerformAssertions + */ + public function testGetComponentWithClassName(): void + { + $factory = $this->factory(); + + $factory->create(WithSlots::class); + $factory->get(WithSlots::class); + $factory->metadataFor(WithSlots::class); + } + private function factory(): ComponentFactory { return self::getContainer()->get('ux.twig_component.component_factory'); diff --git a/src/TwigComponent/tests/Integration/Test/InteractsWithTwigComponentsTest.php b/src/TwigComponent/tests/Integration/Test/InteractsWithTwigComponentsTest.php new file mode 100644 index 00000000000..6ca156e0b15 --- /dev/null +++ b/src/TwigComponent/tests/Integration/Test/InteractsWithTwigComponentsTest.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Integration\Test; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\TwigComponent\Test\InteractsWithTwigComponents; +use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentA; +use Symfony\UX\TwigComponent\Tests\Fixtures\Component\WithSlots; +use Symfony\UX\TwigComponent\Tests\Fixtures\Service\ServiceA; + +final class InteractsWithTwigComponentsTest extends KernelTestCase +{ + use InteractsWithTwigComponents; + + /** + * @dataProvider componentANameProvider + */ + public function testCanMountComponent(string $name): void + { + $component = $this->mountTwigComponent($name, [ + 'propA' => 'prop a value', + 'propB' => 'prop b value', + ]); + + $this->assertInstanceof(ComponentA::class, $component); + $this->assertInstanceOf(ServiceA::class, $component->getService()); + $this->assertSame('prop a value', $component->propA); + $this->assertSame('prop b value', $component->getPropB()); + } + + /** + * @dataProvider componentANameProvider + */ + public function testCanRenderComponent(string $name): void + { + $rendered = $this->renderTwigComponent($name, [ + 'propA' => 'prop a value', + 'propB' => 'prop b value', + ]); + + $this->assertStringContainsString('propA: prop a value', $rendered); + $this->assertStringContainsString('propB: prop b value', $rendered); + $this->assertStringContainsString('service: service a value', $rendered); + } + + /** + * @dataProvider withSlotsNameProvider + */ + public function testCanRenderComponentWithSlots(string $name): void + { + $rendered = $this->renderTwigComponent( + name: $name, + content: '

some content

', + blocks: [ + 'slot1' => '

some slot1 content

', + 'slot2' => $this->renderTwigComponent('component_a', [ + 'propA' => 'prop a value', + 'propB' => 'prop b value', + ]), + ], + ); + + $this->assertStringContainsString('

some content

', $rendered); + $this->assertStringContainsString('

some slot1 content

', $rendered); + $this->assertStringContainsString('propA: prop a value', $rendered); + $this->assertStringContainsString('propB: prop b value', $rendered); + $this->assertStringContainsString('service: service a value', $rendered); + } + + public static function componentANameProvider(): iterable + { + yield ['component_a']; + yield [ComponentA::class]; + } + + public static function withSlotsNameProvider(): iterable + { + yield ['WithSlots']; + yield [WithSlots::class]; + } +}