diff --git a/src/CoreBundle/DependencyInjection/Configuration.php b/src/CoreBundle/DependencyInjection/Configuration.php index 922cdd852..958f5241f 100644 --- a/src/CoreBundle/DependencyInjection/Configuration.php +++ b/src/CoreBundle/DependencyInjection/Configuration.php @@ -91,7 +91,8 @@ public function getConfigTreeBuilder(): TreeBuilder ->cannotBeEmpty() ->defaultValue('/assets/metamodels') ->end() - ->append($this->addJumpToPickerNode()); + ->append($this->addJumpToPickerNode()) + ->append($this->addBackendSectionsNode()); return $treeBuilder; } @@ -116,6 +117,51 @@ private function addJumpToPickerNode(): NodeDefinition return $node; } + /** @psalm-suppress UndefinedMethod */ + private function addBackendSectionsNode(): NodeDefinition + { + $treeBuilder = new TreeBuilder('be_sections'); + + $node = $treeBuilder->getRootNode(); + $node + ->useAttributeAsKey('alias') + ->arrayPrototype() + ->children() + ->arrayNode('name') + ->isRequired() + ->requiresAtLeastOneElement() + ->useAttributeAsKey('locale') + ->scalarPrototype()->cannotBeEmpty()->end() + ->end() + ->arrayNode('tooltip') + ->useAttributeAsKey('locale') + ->scalarPrototype()->cannotBeEmpty()->end() + ->end() + ->scalarNode('icon')->defaultNull()->end() + ->arrayNode('add') + ->isRequired() + ->children() + ->scalarNode('before')->defaultNull()->end() + ->scalarNode('after')->defaultNull()->end() + ->end() + ->validate() + ->ifTrue( + static fn(array $value): bool => + (null === $value['before']) === (null === $value['after']) + ) + ->thenInvalid( + 'Exactly one of "before" or "after" must be set for backend sections.' + ) + ->end() + ->end() + ->booleanNode('collapsed')->defaultFalse()->end() + ->end() + ->end() + ->end(); + + return $node; + } + /** * Resolves a path. * diff --git a/src/CoreBundle/DependencyInjection/MetaModelsCoreExtension.php b/src/CoreBundle/DependencyInjection/MetaModelsCoreExtension.php index 9e5fe39ab..2e8749e90 100644 --- a/src/CoreBundle/DependencyInjection/MetaModelsCoreExtension.php +++ b/src/CoreBundle/DependencyInjection/MetaModelsCoreExtension.php @@ -26,6 +26,7 @@ use MetaModels\CoreBundle\Attribute\DoctrineSchemaProvider; use MetaModels\CoreBundle\Contao\Picker\MetaModelsJumpToPickerProvider; use MetaModels\CoreBundle\DependencyInjection\CompilerPass\CollectDoctrineSchemaGeneratorsPass; +use MetaModels\CoreBundle\EventListener\BackendSectionListener; use MetaModels\CoreBundle\Migration\TableCollationMigration; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Config\Definition\ConfigurationInterface; @@ -131,6 +132,8 @@ static function (ChildDefinition $definition, DoctrineSchemaProvider $attribute) if (null !== $jumpToPicker) { $this->processJumpToPicker($jumpToPicker, $container); } + + $this->processBackendSections($config['be_sections'], $container); } /** @@ -218,4 +221,35 @@ private function processJumpToPicker(mixed $jumpToPicker, ContainerBuilder $cont $container->setDefinition('metamodels_jump_to_picker_' . $metaModelName, $definition); } } + + /** + * Register the configured backend sections. + * + * @param array, + * tooltip: array, + * icon: string|null, + * add: array{before: string|null, after: string|null}, + * collapsed: bool + * }> $sections The configured backend sections. + * @param ContainerBuilder $container The container builder. + */ + private function processBackendSections(array $sections, ContainerBuilder $container): void + { + if ([] === $sections) { + return; + } + + $definition = new Definition(BackendSectionListener::class); + $definition->setArgument('$sections', $sections); + $definition->setArgument('$requestStack', new Reference('request_stack')); + $definition->setArgument('$webDir', '%contao.web_dir%'); + // Must run after Contao's BackendMainListener (prio 10, builds the legacy BE_MOD groups + // like "design"/"content" that "add.before"/"add.after" may target) but before + // MetaModels' own BackendNavigationListener (prio -100), which attaches standalone + // MetaModel screens to their configured backend section and needs it to already exist. + $definition->addTag('kernel.event_listener', ['priority' => -50]); + + $container->setDefinition('metamodels.event_listener.backend_section', $definition); + } } diff --git a/src/CoreBundle/EventListener/BackendSectionListener.php b/src/CoreBundle/EventListener/BackendSectionListener.php new file mode 100644 index 000000000..356725ffc --- /dev/null +++ b/src/CoreBundle/EventListener/BackendSectionListener.php @@ -0,0 +1,237 @@ + + * @copyright 2012-2026 The MetaModels team. + * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later + * @filesource + */ + +namespace MetaModels\CoreBundle\EventListener; + +use Contao\CoreBundle\Event\MenuEvent; +use Knp\Menu\FactoryInterface; +use Knp\Menu\ItemInterface; +use Knp\Menu\Util\MenuManipulator; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\Attribute\AttributeBagInterface; + +use function array_keys; +use function array_search; +use function count; +use function is_file; +use function ltrim; +use function preg_match; +use function sprintf; +use function str_starts_with; + +/** + * This registers user defined backend navigation sections configured via "metamodels.be_sections". + * + * @psalm-type TBackendSectionConfig = array{ + * name: array, + * tooltip: array, + * icon: string|null, + * add: array{before: string|null, after: string|null}, + * collapsed: bool + * } + */ +final class BackendSectionListener +{ + /** + * The icon used when a section does not configure one, or its configured icon file cannot be found. + */ + private const DEFAULT_ICON = '/bundles/metamodelscore/icons/mm_group_icon.svg'; + + /** + * The configured sections, keyed by their alias. + * + * @var array + */ + private array $sections; + + /** + * The request stack. + * + * @var RequestStack + */ + private RequestStack $requestStack; + + /** + * The public/web directory, used to check whether a configured icon file actually exists. + * + * @var string + */ + private string $webDir; + + /** + * Create a new instance. + * + * @param array $sections The configured sections. + * @param RequestStack $requestStack The request stack. + * @param string $webDir The public/web directory. + */ + public function __construct(array $sections, RequestStack $requestStack, string $webDir) + { + $this->sections = $sections; + $this->requestStack = $requestStack; + $this->webDir = $webDir; + } + + /** + * Register the configured backend sections. + * + * @param MenuEvent $event The menu event. + * + * @return void + */ + public function __invoke(MenuEvent $event): void + { + if ([] === $this->sections) { + return; + } + + $factory = $event->getFactory(); + $tree = $event->getTree(); + + if ('mainMenu' !== $tree->getName()) { + return; + } + + if (null === ($request = $this->requestStack->getCurrentRequest())) { + return; + } + + $locale = $request->getLocale(); + $manipulator = new MenuManipulator(); + + foreach ($this->sections as $alias => $config) { + if (null !== $tree->getChild($alias)) { + // Someone else (or a previous request) already built this node - leave it alone. + continue; + } + + $node = $this->buildSectionNode($factory, $alias, $config, $locale); + $tree->addChild($node); + + $targetNode = $config['add']['before'] ?? $config['add']['after']; + $targetPosition = array_search($targetNode, array_keys($tree->getChildren()), true); + $targetPosition = false === $targetPosition + ? count($tree->getChildren()) - 1 + : $targetPosition + (null !== $config['add']['after'] ? 1 : 0); + + $manipulator->moveToPosition($node, $targetPosition); + } + } + + /** + * Build a single section node. + * + * @param FactoryInterface $factory The factory. + * @param string $alias The section alias. + * @param TBackendSectionConfig $config The section configuration. + * @param string $locale The current locale. + * + * @return ItemInterface + */ + private function buildSectionNode( + FactoryInterface $factory, + string $alias, + array $config, + string $locale + ): ItemInterface { + $sessionBag = $this->requestStack->getSession()->getBag('contao_backend'); + $status = ($sessionBag instanceof AttributeBagInterface) ? $sessionBag->get('backend_modules') : []; + $default = $config['collapsed'] ? 0 : 1; + $isCollapsed = ($status[$alias] ?? $default) < 1; + + $label = $this->resolveTranslation($config['name'], $locale, $alias); + $tooltip = [] !== $config['tooltip'] + ? $this->resolveTranslation($config['tooltip'], $locale, $label) + : $label; + + $node = $factory + ->createItem($alias) + ->setUri('/contao?mtg=' . $alias) + ->setLabel($label) + ->setExtra('translation_domain', false) + ->setLinkAttribute('class', 'group-' . $alias) + ->setLinkAttribute('title', $tooltip) + ->setLinkAttribute('data-action', 'contao--toggle-navigation#toggle:prevent') + ->setLinkAttribute('data-contao--toggle-navigation-category-param', $alias) + ->setLinkAttribute('aria-controls', $alias) + ->setLinkAttribute('aria-expanded', $isCollapsed ? 'false' : 'true') + ->setChildrenAttribute('id', $alias); + + $node->setLinkAttribute( + 'style', + sprintf('background: url(%s) 3px 2px no-repeat;', $this->resolveIcon($config['icon'])) + ); + + if ($isCollapsed) { + $node->setAttribute('class', 'collapsed'); + } + + return $node; + } + + /** + * Resolve a translation from a locale map. + * + * @param array $translations The locale => text map. + * @param string $locale The current locale. + * @param string $fallback The fallback value if nothing could be resolved. + * + * @return string + */ + private function resolveTranslation(array $translations, string $locale, string $fallback): string + { + if (isset($translations[$locale])) { + return $translations[$locale]; + } + + if (isset($translations['en'])) { + return $translations['en']; + } + + foreach ($translations as $text) { + return $text; + } + + return $fallback; + } + + /** + * Resolve the web accessible path for a section's icon, falling back to the default group icon if none is + * configured or the configured file does not exist. + * + * @param string|null $icon The icon path as configured. + * + * @return string + */ + private function resolveIcon(?string $icon): string + { + if (null === $icon) { + return self::DEFAULT_ICON; + } + + $isRemote = 1 === preg_match('#^https?://#', $icon); + $webPath = $isRemote || str_starts_with($icon, '/') ? $icon : '/' . ltrim($icon, '/'); + + if (!$isRemote && !is_file($this->webDir . '/' . ltrim($webPath, '/'))) { + return self::DEFAULT_ICON; + } + + return $webPath; + } +} diff --git a/src/CoreBundle/Resources/public/css/be_logo_svg.css b/src/CoreBundle/Resources/public/css/be_logo_svg.css index fd1ad7bdf..230b363e5 100644 --- a/src/CoreBundle/Resources/public/css/be_logo_svg.css +++ b/src/CoreBundle/Resources/public/css/be_logo_svg.css @@ -17,5 +17,5 @@ * @filesource */ nav[id="tl_navigation"] .group-metamodels { - background: url(../icons/mm_group_icon.svg) 3px 2px no-repeat; + background: url(../icons/mm_group_icon_blue.svg) 3px 2px no-repeat; } diff --git a/src/CoreBundle/Resources/public/icons/mm_group_icon_blue.svg b/src/CoreBundle/Resources/public/icons/mm_group_icon_blue.svg new file mode 100644 index 000000000..8920e715e --- /dev/null +++ b/src/CoreBundle/Resources/public/icons/mm_group_icon_blue.svg @@ -0,0 +1 @@ +