diff --git a/src/TwigHooks/composer.json b/src/TwigHooks/composer.json index 7ee06d04..634a47fa 100644 --- a/src/TwigHooks/composer.json +++ b/src/TwigHooks/composer.json @@ -29,7 +29,9 @@ "webmozart/assert": "^1.9" }, "require-dev": { + "symfony/browser-kit": "^6.4 || ^7.4 || ^8.0", "symfony/console": "^6.4 || ^7.4 || ^8.0", + "symfony/css-selector": "^6.4 || ^7.4 || ^8.0", "symfony/dom-crawler": "^6.4 || ^7.4 || ^8.0", "symfony/dotenv": "^6.4 || ^7.4 || ^8.0", "symfony/framework-bundle": "^6.4 || ^7.4 || ^8.0", diff --git a/src/TwigHooks/src/Hookable/Metadata/HookableMetadata.php b/src/TwigHooks/src/Hookable/Metadata/HookableMetadata.php index 8ab3d512..a6c58fdf 100644 --- a/src/TwigHooks/src/Hookable/Metadata/HookableMetadata.php +++ b/src/TwigHooks/src/Hookable/Metadata/HookableMetadata.php @@ -44,4 +44,9 @@ public function withConfiguration(ScalarDataBagInterface $configuration): self { return new self($this->renderedBy, $this->context, $configuration, $this->prefixes); } + + public function withContext(DataBagInterface $context): self + { + return new self($this->renderedBy, $context, $this->configuration, $this->prefixes); + } } diff --git a/src/TwigHooks/src/LiveComponent/HookableLiveComponentTrait.php b/src/TwigHooks/src/LiveComponent/HookableLiveComponentTrait.php index ee64e22b..531fb166 100644 --- a/src/TwigHooks/src/LiveComponent/HookableLiveComponentTrait.php +++ b/src/TwigHooks/src/LiveComponent/HookableLiveComponentTrait.php @@ -20,36 +20,146 @@ use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; +/** + * The hookable context is not sent to the client, as it usually contains objects (grid views, forms, entities...), + * so it is gone during live re-renders. To keep it available in the component template and in its nested hooks: + * - list the scalar context values to keep in the LiveProp with {@see getDehydratedHookableContextKeys()}; + * - give back the other values (built from the component's own LiveProps) with {@see provideHookableContext()}. + */ trait HookableLiveComponentTrait { #[LiveProp(hydrateWith: 'hydrateHookableMetadata', dehydrateWith: 'dehydrateHookableMetadata')] - #[ExposeInTemplate('hookable_metadata')] + #[ExposeInTemplate(name: 'hookable_metadata', getter: 'getHookableMetadataForTemplate')] public ?HookableMetadata $hookableMetadata = null; + /** + * @param array{renderedBy: string, configuration: string, prefixes?: array, context?: array}|null $data + */ public function hydrateHookableMetadata($data): ?HookableMetadata { if (null === $data) { return null; } + $context = new DataBag($data['context'] ?? []); + return new HookableMetadata( - new HookMetadata($data['renderedBy'], new DataBag()), - new DataBag(), + new HookMetadata($data['renderedBy'], $context), + $context, new ScalarDataBag(json_decode($data['configuration'], true)), $data['prefixes'] ?? [], ); } + /** + * @return array{renderedBy: string, configuration: string, prefixes?: array, context?: array}|null + */ public function dehydrateHookableMetadata(?HookableMetadata $metadata = null): ?array { if (null === $metadata) { return null; } - return [ + $data = [ 'renderedBy' => $metadata->renderedBy->name, - 'configuration' => json_encode($metadata->configuration->all()), + 'configuration' => json_encode($metadata->configuration->all(), \JSON_THROW_ON_ERROR), 'prefixes' => $metadata->prefixes, ]; + + $context = $this->dehydrateHookableContext($metadata); + if ([] !== $context) { + $data['context'] = $context; + } + + return $data; + } + + /** + * The hookable metadata exposed as "hookable_metadata", with the context provided by the component + * on top of the hookable context. Called at render time, on the first render and on every re-render. + */ + public function getHookableMetadataForTemplate(): ?HookableMetadata + { + if (null === $this->hookableMetadata) { + return null; + } + + $providedContext = $this->provideHookableContext(); + if ([] === $providedContext) { + return $this->hookableMetadata; + } + + return $this->hookableMetadata->withContext( + new DataBag(array_merge($this->hookableMetadata->context->all(), $providedContext)), + ); + } + + /** + * Keys of the hookable context to keep across re-renders. Their values must be scalars, nulls + * or arrays of them. They are sent to the client: never list secrets here. + * + * @return array + */ + protected function getDehydratedHookableContextKeys(): array + { + return []; + } + + /** + * Context values merged on top of the hookable context at render time, on the first render and on every + * re-render. Use it to give back the objects that cannot be sent to the client, from the component's LiveProps. + * + * @return array + */ + protected function provideHookableContext(): array + { + return []; + } + + /** + * @return array + */ + private function dehydrateHookableContext(HookableMetadata $metadata): array + { + $hookableContext = $metadata->context->all(); + $context = []; + + foreach ($this->getDehydratedHookableContextKeys() as $key) { + if (!array_key_exists($key, $hookableContext)) { + continue; + } + + $value = $hookableContext[$key]; + if (!self::isDehydratableHookableContextValue($value)) { + throw new \LogicException(sprintf( + 'The "%s" hookable context value of the "%s" component cannot be dehydrated, only scalars, nulls and arrays of them can. Provide it with "provideHookableContext()" instead.', + $key, + static::class, + )); + } + + $context[$key] = $value; + } + + return $context; + } + + private static function isDehydratableHookableContextValue(mixed $value): bool + { + if (null === $value || is_scalar($value)) { + return true; + } + + if (!is_array($value)) { + return false; + } + + foreach ($value as $item) { + if (!self::isDehydratableHookableContextValue($item)) { + return false; + } + } + + return true; } } diff --git a/src/TwigHooks/tests/Functional/.application/config/packages/twig_hooks.yaml b/src/TwigHooks/tests/Functional/.application/config/packages/twig_hooks.yaml index 7a22c667..ab4d05ee 100644 --- a/src/TwigHooks/tests/Functional/.application/config/packages/twig_hooks.yaml +++ b/src/TwigHooks/tests/Functional/.application/config/packages/twig_hooks.yaml @@ -32,6 +32,17 @@ sylius_twig_hooks: dummy: component: 'app:dummy_live' + 'hookable_live_component_with_context': + context_aware: + component: 'app:context_aware_live' + props: + productName: '@=_context.product.name' + + 'hookable_live_component_with_context.nested': + flagged: + template: 'hookable_live_component/nested.html.twig' + condition: '@=_context.flag' + 'hookable_twig_component': dummy: component: 'app:dummy' diff --git a/src/TwigHooks/tests/Functional/.application/src/Component/ContextAwareLiveComponent.php b/src/TwigHooks/tests/Functional/.application/src/Component/ContextAwareLiveComponent.php new file mode 100644 index 00000000..b5924e9c --- /dev/null +++ b/src/TwigHooks/tests/Functional/.application/src/Component/ContextAwareLiveComponent.php @@ -0,0 +1,45 @@ + + */ + protected function getDehydratedHookableContextKeys(): array + { + return ['flag', 'not_in_context']; + } + + /** + * @return array + */ + protected function provideHookableContext(): array + { + return ['product' => (object) ['name' => $this->productName]]; + } +} diff --git a/src/TwigHooks/tests/Functional/.application/templates/_component/context_aware_live.html.twig b/src/TwigHooks/tests/Functional/.application/templates/_component/context_aware_live.html.twig new file mode 100644 index 00000000..3cdba1da --- /dev/null +++ b/src/TwigHooks/tests/Functional/.application/templates/_component/context_aware_live.html.twig @@ -0,0 +1,6 @@ +
+ flag: {{ hookable_metadata.context.flag ? 'yes' : 'no' }} + product: {{ hookable_metadata.context.product.name }} + secret: {{ hookable_metadata.context.secret ?? 'none' }} + {% hook 'nested' %} +
diff --git a/src/TwigHooks/tests/Functional/.application/templates/hookable_live_component/hookable_component_rendered_with_context.html.twig b/src/TwigHooks/tests/Functional/.application/templates/hookable_live_component/hookable_component_rendered_with_context.html.twig new file mode 100644 index 00000000..7fedd73c --- /dev/null +++ b/src/TwigHooks/tests/Functional/.application/templates/hookable_live_component/hookable_component_rendered_with_context.html.twig @@ -0,0 +1 @@ +{% hook 'hookable_live_component_with_context' with { flag: true, product, secret: 's3cr3t' } %} diff --git a/src/TwigHooks/tests/Functional/.application/templates/hookable_live_component/nested.html.twig b/src/TwigHooks/tests/Functional/.application/templates/hookable_live_component/nested.html.twig new file mode 100644 index 00000000..ac73ef05 --- /dev/null +++ b/src/TwigHooks/tests/Functional/.application/templates/hookable_live_component/nested.html.twig @@ -0,0 +1 @@ +nested product: {{ get_hookable_context().product.name }} diff --git a/src/TwigHooks/tests/Functional/Twig/HookableLiveComponentTest.php b/src/TwigHooks/tests/Functional/Twig/HookableLiveComponentTest.php index adb8262b..f80ce60f 100644 --- a/src/TwigHooks/tests/Functional/Twig/HookableLiveComponentTest.php +++ b/src/TwigHooks/tests/Functional/Twig/HookableLiveComponentTest.php @@ -13,7 +13,12 @@ namespace Tests\Sylius\TwigHooks\Functional\Twig; +use Sylius\TwigHooks\Bag\DataBag; +use Sylius\TwigHooks\Bag\ScalarDataBag; +use Sylius\TwigHooks\Hook\Metadata\HookMetadata; +use Sylius\TwigHooks\Hookable\Metadata\HookableMetadata; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\LiveComponent\Test\InteractsWithLiveComponents; use Twig\Environment as Twig; /** @@ -21,6 +26,8 @@ */ final class HookableLiveComponentTest extends KernelTestCase { + use InteractsWithLiveComponents; + public function testItRendersHookableLiveComponentWithUsingTwigHooks(): void { $this->assertSame( @@ -43,11 +50,46 @@ public function testItRendersHookableLiveComponentWithoutUsingTwigHooks(): void ); } - private function render(string $path): string + public function testItRendersHookableLiveComponentWithTheHookableContext(): void + { + $rendered = $this->render( + 'hookable_live_component/hookable_component_rendered_with_context.html.twig', + ['product' => (object) ['name' => 'Mug']], + ); + + $this->assertStringContainsString('flag: yes', $rendered); + $this->assertStringContainsString('product: Mug', $rendered); + $this->assertStringContainsString('secret: s3cr3t', $rendered); + $this->assertStringContainsString('nested product: Mug', $rendered); + } + + public function testItKeepsTheHookableContextOnReRender(): void + { + $hookName = 'hookable_live_component_with_context'; + $context = new DataBag(['flag' => true, 'product' => (object) ['name' => 'Mug'], 'secret' => 's3cr3t']); + + $component = $this->createLiveComponent('app:context_aware_live', [ + 'hookableMetadata' => new HookableMetadata(new HookMetadata($hookName, $context), $context, new ScalarDataBag([]), [$hookName]), + 'productName' => 'Mug', + ]); + + $rendered = $component->refresh()->render()->toString(); + + $this->assertStringContainsString('flag: yes', $rendered); + $this->assertStringContainsString('product: Mug', $rendered); + $this->assertStringContainsString('secret: none', $rendered); + $this->assertStringContainsString('nested product: Mug', $rendered); + $this->assertStringNotContainsString('s3cr3t', $rendered); + } + + /** + * @param array $context + */ + private function render(string $path, array $context = []): string { /** @var Twig $twig */ $twig = $this->getContainer()->get('twig'); - return $twig->render($path); + return $twig->render($path, $context); } } diff --git a/src/TwigHooks/tests/Unit/LiveComponent/HookableLiveComponentTraitTest.php b/src/TwigHooks/tests/Unit/LiveComponent/HookableLiveComponentTraitTest.php new file mode 100644 index 00000000..a142dbd1 --- /dev/null +++ b/src/TwigHooks/tests/Unit/LiveComponent/HookableLiveComponentTraitTest.php @@ -0,0 +1,174 @@ +assertSame( + [ + 'renderedBy' => 'some_hook', + 'configuration' => '{"some":"configuration"}', + 'prefixes' => ['some_hook'], + ], + $component->dehydrateHookableMetadata($this->createMetadata(['flag' => true, 'object' => new \stdClass()])), + ); + } + + public function testItDehydratesTheListedContextValues(): void + { + $component = new class() { + use HookableLiveComponentTrait; + + protected function getDehydratedHookableContextKeys(): array + { + return ['flag', 'ids', 'nullable', 'not_in_context']; + } + }; + + $data = $component->dehydrateHookableMetadata($this->createMetadata([ + 'flag' => true, + 'ids' => [1, 2, ['nested' => 'value']], + 'nullable' => null, + 'object' => new \stdClass(), + ])); + + $this->assertSame(['flag' => true, 'ids' => [1, 2, ['nested' => 'value']], 'nullable' => null], $data['context']); + } + + public function testItThrowsAnExceptionWhenAListedContextValueCannotBeDehydrated(): void + { + $component = new class() { + use HookableLiveComponentTrait; + + protected function getDehydratedHookableContextKeys(): array + { + return ['objects']; + } + }; + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The "objects" hookable context value'); + + $component->dehydrateHookableMetadata($this->createMetadata(['objects' => [new \stdClass()]])); + } + + public function testItHydratesTheDehydratedContext(): void + { + $component = new class() { + use HookableLiveComponentTrait; + }; + + $metadata = $component->hydrateHookableMetadata([ + 'renderedBy' => 'some_hook', + 'configuration' => '{"some":"configuration"}', + 'prefixes' => ['some_hook'], + 'context' => ['flag' => true], + ]); + + $this->assertSame('some_hook', $metadata->renderedBy->name); + $this->assertSame(['flag' => true], $metadata->renderedBy->context->all()); + $this->assertSame(['flag' => true], $metadata->context->all()); + $this->assertSame(['some' => 'configuration'], $metadata->configuration->all()); + $this->assertSame(['some_hook'], $metadata->prefixes); + } + + public function testItHydratesDataDehydratedWithoutTheContext(): void + { + $component = new class() { + use HookableLiveComponentTrait; + }; + + $metadata = $component->hydrateHookableMetadata([ + 'renderedBy' => 'some_hook', + 'configuration' => '[]', + 'prefixes' => [], + ]); + + $this->assertSame([], $metadata->context->all()); + $this->assertNull($component->hydrateHookableMetadata(null)); + } + + public function testItExposesTheHookableMetadataAsIsWhenNoContextIsProvided(): void + { + $component = new class() { + use HookableLiveComponentTrait; + }; + $component->hookableMetadata = $this->createMetadata(['flag' => true]); + + $this->assertSame($component->hookableMetadata, $component->getHookableMetadataForTemplate()); + } + + public function testItExposesTheHookableMetadataWithTheProvidedContext(): void + { + $component = new class() { + use HookableLiveComponentTrait; + + public ?object $product = null; + + protected function provideHookableContext(): array + { + return ['product' => $this->product]; + } + }; + $component->product = new \stdClass(); + $component->hookableMetadata = $this->createMetadata(['flag' => true, 'product' => 'overridden']); + + $metadata = $component->getHookableMetadataForTemplate(); + + $this->assertSame(['flag' => true, 'product' => $component->product], $metadata->context->all()); + $this->assertSame($component->hookableMetadata->renderedBy, $metadata->renderedBy); + $this->assertSame($component->hookableMetadata->configuration, $metadata->configuration); + $this->assertSame(['some_hook'], $metadata->prefixes); + } + + public function testItExposesNoHookableMetadataWhenTheComponentIsNotHookable(): void + { + $component = new class() { + use HookableLiveComponentTrait; + + protected function provideHookableContext(): array + { + return ['product' => new \stdClass()]; + } + }; + + $this->assertNull($component->getHookableMetadataForTemplate()); + } + + /** + * @param array $context + */ + private function createMetadata(array $context): HookableMetadata + { + return new HookableMetadata( + new HookMetadata('some_hook', new DataBag($context)), + new DataBag($context), + new ScalarDataBag(['some' => 'configuration']), + ['some_hook'], + ); + } +}