diff --git a/src/TwigComponent/src/ComponentAttributes.php b/src/TwigComponent/src/ComponentAttributes.php new file mode 100644 index 00000000000..e62267a9932 --- /dev/null +++ b/src/TwigComponent/src/ComponentAttributes.php @@ -0,0 +1,88 @@ + + * + * 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 + */ +final class ComponentAttributes +{ + /** + * @param array $attributes + */ + public function __construct(public array $attributes) + { + } + + public function __toString(): string + { + return \array_reduce( + \array_keys($this->attributes), + fn(string $carry, string $key) => \sprintf('%s %s="%s"', $carry, $key, $this->attributes[$key]), + '' + ); + } + + public function merge(array $with): self + { + foreach ($this->attributes as $key => $value) { + $with[$key] = isset($with[$key]) ? "{$with[$key]} {$value}" : $value; + } + + return new self($with); + } + + public function only(string ...$keys): self + { + $attributes = []; + + foreach ($this->attributes as $key => $value) { + if (in_array($key, $keys, true)) { + $attributes[$key] = $value; + } + } + + return new self($attributes); + } + + public function without(string ...$keys): self + { + $clone = clone $this; + + foreach ($keys as $key) { + unset($clone->attributes[$key]); + } + + return $clone; + } + + /** + * @param array $attributes + */ + public function defaults(array $attributes): self + { + $clone = $this; + + foreach ($attributes as $attribute => $value) { + $clone->attributes[$attribute] = $clone->attributes[$attribute] ?? $value; + } + + return $clone; + } + + public function default(string $attribute, string $value): self + { + return $this->defaults([$attribute => $value]); + } +} diff --git a/src/TwigComponent/src/ComponentContext.php b/src/TwigComponent/src/ComponentContext.php new file mode 100644 index 00000000000..fe786fe28cc --- /dev/null +++ b/src/TwigComponent/src/ComponentContext.php @@ -0,0 +1,24 @@ + + * + * 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 + */ +final class ComponentContext +{ + public function __construct(public object $component, public ComponentAttributes $attributes) + { + } +} diff --git a/src/TwigComponent/src/ComponentFactory.php b/src/TwigComponent/src/ComponentFactory.php index 0678cb05a49..8d08d399b09 100644 --- a/src/TwigComponent/src/ComponentFactory.php +++ b/src/TwigComponent/src/ComponentFactory.php @@ -78,7 +78,7 @@ public function configFor($component, string $name = null): array /** * 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 = []): ComponentContext { $component = $this->getComponent($name); $data = $this->preMount($component, $data); @@ -88,13 +88,14 @@ public function create(string $name, array $data = []): object // set data that wasn't set in mount on the component directly foreach ($data as $property => $value) { if (!$this->propertyAccessor->isWritable($component, $property)) { - 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)); + continue; } $this->propertyAccessor->setValue($component, $property, $value); + unset($data[$property]); } - return $component; + return new ComponentContext($component, new ComponentAttributes($data)); } /** diff --git a/src/TwigComponent/src/ComponentRenderer.php b/src/TwigComponent/src/ComponentRenderer.php index 53886c49e00..926e6efb8d2 100644 --- a/src/TwigComponent/src/ComponentRenderer.php +++ b/src/TwigComponent/src/ComponentRenderer.php @@ -27,9 +27,12 @@ public function __construct(Environment $twig) $this->twig = $twig; } - public function render(object $component, string $template): string + public function render(ComponentContext $context, string $template): string { // TODO: Self-Rendering components? - return $this->twig->render($template, ['this' => $component]); + return $this->twig->render($template, [ + 'this' => $context->component, + 'attributes' => $context->attributes, + ]); } } diff --git a/src/TwigComponent/src/Twig/ComponentExtension.php b/src/TwigComponent/src/Twig/ComponentExtension.php index 00855969122..55f9765c0e2 100644 --- a/src/TwigComponent/src/Twig/ComponentExtension.php +++ b/src/TwigComponent/src/Twig/ComponentExtension.php @@ -24,7 +24,11 @@ final class ComponentExtension extends AbstractExtension public function getFunctions(): array { return [ - new TwigFunction('component', [ComponentRuntime::class, 'render'], ['is_safe' => ['all']]), + new TwigFunction( + 'component', + [ComponentRuntime::class, 'render'], + ['is_safe' => ['all'], 'needs_environment' => true] + ), ]; } } diff --git a/src/TwigComponent/src/Twig/ComponentRuntime.php b/src/TwigComponent/src/Twig/ComponentRuntime.php index b8ff3efd233..6b01e0c2272 100644 --- a/src/TwigComponent/src/Twig/ComponentRuntime.php +++ b/src/TwigComponent/src/Twig/ComponentRuntime.php @@ -11,8 +11,11 @@ namespace Symfony\UX\TwigComponent\Twig; +use Symfony\UX\TwigComponent\ComponentAttributes; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; +use Twig\Environment; +use Twig\Extension\EscaperExtension; /** * @author Kevin Bond @@ -23,6 +26,7 @@ final class ComponentRuntime { private ComponentFactory $componentFactory; private ComponentRenderer $componentRenderer; + private bool $safeClassesRegistered = false; public function __construct(ComponentFactory $componentFactory, ComponentRenderer $componentRenderer) { @@ -30,8 +34,14 @@ public function __construct(ComponentFactory $componentFactory, ComponentRendere $this->componentRenderer = $componentRenderer; } - public function render(string $name, array $props = []): string + public function render(Environment $twig, string $name, array $props = []): string { + if (!$this->safeClassesRegistered) { + $twig->getExtension(EscaperExtension::class)->addSafeClass(ComponentAttributes::class, ['html']); + + $this->safeClassesRegistered = true; + } + return $this->componentRenderer->render( $this->componentFactory->create($name, $props), $this->componentFactory->configFor($name)['template'] diff --git a/src/TwigComponent/tests/Integration/ComponentFactoryTest.php b/src/TwigComponent/tests/Integration/ComponentFactoryTest.php index cfd858b6a45..b016b2828bd 100644 --- a/src/TwigComponent/tests/Integration/ComponentFactoryTest.php +++ b/src/TwigComponent/tests/Integration/ComponentFactoryTest.php @@ -31,10 +31,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'])->component; /** @var ComponentA $componentB */ - $componentB = $factory->create('component_a', ['propA' => 'C', 'propB' => 'D']); + $componentB = $factory->create('component_a', ['propA' => 'C', 'propB' => 'D'])->component; $this->assertNotSame(spl_object_id($componentA), spl_object_id($componentB)); $this->assertSame(spl_object_id($componentA->getService()), spl_object_id($componentB->getService())); @@ -79,7 +79,7 @@ public function testMountCanHaveOptionalParameters(): void $component = $factory->create('component_c', [ 'propA' => 'valueA', 'propC' => 'valueC', - ]); + ])->component; $this->assertSame('valueA', $component->propA); $this->assertNull($component->propB); @@ -89,7 +89,7 @@ public function testMountCanHaveOptionalParameters(): void $component = $factory->create('component_c', [ 'propA' => 'valueA', 'propB' => 'valueB', - ]); + ])->component; $this->assertSame('valueA', $component->propA); $this->assertSame('valueB', $component->propB); @@ -107,15 +107,15 @@ public function testExceptionThrownIfRequiredMountParameterIsMissingFromPassedDa $factory->create('component_c'); } - public function testExceptionThrownIfUnableToWritePassedDataToProperty(): void + public function testExtraDataIsUsedForAttributes(): 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\Fixture\Component\ComponentA". Make sure this is a writable property or create a mount() with a $service argument.'); + $context = $factory->create('component_a', ['propB' => 'B', 'class' => 'mt-1']); - $factory->create('component_a', ['propB' => 'B', 'service' => 'invalid']); + $this->assertSame('B', $context->component->getPropB()); + $this->assertSame(['class' => 'mt-1'], $context->attributes->attributes); } public function testTwigComponentServiceTagMustHaveKey(): void