diff --git a/core/lib/Drupal/Core/Menu/MenuLinkTree.php b/core/lib/Drupal/Core/Menu/MenuLinkTree.php index ddf924cec837105cb38efe682109934ae1418d89..25dede7a70c8100859aeb4bf702bbf0de6b17a56 100644 --- a/core/lib/Drupal/Core/Menu/MenuLinkTree.php +++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php @@ -107,7 +107,7 @@ public function getCurrentRouteMenuTreeParameters($menu_name) { ksort($route_parameters); $cid = 'current-route-parameters:' . $menu_name . ':route:' . $this->routeMatch->getRouteName() . ':route_parameters:' . serialize($route_parameters); - if (!isset($this->cachedCurrentRouteParameters[$menu_name])) { + if (!isset($this->cachedCurrentRouteParameters[$cid])) { $cache = $this->cache->get($cid); if ($cache && $cache->data) { $parameters = $cache->data; diff --git a/core/modules/system/config/schema/system.schema.yml b/core/modules/system/config/schema/system.schema.yml index 3f5ecbcace14c06fec9a55b8318c4bcdf36bd89b..af52582949bf03e79923b4b45bba87e2e964185e 100644 --- a/core/modules/system/config/schema/system.schema.yml +++ b/core/modules/system/config/schema/system.schema.yml @@ -355,6 +355,17 @@ block.settings.system_branding_block: type: boolean label: 'Use site slogan' +block.settings.system_menu_block:*: + type: block_settings + label: 'Menu block' + mapping: + level: + type: integer + label: 'Starting level' + depth: + type: integer + label: 'Maximum number of levels' + condition.plugin.request_path: type: condition.plugin mapping: diff --git a/core/modules/system/src/Plugin/Block/SystemMenuBlock.php b/core/modules/system/src/Plugin/Block/SystemMenuBlock.php index dd4c6ad3bf09ddcd7a7c107c8945bc529e4db88e..d2c0270a194d5ba418b10707410eaed455a59bcd 100644 --- a/core/modules/system/src/Plugin/Block/SystemMenuBlock.php +++ b/core/modules/system/src/Plugin/Block/SystemMenuBlock.php @@ -9,12 +9,13 @@ use Drupal\Component\Utility\NestedArray; use Drupal\Core\Block\BlockBase; -use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\Cache\Cache; +use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Menu\MenuActiveTrailInterface; use Drupal\Core\Menu\MenuLinkTreeInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Symfony\Component\DependencyInjection\ContainerInterface; - /** * Provides a generic Menu block. * @@ -74,12 +75,76 @@ public static function create(ContainerInterface $container, array $configuratio ); } + /** + * {@inheritdoc} + */ + public function blockForm($form, FormStateInterface $form_state) { + $config = $this->configuration; + + $defaults = $this->defaultConfiguration(); + $form['menu_levels'] = array( + '#type' => 'details', + '#title' => t('Menu levels'), + // Open if not set to defaults. + '#open' => $defaults['level'] !== $config['level'] || $defaults['depth'] !== $config['depth'], + '#process' => [[get_class(), 'processMenuLevelParents']], + ); + + $options = range(0, $this->menuTree->maxDepth()); + unset($options[0]); + + $form['menu_levels']['level'] = array( + '#type' => 'select', + '#title' => $this->t('Initial menu level'), + '#default_value' => $config['level'], + '#options' => $options, + '#description' => $this->t('The menu will only be visible if the menu item for the current page is at or below the selected starting level. Select level 1 to always keep this menu visible.'), + '#required' => TRUE, + ); + + $options[0] = $this->t('Unlimited'); + + $form['menu_levels']['depth'] = array( + '#type' => 'select', + '#title' => $this->t('Maximum number of menu levels to display'), + '#default_value' => $config['depth'], + '#options' => $options, + '#description' => $this->t('The maximum number of menu levels to show, starting from the initial menu level. For example: with an initial level 2 and a maximum number of 3, menu levels 2, 3 and 4 can be displayed.'), + '#required' => TRUE, + ); + + return $form; + } + + /** + * Form API callback: Processes the menu_levels field element. + * + * Adjusts the #parents of menu_levels to save its children at the top level. + */ + public static function processMenuLevelParents(&$element, FormStateInterface $form_state, &$complete_form) { + array_pop($element['#parents']); + return $element; + } + /** * {@inheritdoc} */ public function build() { $menu_name = $this->getDerivativeId(); $parameters = $this->menuTree->getCurrentRouteMenuTreeParameters($menu_name); + + // Adjust the menu tree parameters based on the block's configuration. + $level = $this->configuration['level']; + $depth = $this->configuration['depth']; + $parameters->setMinDepth($level); + // When the depth is configured to zero, there is no depth limit. When depth + // is non-zero, it indicates the number of levels that must be displayed. + // Hence this is a relative depth that we must convert to an actual + // (absolute) depth, that may never exceed the maximum depth. + if ($depth > 0) { + $parameters->setMaxDepth(min($level + $depth - 1, $this->menuTree->maxDepth())); + } + $tree = $this->menuTree->load($menu_name, $parameters); $manipulators = array( array('callable' => 'menu.default_tree_manipulators:checkAccess'), @@ -101,7 +166,11 @@ public function defaultConfiguration() { // 1) it is possible to set a different max age for individual blocks, since // this is just the default value. // 2) modules can modify caching by implementing hook_block_view_alter() - return array('cache' => array('max_age' => \Drupal\Core\Cache\Cache::PERMANENT)); + return [ + 'cache' => array('max_age' => Cache::PERMANENT), + 'level' => 1, + 'depth' => 0, + ]; } /** diff --git a/core/modules/system/src/Tests/Block/SystemMenuBlockTest.php b/core/modules/system/src/Tests/Block/SystemMenuBlockTest.php index 80c3d3dc1f716f9e1987dab0ac3089aab7eaf881..1016c04f1c704c629415680f655f4b647977d315 100644 --- a/core/modules/system/src/Tests/Block/SystemMenuBlockTest.php +++ b/core/modules/system/src/Tests/Block/SystemMenuBlockTest.php @@ -5,7 +5,15 @@ namespace Drupal\system\Tests\Block; -use Drupal\simpletest\DrupalUnitTestBase; +use Drupal\Core\Render\Element; +use Drupal\simpletest\KernelTestBase; +use Drupal\system\Tests\Routing\MockRouteProvider; +use Drupal\Tests\Core\Menu\MenuLinkMock; +use Drupal\user\Entity\User; +use Symfony\Cmf\Component\Routing\RouteObjectInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; /** * Tests \Drupal\system\Plugin\Block\SystemMenuBlock. @@ -17,32 +25,136 @@ * @see \Drupal\system\Plugin\Derivative\SystemMenuBlock * @see \Drupal\system\Plugin\Block\SystemMenuBlock */ -class SystemMenuBlockTest extends DrupalUnitTestBase { +class SystemMenuBlockTest extends KernelTestBase { /** * Modules to enable. * * @var array */ - public static $modules = array('system', 'block'); + public static $modules = array( + 'system', + 'block', + 'menu_test', + 'menu_link_content', + 'field', + 'user', + ); /** - * Tests calculation of a system menu block's configuration dependencies. + * The block under test. + * + * @var \Drupal\system\Plugin\Block\SystemMenuBlock */ - public function testSystemMenuBlockConfigDependencies() { + protected $block; + + /** + * The menu for testing. + * + * @var \Drupal\system\MenuInterface + */ + protected $menu; + + /** + * The menu link tree service. + * + * @var \Drupal\Core\Menu\MenuLinkTree + */ + protected $linkTree; + + /** + * The menu link plugin manager service. + * + * @var \Drupal\Core\Menu\MenuLinkManagerInterface $menuLinkManager + */ + protected $menuLinkManager; + + /** + * The block manager service. + * + * @var \Drupal\Core\block\BlockManagerInterface + */ + protected $blockManager; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + $this->installSchema('system', 'sequences'); + $this->installEntitySchema('user'); + $this->installSchema('system', array('router')); + $this->installEntitySchema('menu_link_content'); + + $account = User::create([ + 'name' => $this->randomMachineName(), + 'status' => 1, + ]); + $account->save(); + $this->container->get('current_user')->setAccount($account); + + $this->menuLinkManager = $this->container->get('plugin.manager.menu.link'); + $this->linkTree = $this->container->get('menu.link_tree'); + $this->blockManager = $this->container->get('plugin.manager.block'); + + $routes = new RouteCollection(); + $requirements = array('_access' => 'TRUE'); + $options = array('_access_checks' => array('access_check.default')); + $routes->add('example1', new Route('/example1', array(), $requirements, $options)); + $routes->add('example2', new Route('/example2', array(), $requirements, $options)); + $routes->add('example3', new Route('/example3', array(), $requirements, $options)); + $routes->add('example4', new Route('/example4', array(), $requirements, $options)); + $routes->add('example5', new Route('/example5', array(), $requirements, $options)); + $routes->add('example6', new Route('/example6', array(), $requirements, $options)); + $routes->add('example7', new Route('/example7', array(), $requirements, $options)); + $routes->add('example8', new Route('/example8', array(), $requirements, $options)); + + $mock_route_provider = new MockRouteProvider($routes); + $this->container->set('router.route_provider', $mock_route_provider); + // Add a new custom menu. - $menu_name = $this->randomMachineName(16); + $menu_name = 'mock'; $label = $this->randomMachineName(16); - $menu = entity_create('menu', array( + $this->menu = entity_create('menu', array( 'id' => $menu_name, 'label' => $label, 'description' => 'Description text', )); - $menu->save(); + $this->menu->save(); + + // This creates a tree with the following structure: + // - 1 + // - 2 + // - 3 + // - 4 + // - 5 + // - 7 + // - 6 + // - 8 + // With link 6 being the only external link. + $links = array( + 1 => MenuLinkMock::create(array('id' => 'test.example1', 'route_name' => 'example1', 'title' => 'foo', 'parent' => '', 'weight' => 0)), + 2 => MenuLinkMock::create(array('id' => 'test.example2', 'route_name' => 'example2', 'title' => 'bar', 'parent' => '', 'route_parameters' => array('foo' => 'bar'), 'weight' => 1)), + 3 => MenuLinkMock::create(array('id' => 'test.example3', 'route_name' => 'example3', 'title' => 'baz', 'parent' => 'test.example2', 'weight' => 2)), + 4 => MenuLinkMock::create(array('id' => 'test.example4', 'route_name' => 'example4', 'title' => 'qux', 'parent' => 'test.example3', 'weight' => 3)), + 5 => MenuLinkMock::create(array('id' => 'test.example5', 'route_name' => 'example5', 'title' => 'foofoo', 'parent' => '', 'expanded' => TRUE, 'weight' => 4)), + 6 => MenuLinkMock::create(array('id' => 'test.example6', 'route_name' => '', 'url' => 'https://drupal.org/', 'title' => 'barbar', 'parent' => '', 'weight' => 5)), + 7 => MenuLinkMock::create(array('id' => 'test.example7', 'route_name' => 'example7', 'title' => 'bazbaz', 'parent' => 'test.example5', 'weight' => 6)), + 8 => MenuLinkMock::create(array('id' => 'test.example8', 'route_name' => 'example8', 'title' => 'quxqux', 'parent' => '', 'weight' => 7)), + ); + foreach ($links as $instance) { + $this->menuLinkManager->addDefinition($instance->getPluginId(), $instance->getPluginDefinition()); + } + } + + /** + * Tests calculation of a system menu block's configuration dependencies. + */ + public function testSystemMenuBlockConfigDependencies() { $block = entity_create('block', array( - 'plugin' => 'system_menu_block:'. $menu->id(), + 'plugin' => 'system_menu_block:' . $this->menu->id(), 'region' => 'footer', 'id' => 'machinename', 'theme' => 'stark', @@ -51,7 +163,7 @@ public function testSystemMenuBlockConfigDependencies() { $dependencies = $block->calculateDependencies(); $expected = array( 'entity' => array( - 'system.menu.' . $menu->id() + 'system.menu.' . $this->menu->id() ), 'module' => array( 'system' @@ -62,4 +174,129 @@ public function testSystemMenuBlockConfigDependencies() { ); $this->assertIdentical($expected, $dependencies); } + + /** + * Tests the config start level and depth. + */ + public function testConfigLevelDepth() { + // Helper function to generate a configured block instance. + $place_block = function ($level, $depth) { + return $this->blockManager->createInstance('system_menu_block:' . $this->menu->id(), array( + 'region' => 'footer', + 'id' => 'machinename', + 'theme' => 'stark', + 'level' => $level, + 'depth' => $depth, + )); + }; + + // All the different block instances we're going to test. + $blocks = [ + 'all' => $place_block(1, 0), + 'level_1_only' => $place_block(1, 1), + 'level_2_only' => $place_block(2, 1), + 'level_3_only' => $place_block(3, 1), + 'level_1_and_beyond' => $place_block(1, 0), + 'level_2_and_beyond' => $place_block(2, 0), + 'level_3_and_beyond' => $place_block(3, 0), + ]; + + // Scenario 1: test all block instances when there's no active trail. + $no_active_trail_expectations = []; + $no_active_trail_expectations['all'] = [ + 'test.example1' => [], + 'test.example2' => [], + 'test.example5' => [ + 'test.example7' => [], + ], + 'test.example6' => [], + 'test.example8' => [], + ]; + $no_active_trail_expectations['level_1_only'] = [ + 'test.example1' => [], + 'test.example2' => [], + 'test.example5' => [], + 'test.example6' => [], + 'test.example8' => [], + ]; + $no_active_trail_expectations['level_2_only'] = [ + 'test.example7' => [], + ]; + $no_active_trail_expectations['level_3_only'] = []; + $no_active_trail_expectations['level_1_and_beyond'] = $no_active_trail_expectations['all']; + $no_active_trail_expectations['level_2_and_beyond'] = $no_active_trail_expectations['level_2_only']; + $no_active_trail_expectations['level_3_and_beyond'] = []; + foreach ($blocks as $id => $block) { + $this->assertIdentical($no_active_trail_expectations[$id], $this->convertBuiltMenuToIdTree($block->build()), format_string('Menu block %id with no active trail renders the expected tree.', ['%id' => $id])); + } + + // Scenario 2: test all block instances when there's an active trail. + $route = $this->container->get('router.route_provider')->getRouteByName('example3'); + $request = new Request(); + $request->attributes->set(RouteObjectInterface::ROUTE_NAME, 'example3'); + $request->attributes->set(RouteObjectInterface::ROUTE_OBJECT, $route); + $this->container->get('request_stack')->push($request); + $active_trail_expectations = []; + $active_trail_expectations['all'] = [ + 'test.example1' => [], + 'test.example2' => [ + 'test.example3' => [ + 'test.example4' => [], + ] + ], + 'test.example5' => [ + 'test.example7' => [], + ], + 'test.example6' => [], + 'test.example8' => [], + ]; + $active_trail_expectations['level_1_only'] = [ + 'test.example1' => [], + 'test.example2' => [], + 'test.example5' => [], + 'test.example6' => [], + 'test.example8' => [], + ]; + $active_trail_expectations['level_2_only'] = [ + 'test.example3' => [], + 'test.example7' => [], + ]; + $active_trail_expectations['level_3_only'] = [ + 'test.example4' => [], + ]; + $active_trail_expectations['level_1_and_beyond'] = $active_trail_expectations['all']; + $active_trail_expectations['level_2_and_beyond'] = [ + 'test.example3' => [ + 'test.example4' => [], + ], + 'test.example7' => [], + ]; + $active_trail_expectations['level_3_and_beyond'] = $active_trail_expectations['level_3_only']; + foreach ($blocks as $id => $block) { + $this->assertIdentical($active_trail_expectations[$id], $this->convertBuiltMenuToIdTree($block->build()), format_string('Menu block %id with an active trail renders the expected tree.', ['%id' => $id])); + } + } + + /** + * Helper method to allow for easy menu link tree structure assertions. + * + * Converts the result of MenuLinkTree::build() in a "menu link ID tree". + * + * @param array $build + * The return value of of MenuLinkTree::build() + * + * @return array + * The "menu link ID tree" representation of the given render array. + */ + protected function convertBuiltMenuToIdTree(array $build) { + $level = []; + foreach (Element::children($build) as $id) { + $level[$id] = []; + if (isset($build[$id]['#below'])) { + $level[$id] = $this->convertBuiltMenuToIdTree($build[$id]['#below']); + } + } + return $level; + } + } diff --git a/core/profiles/minimal/config/install/block.block.stark_admin.yml b/core/profiles/minimal/config/install/block.block.stark_admin.yml index a7cc0a5b8c449820cdaa6b56719cacb2e24181d3..09340490e74c20b0b6d05299bc974eb111e8fa05 100644 --- a/core/profiles/minimal/config/install/block.block.stark_admin.yml +++ b/core/profiles/minimal/config/install/block.block.stark_admin.yml @@ -10,6 +10,11 @@ settings: label: Administration provider: system label_display: visible + cache: + max_age: -1 + contexts: { } + level: 1 + depth: 0 dependencies: entity: - system.menu.admin diff --git a/core/profiles/minimal/config/install/block.block.stark_tools.yml b/core/profiles/minimal/config/install/block.block.stark_tools.yml index aeb99bbf456dec530c7df1f2f9e4aa739697ba39..c8268bbe4dc4cfe6aac02079983ffeaf8376f256 100644 --- a/core/profiles/minimal/config/install/block.block.stark_tools.yml +++ b/core/profiles/minimal/config/install/block.block.stark_tools.yml @@ -10,6 +10,11 @@ settings: label: Tools provider: system label_display: visible + cache: + max_age: -1 + contexts: { } + level: 1 + depth: 0 dependencies: entity: - system.menu.tools diff --git a/core/profiles/standard/config/install/block.block.bartik_footer.yml b/core/profiles/standard/config/install/block.block.bartik_footer.yml index 26acc4619e73e46690748c2590e885da2b0e550f..3074b3a097cd010de9c5c14d09ee86b8d0d905cd 100644 --- a/core/profiles/standard/config/install/block.block.bartik_footer.yml +++ b/core/profiles/standard/config/install/block.block.bartik_footer.yml @@ -11,6 +11,11 @@ settings: label: 'Footer menu' provider: system label_display: visible + cache: + max_age: -1 + contexts: { } + level: 1 + depth: 0 dependencies: entity: - system.menu.footer diff --git a/core/profiles/standard/config/install/block.block.bartik_tools.yml b/core/profiles/standard/config/install/block.block.bartik_tools.yml index 6ed680850fafe0198bc37da1729d76fd5d76e9ed..9a4b8b349c66fb464d94d75f2f8c36f2101f7118 100644 --- a/core/profiles/standard/config/install/block.block.bartik_tools.yml +++ b/core/profiles/standard/config/install/block.block.bartik_tools.yml @@ -11,6 +11,11 @@ settings: label: Tools provider: system label_display: visible + cache: + max_age: -1 + contexts: { } + level: 1 + depth: 0 dependencies: entity: - system.menu.tools