From 93b1c4bd27b83fe78b56a1c5950b0a8d9ddc4c6e Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 27 Feb 2026 15:07:01 +0300 Subject: [PATCH 1/4] [fix] registerForAutoconfiguration in build() + tagged_iterator backslash Co-Authored-By: Claude Sonnet 4.6 --- src/ChamberOrchestraMenuBundle.php | 17 +++++++++++++++++ src/Resources/config/services.php | 6 +++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/ChamberOrchestraMenuBundle.php b/src/ChamberOrchestraMenuBundle.php index c77522e..95d6280 100644 --- a/src/ChamberOrchestraMenuBundle.php +++ b/src/ChamberOrchestraMenuBundle.php @@ -11,8 +11,25 @@ namespace ChamberOrchestra\MenuBundle; +use ChamberOrchestra\MenuBundle\Factory\Extension\ExtensionInterface; +use ChamberOrchestra\MenuBundle\Factory\Extension\RuntimeExtensionInterface; +use ChamberOrchestra\MenuBundle\Navigation\NavigationInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; final class ChamberOrchestraMenuBundle extends Bundle { + public function build(ContainerBuilder $container): void + { + parent::build($container); + + $container->registerForAutoconfiguration(NavigationInterface::class) + ->addTag('chamber_orchestra_menu.navigation'); + + $container->registerForAutoconfiguration(ExtensionInterface::class) + ->addTag('chamber_orchestra_menu.factory.extension'); + + $container->registerForAutoconfiguration(RuntimeExtensionInterface::class) + ->addTag('chamber_orchestra_menu.factory.runtime_extension'); + } } diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 07158e5..2871fc1 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -39,11 +39,11 @@ ->exclude('../../{DependencyInjection,Resources,Exception,Navigation}'); $services->set(Factory::class) - ->call('addExtensions', [\tagged_iterator('chamber_orchestra_menu.factory.extension')]); + ->call('addExtensions', [tagged_iterator('chamber_orchestra_menu.factory.extension')]); $services->set(NavigationFactory::class) - ->call('addRuntimeExtensions', [\tagged_iterator('chamber_orchestra_menu.factory.runtime_extension')]); + ->call('addRuntimeExtensions', [tagged_iterator('chamber_orchestra_menu.factory.runtime_extension')]); $services->set(Matcher::class) - ->call('addVoters', [\tagged_iterator('chamber_orchestra_menu.matcher.voter')]); + ->call('addVoters', [tagged_iterator('chamber_orchestra_menu.matcher.voter')]); }; From 0af2ccec9164353fa01b148d9030fd65bca2277f Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 27 Feb 2026 15:09:26 +0300 Subject: [PATCH 2/4] [fix] native_function_invocation @all -> @internal to allow Symfony tagged_iterator Co-Authored-By: Claude Sonnet 4.6 --- .php-cs-fixer.dist.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 10928e2..969136d 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -25,7 +25,7 @@ 'import_functions' => false, ], 'native_function_invocation' => [ - 'include' => ['@all'], + 'include' => ['@internal'], 'scope' => 'all', 'strict' => true, ], From fa27a5e01bd63f4c649f664f1ecc2fd2e63c737c Mon Sep 17 00:00:00 2001 From: Dev Date: Sun, 1 Mar 2026 13:26:10 +0000 Subject: [PATCH 3/4] [8.0] add VoterInterface, icon/divider/visibility/counter/translation extensions, bundle config, breadcrumbs, menu_get - Add VoterInterface and refactor Matcher to use it instead of concrete RouteVoter - Add Item::setLabel() for runtime label mutation - Add build-time IconExtension and DividerExtension - Add runtime VisibilityExtension and CounterExtension - Add runtime TranslationExtension with auto-disable when no translator available - Add bundle Configuration (default_template, translation.domain, cache.namespace) - Add menu_get() and menu_breadcrumbs() Twig functions - Make render_menu() template argument optional with default_template fallback - Add symfony/translation-contracts dependency - Update README with all new features Co-Authored-By: Claude Opus 4.6 --- .claude/settings.json | 12 + .php-cs-fixer.dist.php | 34 +-- README.md | 267 ++++++++++++++++-- composer.json | 1 + src/ChamberOrchestraMenuBundle.php | 18 +- .../ChamberOrchestraMenuExtension.php | 8 + src/DependencyInjection/Configuration.php | 49 ++++ src/Factory/Extension/CounterExtension.php | 35 +++ src/Factory/Extension/DividerExtension.php | 36 +++ src/Factory/Extension/IconExtension.php | 36 +++ src/Factory/Extension/RoutingExtension.php | 3 +- .../Extension/TranslationExtension.php | 38 +++ src/Factory/Extension/VisibilityExtension.php | 28 ++ src/Matcher/Matcher.php | 6 +- src/Matcher/Voter/RouteVoter.php | 2 +- src/Matcher/Voter/VoterInterface.php | 19 ++ src/Menu/Item.php | 7 + src/Registry/NavigationRegistry.php | 5 +- src/Resources/config/services.php | 21 +- src/Twig/Helper/Helper.php | 46 ++- src/Twig/MenuExtension.php | 2 + src/Twig/MenuRuntime.php | 16 +- .../Extension/CounterExtensionTest.php | 72 +++++ .../Extension/DividerExtensionTest.php | 63 +++++ .../Factory/Extension/IconExtensionTest.php | 55 ++++ .../Extension/TranslationExtensionTest.php | 85 ++++++ .../Extension/VisibilityExtensionTest.php | 76 +++++ tests/Unit/Factory/FactoryTest.php | 10 +- tests/Unit/Matcher/MatcherTest.php | 18 +- tests/Unit/Menu/ItemTest.php | 47 ++- .../Unit/Navigation/ClosureNavigationTest.php | 4 +- tests/Unit/Twig/Helper/HelperTest.php | 129 +++++++++ 32 files changed, 1160 insertions(+), 88 deletions(-) create mode 100644 .claude/settings.json create mode 100644 src/DependencyInjection/Configuration.php create mode 100644 src/Factory/Extension/CounterExtension.php create mode 100644 src/Factory/Extension/DividerExtension.php create mode 100644 src/Factory/Extension/IconExtension.php create mode 100644 src/Factory/Extension/TranslationExtension.php create mode 100644 src/Factory/Extension/VisibilityExtension.php create mode 100644 src/Matcher/Voter/VoterInterface.php create mode 100644 tests/Unit/Factory/Extension/CounterExtensionTest.php create mode 100644 tests/Unit/Factory/Extension/DividerExtensionTest.php create mode 100644 tests/Unit/Factory/Extension/IconExtensionTest.php create mode 100644 tests/Unit/Factory/Extension/TranslationExtensionTest.php create mode 100644 tests/Unit/Factory/Extension/VisibilityExtensionTest.php create mode 100644 tests/Unit/Twig/Helper/HelperTest.php diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..432db3f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,12 @@ +{ + "enabledPlugins": { + "code-review@claude-plugins-official": true, + "github@claude-plugins-official": true, + "feature-dev@claude-plugins-official": true, + "code-simplifier@claude-plugins-official": true, + "ralph-loop@claude-plugins-official": true, + "pr-review-toolkit@claude-plugins-official": true, + "claude-md-management@claude-plugins-official": true, + "php-lsp@claude-plugins-official": true + } +} diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 969136d..52e4c56 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,29 +1,29 @@ in(__DIR__) - ->exclude('var') - ->exclude('vendor') + ->in(__DIR__.'/src') + ->in(__DIR__.'/tests') + ->notPath('Resources/config/') ; return (new PhpCsFixer\Config()) ->setRules([ '@PER-CS' => true, '@Symfony' => true, - 'declare_strict_types' => true, - 'strict_param' => true, - 'array_syntax' => ['syntax' => 'short'], - 'ordered_imports' => ['sort_algorithm' => 'alpha'], - 'no_unused_imports' => true, - 'trailing_comma_in_multiline' => true, - 'single_quote' => true, - 'global_namespace_import' => [ - 'import_classes' => false, - 'import_constants' => false, - 'import_functions' => false, + '@Symfony:risky' => true, + '@PHP85Migration' => true, + '@PHP8x5Migration:risky' => true, + 'header_comment' => [ + 'header' => <<<'EOF' +This file is part of the ChamberOrchestra package. + +For the full copyright and license information, please view the LICENSE +file that was distributed with this source code. +EOF, + 'location' => 'after_declare_strict', + 'separate' => 'both', ], + 'strict_param' => true, 'native_function_invocation' => [ 'include' => ['@internal'], 'scope' => 'all', @@ -32,4 +32,4 @@ ]) ->setFinder($finder) ->setRiskyAllowed(true) -; + ; diff --git a/README.md b/README.md index 977b94e..da5ccf5 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,20 @@ A **Symfony 8** bundle for building navigation menus, sidebars, and breadcrumbs - **Fluent builder API** — `add()`, `children()`, `end()` for deeply-nested trees - **Route-based matching** — `RouteVoter` marks the current item and its ancestors active; route values are treated as regex patterns +- **Custom voters** — implement `VoterInterface` to add custom matching logic alongside the built-in `RouteVoter` - **Role-based access** — `Accessor` gates items by Symfony security roles; results are memoized per request - **PSR-6 caching** — `AbstractCachedNavigation` caches the item tree for 24 h with tag-based invalidation - **Runtime extensions** — `RuntimeExtensionInterface` runs post-cache on every request for fresh dynamic data without rebuilding the tree - **Badge support** — `BadgeExtension` resolves `int` and `\Closure` badges at runtime; implement `RuntimeExtensionInterface` for service-injected dynamic badges -- **Twig integration** — `render_menu()` function with fully customisable templates +- **Counters** — `CounterExtension` resolves multiple named counters (`int` or `\Closure`) at runtime +- **Icons** — `IconExtension` moves the `icon` option into `extras['icon']` at build time +- **Dividers** — `DividerExtension` marks items as dividers via `extras['divider']` at build time +- **Visibility** — `VisibilityExtension` resolves `visible` (bool or `\Closure`) at runtime into `extras['visible']` +- **Label translation** — `TranslationExtension` translates item labels via Symfony's `TranslatorInterface` (auto-disabled when no translator is available) +- **Breadcrumbs** — `menu_breadcrumbs()` Twig function returns the path from root to the current item +- **Raw tree access** — `menu_get()` Twig function returns the root `Item` without rendering +- **Twig integration** — `render_menu()` function with fully customisable templates and optional default template +- **Bundle configuration** — centralised config for default template, translation domain, and cache namespace - **Extension system** — build-time `ExtensionInterface` for cached option enrichment, runtime `RuntimeExtensionInterface` for post-cache processing - **DI autoconfiguration** — implement an interface, done; no manual service tags required @@ -34,6 +43,7 @@ A **Symfony 8** bundle for building navigation menus, sidebars, and breadcrumbs | ext-ds | `*` | | doctrine/collections | `^2.0 \|\| ^3.0` | | symfony/\* | `^8.0` | +| symfony/translation-contracts | `^3.4` | | twig/twig | `^3.0` | --- @@ -56,6 +66,22 @@ return [ --- +## Configuration + +```yaml +# config/packages/chamber_orchestra_menu.yaml +chamber_orchestra_menu: + default_template: ~ # ?string — fallback template for render_menu() + translation: + domain: 'messages' # string — default translation domain for labels + cache: + namespace: '$NAVIGATION$' # string — cache key namespace prefix +``` + +All values are optional with sensible defaults. + +--- + ## Quick Start ### 1. Create a navigation class @@ -73,7 +99,7 @@ final class SidebarNavigation extends AbstractCachedNavigation public function build(MenuBuilder $builder, array $options = []): void { $builder - ->add('dashboard', ['label' => 'Dashboard', 'route' => 'app_dashboard']) + ->add('dashboard', ['label' => 'Dashboard', 'route' => 'app_dashboard', 'icon' => 'fa-home']) ->add('blog', ['label' => 'Blog']) ->children() ->add('posts', ['label' => 'Posts', 'route' => 'app_blog_post_index']) @@ -113,18 +139,23 @@ The class is auto-tagged as a navigation service — no YAML/XML service definit Options are passed as the second argument to `MenuBuilder::add()`: -| Option | Type | Description | -|---|---|---| -| `label` | `string` | Display text; falls back to item name if absent (`LabelExtension`) | -| `route` | `string` | Route name; generates `uri` and appends to `routes` (`RoutingExtension`) | -| `route_params` | `array` | Route parameters passed to the URL generator (`RoutingExtension`) | -| `route_type` | `int` | `UrlGeneratorInterface::ABSOLUTE_PATH` (default) or `ABSOLUTE_URL` (`RoutingExtension`) | -| `routes` | `array` | Additional routes that activate this item (supports regex) | -| `uri` | `string` | Raw URI; set directly if not using `route` | -| `roles` | `array` | Security roles **all** required to display the item (AND logic) | -| `badge` | `int\|\Closure` | Badge count; resolved post-cache by `BadgeExtension` (a runtime extension); stored in `extras['badge']` | -| `attributes` | `array` | HTML attributes merged onto the rendered element (`CoreExtension`) | -| `extras` | `array` | Arbitrary extra data attached to the item (`CoreExtension`) | +| Option | Type | Extension | Description | +|---|---|---|---| +| `label` | `string` | `LabelExtension` | Display text; falls back to item name if absent | +| `route` | `string` | `RoutingExtension` | Route name; generates `uri` and appends to `routes` | +| `route_params` | `array` | `RoutingExtension` | Route parameters passed to the URL generator | +| `route_type` | `int` | `RoutingExtension` | `UrlGeneratorInterface::ABSOLUTE_PATH` (default) or `ABSOLUTE_URL` | +| `routes` | `array` | — | Additional routes that activate this item (supports regex) | +| `uri` | `string` | — | Raw URI; set directly if not using `route` | +| `roles` | `array` | — | Security roles **all** required to display the item (AND logic) | +| `icon` | `string` | `IconExtension` | Icon identifier; moved to `extras['icon']` at build time | +| `divider` | `bool` | `DividerExtension` | When `true`, marks the item as a divider via `extras['divider']` | +| `badge` | `int\|\Closure` | `BadgeExtension` | Badge count; resolved post-cache; stored in `extras['badge']` | +| `counters` | `array` | `CounterExtension` | Named counters; resolved post-cache; stored in `extras['counters']` | +| `visible` | `bool\|\Closure` | `VisibilityExtension` | Visibility flag; resolved post-cache; stored in `extras['visible']` | +| `translation_domain` | `string` | `TranslationExtension` | Per-item translation domain override | +| `attributes` | `array` | `CoreExtension` | HTML attributes merged onto the rendered element | +| `extras` | `array` | `CoreExtension` | Arbitrary extra data attached to the item | ### Section items @@ -196,6 +227,8 @@ final class MainNavigation extends AbstractCachedNavigation The default cache key is the fully-qualified class name; default TTL is **24 hours**; default tag is `chamber_orchestra_menu`. +The cache namespace prefix defaults to `$NAVIGATION$` and can be changed via bundle configuration. + --- ## Route Matching @@ -212,6 +245,42 @@ $builder->add('blog', [ ]); ``` +### Custom Voters + +Implement `VoterInterface` to add custom matching logic. Custom voters are auto-tagged and used alongside `RouteVoter`: + +```php +requestStack->getCurrentRequest(); + if (null === $request) { + return null; + } + + $expectedTab = $item->getOption('tab'); + if (null === $expectedTab) { + return null; + } + + return $request->query->get('tab') === $expectedTab ? true : null; + } +} +``` + --- ## Role-Based Access @@ -232,6 +301,65 @@ The `accessor` variable is injected into every rendered template. Call `hasAcces --- +## Icons + +The built-in `IconExtension` moves the `icon` option into `extras['icon']` at build time, so the value is cached with the tree: + +```php +$builder->add('dashboard', ['label' => 'Dashboard', 'icon' => 'fa-home']); +``` + +In Twig: + +```twig +{% set icon = item.option('extras').icon|default(null) %} +{% if icon %} + +{% endif %} +``` + +--- + +## Dividers + +The `DividerExtension` marks items as visual dividers at build time: + +```php +$builder->add('separator', ['divider' => true]); +``` + +In Twig: + +```twig +{% if item.option('extras').divider|default(false) %} +
+{% else %} + {{ item.label }} +{% endif %} +``` + +--- + +## Visibility + +The `VisibilityExtension` resolves the `visible` option at runtime. Pass a `bool` or a `\Closure`: + +```php +$builder + ->add('beta_feature', ['label' => 'Beta', 'visible' => false]) + ->add('promo', ['label' => 'Promo', 'visible' => fn (): bool => $this->featureFlags->isEnabled('promo')]); +``` + +In Twig: + +```twig +{% if item.option('extras').visible|default(true) %} + {{ item.label }} +{% endif %} +``` + +--- + ## Badges ### Via the `badge` option @@ -282,6 +410,48 @@ In Twig, read the badge via `item.badge`: --- +## Counters + +The `CounterExtension` resolves multiple named counters at runtime. Pass a `array`: + +```php +$builder->add('orders', [ + 'label' => 'Orders', + 'counters' => [ + 'pending' => fn (): int => $this->orders->countPending(), + 'shipped' => fn (): int => $this->orders->countShipped(), + ], +]); +``` + +In Twig: + +```twig +{% set counters = item.option('extras').counters|default({}) %} +{% for name, count in counters %} + {{ count }} +{% endfor %} +``` + +--- + +## Label Translation + +The `TranslationExtension` translates item labels using Symfony's `TranslatorInterface`. It runs at runtime (post-cache) so translated labels are always fresh. + +- **Default domain:** configured via `chamber_orchestra_menu.translation.domain` (defaults to `messages`) +- **Per-item override:** set the `translation_domain` option on an item +- **Empty labels** are skipped +- **Auto-disabled** when no `TranslatorInterface` service is available in the container + +```php +$builder + ->add('scores', ['label' => 'nav.scores']) + ->add('rehearsals', ['label' => 'nav.rehearsals', 'translation_domain' => 'navigation']); +``` + +--- + ## Factory Extensions ### Build-time extensions (cached) @@ -291,18 +461,32 @@ Implement `ExtensionInterface` to enrich item options before the `Item` is creat ```php use ChamberOrchestra\MenuBundle\Factory\Extension\ExtensionInterface; -final class IconExtension implements ExtensionInterface +final class TooltipExtension implements ExtensionInterface { public function buildOptions(array $options): array { - $options['attributes']['data-icon'] ??= $options['icon'] ?? null; - unset($options['icon']); + if (isset($options['tooltip'])) { + $extras = $options['extras'] ?? []; + $extras['tooltip'] = $options['tooltip']; + $options['extras'] = $extras; + unset($options['tooltip']); + } return $options; } } ``` +### Built-in build-time extensions + +| Extension | Option | Stored in | Description | +|---|---|---|---| +| `RoutingExtension` | `route`, `route_params`, `route_type` | `uri`, `routes` | Generates URI from route | +| `LabelExtension` | `label` | `label` | Falls back to item name | +| `IconExtension` | `icon` | `extras['icon']` | Icon identifier | +| `DividerExtension` | `divider` | `extras['divider']` | Divider flag | +| `CoreExtension` | `attributes`, `extras` | — | Defaults (priority `-10`, runs last) | + ### Runtime extensions (post-cache) Implement `RuntimeExtensionInterface` to apply fresh data after every cache fetch. `processItem()` is called on every `Item` in the tree: @@ -324,6 +508,15 @@ final class NotificationBadgeExtension implements RuntimeExtensionInterface } ``` +### Built-in runtime extensions + +| Extension | Option | Stored in | Description | +|---|---|---|---| +| `BadgeExtension` | `badge` | `extras['badge']` | Single badge count (`int\|\Closure`) | +| `CounterExtension` | `counters` | `extras['counters']` | Named counters map | +| `VisibilityExtension` | `visible` | `extras['visible']` | Visibility flag (`bool\|\Closure`) | +| `TranslationExtension` | `translation_domain` | label (via `setLabel()`) | Translates labels | + --- ## DI Autoconfiguration @@ -335,6 +528,33 @@ Implement an interface and you're done — no manual service tags required: | `NavigationInterface` | `chamber_orchestra_menu.navigation` | | `ExtensionInterface` | `chamber_orchestra_menu.factory.extension` | | `RuntimeExtensionInterface` | `chamber_orchestra_menu.factory.runtime_extension` | +| `VoterInterface` | `chamber_orchestra_menu.matcher.voter` | + +--- + +## Breadcrumbs + +The `menu_breadcrumbs()` Twig function returns the path from root to the current item (root excluded): + +```twig +{% set crumbs = menu_breadcrumbs('App\\Navigation\\SidebarNavigation') %} + + +``` + +Returns an empty array when no item is currently active. --- @@ -344,11 +564,20 @@ Implement an interface and you're done — no manual service tags required: {# Renders a navigation using the given template #} {{ render_menu('App\\Navigation\\MyNavigation', 'nav/my.html.twig') }} -{# With extra options passed to build() #} +{# Uses the default_template from bundle config (template argument omitted) #} +{{ render_menu('App\\Navigation\\MyNavigation') }} + +{# With extra options passed to the template #} {{ render_menu('App\\Navigation\\MyNavigation', 'nav/my.html.twig', {locale: app.request.locale}) }} + +{# Get the raw Item tree without rendering #} +{% set root = menu_get('App\\Navigation\\MyNavigation') %} + +{# Get the breadcrumb path to the current item #} +{% set crumbs = menu_breadcrumbs('App\\Navigation\\MyNavigation') %} ``` -**Template variables:** +**Template variables (available inside `render_menu` templates):** | Variable | Type | Description | |---|---|---| diff --git a/composer.json b/composer.json index c1ea780..482f60b 100644 --- a/composer.json +++ b/composer.json @@ -67,6 +67,7 @@ "symfony/http-kernel": "^8.0", "symfony/routing": "^8.0", "symfony/security-core": "^8.0", + "symfony/translation-contracts": "^3.4", "twig/twig": "^3.0" }, "require-dev": { diff --git a/src/ChamberOrchestraMenuBundle.php b/src/ChamberOrchestraMenuBundle.php index 95d6280..056be35 100644 --- a/src/ChamberOrchestraMenuBundle.php +++ b/src/ChamberOrchestraMenuBundle.php @@ -13,11 +13,15 @@ use ChamberOrchestra\MenuBundle\Factory\Extension\ExtensionInterface; use ChamberOrchestra\MenuBundle\Factory\Extension\RuntimeExtensionInterface; +use ChamberOrchestra\MenuBundle\Factory\Extension\TranslationExtension; +use ChamberOrchestra\MenuBundle\Matcher\Voter\VoterInterface; use ChamberOrchestra\MenuBundle\Navigation\NavigationInterface; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; +use Symfony\Contracts\Translation\TranslatorInterface; -final class ChamberOrchestraMenuBundle extends Bundle +final class ChamberOrchestraMenuBundle extends Bundle implements CompilerPassInterface { public function build(ContainerBuilder $container): void { @@ -31,5 +35,17 @@ public function build(ContainerBuilder $container): void $container->registerForAutoconfiguration(RuntimeExtensionInterface::class) ->addTag('chamber_orchestra_menu.factory.runtime_extension'); + + $container->registerForAutoconfiguration(VoterInterface::class) + ->addTag('chamber_orchestra_menu.matcher.voter'); + + $container->addCompilerPass($this); + } + + public function process(ContainerBuilder $container): void + { + if (!$container->has(TranslatorInterface::class)) { + $container->removeDefinition(TranslationExtension::class); + } } } diff --git a/src/DependencyInjection/ChamberOrchestraMenuExtension.php b/src/DependencyInjection/ChamberOrchestraMenuExtension.php index d9e0362..ebdf72f 100644 --- a/src/DependencyInjection/ChamberOrchestraMenuExtension.php +++ b/src/DependencyInjection/ChamberOrchestraMenuExtension.php @@ -23,6 +23,14 @@ final class ChamberOrchestraMenuExtension extends Extension */ public function load(array $configs, ContainerBuilder $container): void { + $configuration = new Configuration(); + /** @var array{default_template: ?string, translation: array{domain: string}, cache: array{namespace: string}} $config */ + $config = $this->processConfiguration($configuration, $configs); + + $container->setParameter('chamber_orchestra_menu.default_template', $config['default_template']); + $container->setParameter('chamber_orchestra_menu.translation.domain', $config['translation']['domain']); + $container->setParameter('chamber_orchestra_menu.cache.namespace', $config['cache']['namespace']); + $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('services.php'); } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100644 index 0000000..588107c --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,49 @@ +getRootNode() + ->children() + ->scalarNode('default_template') + ->defaultNull() + ->end() + ->arrayNode('translation') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('domain') + ->defaultValue('messages') + ->end() + ->end() + ->end() + ->arrayNode('cache') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('namespace') + ->defaultValue('$NAVIGATION$') + ->end() + ->end() + ->end() + ->end() + ; + + return $treeBuilder; + } +} diff --git a/src/Factory/Extension/CounterExtension.php b/src/Factory/Extension/CounterExtension.php new file mode 100644 index 0000000..407ee59 --- /dev/null +++ b/src/Factory/Extension/CounterExtension.php @@ -0,0 +1,35 @@ +|null $counters */ + $counters = $item->getOption('counters'); + + if (null === $counters) { + return; + } + + /** @var array $resolved */ + $resolved = []; + foreach ($counters as $name => $value) { + $resolved[$name] = $value instanceof \Closure ? $value() : $value; + } + + $item->setExtra('counters', $resolved); + } +} diff --git a/src/Factory/Extension/DividerExtension.php b/src/Factory/Extension/DividerExtension.php new file mode 100644 index 0000000..16920d8 --- /dev/null +++ b/src/Factory/Extension/DividerExtension.php @@ -0,0 +1,36 @@ + $options + * + * @return array + */ + public function buildOptions(array $options): array + { + if (empty($options['divider'])) { + return $options; + } + + /** @var array $extras */ + $extras = $options['extras'] ?? []; + $extras['divider'] = true; + $options['extras'] = $extras; + + unset($options['divider']); + + return $options; + } +} diff --git a/src/Factory/Extension/IconExtension.php b/src/Factory/Extension/IconExtension.php new file mode 100644 index 0000000..49a34bb --- /dev/null +++ b/src/Factory/Extension/IconExtension.php @@ -0,0 +1,36 @@ + $options + * + * @return array + */ + public function buildOptions(array $options): array + { + if (!isset($options['icon'])) { + return $options; + } + + /** @var array $extras */ + $extras = $options['extras'] ?? []; + $extras['icon'] = $options['icon']; + $options['extras'] = $extras; + + unset($options['icon']); + + return $options; + } +} diff --git a/src/Factory/Extension/RoutingExtension.php b/src/Factory/Extension/RoutingExtension.php index 135d3ff..8819cf8 100644 --- a/src/Factory/Extension/RoutingExtension.php +++ b/src/Factory/Extension/RoutingExtension.php @@ -47,7 +47,8 @@ public function buildOptions(array $options = []): array 'route' => $route, 'route_params' => $params, ], - ]); + ] + ); return $options; } diff --git a/src/Factory/Extension/TranslationExtension.php b/src/Factory/Extension/TranslationExtension.php new file mode 100644 index 0000000..f8edc74 --- /dev/null +++ b/src/Factory/Extension/TranslationExtension.php @@ -0,0 +1,38 @@ +getLabel(); + + if ('' === $label) { + return; + } + + /** @var string $domain */ + $domain = $item->getOption('translation_domain', $this->defaultDomain); + + $item->setLabel($this->translator->trans($label, [], $domain)); + } +} diff --git a/src/Factory/Extension/VisibilityExtension.php b/src/Factory/Extension/VisibilityExtension.php new file mode 100644 index 0000000..35d2b5c --- /dev/null +++ b/src/Factory/Extension/VisibilityExtension.php @@ -0,0 +1,28 @@ +getOption('visible'); + + if (null === $visible) { + return; + } + + $item->setExtra('visible', $visible instanceof \Closure ? $visible() : $visible); + } +} diff --git a/src/Matcher/Matcher.php b/src/Matcher/Matcher.php index 44f5d02..39493d1 100644 --- a/src/Matcher/Matcher.php +++ b/src/Matcher/Matcher.php @@ -11,14 +11,14 @@ namespace ChamberOrchestra\MenuBundle\Matcher; -use ChamberOrchestra\MenuBundle\Matcher\Voter\RouteVoter; +use ChamberOrchestra\MenuBundle\Matcher\Voter\VoterInterface; use ChamberOrchestra\MenuBundle\Menu\Item; class Matcher { /** @var \SplObjectStorage */ private \SplObjectStorage $cache; - /** @var iterable */ + /** @var iterable */ private iterable $voters = []; public function __construct() @@ -27,7 +27,7 @@ public function __construct() } /** - * @param iterable $voters + * @param iterable $voters */ public function addVoters(iterable $voters): void { diff --git a/src/Matcher/Voter/RouteVoter.php b/src/Matcher/Voter/RouteVoter.php index ccfa100..dd76af9 100644 --- a/src/Matcher/Voter/RouteVoter.php +++ b/src/Matcher/Voter/RouteVoter.php @@ -16,7 +16,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; -class RouteVoter +class RouteVoter implements VoterInterface { private ?Request $lastRequest = null; private ?string $lastRoute = null; diff --git a/src/Matcher/Voter/VoterInterface.php b/src/Matcher/Voter/VoterInterface.php new file mode 100644 index 0000000..1fbd175 --- /dev/null +++ b/src/Matcher/Voter/VoterInterface.php @@ -0,0 +1,19 @@ +options['label'] = $label; + + return $this; + } + public function getUri(): ?string { $uri = $this->options['uri'] ?? null; diff --git a/src/Registry/NavigationRegistry.php b/src/Registry/NavigationRegistry.php index 8049ca4..157827e 100644 --- a/src/Registry/NavigationRegistry.php +++ b/src/Registry/NavigationRegistry.php @@ -22,8 +22,9 @@ class NavigationRegistry * @param ServiceLocator $locator */ public function __construct( - #[AutowireLocator('chamber_orchestra_menu.navigation')] private readonly ServiceLocator $locator) - { + #[AutowireLocator('chamber_orchestra_menu.navigation')] + private readonly ServiceLocator $locator + ) { } public function get(string $id): NavigationInterface diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 2871fc1..4ea0d63 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -11,12 +11,11 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; -use ChamberOrchestra\MenuBundle\Factory\Extension\ExtensionInterface; -use ChamberOrchestra\MenuBundle\Factory\Extension\RuntimeExtensionInterface; +use ChamberOrchestra\MenuBundle\Factory\Extension\TranslationExtension; use ChamberOrchestra\MenuBundle\Factory\Factory; use ChamberOrchestra\MenuBundle\Matcher\Matcher; -use ChamberOrchestra\MenuBundle\Navigation\NavigationInterface; use ChamberOrchestra\MenuBundle\NavigationFactory; +use ChamberOrchestra\MenuBundle\Twig\Helper\Helper; return static function (ContainerConfigurator $container): void { $services = $container->services() @@ -24,15 +23,6 @@ ->autowire() ->autoconfigure() ->private() - - ->instanceof(ExtensionInterface::class) - ->tag('chamber_orchestra_menu.factory.extension') - - ->instanceof(RuntimeExtensionInterface::class) - ->tag('chamber_orchestra_menu.factory.runtime_extension') - - ->instanceof(NavigationInterface::class) - ->tag('chamber_orchestra_menu.navigation') ; $services->load('ChamberOrchestra\\MenuBundle\\', '../../') @@ -42,8 +32,15 @@ ->call('addExtensions', [tagged_iterator('chamber_orchestra_menu.factory.extension')]); $services->set(NavigationFactory::class) + ->arg('$options', ['namespace' => '%chamber_orchestra_menu.cache.namespace%']) ->call('addRuntimeExtensions', [tagged_iterator('chamber_orchestra_menu.factory.runtime_extension')]); $services->set(Matcher::class) ->call('addVoters', [tagged_iterator('chamber_orchestra_menu.matcher.voter')]); + + $services->set(TranslationExtension::class) + ->arg('$defaultDomain', '%chamber_orchestra_menu.translation.domain%'); + + $services->set(Helper::class) + ->arg('$defaultTemplate', '%chamber_orchestra_menu.default_template%'); }; diff --git a/src/Twig/Helper/Helper.php b/src/Twig/Helper/Helper.php index cac5eed..f6461df 100644 --- a/src/Twig/Helper/Helper.php +++ b/src/Twig/Helper/Helper.php @@ -11,6 +11,8 @@ namespace ChamberOrchestra\MenuBundle\Twig\Helper; +use ChamberOrchestra\MenuBundle\Matcher\Matcher; +use ChamberOrchestra\MenuBundle\Menu\Item; use ChamberOrchestra\MenuBundle\Navigation\NavigationInterface; use ChamberOrchestra\MenuBundle\NavigationFactory; use ChamberOrchestra\MenuBundle\Renderer\TwigRenderer; @@ -20,14 +22,56 @@ class Helper public function __construct( private readonly TwigRenderer $renderer, private readonly NavigationFactory $factory, + private readonly Matcher $matcher, + private readonly ?string $defaultTemplate = null, ) { } /** * @param array $options */ - public function render(NavigationInterface|string $menu, string $template, array $options = []): string + public function render(NavigationInterface|string $menu, ?string $template = null, array $options = []): string { + $template ??= $this->defaultTemplate; + + if (null === $template) { + throw new \InvalidArgumentException('No template provided and no default template configured. Pass a template argument or set "chamber_orchestra_menu.default_template".'); + } + return $this->renderer->render($this->factory->create($menu, []), $template, $options); } + + public function get(NavigationInterface|string $menu): Item + { + return $this->factory->create($menu, []); + } + + /** + * @return list + */ + public function breadcrumbs(NavigationInterface|string $menu): array + { + $root = $this->factory->create($menu, []); + + return $this->findCurrentPath($root); + } + + /** + * @return list + */ + private function findCurrentPath(Item $item): array + { + foreach ($item->getChildren() as $child) { + if ($this->matcher->isCurrent($child)) { + return [$child]; + } + + $path = $this->findCurrentPath($child); + if ([] !== $path) { + return [$child, ...$path]; + } + } + + return []; + } } diff --git a/src/Twig/MenuExtension.php b/src/Twig/MenuExtension.php index 3a635c8..ada5c1c 100644 --- a/src/Twig/MenuExtension.php +++ b/src/Twig/MenuExtension.php @@ -20,6 +20,8 @@ public function getFunctions(): array { return [ new TwigFunction('render_menu', [MenuRuntime::class, 'render'], ['is_safe' => ['html']]), + new TwigFunction('menu_get', [MenuRuntime::class, 'get']), + new TwigFunction('menu_breadcrumbs', [MenuRuntime::class, 'breadcrumbs']), ]; } } diff --git a/src/Twig/MenuRuntime.php b/src/Twig/MenuRuntime.php index 25de90f..4f5b0f5 100644 --- a/src/Twig/MenuRuntime.php +++ b/src/Twig/MenuRuntime.php @@ -11,6 +11,7 @@ namespace ChamberOrchestra\MenuBundle\Twig; +use ChamberOrchestra\MenuBundle\Menu\Item; use ChamberOrchestra\MenuBundle\Navigation\NavigationInterface; use ChamberOrchestra\MenuBundle\Twig\Helper\Helper; use Twig\Extension\RuntimeExtensionInterface; @@ -26,8 +27,21 @@ public function __construct(private readonly Helper $helper) * * @throws \Psr\Cache\InvalidArgumentException */ - public function render(NavigationInterface|string $menu, string $template, array $options = []): string + public function render(NavigationInterface|string $menu, ?string $template = null, array $options = []): string { return $this->helper->render($menu, $template, $options); } + + public function get(NavigationInterface|string $menu): Item + { + return $this->helper->get($menu); + } + + /** + * @return list + */ + public function breadcrumbs(NavigationInterface|string $menu): array + { + return $this->helper->breadcrumbs($menu); + } } diff --git a/tests/Unit/Factory/Extension/CounterExtensionTest.php b/tests/Unit/Factory/Extension/CounterExtensionTest.php new file mode 100644 index 0000000..3554b14 --- /dev/null +++ b/tests/Unit/Factory/Extension/CounterExtensionTest.php @@ -0,0 +1,72 @@ +ext = new CounterExtension(); + } + + #[Test] + public function skipsItemWithoutCountersOption(): void + { + $item = new Item('scores', ['label' => 'Scores']); + $this->ext->processItem($item); + + self::assertNull($item->getOption('extras')['counters'] ?? null); + } + + #[Test] + public function storesIntValuesDirectly(): void + { + $item = new Item('scores', ['counters' => ['rehearsals' => 5, 'compositions' => 12]]); + $this->ext->processItem($item); + + /** @var array $counters */ + $counters = $item->getOption('extras')['counters']; + self::assertSame(['rehearsals' => 5, 'compositions' => 12], $counters); + } + + #[Test] + public function resolvesClosureValues(): void + { + $item = new Item('scores', [ + 'counters' => [ + 'rehearsals' => static fn (): int => 7, + 'compositions' => 3, + ], + ]); + $this->ext->processItem($item); + + /** @var array $counters */ + $counters = $item->getOption('extras')['counters']; + self::assertSame(['rehearsals' => 7, 'compositions' => 3], $counters); + } + + #[Test] + public function emptyArrayStoresEmptyCounters(): void + { + $item = new Item('scores', ['counters' => []]); + $this->ext->processItem($item); + + self::assertSame([], $item->getOption('extras')['counters']); + } +} diff --git a/tests/Unit/Factory/Extension/DividerExtensionTest.php b/tests/Unit/Factory/Extension/DividerExtensionTest.php new file mode 100644 index 0000000..0ef717b --- /dev/null +++ b/tests/Unit/Factory/Extension/DividerExtensionTest.php @@ -0,0 +1,63 @@ +ext = new DividerExtension(); + } + + #[Test] + public function skipsOptionsWithoutDividerKey(): void + { + $options = ['label' => 'Scores']; + + self::assertSame($options, $this->ext->buildOptions($options)); + } + + #[Test] + public function skipsWhenDividerIsFalse(): void + { + $options = ['divider' => false]; + + self::assertSame($options, $this->ext->buildOptions($options)); + } + + #[Test] + public function setsDividerTrueInExtras(): void + { + $result = $this->ext->buildOptions(['divider' => true]); + + self::assertTrue($result['extras']['divider']); + self::assertArrayNotHasKey('divider', $result); + } + + #[Test] + public function preservesExistingExtras(): void + { + $result = $this->ext->buildOptions([ + 'extras' => ['icon' => 'fa-music'], + 'divider' => true, + ]); + + self::assertSame('fa-music', $result['extras']['icon']); + self::assertTrue($result['extras']['divider']); + } +} diff --git a/tests/Unit/Factory/Extension/IconExtensionTest.php b/tests/Unit/Factory/Extension/IconExtensionTest.php new file mode 100644 index 0000000..fe8cc2c --- /dev/null +++ b/tests/Unit/Factory/Extension/IconExtensionTest.php @@ -0,0 +1,55 @@ +ext = new IconExtension(); + } + + #[Test] + public function skipsOptionsWithoutIcon(): void + { + $options = ['label' => 'Scores']; + + self::assertSame($options, $this->ext->buildOptions($options)); + } + + #[Test] + public function movesIconToExtras(): void + { + $result = $this->ext->buildOptions(['icon' => 'fa-music']); + + self::assertSame('fa-music', $result['extras']['icon']); + self::assertArrayNotHasKey('icon', $result); + } + + #[Test] + public function preservesExistingExtras(): void + { + $result = $this->ext->buildOptions([ + 'extras' => ['badge' => 3], + 'icon' => 'fa-violin', + ]); + + self::assertSame(3, $result['extras']['badge']); + self::assertSame('fa-violin', $result['extras']['icon']); + } +} diff --git a/tests/Unit/Factory/Extension/TranslationExtensionTest.php b/tests/Unit/Factory/Extension/TranslationExtensionTest.php new file mode 100644 index 0000000..4644b9e --- /dev/null +++ b/tests/Unit/Factory/Extension/TranslationExtensionTest.php @@ -0,0 +1,85 @@ +createMock(TranslatorInterface::class); + $translator->expects(self::once()) + ->method('trans') + ->with('menu.scores', [], 'messages') + ->willReturn('Partituren'); + + $ext = new TranslationExtension($translator); + $item = new Item('scores', ['label' => 'menu.scores']); + $ext->processItem($item); + + self::assertSame('Partituren', $item->getLabel()); + } + + #[Test] + public function usesPerItemTranslationDomain(): void + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->expects(self::once()) + ->method('trans') + ->with('menu.rehearsals', [], 'navigation') + ->willReturn('Proben'); + + $ext = new TranslationExtension($translator); + $item = new Item('rehearsals', [ + 'label' => 'menu.rehearsals', + 'translation_domain' => 'navigation', + ]); + $ext->processItem($item); + + self::assertSame('Proben', $item->getLabel()); + } + + #[Test] + public function skipsEmptyLabel(): void + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->expects(self::never())->method('trans'); + + $ext = new TranslationExtension($translator); + $item = new Item('scores'); + $ext->processItem($item); + + self::assertSame('', $item->getLabel()); + } + + #[Test] + public function usesCustomDefaultDomain(): void + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->expects(self::once()) + ->method('trans') + ->with('menu.compositions', [], 'orchestra') + ->willReturn('Kompositionen'); + + $ext = new TranslationExtension($translator, 'orchestra'); + $item = new Item('compositions', ['label' => 'menu.compositions']); + $ext->processItem($item); + + self::assertSame('Kompositionen', $item->getLabel()); + } +} diff --git a/tests/Unit/Factory/Extension/VisibilityExtensionTest.php b/tests/Unit/Factory/Extension/VisibilityExtensionTest.php new file mode 100644 index 0000000..b8a9b97 --- /dev/null +++ b/tests/Unit/Factory/Extension/VisibilityExtensionTest.php @@ -0,0 +1,76 @@ +ext = new VisibilityExtension(); + } + + #[Test] + public function skipsItemWithoutVisibleOption(): void + { + $item = new Item('scores', ['label' => 'Scores']); + $this->ext->processItem($item); + + self::assertNull($item->getOption('extras')['visible'] ?? null); + } + + #[Test] + public function storesFalseInExtras(): void + { + $item = new Item('scores', ['visible' => false]); + $this->ext->processItem($item); + + self::assertFalse($item->getOption('extras')['visible']); + } + + #[Test] + public function storesTrueInExtras(): void + { + $item = new Item('scores', ['visible' => true]); + $this->ext->processItem($item); + + self::assertTrue($item->getOption('extras')['visible']); + } + + #[Test] + public function resolvesClosureAndStoresResult(): void + { + $item = new Item('scores', ['visible' => static fn (): bool => false]); + $this->ext->processItem($item); + + self::assertFalse($item->getOption('extras')['visible']); + } + + #[Test] + public function preservesExistingExtras(): void + { + $item = new Item('scores', [ + 'extras' => ['icon' => 'fa-music'], + 'visible' => true, + ]); + $this->ext->processItem($item); + + self::assertSame('fa-music', $item->getOption('extras')['icon']); + self::assertTrue($item->getOption('extras')['visible']); + } +} diff --git a/tests/Unit/Factory/FactoryTest.php b/tests/Unit/Factory/FactoryTest.php index 68dfd0f..502afb1 100644 --- a/tests/Unit/Factory/FactoryTest.php +++ b/tests/Unit/Factory/FactoryTest.php @@ -22,25 +22,25 @@ final class FactoryTest extends TestCase #[Test] public function createItemReturnsItemInstance(): void { - self::assertInstanceOf(Item::class, (new Factory())->createItem('home')); + self::assertInstanceOf(Item::class, new Factory()->createItem('home')); } #[Test] public function createItemSetsCorrectName(): void { - self::assertSame('home', (new Factory())->createItem('home')->getName()); + self::assertSame('home', new Factory()->createItem('home')->getName()); } #[Test] public function createItemInjectsKeyFromName(): void { - self::assertSame('home', (new Factory())->createItem('home')->getOption('key')); + self::assertSame('home', new Factory()->createItem('home')->getOption('key')); } #[Test] public function createItemPassesOptionsToItem(): void { - $item = (new Factory())->createItem('home', ['label' => 'Home', 'uri' => '/']); + $item = new Factory()->createItem('home', ['label' => 'Home', 'uri' => '/']); self::assertSame('Home', $item->getLabel()); self::assertSame('/', $item->getUri()); @@ -49,7 +49,7 @@ public function createItemPassesOptionsToItem(): void #[Test] public function createItemWithSectionFlag(): void { - self::assertTrue((new Factory())->createItem('section', [], true)->isSection()); + self::assertTrue(new Factory()->createItem('section', [], true)->isSection()); } #[Test] diff --git a/tests/Unit/Matcher/MatcherTest.php b/tests/Unit/Matcher/MatcherTest.php index feb756e..b7dc806 100644 --- a/tests/Unit/Matcher/MatcherTest.php +++ b/tests/Unit/Matcher/MatcherTest.php @@ -12,7 +12,7 @@ namespace Tests\Unit\Matcher; use ChamberOrchestra\MenuBundle\Matcher\Matcher; -use ChamberOrchestra\MenuBundle\Matcher\Voter\RouteVoter; +use ChamberOrchestra\MenuBundle\Matcher\Voter\VoterInterface; use ChamberOrchestra\MenuBundle\Menu\Item; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -60,7 +60,7 @@ public function isCurrentReturnsFalseWhenVoterVotesNo(): void public function isCurrentStopsAtFirstDecisiveVoter(): void { $decisive = $this->makeVoter(true); - $neverCalled = $this->createMock(RouteVoter::class); + $neverCalled = $this->createMock(VoterInterface::class); $neverCalled->expects(self::never())->method('matchItem'); $this->matcher->addVoters([$decisive, $neverCalled]); @@ -70,7 +70,7 @@ public function isCurrentStopsAtFirstDecisiveVoter(): void #[Test] public function isCurrentCachesResultAndCallsVoterOnlyOnce(): void { - $voter = $this->createMock(RouteVoter::class); + $voter = $this->createMock(VoterInterface::class); $voter->expects(self::once())->method('matchItem')->willReturn(true); $this->matcher->addVoters([$voter]); @@ -82,7 +82,7 @@ public function isCurrentCachesResultAndCallsVoterOnlyOnce(): void #[Test] public function clearInvalidatesCacheAndReVotes(): void { - $voter = $this->createMock(RouteVoter::class); + $voter = $this->createMock(VoterInterface::class); $voter->expects(self::exactly(2))->method('matchItem')->willReturn(false); $this->matcher->addVoters([$voter]); @@ -110,7 +110,7 @@ public function isAncestorReturnsTrueWhenDirectChildIsCurrent(): void $parent = new Item('parent'); $parent->add($child); - $voter = $this->createStub(RouteVoter::class); + $voter = $this->createStub(VoterInterface::class); $voter->method('matchItem') ->willReturnCallback(static fn (Item $item) => $item === $child ? true : null); $this->matcher->addVoters([$voter]); @@ -138,7 +138,7 @@ public function isAncestorRespectsDepthLimit(): void $parent = new Item('parent'); $parent->add($child); - $voter = $this->createStub(RouteVoter::class); + $voter = $this->createStub(VoterInterface::class); $voter->method('matchItem') ->willReturnCallback(static fn (Item $item) => $item === $grandchild ? true : null); $this->matcher->addVoters([$voter]); @@ -157,7 +157,7 @@ public function isAncestorIsTrueForGrandchildWithUnlimitedDepth(): void $parent = new Item('parent'); $parent->add($child); - $voter = $this->createStub(RouteVoter::class); + $voter = $this->createStub(VoterInterface::class); $voter->method('matchItem') ->willReturnCallback(static fn (Item $item) => $item === $grandchild ? true : null); $this->matcher->addVoters([$voter]); @@ -165,9 +165,9 @@ public function isAncestorIsTrueForGrandchildWithUnlimitedDepth(): void self::assertTrue($this->matcher->isAncestor($parent)); } - private function makeVoter(?bool $result): RouteVoter + private function makeVoter(?bool $result): VoterInterface { - $voter = $this->createStub(RouteVoter::class); + $voter = $this->createStub(VoterInterface::class); $voter->method('matchItem')->willReturn($result); return $voter; diff --git a/tests/Unit/Menu/ItemTest.php b/tests/Unit/Menu/ItemTest.php index 8d21da6..af16ee8 100644 --- a/tests/Unit/Menu/ItemTest.php +++ b/tests/Unit/Menu/ItemTest.php @@ -21,55 +21,74 @@ final class ItemTest extends TestCase #[Test] public function getName(): void { - self::assertSame('home', (new Item('home'))->getName()); + self::assertSame('home', new Item('home')->getName()); } #[Test] public function getLabelDefaultsToEmpty(): void { - self::assertSame('', (new Item('home'))->getLabel()); + self::assertSame('', new Item('home')->getLabel()); } #[Test] public function getLabel(): void { - self::assertSame('Home', (new Item('home', ['label' => 'Home']))->getLabel()); + self::assertSame('Home', new Item('home', ['label' => 'Home'])->getLabel()); + } + + #[Test] + public function setLabelUpdatesLabel(): void + { + $item = new Item('home', ['label' => 'Home']); + $result = $item->setLabel('Dashboard'); + + self::assertSame($item, $result); + self::assertSame('Dashboard', $item->getLabel()); + } + + #[Test] + public function setLabelSetsLabelWhenNoneExists(): void + { + $item = new Item('home'); + $item->setLabel('Homepage'); + + self::assertSame('Homepage', $item->getLabel()); } #[Test] public function getUriDefaultsToNull(): void { - self::assertNull((new Item('home'))->getUri()); + self::assertNull(new Item('home')->getUri()); } #[Test] public function getUri(): void { - self::assertSame('/home', (new Item('home', ['uri' => '/home']))->getUri()); + self::assertSame('/home', new Item('home', ['uri' => '/home'])->getUri()); } #[Test] public function getRolesDefaultsToEmpty(): void { - self::assertSame([], (new Item('home'))->getRoles()); + self::assertSame([], new Item('home')->getRoles()); } #[Test] public function getRoles(): void { - self::assertSame(['ROLE_ADMIN'], (new Item('home', ['roles' => ['ROLE_ADMIN']]))->getRoles()); + self::assertSame(['ROLE_ADMIN'], new Item('home', ['roles' => ['ROLE_ADMIN']])->getRoles()); } #[Test] public function getOptionReturnsFallbackForMissingKey(): void { - self::assertSame('default', (new Item('home'))->getOption('missing', 'default')); + self::assertSame('default', new Item('home')->getOption('missing', 'default')); } #[Test] public function getOptionReturnsFallbackWhenKeyMissingAndDefaultIsNull(): void { - self::assertNull((new Item('home'))->getOption('missing')); + self::assertNull(new Item('home')->getOption('missing')); } #[Test] @@ -83,13 +102,13 @@ public function getOptionReturnsFalseWhenStoredAsFalse(): void #[Test] public function isSectionFalseByDefault(): void { - self::assertFalse((new Item('home'))->isSection()); + self::assertFalse(new Item('home')->isSection()); } #[Test] public function isSection(): void { - self::assertTrue((new Item('section', [], true))->isSection()); + self::assertTrue(new Item('section', [], true)->isSection()); } #[Test] @@ -132,13 +151,13 @@ public function addWithPrependInsertsAtFront(): void #[Test] public function getFirstChildReturnsNullForEmpty(): void { - self::assertNull((new Item('root'))->getFirstChild()); + self::assertNull(new Item('root')->getFirstChild()); } #[Test] public function getLastChildReturnsNullForEmpty(): void { - self::assertNull((new Item('root'))->getLastChild()); + self::assertNull(new Item('root')->getLastChild()); } #[Test] @@ -154,7 +173,7 @@ public function countReflectsChildren(): void #[Test] public function getChildrenReturnsDoctrineCollection(): void { - self::assertInstanceOf(Collection::class, (new Item('root'))->getChildren()); + self::assertInstanceOf(Collection::class, new Item('root')->getChildren()); } #[Test] diff --git a/tests/Unit/Navigation/ClosureNavigationTest.php b/tests/Unit/Navigation/ClosureNavigationTest.php index ecf4deb..6a0d5db 100644 --- a/tests/Unit/Navigation/ClosureNavigationTest.php +++ b/tests/Unit/Navigation/ClosureNavigationTest.php @@ -26,7 +26,7 @@ public function buildInvokesClosureWithBuilderAndOptions(): void $receivedOptions = null; $nav = new ClosureNavigation( - function (MenuBuilder $builder, array $options) use (&$called, &$receivedBuilder, &$receivedOptions): void { + static function (MenuBuilder $builder, array $options) use (&$called, &$receivedBuilder, &$receivedOptions): void { $called = true; $receivedBuilder = $builder; $receivedOptions = $options; @@ -46,7 +46,7 @@ public function buildPassesEmptyOptionsWhenNoneGiven(): void { $received = null; $nav = new ClosureNavigation( - function (MenuBuilder $b, array $options) use (&$received): void { + static function (MenuBuilder $b, array $options) use (&$received): void { $received = $options; } ); diff --git a/tests/Unit/Twig/Helper/HelperTest.php b/tests/Unit/Twig/Helper/HelperTest.php new file mode 100644 index 0000000..6e28ccd --- /dev/null +++ b/tests/Unit/Twig/Helper/HelperTest.php @@ -0,0 +1,129 @@ +matcher = new Matcher(); + } + + #[Test] + public function breadcrumbsReturnsEmptyWhenNoCurrentItem(): void + { + $root = new Item('root'); + $root->add(new Item('scores')); + $root->add(new Item('rehearsals')); + + $this->matcher->addVoters([$this->makeVoter(null)]); + + $helper = $this->createHelper($root); + + self::assertSame([], $helper->breadcrumbs('App\\Nav')); + } + + #[Test] + public function breadcrumbsReturnsPathToNestedCurrentItem(): void + { + $grandchild = new Item('sonata'); + $child = new Item('compositions'); + $child->add($grandchild); + $root = new Item('root'); + $root->add($child); + + $voter = $this->createStub(VoterInterface::class); + $voter->method('matchItem') + ->willReturnCallback(static fn (Item $item) => $item === $grandchild ? true : null); + $this->matcher->addVoters([$voter]); + + $helper = $this->createHelper($root); + $path = $helper->breadcrumbs('App\\Nav'); + + self::assertCount(2, $path); + self::assertSame('compositions', $path[0]->getName()); + self::assertSame('sonata', $path[1]->getName()); + } + + #[Test] + public function breadcrumbsExcludesRoot(): void + { + $child = new Item('scores'); + $root = new Item('root'); + $root->add($child); + + $voter = $this->createStub(VoterInterface::class); + $voter->method('matchItem') + ->willReturnCallback(static fn (Item $item) => $item === $child ? true : null); + $this->matcher->addVoters([$voter]); + + $helper = $this->createHelper($root); + $path = $helper->breadcrumbs('App\\Nav'); + + self::assertCount(1, $path); + self::assertSame('scores', $path[0]->getName()); + } + + #[Test] + public function breadcrumbsReturnsDeeplyNestedPath(): void + { + $level3 = new Item('movement_iii'); + $level2 = new Item('symphony'); + $level2->add($level3); + $level1 = new Item('compositions'); + $level1->add($level2); + $root = new Item('root'); + $root->add($level1); + + $voter = $this->createStub(VoterInterface::class); + $voter->method('matchItem') + ->willReturnCallback(static fn (Item $item) => $item === $level3 ? true : null); + $this->matcher->addVoters([$voter]); + + $helper = $this->createHelper($root); + $path = $helper->breadcrumbs('App\\Nav'); + + self::assertCount(3, $path); + self::assertSame('compositions', $path[0]->getName()); + self::assertSame('symphony', $path[1]->getName()); + self::assertSame('movement_iii', $path[2]->getName()); + } + + private function createHelper(Item $root): Helper + { + $factory = $this->createStub(NavigationFactory::class); + $factory->method('create')->willReturn($root); + + $renderer = $this->createStub(TwigRenderer::class); + + return new Helper($renderer, $factory, $this->matcher); + } + + private function makeVoter(?bool $result): VoterInterface + { + $voter = $this->createStub(VoterInterface::class); + $voter->method('matchItem')->willReturn($result); + + return $voter; + } +} From 3502659f8d41ffe2980442418e05662e178928ad Mon Sep 17 00:00:00 2001 From: Dev Date: Sun, 1 Mar 2026 13:40:21 +0000 Subject: [PATCH 4/4] [8.0] add optional caching to ClosureNavigation via cacheKey and ttl params Co-Authored-By: Claude Opus 4.6 --- README.md | 25 +++++++++++++ src/Navigation/ClosureNavigation.php | 11 ++++-- .../Unit/Navigation/ClosureNavigationTest.php | 37 +++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index da5ccf5..0cef958 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,7 @@ AbstractNavigation (base: 0 TTL, no tags) |---|---|---|---| | `AbstractCachedNavigation` | 24 h | `chamber_orchestra_menu` | Menu structures (recommended) | | `AbstractNavigation` | 0 | none | Base class, no caching across requests | +| `ClosureNavigation` | 0 (configurable) | none | Quick one-off menus; optionally cacheable | All navigations are deduped within the same request via `NavigationFactory`. When a PSR-6 `CacheInterface` (tag-aware) is wired in, `AbstractCachedNavigation` stores the tree across requests. Without one, an in-memory `ArrayAdapter` is used automatically. @@ -227,6 +228,30 @@ final class MainNavigation extends AbstractCachedNavigation The default cache key is the fully-qualified class name; default TTL is **24 hours**; default tag is `chamber_orchestra_menu`. +### ClosureNavigation caching + +`ClosureNavigation` is uncached by default (TTL 0), but you can opt in to caching by providing a unique `cacheKey` and a `ttl`: + +```php +use ChamberOrchestra\MenuBundle\Navigation\ClosureNavigation; + +// Uncached (default) +$nav = new ClosureNavigation(function (MenuBuilder $builder): void { + $builder->add('home', ['label' => 'Home', 'route' => 'app_home']); +}); + +// Cached for 1 hour +$nav = new ClosureNavigation( + callback: function (MenuBuilder $builder): void { + $builder->add('home', ['label' => 'Home', 'route' => 'app_home']); + }, + cacheKey: 'sidebar_nav', + ttl: 3600, +); +``` + +Each cached `ClosureNavigation` **must** have a unique `cacheKey` — without one, all instances share the same key and overwrite each other. + The cache namespace prefix defaults to `$NAVIGATION$` and can be changed via bundle configuration. --- diff --git a/src/Navigation/ClosureNavigation.php b/src/Navigation/ClosureNavigation.php index 5d19101..cde39d5 100644 --- a/src/Navigation/ClosureNavigation.php +++ b/src/Navigation/ClosureNavigation.php @@ -16,8 +16,11 @@ class ClosureNavigation implements NavigationInterface { - public function __construct(private readonly \Closure $callback) - { + public function __construct( + private readonly \Closure $callback, + private readonly ?string $cacheKey = null, + private readonly int $ttl = 0, + ) { } /** @@ -30,12 +33,12 @@ public function build(MenuBuilder $builder, array $options = []): void public function getCacheKey(): string { - return static::class; + return $this->cacheKey ?? static::class; } public function configureCacheItem(ItemInterface $item): void { - $item->expiresAfter(0); + $item->expiresAfter($this->ttl); } public function getCacheBeta(): ?float diff --git a/tests/Unit/Navigation/ClosureNavigationTest.php b/tests/Unit/Navigation/ClosureNavigationTest.php index 6a0d5db..6662152 100644 --- a/tests/Unit/Navigation/ClosureNavigationTest.php +++ b/tests/Unit/Navigation/ClosureNavigationTest.php @@ -15,6 +15,7 @@ use ChamberOrchestra\MenuBundle\Navigation\ClosureNavigation; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Symfony\Contracts\Cache\ItemInterface; final class ClosureNavigationTest extends TestCase { @@ -55,4 +56,40 @@ static function (MenuBuilder $b, array $options) use (&$received): void { self::assertSame([], $received); } + + #[Test] + public function cacheKeyDefaultsToClassName(): void + { + $nav = new ClosureNavigation(static function (): void {}); + + self::assertSame(ClosureNavigation::class, $nav->getCacheKey()); + } + + #[Test] + public function cacheKeyUsesCustomValue(): void + { + $nav = new ClosureNavigation(static function (): void {}, cacheKey: 'sidebar_nav'); + + self::assertSame('sidebar_nav', $nav->getCacheKey()); + } + + #[Test] + public function ttlDefaultsToZero(): void + { + $nav = new ClosureNavigation(static function (): void {}); + $cacheItem = $this->createMock(ItemInterface::class); + $cacheItem->expects(self::once())->method('expiresAfter')->with(0); + + $nav->configureCacheItem($cacheItem); + } + + #[Test] + public function ttlUsesCustomValue(): void + { + $nav = new ClosureNavigation(static function (): void {}, cacheKey: 'scores', ttl: 3600); + $cacheItem = $this->createMock(ItemInterface::class); + $cacheItem->expects(self::once())->method('expiresAfter')->with(3600); + + $nav->configureCacheItem($cacheItem); + } }