Commit d5b67717 authored by Dries's avatar Dries

Issue #2004334 by pwolanin, dawehner, fubhy: Separate Tabs (MENU_LOCAL_TASK) from hook_menu().

parent c02ba600
......@@ -172,6 +172,9 @@ services:
plugin.manager.menu.local_action:
class: Drupal\Core\Menu\LocalActionManager
arguments: ['@container.namespaces', '@controller_resolver', '@request', '@module_handler']
plugin.manager.menu.local_task:
class: Drupal\Core\Menu\LocalTaskManager
arguments: ['@container.namespaces', '@controller_resolver', '@request', '@router.route_provider', '@module_handler']
request:
class: Symfony\Component\HttpFoundation\Request
# @TODO the synthetic setting must be uncommented whenever drupal_session_initialize()
......
......@@ -1948,6 +1948,10 @@ function menu_local_tasks($level = 0) {
if (!$router_item || !$router_item['access']) {
return $empty;
}
// @todo remove all code using {menu_router} and anything using MENU_*
// constants when all local actions and local tasks are converted to
// plugins. The remaining code should just invoke those managers plus do the
// invocations of hook_menu_local_tasks() and hook_menu_local_tasks_alter().
// Get all tabs (also known as local tasks) and the root page.
$result = db_select('menu_router', NULL, array('fetch' => PDO::FETCH_ASSOC))
......@@ -2117,6 +2121,15 @@ function menu_local_tasks($level = 0) {
// Remove the depth, we are interested only in their relative placement.
$tabs = array_values($tabs);
$data['tabs'] = $tabs;
// Look for route-based tabs.
$route_name = Drupal::request()->attributes->get('_route');
if (!empty($route_name)) {
$manager = Drupal::service('plugin.manager.menu.local_task');
$local_tasks = $manager->getTasksBuild($route_name);
foreach ($local_tasks as $level => $items) {
$data['tabs'][$level] = empty($data['tabs'][$level]) ? $items : array_merge($data['tabs'][$level], $items);
}
}
// Allow modules to dynamically add further tasks.
$module_handler = Drupal::moduleHandler();
......@@ -2616,7 +2629,7 @@ function menu_get_active_breadcrumb() {
// Don't show a link to the current page in the breadcrumb trail.
$end = end($active_trail);
if ($item['href'] == $end['href']) {
if (Drupal::request()->attributes->get('system_path') == $end['href']) {
array_pop($active_trail);
}
......
<?php
/**
* @file
* Contains \Drupal\Core\Annotation\Menu\LocalTask.
*/
namespace Drupal\Core\Annotation\Menu;
use Drupal\Component\Annotation\Plugin;
/**
* Defines a local task plugin annotation object.
*
* @Annotation
*/
class LocalTask extends Plugin {
/**
* The ID.
*
* @var string
*/
public $id;
/**
* The static title for the local task.
*
* @ingroup plugin_translatable
*
* @var \Drupal\Core\Annotation\Translation
*/
public $title;
/**
* The route name.
*
* @var string
*/
public $route_name;
/**
* The plugin ID of the root tab.
*
* @var array
*/
public $tab_root_id;
/**
* The plugin ID of the parent tab (or NULL for a top-level tab).
*
* @var array|NULL
*/
public $tab_parent_id;
/**
* The weight of the tab.
*
* @var int|NULL
*/
public $weight;
/**
* The default link options.
*
* @var array (optional)
*/
public $options = array();
}
<?php
/**
* @file
* Contains \Drupal\Core\Menu\LocalTaskBase.
*/
namespace Drupal\Core\Menu;
use Drupal\Component\Plugin\PluginBase;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\Translator\TranslatorInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Provides defaults and base methods for menu local tasks plugins.
*/
abstract class LocalTaskBase extends PluginBase implements LocalTaskInterface, ContainerFactoryPluginInterface{
/**
* String translation object.
*
* @var \Drupal\Core\StringTranslation\Translator\TranslatorInterface
*/
protected $t;
/**
* URL generator object.
*
* @var \Symfony\Component\Routing\Generator\UrlGeneratorInterface
*/
protected $generator;
/**
* TRUE if this plugin is forced active for options attributes.
*
* @var bool
*/
protected $active = FALSE;
/**
* Constructs a \Drupal\system\Plugin\LocalTaskBase object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param array $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\StringTranslation\Translator\TranslatorInterface $string_translation
* The string translation object.
* @param \Symfony\Component\Routing\Generator\UrlGeneratorInterface $generator
* The url generator object.
*/
public function __construct(array $configuration, $plugin_id, array $plugin_definition, TranslatorInterface $string_translation, UrlGeneratorInterface $generator) {
// This is available for subclasses that need to translate a dynamic title.
$this->t = $string_translation;
$this->generator = $generator;
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, array $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('string_translation'),
$container->get('url_generator')
);
}
/**
* {@inheritdoc}
*/
public function getRouteName() {
return $this->pluginDefinition['route_name'];
}
/**
* {@inheritdoc}
*/
public function getTitle() {
// Subclasses may pull in the request or specific attributes as parameters.
return $this->pluginDefinition['title'];
}
/**
* {@inheritdoc}
*/
public function getPath() {
// Subclasses may set a request into the generator or use any desired method
// to generate the path.
// @todo - use the new method from https://drupal.org/node/2031353
$path = $this->generator->generate($this->getRouteName());
// In order to get the Drupal path the base URL has to be stripped off.
$base_url = $this->generator->getContext()->getBaseUrl();
if (!empty($base_url) && strpos($path, $base_url) === 0) {
$path = substr($path, strlen($base_url));
}
return trim($path, '/');
}
/**
* Returns the weight of the local task.
*
* @return int
* The weight of the task. If not defined in the annotation returns 0 by
* default or -10 for the root tab.
*/
public function getWeight() {
// By default the weight is 0, or -10 for the root tab.
if (!isset($this->pluginDefinition['weight'])) {
if ($this->pluginDefinition['tab_root_id'] == $this->pluginDefinition['id']) {
$this->pluginDefinition['weight'] = -10;
}
else {
$this->pluginDefinition['weight'] = 0;
}
}
return (int) $this->pluginDefinition['weight'];
}
/**
* {@inheritdoc}
*/
public function getOptions() {
$options = $this->pluginDefinition['options'];
if ($this->active) {
if (empty($options['attributes']['class']) || !in_array('active', $options['attributes']['class'])) {
$options['attributes']['class'][] = 'active';
}
}
return (array) $options;
}
/**
* {@inheritdoc}
*/
public function setActive($active = TRUE) {
$this->active = $active;
return $this;
}
/**
* {@inheritdoc}
*/
public function getActive() {
return $this->active;
}
}
<?php
/**
* @file
* Contains \DrupalCore\Menu\LocalTaskInterface.
*/
namespace Drupal\Core\Menu;
/**
* Defines an interface for menu local tasks.
*/
interface LocalTaskInterface {
/**
* Get the route name from the settings.
*
* @return string
* The name of the route this local task links to.
*/
public function getRouteName();
/**
* Returns the localized title to be shown for this tab.
*
* Subclasses may add optional arguments like NodeInterface $node = NULL that
* will be supplied by the ControllerResolver.
*
* @return string
* The title of the local task.
*/
public function getTitle();
/**
* Returns an internal Drupal path to use when creating the link for the tab.
*
* Subclasses may add optional arguments like NodeInterface $node = NULL that
* will be supplied by the ControllerResolver.
*
* @return string
* The path of this local task.
*/
public function getPath();
/**
* Returns the weight of the local task.
*
* @return int|null
* The weight of the task or NULL.
*/
public function getWeight();
/**
* Returns an array of options suitable to pass to l().
*
* @return array
* Associative array of options.
*
* @see l()
*/
public function getOptions();
/**
* Sets the active status.
*
* @param bool $active
* Sets whether this tab is active (e.g. a parent of the current tab).
*
* @return \Drupal\Core\Menu\LocalTaskInterface
* The called object for chaining.
*/
public function setActive($active = TRUE);
/**
* Gets the active status.
*
* @return bool
* TRUE if the local task is active, FALSE otherwise.
*
* @see \Drupal\system\Plugin\MenuLocalTaskInterface::setActive()
*/
public function getActive();
}
<?php
/**
* @file
* Contains \Drupal\Core\Menu\MenuLocalTaskManager.
*/
namespace Drupal\Core\Menu;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\Core\Routing\RouteProviderInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
/**
* Manages discovery and instantiation of menu local task plugins.
*
* This manager finds plugins that are rendered as local tasks (usually tabs).
* Derivatives are supported for modules that wish to generate multiple tabs on
* behalf of something else.
*/
class LocalTaskManager extends DefaultPluginManager {
/**
* A controller resolver object.
*
* @var \Symfony\Component\HttpKernel\Controller\ControllerResolverInterface
*/
protected $controllerResolver;
/**
* A request object.
*
* @var \Symfony\Component\HttpFoundation\Request
*/
protected $request;
/**
* The plugin instances.
*
* @var array
*/
protected $instances = array();
/**
* The route provider to load routes by name.
*
* @var \Drupal\Core\Routing\RouteProviderInterface
*/
protected $routeProvider;
/**
* Constructs a \Drupal\Core\Menu\LocalTaskManager object.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations,
* @param \Symfony\Component\HttpKernel\Controller\ControllerResolverInterface $controller_resolver
* An object to use in introspecting route methods.
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object to use for building titles and paths for plugin instances.
* @param \Drupal\Core\Routing\RouteProviderInterface $route_provider
* The route provider to load routes by name.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.u
*/
public function __construct(\Traversable $namespaces, ControllerResolverInterface $controller_resolver, Request $request, RouteProviderInterface $route_provider, ModuleHandlerInterface $module_handler) {
parent::__construct('Menu\LocalTask', $namespaces, array(), 'Drupal\Core\Annotation\Menu\LocalTask');
$this->controllerResolver = $controller_resolver;
$this->request = $request;
$this->routeProvider = $route_provider;
$this->alterInfo($module_handler, 'local_tasks');
}
/**
* Gets the title for a local task.
*
* @param \Drupal\Core\Menu\LocalTaskInterface $local_task
* A local task plugin instance to get the title for.
*
* @return string
* The localized title.
*/
public function getTitle(LocalTaskInterface $local_task) {
$controller = array($local_task, 'getTitle');
$arguments = $this->controllerResolver->getArguments($this->request, $controller);
return call_user_func_array($controller, $arguments);
}
/**
* Gets the Drupal path for a local task.
*
* @param \Drupal\Core\Menu\LocalTaskInterface $local_task
* The local task plugin instance to get the path for.
*
* @return string
* The path.
*/
public function getPath(LocalTaskInterface $local_task) {
$controller = array($local_task, 'getPath');
$arguments = $this->controllerResolver->getArguments($this->request, $controller);
return call_user_func_array($controller, $arguments);
}
/**
* Find all local tasks that appear on a named route.
*
* @param string $route_name
* The route for which to find local tasks.
*
* @return array
* Returns an array of task levels. Each task level contains instances
* of local tasks (LocalTaskInterface) which appear on the tab route.
* The array keys are the depths and the values are arrays of plugin
* instances.
*/
public function getLocalTasksForRoute($route_name) {
if (!isset($this->instances[$route_name])) {
$this->instances[$route_name] = array();
// @todo - optimize this lookup by compiling or caching.
$definitions = $this->getDefinitions();
// We build the hierarchy by finding all tabs that should
// appear on the current route.
$tab_root_ids = array();
$parents = array();
foreach ($definitions as $plugin_id => $task_info) {
if ($route_name == $task_info['route_name']) {
$tab_root_ids[$task_info['tab_root_id']] = TRUE;
// Tabs that link to the current route are viable parents
// and their parent and children should be visible also.
// @todo - this only works for 2 levels of tabs.
// instead need to iterate up.
$parents[$plugin_id] = TRUE;
if (!empty($task_info['tab_parent_id'])) {
$parents[$task_info['tab_parent_id']] = TRUE;
}
}
}
if ($tab_root_ids) {
// Find all the plugins with the same root and that are at the top
// level or that have a visible parent.
$children = array();
foreach ($definitions as $plugin_id => $task_info) {
if (!empty($tab_root_ids[$task_info['tab_root_id']]) && (empty($task_info['tab_parent_id']) || !empty($parents[$task_info['tab_parent_id']]))) {
// Concat '> ' with root ID for the parent of top-level tabs.
$parent = empty($task_info['tab_parent_id']) ? '> ' . $task_info['tab_root_id'] : $task_info['tab_parent_id'];
$children[$parent][$plugin_id] = $task_info;
}
}
foreach (array_keys($tab_root_ids) as $root_id) {
// Convert the tree keyed by plugin IDs into a simple one with
// integer depth. Create instances for each plugin along the way.
$level = 0;
// We used this above as the top-level parent array key.
$next_parent = '> ' . $root_id;
do {
$parent = $next_parent;
$next_parent = FALSE;
foreach ($children[$parent] as $plugin_id => $task_info) {
$plugin = $this->createInstance($plugin_id);
$this->instances[$route_name][$level][$plugin_id] = $plugin;
// Normally, l() compares the href of every link with the current
// path and sets the active class accordingly. But the parents of
// the current local task may be on a different route in which
// case we have to set the class manually by flagging it active.
if (!empty($parents[$plugin_id]) && $route_name != $task_info['route_name']) {
$plugin->setActive();
}
if (isset($children[$plugin_id])) {
// This tab has visible children
$next_parent = $plugin_id;
}
}
$level++;
} while ($next_parent);
}
}
}
return $this->instances[$route_name];
}
/**
* Gets the render array for all local tasks.
*
* @param string $route_name
* The route for which to make renderable local tasks.
*
* @return array
* A render array as expected by theme_menu_local_tasks.
*/
public function getTasksBuild($route_name) {
$tree = $this->getLocalTasksForRoute($route_name);
$build = array();
foreach ($tree as $level => $instances) {
foreach ($instances as $child) {
$path = $this->getPath($child);
// Find out whether the user has access to the task.
$route = $this->routeProvider->getRouteByName($child->getRouteName());
$map = array();
// @todo - replace this call when we have a real service for it.
$access = menu_item_route_access($route, $path, $map);
if ($access) {
// Need to flag the list element as active for a tab for the current
// route or if the plugin is set active (i.e. the parent tab).
$active = ($route_name == $child->getRouteName() || $child->getActive());
// @todo It might make sense to use menu link entities instead of
// arrays.
$menu_link = array(
'title' => $this->getTitle($child),
'href' => $path,
'localized_options' => $child->getOptions(),
);
$build[$level][$path] = array(
'#theme' => 'menu_local_task',
'#link' => $menu_link,
'#active' => $active,
'#weight' => $child->getWeight(),
'#access' => $access,
);
}
}
}
return $build;
}
}
......@@ -140,4 +140,48 @@ protected function assertLocalTasks(array $hrefs, $level = 0) {
}
}
/**
* Tests the plugin based local tasks.
*/
public function testPluginLocalTask() {
// Verify that local tasks appear as defined in the router.
$this->drupalGet('menu-local-task-test/tasks');
$this->drupalGet('menu-local-task-test/tasks/view');
$this->assertLocalTasks(array(
'menu-local-task-test/tasks/view',
'menu-local-task-test/tasks/settings',
'menu-local-task-test/tasks/edit',
));
// Ensure the view tab is active.
$result = $this->xpath('//ul[contains(@class, "tabs")]//a[contains(@class, "active")]');
$this->assertEqual(1, count($result), 'There is just a single active tab.');
$this->assertEqual('View', (string) $result[0], 'The view tab is active.');
// Verify that local tasks in the second level appear.
$this->drupalGet('menu-local-task-test/tasks/settings');
$this->assertLocalTasks(array(
'menu-local-task-test/tasks/settings/sub1',
'menu-local-task-test/tasks/settings/sub2',
), 1);
$result = $this->xpath('//ul[contains(@class, "tabs")]//a[contains(@class, "active")]');
$this->assertEqual(1, count($result), 'There is just a single active tab.');
$this->assertEqual('Settings', (string) $result[0], 'The settings tab is active.');
$this->drupalGet('menu-local-task-test/tasks/settings/sub1');
$this->assertLocalTasks(array(
'menu-local-task-test/tasks/settings/sub1',
'menu-local-task-test/tasks/settings/sub2',
), 1);
$result = $this->xpath('//ul[contains(@class, "tabs")]//a[contains(@class, "active")]');
$this->assertEqual(2, count($result), 'There are tabs active on both levels.');
$this->assertEqual('Settings', (string) $result[0], 'The settings tab is active.');
$this->assertEqual('sub1', (string) $result[1], 'The sub1 tab is active.');
}
}
......@@ -925,6 +925,20 @@ function hook_menu_local_tasks_alter(&$data, $router_item, $root_path) {
function hook_menu_local_actions_alter(&$local_actions) {
}
/**
* Alter local tasks plugins.
*
* @param array $local_tasks
* The array of local tasks plugin definitions, keyed by plugin ID.
*
* @see \Drupal\Core\Menu\LocalTaskInterface
* @see \Drupal\Core\Menu\LocalTaskManager
*/
function hook_local_task_alter(&$local_tasks) {
// Remove a specified local task plugin.
unset($local_tasks['example_plugin_id']);
}
/**
* Alter links in the active trail before it is rendered as the breadcrumb.
*
......
<?php
/**
* @file
* Contains \Drupal\menu_test\Plugin\Menu\LocalTask\TestTasksEdit.
*/
namespace Drupal\menu_test\Plugin\Menu\LocalTask;
use Drupal\Core\Annotation\Menu\LocalTask;
use Drupal\Core\Menu\LocalTaskBase;
use Drupal\Core\Annotation\Translation;
/**
* @LocalTask(
* id = "menu_local_task_test_tasks_edit",
* route_name = "menu_local_task_test_tasks_edit",
* title = @Translation("Edit"),
* tab_root_id = "menu_local_task_test_tasks_view",
* weight = "10"
* )
*/
class TestTasksEdit extends LocalTaskBase {
}
<?php
/**
* @file
* Contains \Drupal\menu_test\Plugin\Menu\LocalTask\TestTasksSettings.
*/
namespace Drupal\menu_test\Plugin\Menu\LocalTask;
use Drupal\Core\Annotation\Menu\LocalTask;
use Drupal\Core\Menu\LocalTaskBase;
use Drupal\Core\Annotation\Translation;
/**
* @LocalTask(
* id = "menu_local_task_test_tasks_settings",
* route_name = "menu_local_task_test_tasks_settings",
* title = @Translation("Settings"),
* tab_root_id = "menu_local_task_test_tasks_view"