LocalTaskManager.php 16 KB

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