LocalTaskManager.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. <?php
  2. namespace Drupal\Core\Menu;
  3. use Drupal\Component\Plugin\Exception\PluginException;
  4. use Drupal\Core\Access\AccessManagerInterface;
  5. use Drupal\Core\Cache\Cache;
  6. use Drupal\Core\Cache\CacheableMetadata;
  7. use Drupal\Core\Cache\CacheBackendInterface;
  8. use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
  9. use Drupal\Core\Controller\ControllerResolverInterface;
  10. use Drupal\Core\Extension\ModuleHandlerInterface;
  11. use Drupal\Core\Language\LanguageManagerInterface;
  12. use Drupal\Core\Plugin\DefaultPluginManager;
  13. use Drupal\Core\Plugin\Discovery\ContainerDerivativeDiscoveryDecorator;
  14. use Drupal\Core\Plugin\Discovery\YamlDiscovery;
  15. use Drupal\Core\Plugin\Factory\ContainerFactory;
  16. use Drupal\Core\Routing\RouteMatchInterface;
  17. use Drupal\Core\Routing\RouteProviderInterface;
  18. use Drupal\Core\Session\AccountInterface;
  19. use Drupal\Core\Url;
  20. use Symfony\Component\HttpFoundation\RequestStack;
  21. /**
  22. * Provides the default local task manager using YML as primary definition.
  23. */
  24. class LocalTaskManager extends DefaultPluginManager implements LocalTaskManagerInterface {
  25. /**
  26. * {@inheritdoc}
  27. */
  28. protected $defaults = [
  29. // (required) The name of the route this task links to.
  30. 'route_name' => '',
  31. // Parameters for route variables when generating a link.
  32. 'route_parameters' => [],
  33. // The static title for the local task.
  34. 'title' => '',
  35. // The route name where the root tab appears.
  36. 'base_route' => '',
  37. // The plugin ID of the parent tab (or NULL for the top-level tab).
  38. 'parent_id' => NULL,
  39. // The weight of the tab.
  40. 'weight' => NULL,
  41. // The default link options.
  42. 'options' => [],
  43. // Default class for local task implementations.
  44. 'class' => 'Drupal\Core\Menu\LocalTaskDefault',
  45. // The plugin id. Set by the plugin system based on the top-level YAML key.
  46. 'id' => '',
  47. ];
  48. /**
  49. * A controller resolver object.
  50. *
  51. * @var \Drupal\Core\Controller\ControllerResolverInterface
  52. */
  53. protected $controllerResolver;
  54. /**
  55. * The request stack.
  56. *
  57. * @var \Symfony\Component\HttpFoundation\RequestStack
  58. */
  59. protected $requestStack;
  60. /**
  61. * The current route match.
  62. *
  63. * @var \Drupal\Core\Routing\RouteMatchInterface
  64. */
  65. protected $routeMatch;
  66. /**
  67. * The plugin instances.
  68. *
  69. * @var array
  70. */
  71. protected $instances = [];
  72. /**
  73. * The local task render arrays for the current route.
  74. *
  75. * @var array
  76. */
  77. protected $taskData;
  78. /**
  79. * The route provider to load routes by name.
  80. *
  81. * @var \Drupal\Core\Routing\RouteProviderInterface
  82. */
  83. protected $routeProvider;
  84. /**
  85. * The access manager.
  86. *
  87. * @var \Drupal\Core\Access\AccessManagerInterface
  88. */
  89. protected $accessManager;
  90. /**
  91. * The current user.
  92. *
  93. * @var \Drupal\Core\Session\AccountInterface
  94. */
  95. protected $account;
  96. /**
  97. * Constructs a \Drupal\Core\Menu\LocalTaskManager object.
  98. *
  99. * @param \Drupal\Core\Controller\ControllerResolverInterface $controller_resolver
  100. * An object to use in introspecting route methods.
  101. * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
  102. * The request object to use for building titles and paths for plugin instances.
  103. * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
  104. * The current route match.
  105. * @param \Drupal\Core\Routing\RouteProviderInterface $route_provider
  106. * The route provider to load routes by name.
  107. * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
  108. * The module handler.
  109. * @param \Drupal\Core\Cache\CacheBackendInterface $cache
  110. * The cache backend.
  111. * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
  112. * The language manager.
  113. * @param \Drupal\Core\Access\AccessManagerInterface $access_manager
  114. * The access manager.
  115. * @param \Drupal\Core\Session\AccountInterface $account
  116. * The current user.
  117. */
  118. public function __construct(ControllerResolverInterface $controller_resolver, RequestStack $request_stack, RouteMatchInterface $route_match, RouteProviderInterface $route_provider, ModuleHandlerInterface $module_handler, CacheBackendInterface $cache, LanguageManagerInterface $language_manager, AccessManagerInterface $access_manager, AccountInterface $account) {
  119. $this->factory = new ContainerFactory($this, '\Drupal\Core\Menu\LocalTaskInterface');
  120. $this->controllerResolver = $controller_resolver;
  121. $this->requestStack = $request_stack;
  122. $this->routeMatch = $route_match;
  123. $this->routeProvider = $route_provider;
  124. $this->accessManager = $access_manager;
  125. $this->account = $account;
  126. $this->moduleHandler = $module_handler;
  127. $this->alterInfo('local_tasks');
  128. $this->setCacheBackend($cache, 'local_task_plugins:' . $language_manager->getCurrentLanguage()->getId(), ['local_task']);
  129. }
  130. /**
  131. * {@inheritdoc}
  132. */
  133. protected function getDiscovery() {
  134. if (!isset($this->discovery)) {
  135. $yaml_discovery = new YamlDiscovery('links.task', $this->moduleHandler->getModuleDirectories());
  136. $yaml_discovery->addTranslatableProperty('title', 'title_context');
  137. $this->discovery = new ContainerDerivativeDiscoveryDecorator($yaml_discovery);
  138. }
  139. return $this->discovery;
  140. }
  141. /**
  142. * {@inheritdoc}
  143. */
  144. public function processDefinition(&$definition, $plugin_id) {
  145. parent::processDefinition($definition, $plugin_id);
  146. // If there is no route name, this is a broken definition.
  147. if (empty($definition['route_name'])) {
  148. throw new PluginException(sprintf('Plugin (%s) definition must include "route_name"', $plugin_id));
  149. }
  150. }
  151. /**
  152. * {@inheritdoc}
  153. */
  154. public function getTitle(LocalTaskInterface $local_task) {
  155. $controller = [$local_task, 'getTitle'];
  156. $request = $this->requestStack->getCurrentRequest();
  157. $arguments = $this->controllerResolver->getArguments($request, $controller);
  158. return call_user_func_array($controller, $arguments);
  159. }
  160. /**
  161. * {@inheritdoc}
  162. */
  163. public function getDefinitions() {
  164. $definitions = parent::getDefinitions();
  165. $count = 0;
  166. foreach ($definitions as &$definition) {
  167. if (isset($definition['weight'])) {
  168. // Add some micro weight.
  169. $definition['weight'] += ($count++) * 1e-6;
  170. }
  171. }
  172. return $definitions;
  173. }
  174. /**
  175. * {@inheritdoc}
  176. */
  177. public function getLocalTasksForRoute($route_name) {
  178. if (!isset($this->instances[$route_name])) {
  179. $this->instances[$route_name] = [];
  180. if ($cache = $this->cacheBackend->get($this->cacheKey . ':' . $route_name)) {
  181. $base_routes = $cache->data['base_routes'];
  182. $parents = $cache->data['parents'];
  183. $children = $cache->data['children'];
  184. }
  185. else {
  186. $definitions = $this->getDefinitions();
  187. // We build the hierarchy by finding all tabs that should
  188. // appear on the current route.
  189. $base_routes = [];
  190. $parents = [];
  191. $children = [];
  192. foreach ($definitions as $plugin_id => $task_info) {
  193. // Fill in the base_route from the parent to insure consistency.
  194. if (!empty($task_info['parent_id']) && !empty($definitions[$task_info['parent_id']])) {
  195. $task_info['base_route'] = $definitions[$task_info['parent_id']]['base_route'];
  196. // Populate the definitions we use in the next loop. Using a
  197. // reference like &$task_info causes bugs.
  198. $definitions[$plugin_id]['base_route'] = $definitions[$task_info['parent_id']]['base_route'];
  199. }
  200. if ($route_name == $task_info['route_name']) {
  201. if (!empty($task_info['base_route'])) {
  202. $base_routes[$task_info['base_route']] = $task_info['base_route'];
  203. }
  204. // Tabs that link to the current route are viable parents
  205. // and their parent and children should be visible also.
  206. // @todo - this only works for 2 levels of tabs.
  207. // instead need to iterate up.
  208. $parents[$plugin_id] = TRUE;
  209. if (!empty($task_info['parent_id'])) {
  210. $parents[$task_info['parent_id']] = TRUE;
  211. }
  212. }
  213. }
  214. if ($base_routes) {
  215. // Find all the plugins with the same root and that are at the top
  216. // level or that have a visible parent.
  217. foreach ($definitions as $plugin_id => $task_info) {
  218. if (!empty($base_routes[$task_info['base_route']]) && (empty($task_info['parent_id']) || !empty($parents[$task_info['parent_id']]))) {
  219. // Concat '> ' with root ID for the parent of top-level tabs.
  220. $parent = empty($task_info['parent_id']) ? '> ' . $task_info['base_route'] : $task_info['parent_id'];
  221. $children[$parent][$plugin_id] = $task_info;
  222. }
  223. }
  224. }
  225. $data = [
  226. 'base_routes' => $base_routes,
  227. 'parents' => $parents,
  228. 'children' => $children,
  229. ];
  230. $this->cacheBackend->set($this->cacheKey . ':' . $route_name, $data, Cache::PERMANENT, $this->cacheTags);
  231. }
  232. // Create a plugin instance for each element of the hierarchy.
  233. foreach ($base_routes as $base_route) {
  234. // Convert the tree keyed by plugin IDs into a simple one with
  235. // integer depth. Create instances for each plugin along the way.
  236. $level = 0;
  237. // We used this above as the top-level parent array key.
  238. $next_parent = '> ' . $base_route;
  239. do {
  240. $parent = $next_parent;
  241. $next_parent = FALSE;
  242. foreach ($children[$parent] as $plugin_id => $task_info) {
  243. $plugin = $this->createInstance($plugin_id);
  244. $this->instances[$route_name][$level][$plugin_id] = $plugin;
  245. // Normally, the link generator compares the href of every link with
  246. // the current path and sets the active class accordingly. But the
  247. // parents of the current local task may be on a different route in
  248. // which case we have to set the class manually by flagging it
  249. // active.
  250. if (!empty($parents[$plugin_id]) && $route_name != $task_info['route_name']) {
  251. $plugin->setActive();
  252. }
  253. if (isset($children[$plugin_id])) {
  254. // This tab has visible children.
  255. $next_parent = $plugin_id;
  256. }
  257. }
  258. $level++;
  259. } while ($next_parent);
  260. }
  261. }
  262. return $this->instances[$route_name];
  263. }
  264. /**
  265. * {@inheritdoc}
  266. */
  267. public function getTasksBuild($current_route_name, RefinableCacheableDependencyInterface &$cacheability) {
  268. $tree = $this->getLocalTasksForRoute($current_route_name);
  269. $build = [];
  270. // Collect all route names.
  271. $route_names = [];
  272. foreach ($tree as $instances) {
  273. foreach ($instances as $child) {
  274. $route_names[] = $child->getRouteName();
  275. }
  276. }
  277. // Pre-fetch all routes involved in the tree. This reduces the number
  278. // of SQL queries that would otherwise be triggered by the access manager.
  279. if ($route_names) {
  280. $this->routeProvider->getRoutesByNames($route_names);
  281. }
  282. foreach ($tree as $level => $instances) {
  283. /** @var $instances \Drupal\Core\Menu\LocalTaskInterface[] */
  284. foreach ($instances as $plugin_id => $child) {
  285. $route_name = $child->getRouteName();
  286. $route_parameters = $child->getRouteParameters($this->routeMatch);
  287. // Given that the active flag depends on the route we have to add the
  288. // route cache context.
  289. $cacheability->addCacheContexts(['route']);
  290. $active = $this->isRouteActive($current_route_name, $route_name, $route_parameters);
  291. // The plugin may have been set active in getLocalTasksForRoute() if
  292. // one of its child tabs is the active tab.
  293. $active = $active || $child->getActive();
  294. // @todo It might make sense to use link render elements instead.
  295. $link = [
  296. 'title' => $this->getTitle($child),
  297. 'url' => Url::fromRoute($route_name, $route_parameters),
  298. 'localized_options' => $child->getOptions($this->routeMatch),
  299. ];
  300. $access = $this->accessManager->checkNamedRoute($route_name, $route_parameters, $this->account, TRUE);
  301. $build[$level][$plugin_id] = [
  302. '#theme' => 'menu_local_task',
  303. '#link' => $link,
  304. '#active' => $active,
  305. '#weight' => $child->getWeight(),
  306. '#access' => $access,
  307. ];
  308. $cacheability->addCacheableDependency($access)->addCacheableDependency($child);
  309. }
  310. }
  311. return $build;
  312. }
  313. /**
  314. * {@inheritdoc}
  315. */
  316. public function getLocalTasks($route_name, $level = 0) {
  317. if (!isset($this->taskData[$route_name])) {
  318. $cacheability = new CacheableMetadata();
  319. $cacheability->addCacheContexts(['route']);
  320. // Look for route-based tabs.
  321. $this->taskData[$route_name] = [
  322. 'tabs' => [],
  323. 'cacheability' => $cacheability,
  324. ];
  325. if (!$this->requestStack->getCurrentRequest()->attributes->has('exception')) {
  326. // Safe to build tasks only when no exceptions raised.
  327. $data = [];
  328. $local_tasks = $this->getTasksBuild($route_name, $cacheability);
  329. foreach ($local_tasks as $tab_level => $items) {
  330. $data[$tab_level] = empty($data[$tab_level]) ? $items : array_merge($data[$tab_level], $items);
  331. }
  332. $this->taskData[$route_name]['tabs'] = $data;
  333. // Allow modules to alter local tasks.
  334. $this->moduleHandler->alter('menu_local_tasks', $this->taskData[$route_name], $route_name, $cacheability);
  335. $this->taskData[$route_name]['cacheability'] = $cacheability;
  336. }
  337. }
  338. if (isset($this->taskData[$route_name]['tabs'][$level])) {
  339. return [
  340. 'tabs' => $this->taskData[$route_name]['tabs'][$level],
  341. 'route_name' => $route_name,
  342. 'cacheability' => $this->taskData[$route_name]['cacheability'],
  343. ];
  344. }
  345. return [
  346. 'tabs' => [],
  347. 'route_name' => $route_name,
  348. 'cacheability' => $this->taskData[$route_name]['cacheability'],
  349. ];
  350. }
  351. /**
  352. * Determines whether the route of a certain local task is currently active.
  353. *
  354. * @param string $current_route_name
  355. * The route name of the current main request.
  356. * @param string $route_name
  357. * The route name of the local task to determine the active status.
  358. * @param array $route_parameters
  359. *
  360. * @return bool
  361. * Returns TRUE if the passed route_name and route_parameters is considered
  362. * as the same as the one from the request, otherwise FALSE.
  363. */
  364. protected function isRouteActive($current_route_name, $route_name, $route_parameters) {
  365. // Flag the list element as active if this tab's route and parameters match
  366. // the current request's route and route variables.
  367. $active = $current_route_name == $route_name;
  368. if ($active) {
  369. // The request is injected, so we need to verify that we have the expected
  370. // _raw_variables attribute.
  371. $raw_variables_bag = $this->routeMatch->getRawParameters();
  372. // If we don't have _raw_variables, we assume the attributes are still the
  373. // original values.
  374. $raw_variables = $raw_variables_bag ? $raw_variables_bag->all() : $this->routeMatch->getParameters()->all();
  375. $active = array_intersect_assoc($route_parameters, $raw_variables) == $route_parameters;
  376. }
  377. return $active;
  378. }
  379. }