Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion src/CoreBundle/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ public function getConfigTreeBuilder(): TreeBuilder
->cannotBeEmpty()
->defaultValue('/assets/metamodels')
->end()
->append($this->addJumpToPickerNode());
->append($this->addJumpToPickerNode())
->append($this->addBackendSectionsNode());

return $treeBuilder;
}
Expand All @@ -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.
*
Expand Down
34 changes: 34 additions & 0 deletions src/CoreBundle/DependencyInjection/MetaModelsCoreExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -131,6 +132,8 @@ static function (ChildDefinition $definition, DoctrineSchemaProvider $attribute)
if (null !== $jumpToPicker) {
$this->processJumpToPicker($jumpToPicker, $container);
}

$this->processBackendSections($config['be_sections'], $container);
}

/**
Expand Down Expand Up @@ -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<string, array{
* name: array<string, string>,
* tooltip: array<string, string>,
* 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);
}
}
237 changes: 237 additions & 0 deletions src/CoreBundle/EventListener/BackendSectionListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
<?php

/**
* This file is part of MetaModels/core.
*
* (c) 2012-2026 The MetaModels team.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* This project is provided in good faith and hope to be usable by anyone.
*
* @package MetaModels/core
* @author Ingolf Steinhardt <info@e-spin.de>
* @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<string, string>,
* tooltip: array<string, string>,
* 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<string, TBackendSectionConfig>
*/
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<string, TBackendSectionConfig> $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<string, string> $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;
}
}
2 changes: 1 addition & 1 deletion src/CoreBundle/Resources/public/css/be_logo_svg.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.