From fc2d8c0f24b8f5233fbf96ebaacef7a74ca1a146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Fr=C3=A9mont?= Date: Thu, 24 Sep 2026 12:09:33 +0200 Subject: [PATCH] Add expression-based conditions to enable or disable Twig hookables Introduce a new 'condition' configuration option for hookables. The value is an Expression Language expression prefixed with '@=' that is evaluated at runtime against the hook context. The hookable is rendered only when the expression returns true. Conditions are linted at the application boot to fail fast on invalid expressions, and evaluated by a new HookableConditionChecker injected into the HookRenderer. 'enabled' remains a static boot-time flag, keeping the two mechanisms clearly separated. --- docs/twig-hooks/getting-started.md | 17 +++++ phpstan-baseline.neon | 5 ++ src/TwigHooks/config/services.php | 9 +++ .../config/services/hook_renderer.php | 1 + .../src/DependencyInjection/Configuration.php | 18 +++++ .../SyliusTwigHooksExtension.php | 38 +++++++++- .../src/Hook/Renderer/HookRenderer.php | 7 ++ .../src/Hookable/AbstractHookable.php | 4 + .../Checker/HookableConditionChecker.php | 56 ++++++++++++++ .../HookableConditionCheckerInterface.php | 24 ++++++ .../src/Hookable/DisabledHookable.php | 4 +- .../src/Hookable/HookableComponent.php | 4 +- .../src/Hookable/HookableTemplate.php | 4 +- .../config/packages/twig_hooks.yaml | 8 ++ .../enabled_by_condition/index.html.twig | 3 + .../index/block.html.twig | 1 + .../Twig/EnabledHookableConditionTest.php | 51 +++++++++++++ .../DependencyInjection/ConfigurationTest.php | 58 +++++++++++++++ .../Unit/Hook/Renderer/HookRendererTest.php | 27 +++++++ .../Checker/HookableConditionCheckerTest.php | 74 +++++++++++++++++++ 20 files changed, 408 insertions(+), 5 deletions(-) create mode 100644 src/TwigHooks/src/Hookable/Checker/HookableConditionChecker.php create mode 100644 src/TwigHooks/src/Hookable/Checker/HookableConditionCheckerInterface.php create mode 100644 src/TwigHooks/tests/Functional/.application/templates/enabled_by_condition/index.html.twig create mode 100644 src/TwigHooks/tests/Functional/.application/templates/enabled_by_condition/index/block.html.twig create mode 100644 src/TwigHooks/tests/Functional/Twig/EnabledHookableConditionTest.php create mode 100644 src/TwigHooks/tests/Unit/Hookable/Checker/HookableConditionCheckerTest.php diff --git a/docs/twig-hooks/getting-started.md b/docs/twig-hooks/getting-started.md index d65dcd19..e1c965dd 100644 --- a/docs/twig-hooks/getting-started.md +++ b/docs/twig-hooks/getting-started.md @@ -94,6 +94,7 @@ sylius_twig_hooks: enabled: true # whether the hookable is enabled context: [] # key-value pair that will be passed to the context bag configuration: [] # key-value pair that will be passed to the configuration bag + condition: '@=user != null' # expression evaluated at runtime; the hookable is rendered only when it returns true priority: 0 # priority, the higher the number, the earlier the hookable will be hooked ``` {% endcode %} @@ -111,8 +112,24 @@ sylius_twig_hooks: context: [] # key-value pair that will be passed to the context bag props: [] # key-value pair that will be passed to our component as props configuration: [] # key-value pair that will be passed to the configuration bag + condition: '@=user != null' # expression evaluated at runtime; the hookable is rendered only when it returns true priority: 0 # priority, the higher the number, the earlier the hookable will be hooked ``` {% endcode %} {% endtab %} {% endtabs %} + +**Conditions in hookables** + +Unlike the `enabled` flag — which is evaluated statically during the application boot — the `condition` option is an [Expression Language](https://symfony.com/doc/current/expression_language.html) expression that is evaluated at runtime, each time the hook is rendered. The hookable is rendered only when the expression returns `true`. Both the whole hook context and the `_context` variable are available within the expression, so both of these work: + +```yaml +condition: '@=user != null' +condition: '@=_context.user != null' +``` + +Because the expression is evaluated with the whole hook context and the `_context` variable, this behaves like [Twig's `defined`](https://twig.symfony.com/doc/3.x/tests/defined.html)-style checks: `@=_context.product` returns `null` when the variable is absent from the context, so the hookable is simply not rendered. Referencing a missing variable directly (e.g. `@=product ...`) throws a clear `InvalidExpressionException` instead, which makes invalid conditions fail fast during development. + +{% hint style="info" %} +On Symfony 7.1 and newer, conditions are validated at the application boot: an invalid expression is reported as soon as the container is compiled. On older versions, errors are only reported at runtime, when the hook is rendered. +{% endhint %} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index fa60158f..6a8b362b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -53,3 +53,8 @@ parameters: identifier: function.alreadyNarrowedType count: 2 path: src/TwigHooks/src/Twig/TokenParser/HookTokenParser.php + + - + message: '#^Method Symfony\\Component\\ExpressionLanguage\\ExpressionLanguage\:\:lint\(\) invoked with 3 parameters, 2 required\.$#' + count: 1 + path: src/TwigHooks/src/DependencyInjection/SyliusTwigHooksExtension.php diff --git a/src/TwigHooks/config/services.php b/src/TwigHooks/config/services.php index 33953965..a5d723a5 100644 --- a/src/TwigHooks/config/services.php +++ b/src/TwigHooks/config/services.php @@ -18,6 +18,8 @@ use Sylius\TwigHooks\Hook\Normalizer\Prefix\CompositePrefixNormalizer; use Sylius\TwigHooks\Hook\Normalizer\Prefix\PrefixNormalizerInterface; use Sylius\TwigHooks\Hook\Normalizer\Prefix\RemoveSectionPartNormalizer; +use Sylius\TwigHooks\Hookable\Checker\HookableConditionChecker; +use Sylius\TwigHooks\Hookable\Checker\HookableConditionCheckerInterface; use Sylius\TwigHooks\Hookable\Metadata\HookableMetadataFactory; use Sylius\TwigHooks\Provider\ComponentPropsProvider; use Sylius\TwigHooks\Provider\DefaultConfigurationProvider; @@ -53,6 +55,13 @@ ; $services->alias(TemplateConfigurationProviderInterface::class, 'sylius_twig_hooks.provider.template_configuration'); + $services->set('sylius_twig_hooks.checker.hookable_condition', HookableConditionChecker::class) + ->args([ + inline_service(ExpressionLanguage::class), + ]) + ; + $services->alias(HookableConditionCheckerInterface::class, 'sylius_twig_hooks.checker.hookable_condition'); + $services->set('sylius_twig_hooks.registry.hookables', HookablesRegistry::class) ->args([ tagged_iterator('sylius_twig_hooks.hookable'), diff --git a/src/TwigHooks/config/services/hook_renderer.php b/src/TwigHooks/config/services/hook_renderer.php index 90b83e0f..878ea3c1 100644 --- a/src/TwigHooks/config/services/hook_renderer.php +++ b/src/TwigHooks/config/services/hook_renderer.php @@ -26,6 +26,7 @@ service('sylius_twig_hooks.provider.default_context'), service('sylius_twig_hooks.provider.default_configuration'), service('sylius_twig_hooks.factory.hookable_metadata'), + service('sylius_twig_hooks.checker.hookable_condition'), ]) ->alias(HookRendererInterface::class, 'sylius_twig_hooks.renderer.hook') ; diff --git a/src/TwigHooks/src/DependencyInjection/Configuration.php b/src/TwigHooks/src/DependencyInjection/Configuration.php index 35e652d1..e1af7f74 100644 --- a/src/TwigHooks/src/DependencyInjection/Configuration.php +++ b/src/TwigHooks/src/DependencyInjection/Configuration.php @@ -129,6 +129,24 @@ private function addHooksConfiguration(ArrayNodeDefinition $rootNode): void ->prototype('variable')->end() ->end() ->integerNode('priority')->defaultNull()->end() + ->scalarNode('condition') + ->defaultNull() + ->validate() + ->always(function ($value) { + if (null === $value) { + return $value; + } + + if (!is_string($value) || !str_starts_with($value, '@=')) { + throw new \InvalidArgumentException( + sprintf('The "condition" value must be an expression prefixed with "@=". Got "%s".', get_debug_type($value)), + ); + } + + return $value; + }) + ->end() + ->end() ->end() ->end() ->end() diff --git a/src/TwigHooks/src/DependencyInjection/SyliusTwigHooksExtension.php b/src/TwigHooks/src/DependencyInjection/SyliusTwigHooksExtension.php index 1305465f..ec4f8bac 100644 --- a/src/TwigHooks/src/DependencyInjection/SyliusTwigHooksExtension.php +++ b/src/TwigHooks/src/DependencyInjection/SyliusTwigHooksExtension.php @@ -16,10 +16,13 @@ use Sylius\TwigHooks\Hookable\DisabledHookable; use Sylius\TwigHooks\Hookable\HookableComponent; use Sylius\TwigHooks\Hookable\HookableTemplate; +use Sylius\TwigHooks\Provider\Exception\InvalidExpressionException; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\ExpressionLanguage\Parser; final class SyliusTwigHooksExtension extends Extension { @@ -46,12 +49,16 @@ public function load(array $configs, ContainerBuilder $container): void */ private function registerHooks(ContainerBuilder $container, array $hooks, array $supportedHookableTypes): void { + $expressionLanguage = new ExpressionLanguage(); + foreach ($hooks as $hookName => $hookables) { foreach ($hookables as $hookableName => $hookable) { if (!array_key_exists($hookable['type'], $supportedHookableTypes)) { throw new \InvalidArgumentException(sprintf('Hookable type "%s" is not supported.', $hookable['type'])); } + $this->lintCondition($expressionLanguage, $hookName, $hookableName, $hookable); + $this->registerHookable( $container, $supportedHookableTypes[$hookable['type']], @@ -63,6 +70,33 @@ private function registerHooks(ContainerBuilder $container, array $hooks, array } } + /** + * @param array $hookable + */ + private function lintCondition(ExpressionLanguage $expressionLanguage, string $hookName, string $hookableName, array $hookable): void + { + $condition = $hookable['condition'] ?? null; + + if (null === $condition || !defined(Parser::class . '::IGNORE_UNKNOWN_VARIABLES')) { + return; + } + + try { + $expressionLanguage->lint(substr($condition, 2), [], Parser::IGNORE_UNKNOWN_VARIABLES); + } catch (\Throwable $e) { + throw new InvalidExpressionException( + sprintf( + 'Failed to lint the "%s" condition of the "%s" hookable in the "%s" hook. Error: %s".', + $condition, + $hookableName, + $hookName, + $e->getMessage(), + ), + previous: $e, + ); + } + } + /** * @param array $hookable */ @@ -99,7 +133,7 @@ private function registerTemplateHookable( $hookable['context'], $hookable['configuration'], $hookable['priority'], - $hookable['enabled'], + $hookable['condition'], ]) ->addTag('sylius_twig_hooks.hookable', ['priority' => $hookable['priority']]) ; @@ -124,7 +158,7 @@ private function registerComponentHookable( $hookable['context'], $hookable['configuration'], $hookable['priority'], - $hookable['enabled'], + $hookable['condition'], ]) ->addTag('sylius_twig_hooks.hookable', ['priority' => $hookable['priority']]) ; diff --git a/src/TwigHooks/src/Hook/Renderer/HookRenderer.php b/src/TwigHooks/src/Hook/Renderer/HookRenderer.php index 63db812a..f68c326d 100644 --- a/src/TwigHooks/src/Hook/Renderer/HookRenderer.php +++ b/src/TwigHooks/src/Hook/Renderer/HookRenderer.php @@ -16,6 +16,7 @@ use Sylius\TwigHooks\Bag\DataBag; use Sylius\TwigHooks\Bag\ScalarDataBag; use Sylius\TwigHooks\Hook\Metadata\HookMetadata; +use Sylius\TwigHooks\Hookable\Checker\HookableConditionCheckerInterface; use Sylius\TwigHooks\Hookable\Metadata\HookableMetadataFactoryInterface; use Sylius\TwigHooks\Hookable\Renderer\HookableRendererInterface; use Sylius\TwigHooks\Provider\ConfigurationProviderInterface; @@ -30,6 +31,7 @@ public function __construct( private readonly ContextProviderInterface $contextProvider, private readonly ConfigurationProviderInterface $configurationProvider, private readonly HookableMetadataFactoryInterface $hookableMetadataFactory, + private readonly HookableConditionCheckerInterface $hookableConditionChecker, ) { } @@ -46,6 +48,11 @@ public function render(array $hookNames, array $hookContext = []): string $hookMetadata = new HookMetadata($hookable->hookName, new DataBag($hookContext)); $context = $this->contextProvider->provide($hookable, $hookContext); + + if (!$this->hookableConditionChecker->isEnabled($hookable, $context)) { + continue; + } + $configuration = $this->configurationProvider->provide($hookable); $hookableMetadata = $this->hookableMetadataFactory->create( diff --git a/src/TwigHooks/src/Hookable/AbstractHookable.php b/src/TwigHooks/src/Hookable/AbstractHookable.php index 7cfe8592..21e0831a 100644 --- a/src/TwigHooks/src/Hookable/AbstractHookable.php +++ b/src/TwigHooks/src/Hookable/AbstractHookable.php @@ -27,6 +27,8 @@ abstract class AbstractHookable /** @var array */ public readonly array $configuration; + public readonly ?string $condition; + private readonly ?int $priority; public const DEFAULT_PRIORITY = 0; @@ -41,6 +43,7 @@ public function __construct( array $context = [], array $configuration = [], ?int $priority = null, + ?string $condition = null, ) { $this->id = sprintf('%s#%s', $hookName, $name); $this->hookName = $hookName; @@ -48,6 +51,7 @@ public function __construct( $this->context = $context; $this->configuration = $configuration; $this->priority = $priority; + $this->condition = $condition; } public function priority(): int diff --git a/src/TwigHooks/src/Hookable/Checker/HookableConditionChecker.php b/src/TwigHooks/src/Hookable/Checker/HookableConditionChecker.php new file mode 100644 index 00000000..9a691b79 --- /dev/null +++ b/src/TwigHooks/src/Hookable/Checker/HookableConditionChecker.php @@ -0,0 +1,56 @@ + $context + */ + public function isEnabled(AbstractHookable $hookable, array $context): bool + { + $condition = $hookable->condition; + + if (null === $condition) { + return true; + } + + $values = array_merge($context, ['_context' => new DataBag($context)]); + + try { + return (bool) $this->expressionLanguage->evaluate(substr($condition, 2), $values); + } catch (\Throwable $e) { + throw new InvalidExpressionException( + sprintf( + 'Failed to evaluate the "%s" condition while rendering the "%s" hookable in the "%s" hook. Error: %s".', + $condition, + $hookable->name, + $hookable->hookName, + $e->getMessage(), + ), + previous: $e, + ); + } + } +} diff --git a/src/TwigHooks/src/Hookable/Checker/HookableConditionCheckerInterface.php b/src/TwigHooks/src/Hookable/Checker/HookableConditionCheckerInterface.php new file mode 100644 index 00000000..b1b8c2bc --- /dev/null +++ b/src/TwigHooks/src/Hookable/Checker/HookableConditionCheckerInterface.php @@ -0,0 +1,24 @@ + $context + */ + public function isEnabled(AbstractHookable $hookable, array $context): bool; +} diff --git a/src/TwigHooks/src/Hookable/DisabledHookable.php b/src/TwigHooks/src/Hookable/DisabledHookable.php index 2c5afc4a..604ed023 100644 --- a/src/TwigHooks/src/Hookable/DisabledHookable.php +++ b/src/TwigHooks/src/Hookable/DisabledHookable.php @@ -21,8 +21,9 @@ public function __construct( array $context = [], array $configuration = [], ?int $priority = null, + ?string $condition = null, ) { - parent::__construct($hookName, $name, $context, $configuration, $priority); + parent::__construct($hookName, $name, $context, $configuration, $priority, $condition); } public function toArray(): array @@ -32,6 +33,7 @@ public function toArray(): array 'name' => $this->name, 'context' => $this->context, 'configuration' => $this->configuration, + 'condition' => $this->condition, 'priority' => $this->priority(), ]; } diff --git a/src/TwigHooks/src/Hookable/HookableComponent.php b/src/TwigHooks/src/Hookable/HookableComponent.php index 207cd8bf..aa2352c2 100644 --- a/src/TwigHooks/src/Hookable/HookableComponent.php +++ b/src/TwigHooks/src/Hookable/HookableComponent.php @@ -28,8 +28,9 @@ public function __construct( array $context = [], array $configuration = [], ?int $priority = null, + ?string $condition = null, ) { - parent::__construct($hookName, $name, $context, $configuration, $priority); + parent::__construct($hookName, $name, $context, $configuration, $priority, $condition); } public function toArray(): array @@ -41,6 +42,7 @@ public function toArray(): array 'props' => $this->props, 'context' => $this->context, 'configuration' => $this->configuration, + 'condition' => $this->condition, 'priority' => $this->priority(), ]; } diff --git a/src/TwigHooks/src/Hookable/HookableTemplate.php b/src/TwigHooks/src/Hookable/HookableTemplate.php index a87ad91b..6eb448b0 100644 --- a/src/TwigHooks/src/Hookable/HookableTemplate.php +++ b/src/TwigHooks/src/Hookable/HookableTemplate.php @@ -22,8 +22,9 @@ public function __construct( array $context = [], array $configuration = [], ?int $priority = null, + ?string $condition = null, ) { - parent::__construct($hookName, $name, $context, $configuration, $priority); + parent::__construct($hookName, $name, $context, $configuration, $priority, $condition); } public function toArray(): array @@ -34,6 +35,7 @@ public function toArray(): array 'template' => $this->template, 'context' => $this->context, 'configuration' => $this->configuration, + 'condition' => $this->condition, 'priority' => $this->priority(), ]; } 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 de8e5235..7a22c667 100644 --- a/src/TwigHooks/tests/Functional/.application/config/packages/twig_hooks.yaml +++ b/src/TwigHooks/tests/Functional/.application/config/packages/twig_hooks.yaml @@ -35,3 +35,11 @@ sylius_twig_hooks: 'hookable_twig_component': dummy: component: 'app:dummy' + + 'enabled_by_condition.index': + with_condition_true: + template: 'enabled_by_condition/index/block.html.twig' + condition: '@=show === true' + with_condition_false: + template: 'enabled_by_condition/index/block.html.twig' + condition: '@=show === false' diff --git a/src/TwigHooks/tests/Functional/.application/templates/enabled_by_condition/index.html.twig b/src/TwigHooks/tests/Functional/.application/templates/enabled_by_condition/index.html.twig new file mode 100644 index 00000000..4ab80e5d --- /dev/null +++ b/src/TwigHooks/tests/Functional/.application/templates/enabled_by_condition/index.html.twig @@ -0,0 +1,3 @@ +{% hook 'enabled_by_condition.index' with { + show: show, +} only %} diff --git a/src/TwigHooks/tests/Functional/.application/templates/enabled_by_condition/index/block.html.twig b/src/TwigHooks/tests/Functional/.application/templates/enabled_by_condition/index/block.html.twig new file mode 100644 index 00000000..61fe1569 --- /dev/null +++ b/src/TwigHooks/tests/Functional/.application/templates/enabled_by_condition/index/block.html.twig @@ -0,0 +1 @@ +Condition block rendered. diff --git a/src/TwigHooks/tests/Functional/Twig/EnabledHookableConditionTest.php b/src/TwigHooks/tests/Functional/Twig/EnabledHookableConditionTest.php new file mode 100644 index 00000000..18ff28b1 --- /dev/null +++ b/src/TwigHooks/tests/Functional/Twig/EnabledHookableConditionTest.php @@ -0,0 +1,51 @@ +render('enabled_by_condition/index.html.twig', ['show' => true]); + + $this->assertStringContainsString('with_condition_true', $result); + $this->assertStringContainsString('Condition block rendered.', $result); + $this->assertStringNotContainsString('with_condition_false', $result); + } + + public function testItSkipsHookableWhenConditionIsNotSatisfied(): void + { + $result = $this->render('enabled_by_condition/index.html.twig', ['show' => false]); + + $this->assertStringContainsString('with_condition_false', $result); + $this->assertStringNotContainsString('with_condition_true', $result); + } + + /** + * @param array $variables + */ + private function render(string $path, array $variables = []): string + { + /** @var Twig $twig */ + $twig = $this->getContainer()->get('twig'); + + return $twig->render($path, $variables); + } +} diff --git a/src/TwigHooks/tests/Integration/DependencyInjection/ConfigurationTest.php b/src/TwigHooks/tests/Integration/DependencyInjection/ConfigurationTest.php index 823fbd0e..b5ea3825 100644 --- a/src/TwigHooks/tests/Integration/DependencyInjection/ConfigurationTest.php +++ b/src/TwigHooks/tests/Integration/DependencyInjection/ConfigurationTest.php @@ -67,6 +67,7 @@ public function testItSetsDefaultValuesForHookable(): void 'enabled' => true, 'component' => null, 'props' => [], + 'condition' => null, ], ], ], @@ -122,6 +123,7 @@ public function testItAllowsToUseComponentShortcut(): void 'component' => 'MyAwesomeComponent', 'template' => null, 'props' => [], + 'condition' => null, ], ], ], @@ -156,6 +158,7 @@ public function testItAllowsToUseTemplateShortcut(): void 'component' => null, 'template' => 'some_target.html.twig', 'props' => [], + 'condition' => null, ], ], ], @@ -202,6 +205,61 @@ public function testItThrowsExceptionWhenPropsAreDefinedForNonComponentHookable( ); } + public function testItAllowsToDefineCondition(): void + { + $this->assertProcessedConfigurationEquals( + [ + [ + 'hooks' => [ + 'some_hook' => [ + 'some_hookable' => [ + 'template' => 'some_target.html.twig', + 'condition' => '@=user != null', + ], + ], + ], + ], + ], + [ + 'hooks' => [ + 'some_hook' => [ + 'some_hookable' => [ + 'type' => 'template', + 'context' => [], + 'configuration' => [], + 'priority' => null, + 'enabled' => true, + 'component' => null, + 'template' => 'some_target.html.twig', + 'props' => [], + 'condition' => '@=user != null', + ], + ], + ], + ], + 'hooks.*', + ); + } + + public function testItThrowsExceptionWhenConditionDoesNotStartWithExpressionPrefix(): void + { + $this->assertConfigurationIsInvalid( + [ + [ + 'hooks' => [ + 'some_hook' => [ + 'some_hookable' => [ + 'template' => 'some_target.html.twig', + 'condition' => 'user is not null', + ], + ], + ], + ], + ], + 'must be an expression prefixed with "@=".', + ); + } + protected function getConfiguration(): Configuration { return new Configuration(); diff --git a/src/TwigHooks/tests/Unit/Hook/Renderer/HookRendererTest.php b/src/TwigHooks/tests/Unit/Hook/Renderer/HookRendererTest.php index 6610f702..21ebe5a4 100644 --- a/src/TwigHooks/tests/Unit/Hook/Renderer/HookRendererTest.php +++ b/src/TwigHooks/tests/Unit/Hook/Renderer/HookRendererTest.php @@ -17,6 +17,7 @@ use PHPUnit\Framework\TestCase; use Sylius\TwigHooks\Hook\Renderer\HookRenderer; use Sylius\TwigHooks\Hookable\AbstractHookable; +use Sylius\TwigHooks\Hookable\Checker\HookableConditionCheckerInterface; use Sylius\TwigHooks\Hookable\Metadata\HookableMetadataFactoryInterface; use Sylius\TwigHooks\Hookable\Renderer\HookableRendererInterface; use Sylius\TwigHooks\Provider\ConfigurationProviderInterface; @@ -41,6 +42,9 @@ final class HookRendererTest extends TestCase /** @var HookableMetadataFactoryInterface&MockObject */ private HookableMetadataFactoryInterface $hookableMetadataFactory; + /** @var HookableConditionCheckerInterface&MockObject */ + private HookableConditionCheckerInterface $hookableConditionChecker; + protected function setUp(): void { $this->hookablesRegistry = $this->createMock(HookablesRegistry::class); @@ -48,6 +52,7 @@ protected function setUp(): void $this->contextProvider = $this->createMock(ContextProviderInterface::class); $this->configurationProvider = $this->createMock(ConfigurationProviderInterface::class); $this->hookableMetadataFactory = $this->createMock(HookableMetadataFactoryInterface::class); + $this->hookableConditionChecker = $this->createMock(HookableConditionCheckerInterface::class); } public function testItReturnsRenderedHookables(): void @@ -58,6 +63,7 @@ public function testItReturnsRenderedHookables(): void $this->hookablesRegistry->method('getEnabledFor')->willReturn([$hookableOne, $hookableTwo]); $this->contextProvider->method('provide')->willReturn([]); $this->configurationProvider->method('provide')->willReturn([]); + $this->hookableConditionChecker->method('isEnabled')->willReturn(true); $this->hookableRenderer->expects($this->exactly(2))->method('render')->willReturnCallback( static fn (AbstractHookable $hookable): string => match ($hookable) { @@ -86,6 +92,26 @@ public function testItReturnsEmptyStringWhenNoHookablesAreFound(): void $this->assertSame('', $result); } + public function testItSkipsHookablesThatAreNotEnabledByTheirCondition(): void + { + $hookableOne = HookableTemplateMotherObject::withName('first_hook'); + $hookableTwo = HookableTemplateMotherObject::withName('second_hook'); + + $this->hookablesRegistry->method('getEnabledFor')->willReturn([$hookableOne, $hookableTwo]); + $this->contextProvider->method('provide')->willReturn([]); + $this->configurationProvider->method('provide')->willReturn([]); + + $this->hookableConditionChecker->expects($this->exactly(2))->method('isEnabled')->willReturnCallback( + static fn (AbstractHookable $hookable): bool => $hookable === $hookableOne, + ); + + $this->hookableRenderer->expects($this->once())->method('render')->willReturn('hookable_one_rendered'); + + $result = $this->getTestSubject()->render(['hook_name']); + + $this->assertSame('hookable_one_rendered', $result); + } + private function getTestSubject(): HookRenderer { return new HookRenderer( @@ -94,6 +120,7 @@ private function getTestSubject(): HookRenderer $this->contextProvider, $this->configurationProvider, $this->hookableMetadataFactory, + $this->hookableConditionChecker, ); } } diff --git a/src/TwigHooks/tests/Unit/Hookable/Checker/HookableConditionCheckerTest.php b/src/TwigHooks/tests/Unit/Hookable/Checker/HookableConditionCheckerTest.php new file mode 100644 index 00000000..2d169af1 --- /dev/null +++ b/src/TwigHooks/tests/Unit/Hookable/Checker/HookableConditionCheckerTest.php @@ -0,0 +1,74 @@ +assertTrue($this->createTestSubject()->isEnabled($hookable, [])); + } + + public function testItReturnsTrueWhenConditionIsSatisfied(): void + { + $hookable = new HookableTemplate('some_hook', 'some_name', 'some_target', condition: '@=user !== null'); + + $this->assertTrue($this->createTestSubject()->isEnabled($hookable, ['user' => new \stdClass()])); + } + + public function testItReturnsFalseWhenConditionIsNotSatisfied(): void + { + $hookable = new HookableTemplate('some_hook', 'some_name', 'some_target', condition: '@=user !== null'); + + $this->assertFalse($this->createTestSubject()->isEnabled($hookable, ['user' => null])); + } + + public function testItResolvesConditionAgainstContextVariable(): void + { + $hookable = new HookableTemplate('some_hook', 'some_name', 'some_target', condition: '@=_context.user !== null'); + + $this->assertTrue($this->createTestSubject()->isEnabled($hookable, ['user' => new \stdClass()])); + } + + public function testItKeepsContextVariableReservedWhenContextContainsCollidingKey(): void + { + $hookable = new HookableTemplate('some_hook', 'some_name', 'some_target', condition: '@=_context.user !== null'); + + $this->assertTrue($this->createTestSubject()->isEnabled($hookable, ['_context' => 'unexpected', 'user' => new \stdClass()])); + } + + public function testItThrowsExceptionWhenConditionCannotBeEvaluated(): void + { + $hookable = new HookableTemplate('some_hook', 'some_name', 'some_target', condition: '@=this is not a valid expression !!!'); + + $this->expectException(InvalidExpressionException::class); + + $this->createTestSubject()->isEnabled($hookable, []); + } + + private function createTestSubject(): HookableConditionCheckerInterface + { + return new HookableConditionChecker(new ExpressionLanguage()); + } +}