From 8bb62da27d7d0f97168a9f868ceabc152e00cbb8 Mon Sep 17 00:00:00 2001 From: Alex Pott <alex.a.pott@googlemail.com> Date: Tue, 15 Jul 2014 09:15:14 +0100 Subject: [PATCH] Issue #2301239 by pwolanin, dawehner, Wim Leers, effulgentsia, joelpittet, larowlan, xjm, YesCT, kgoel, victoru, berdir, likin, and plach: MenuLinkNG part1 (no UI or conversions): plugins (static + MenuLinkContent) + MenuLinkManager + MenuTreeStorage. --- core/core.services.yml | 17 + core/lib/Drupal/Core/Menu/MenuLinkBase.php | 197 +++ core/lib/Drupal/Core/Menu/MenuLinkDefault.php | 88 + .../Drupal/Core/Menu/MenuLinkInterface.php | 233 +++ core/lib/Drupal/Core/Menu/MenuLinkManager.php | 416 +++++ .../Core/Menu/MenuLinkManagerInterface.php | 200 +++ .../Drupal/Core/Menu/MenuTreeParameters.php | 222 +++ core/lib/Drupal/Core/Menu/MenuTreeStorage.php | 1441 +++++++++++++++++ .../Core/Menu/MenuTreeStorageInterface.php | 278 ++++ .../Core/Menu/StaticMenuLinkOverrides.php | 163 ++ .../Menu/StaticMenuLinkOverridesInterface.php | 87 + .../Core/Plugin/CachedDiscoveryClearer.php | 2 +- .../Drupal/Core/Plugin/PluginManagerPass.php | 4 +- .../menu_link_content.info.yml | 6 + .../menu_link_content.module | 17 + .../src/Entity/MenuLinkContent.php | 385 +++++ .../src/Entity/MenuLinkContentInterface.php | 152 ++ .../src/MenuLinkContentAccessController.php | 68 + .../src/Plugin/Menu/MenuLinkContent.php | 250 +++ .../src/Tests/Menu/MenuTreeStorageTest.php | 389 +++++ .../Core/Menu/StaticMenuLinkOverridesTest.php | 219 +++ 21 files changed, 4832 insertions(+), 2 deletions(-) create mode 100644 core/lib/Drupal/Core/Menu/MenuLinkBase.php create mode 100644 core/lib/Drupal/Core/Menu/MenuLinkDefault.php create mode 100644 core/lib/Drupal/Core/Menu/MenuLinkInterface.php create mode 100644 core/lib/Drupal/Core/Menu/MenuLinkManager.php create mode 100644 core/lib/Drupal/Core/Menu/MenuLinkManagerInterface.php create mode 100644 core/lib/Drupal/Core/Menu/MenuTreeParameters.php create mode 100644 core/lib/Drupal/Core/Menu/MenuTreeStorage.php create mode 100644 core/lib/Drupal/Core/Menu/MenuTreeStorageInterface.php create mode 100644 core/lib/Drupal/Core/Menu/StaticMenuLinkOverrides.php create mode 100644 core/lib/Drupal/Core/Menu/StaticMenuLinkOverridesInterface.php create mode 100644 core/modules/menu_link_content/menu_link_content.info.yml create mode 100644 core/modules/menu_link_content/menu_link_content.module create mode 100644 core/modules/menu_link_content/src/Entity/MenuLinkContent.php create mode 100644 core/modules/menu_link_content/src/Entity/MenuLinkContentInterface.php create mode 100644 core/modules/menu_link_content/src/MenuLinkContentAccessController.php create mode 100644 core/modules/menu_link_content/src/Plugin/Menu/MenuLinkContent.php create mode 100644 core/modules/system/src/Tests/Menu/MenuTreeStorageTest.php create mode 100644 core/tests/Drupal/Tests/Core/Menu/StaticMenuLinkOverridesTest.php diff --git a/core/core.services.yml b/core/core.services.yml index ac4f816c9f8e..a43f90c24215 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -63,6 +63,13 @@ services: factory_method: get factory_service: cache_factory arguments: [entity] + cache.menu: + class: Drupal\Core\Cache\CacheBackendInterface + tags: + - { name: cache.bin } + factory_method: get + factory_service: cache_factory + arguments: [menu] cache.render: class: Drupal\Core\Cache\CacheBackendInterface tags: @@ -265,6 +272,9 @@ services: plugin.manager.action: class: Drupal\Core\Action\ActionManager arguments: ['@container.namespaces', '@cache.discovery', '@module_handler'] + plugin.manager.menu.link: + class: Drupal\Core\Menu\MenuLinkManager + arguments: ['@menu.tree_storage', '@menu_link.static.overrides', '@module_handler'] plugin.manager.menu.local_action: class: Drupal\Core\Menu\LocalActionManager arguments: ['@controller_resolver', '@request_stack', '@router.route_provider', '@module_handler', '@cache.discovery', '@language_manager', '@access_manager', '@current_user'] @@ -279,6 +289,13 @@ services: parent: default_plugin_manager plugin.cache_clearer: class: Drupal\Core\Plugin\CachedDiscoveryClearer + menu.tree_storage: + class: Drupal\Core\Menu\MenuTreeStorage + arguments: ['@database', '@cache.menu', 'menu_tree'] + public: false # Private to plugin.manager.menu.link and menu.link_tree + menu_link.static.overrides: + class: Drupal\Core\Menu\StaticMenuLinkOverrides + arguments: ['@config.factory'] request_stack: class: Symfony\Component\HttpFoundation\RequestStack tags: diff --git a/core/lib/Drupal/Core/Menu/MenuLinkBase.php b/core/lib/Drupal/Core/Menu/MenuLinkBase.php new file mode 100644 index 000000000000..438d862bf4ca --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuLinkBase.php @@ -0,0 +1,197 @@ +<?php + +/** + * @file + * Contains \Drupal\Core\Menu\MenuLinkBase. + */ + +namespace Drupal\Core\Menu; + +use Drupal\Component\Plugin\Exception\PluginException; +use Drupal\Component\Utility\String; +use Drupal\Core\Plugin\PluginBase; +use Drupal\Core\Url; + +/** + * Defines a base menu link class. + */ +abstract class MenuLinkBase extends PluginBase implements MenuLinkInterface { + + /** + * The list of definition values where an override is allowed. + * + * The keys are definition names. The values are ignored. + * + * @var array + */ + protected $overrideAllowed = array(); + + /** + * {@inheritdoc} + */ + public function getWeight() { + // By default the weight is 0. + if (!isset($this->pluginDefinition['weight'])) { + $this->pluginDefinition['weight'] = 0; + } + return $this->pluginDefinition['weight']; + } + + /** + * {@inheritdoc} + */ + public function getTitle() { + // Subclasses may pull in the request or specific attributes as parameters. + $options = array(); + if (!empty($this->pluginDefinition['title_context'])) { + $options['context'] = $this->pluginDefinition['title_context']; + } + $args = array(); + if (isset($this->pluginDefinition['title_arguments']) && $title_arguments = $this->pluginDefinition['title_arguments']) { + $args = (array) $title_arguments; + } + return $this->t($this->pluginDefinition['title'], $args, $options); + } + + /** + * {@inheritdoc} + */ + public function getMenuName() { + return $this->pluginDefinition['menu_name']; + } + + /** + * {@inheritdoc} + */ + public function getProvider() { + return $this->pluginDefinition['provider']; + } + + /** + * {@inheritdoc} + */ + public function getParent() { + return $this->pluginDefinition['parent']; + } + + /** + * {@inheritdoc} + */ + public function isHidden() { + return (bool) $this->pluginDefinition['hidden']; + } + + /** + * {@inheritdoc} + */ + public function isExpanded() { + return (bool) $this->pluginDefinition['expanded']; + } + + /** + * {@inheritdoc} + */ + public function isResetable() { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function isTranslatable() { + return (bool) $this->getTranslateRoute(); + } + + /** + * {@inheritdoc} + */ + public function isDeletable() { + return (bool) $this->getDeleteRoute(); + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + if ($this->pluginDefinition['description']) { + return $this->t($this->pluginDefinition['description']); + } + return ''; + } + + /** + * {@inheritdoc} + */ + public function getOptions() { + return $this->pluginDefinition['options'] ?: array(); + } + + /** + * {@inheritdoc} + */ + public function getMetaData() { + return $this->pluginDefinition['metadata'] ?: array(); + } + + /** + * {@inheritdoc} + */ + public function isCacheable() { + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function getUrlObject($title_attribute = TRUE) { + $options = $this->getOptions(); + $description = $this->getDescription(); + if ($title_attribute && $description) { + $options['attributes']['title'] = $description; + } + if (empty($this->pluginDefinition['url'])) { + return new Url($this->pluginDefinition['route_name'], $this->pluginDefinition['route_parameters'], $options); + } + else { + $url = Url::createFromPath($this->pluginDefinition['url']); + $url->setOptions($options); + return $url; + } + } + + /** + * {@inheritdoc} + */ + public function getFormClass() { + return $this->pluginDefinition['form_class']; + } + + /** + * {@inheritdoc} + */ + public function getDeleteRoute() { + return NULL; + } + + /** + * {@inheritdoc} + */ + public function getEditRoute() { + return NULL; + } + + /** + * {@inheritdoc} + */ + public function getTranslateRoute() { + return NULL; + } + + /** + * {@inheritdoc} + */ + public function deleteLink() { + throw new PluginException(String::format('Menu link plugin with ID @id does not support deletion', array('@id' => $this->getPluginId()))); + } + +} diff --git a/core/lib/Drupal/Core/Menu/MenuLinkDefault.php b/core/lib/Drupal/Core/Menu/MenuLinkDefault.php new file mode 100644 index 000000000000..0439937e78ff --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuLinkDefault.php @@ -0,0 +1,88 @@ +<?php + +/** + * @file + * Contains \Drupal\Core\Menu\MenuLinkDefault. + */ + +namespace Drupal\Core\Menu; + +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides a default implementation for menu link plugins. + */ +class MenuLinkDefault extends MenuLinkBase implements ContainerFactoryPluginInterface { + + /** + * {@inheritdoc} + */ + protected $overrideAllowed = array( + 'menu_name' => 1, + 'parent' => 1, + 'weight' => 1, + 'expanded' => 1, + 'hidden' => 1, + ); + + /** + * The static menu link service used to store updates to weight/parent etc. + * + * @var \Drupal\Core\Menu\StaticMenuLinkOverridesInterface + */ + protected $staticOverride; + + /** + * Constructs a new MenuLinkDefault. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Drupal\Core\Menu\StaticMenuLinkOverridesInterface $static_override + * The static override storage. + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, StaticMenuLinkOverridesInterface $static_override) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + + $this->staticOverride = $static_override; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('menu_link.static.overrides') + ); + } + + /** + * {@inheritdoc} + */ + public function isResetable() { + // The link can be reset if it has an override. + return (bool) $this->staticOverride->loadOverride($this->getPluginId()); + } + + /** + * {@inheritdoc} + */ + public function updateLink(array $new_definition_values, $persist) { + // Filter the list of updates to only those that are allowed. + $overrides = array_intersect_key($new_definition_values, $this->overrideAllowed); + if ($persist) { + $this->staticOverride->saveOverride($this->getPluginId(), $overrides); + } + // Update the definition. + $this->pluginDefinition = $overrides + $this->getPluginDefinition(); + return $this->pluginDefinition; + } + +} diff --git a/core/lib/Drupal/Core/Menu/MenuLinkInterface.php b/core/lib/Drupal/Core/Menu/MenuLinkInterface.php new file mode 100644 index 000000000000..6ab14342e33d --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuLinkInterface.php @@ -0,0 +1,233 @@ +<?php + +/** + * @file + * Contains \Drupal\Core\Menu\MenuLinkInterface. + */ + +namespace Drupal\Core\Menu; + +use Drupal\Component\Plugin\PluginInspectionInterface; +use Drupal\Component\Plugin\DerivativeInspectionInterface; + +/** + * Defines an interface for classes providing a type of menu link. + */ +interface MenuLinkInterface extends PluginInspectionInterface, DerivativeInspectionInterface { + + /** + * Returns the weight of the menu link. + * + * @return int + * The weight of the menu link, 0 by default. + */ + public function getWeight(); + + /** + * Returns the localized title to be shown for this link. + * + * @return string + * The title of the menu link. + */ + public function getTitle(); + + /** + * Returns the description of the menu link. + * + * @return string + * The description of the menu link. + */ + public function getDescription(); + + /** + * Returns the menu name of the menu link. + * + * @return string + * The menu name of the menu link. + */ + public function getMenuName(); + + /** + * Returns the provider (module name) of the menu link. + * + * @return string + * The provider of the menu link. + */ + public function getProvider(); + + /** + * Returns the plugin ID of the menu link's parent, or an empty string. + * + * @return string + * The parent plugin ID. + */ + public function getParent(); + + /** + * Returns whether the menu link is hidden. + * + * @return bool + * TRUE for hidden, FALSE otherwise. + */ + public function isHidden(); + + /** + * Returns whether the child menu links should always been shown. + * + * @return bool + * TRUE for expanded, FALSE otherwise. + */ + public function isExpanded(); + + /** + * Returns whether this link can be reset. + * + * In general, only links that store overrides using the + * menu_link.static.overrides service should return TRUE for this method. + * + * @return bool + * TRUE if it can be reset, FALSE otherwise. + */ + public function isResetable(); + + /** + * Returns whether this link can be translated. + * + * @return bool + * TRUE if the link can be translated, FALSE otherwise. + */ + public function isTranslatable(); + + /** + * Returns whether this link can be deleted. + * + * @return bool + * TRUE if the link can be deleted, FALSE otherwise. + */ + public function isDeletable(); + + /** + * Returns a URL object containing either the external path or route. + * + * @param bool $title_attribute + * (optional) If TRUE, add the link description as the title attribute if + * the description is not empty. + * + * @return \Drupal\Core\Url + * A a URL object containing either the external path or route. + */ + public function getUrlObject($title_attribute = TRUE); + + /** + * Returns the options for this link. + * + * @return array + * The options for the menu link. + */ + public function getOptions(); + + /** + * Returns any metadata for this link. + * + * @return array + * The metadata for the menu link. + */ + public function getMetaData(); + + /** + * Returns whether the rendered link can be cached. + * + * The plugin class may make some or all of the data used in the Url object + * and build array dynamic. For example, it could include the current user + * name in the title, the current time in the description, or a destination + * query string. In addition the route parameters may be dynamic so an access + * check should be performed for each user. + * + * @return bool + * TRUE if the link can be cached, FALSE otherwise. + */ + public function isCacheable(); + + /** + * Updates the definition values for a menu link. + * + * Depending on the implementation details of the class, not all definition + * values may be changed. For example, changes to the title of a static link + * will be discarded. + * + * In general, this method should not be called directly, but will be called + * automatically from MenuLinkManagerInterface::updateDefinition(). + * + * @param array $new_definition_values + * The new values for the link definition. This will usually be just a + * subset of the plugin definition. + * @param bool $persist + * TRUE to have the link persist the changed values to any additional + * storage. + * + * @return array + * The plugin definition incorporating any allowed changes. + */ + public function updateLink(array $new_definition_values, $persist); + + /** + * Deletes a menu link. + * + * In general, this method should not be called directly, but will be called + * automatically from MenuLinkManagerInterface::removeDefinition(). + * + * This method will only delete the link from any additional storage, but not + * from the plugin.manager.menu.link service. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * If the link is not deletable. + */ + public function deleteLink(); + + /** + * Returns the name of a class that can build an editing form for this link. + * + * To instantiate the form class, use an instance of the + * \Drupal\Core\DependencyInjection\ClassResolverInterface, such as from the + * class_resolver service. Then call the setMenuLinkInstance() method on the + * form instance with the menu link plugin instance. + * + * @todo Add a code example. https://www.drupal.org/node/2302849 + * + * @return string + * A class that implements \Drupal\Core\Menu\Form\MenuLinkFormInterface. + */ + public function getFormClass(); + + /** + * Returns route information for a route to delete the menu link. + * + * @return array|null + * An array with keys route_name and route_parameters, or NULL if there is + * no route (e.g. when the link is not deletable). + */ + public function getDeleteRoute(); + + /** + * Returns route information for a custom edit form for the menu link. + * + * Plugins should return a value here if they have a special edit form, or if + * they need to define additional local tasks, local actions, etc. that are + * visible from the edit form. + * + * @return array|null + * An array with keys route_name and route_parameters, or NULL if there is + * no route because there is no custom edit route for this instance. + */ + public function getEditRoute(); + + /** + * Returns route information for a route to translate the menu link. + * + * @return array + * An array with keys route_name and route_parameters, or NULL if there is + * no route (e.g. when the link is not translatable). + */ + public function getTranslateRoute(); + +} diff --git a/core/lib/Drupal/Core/Menu/MenuLinkManager.php b/core/lib/Drupal/Core/Menu/MenuLinkManager.php new file mode 100644 index 000000000000..ab1f5ddd5197 --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuLinkManager.php @@ -0,0 +1,416 @@ +<?php + +/** + * @file + * Contains \Drupal\Core\Menu\MenuLinkManager. + */ + +namespace Drupal\Core\Menu; + +use Drupal\Component\Plugin\Exception\PluginException; +use Drupal\Component\Plugin\Exception\PluginNotFoundException; +use Drupal\Component\Utility\NestedArray; +use Drupal\Component\Utility\String; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Plugin\Discovery\ContainerDerivativeDiscoveryDecorator; +use Drupal\Core\Plugin\Discovery\YamlDiscovery; +use Drupal\Core\Plugin\Factory\ContainerFactory; + + +/** + * Manages discovery, instantiation, and tree building of menu link plugins. + * + * This manager finds plugins that are rendered as menu links. + */ +class MenuLinkManager implements MenuLinkManagerInterface { + + /** + * Provides some default values for the definition of all menu link plugins. + * + * @todo Decide how to keep these field definitions in sync. + * https://www.drupal.org/node/2302085 + * + * @var array + */ + protected $defaults = array( + // (required) The name of the menu for this link. + 'menu_name' => 'tools', + // (required) The name of the route this links to, unless it's external. + 'route_name' => '', + // Parameters for route variables when generating a link. + 'route_parameters' => array(), + // The external URL if this link has one (required if route_name is empty). + 'url' => '', + // The static title for the menu link. You can specify placeholders like on + // any translatable string and the values in title_arguments. + 'title' => '', + // The values for the menu link placeholders. + 'title_arguments' => array(), + // A context for the title string. + // @see \Drupal\Core\StringTranslation\TranslationInterface::translate() + 'title_context' => '', + // The description. + 'description' => '', + // The plugin ID of the parent link (or NULL for a top-level link). + 'parent' => '', + // The weight of the link. + 'weight' => 0, + // The default link options. + 'options' => array(), + 'expanded' => 0, + 'hidden' => 0, + // The name of the module providing this link. + 'provider' => '', + 'metadata' => array(), + // Default class for local task implementations. + 'class' => 'Drupal\Core\Menu\MenuLinkDefault', + 'form_class' => 'Drupal\Core\Menu\Form\MenuLinkDefaultForm', + // The plugin ID. Set by the plugin system based on the top-level YAML key. + 'id' => '', + ); + + /** + * The object that discovers plugins managed by this manager. + * + * @var \Drupal\Component\Plugin\Discovery\DiscoveryInterface + */ + protected $discovery; + + /** + * The object that instantiates plugins managed by this manager. + * + * @var \Drupal\Component\Plugin\Factory\FactoryInterface + */ + protected $factory; + + /** + * The menu link tree storage. + * + * @var \Drupal\Core\Menu\MenuTreeStorageInterface + */ + protected $treeStorage; + + /** + * Service providing overrides for static links. + * + * @var \Drupal\Core\Menu\StaticMenuLinkOverridesInterface + */ + protected $overrides; + + /** + * The module handler. + * + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ + protected $moduleHandler; + + + /** + * Constructs a \Drupal\Core\Menu\MenuLinkManager object. + * + * @param \Drupal\Core\Menu\MenuTreeStorageInterface $tree_storage + * The menu link tree storage. + * @param \Drupal\Core\Menu\StaticMenuLinkOverridesInterface $overrides + * The service providing overrides for static links. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler. + */ + public function __construct(MenuTreeStorageInterface $tree_storage, StaticMenuLinkOverridesInterface $overrides, ModuleHandlerInterface $module_handler) { + $this->treeStorage = $tree_storage; + $this->overrides = $overrides; + $this->factory = new ContainerFactory($this); + $this->moduleHandler = $module_handler; + } + + /** + * Performs extra processing on plugin definitions. + * + * By default we add defaults for the type to the definition. If a type has + * additional processing logic, the logic can be added by replacing or + * extending this method. + * + * @param array $definition + * The definition to be processed and modified by reference. + * @param $plugin_id + * The ID of the plugin this definition is being used for. + */ + protected function processDefinition(array &$definition, $plugin_id) { + $definition = NestedArray::mergeDeep($this->defaults, $definition); + // Typecast so NULL, no parent, will be an empty string since the parent ID + // should be a string. + $definition['parent'] = (string) $definition['parent']; + $definition['id'] = $plugin_id; + } + + /** + * Instantiates if necessary and returns a YamlDiscovery instance. + * + * Since the discovery is very rarely used - only when the rebuild() method + * is called - it's instantiated only when actually needed instead of in the + * constructor. + * + * @return \Drupal\Core\Plugin\Discovery\ContainerDerivativeDiscoveryDecorator + * A plugin discovery instance. + */ + protected function getDiscovery() { + if (empty($this->discovery)) { + $yaml = new YamlDiscovery('menu_links', $this->moduleHandler->getModuleDirectories()); + $this->discovery = new ContainerDerivativeDiscoveryDecorator($yaml); + } + return $this->discovery; + } + + /** + * {@inheritdoc} + */ + public function getDefinitions() { + // Since this function is called rarely, instantiate the discovery here. + $definitions = $this->getDiscovery()->getDefinitions(); + + $this->moduleHandler->alter('menu_links_discovered', $definitions); + + foreach ($definitions as $plugin_id => &$definition) { + $definition['id'] = $plugin_id; + $this->processDefinition($definition, $plugin_id); + } + + // If this plugin was provided by a module that does not exist, remove the + // plugin definition. + // @todo Address what to do with an invalid plugin. + // https://www.drupal.org/node/2302623 + foreach ($definitions as $plugin_id => $plugin_definition) { + if (!empty($plugin_definition['provider']) && !$this->moduleHandler->moduleExists($plugin_definition['provider'])) { + unset($definitions[$plugin_id]); + } + } + return $definitions; + } + + /** + * {@inheritdoc} + */ + public function rebuild() { + $definitions = $this->getDefinitions(); + // Apply overrides from config. + $overrides = $this->overrides->loadMultipleOverrides(array_keys($definitions)); + foreach ($overrides as $id => $changes) { + if (!empty($definitions[$id])) { + $definitions[$id] = $changes + $definitions[$id]; + } + } + $this->treeStorage->rebuild($definitions); + } + + /** + * {@inheritdoc} + */ + public function getDefinition($plugin_id, $exception_on_invalid = TRUE) { + $definition = $this->treeStorage->load($plugin_id); + if (empty($definition) && $exception_on_invalid) { + throw new PluginNotFoundException(String::format('@plugin_id could not be found', array('@plugin_id' => $plugin_id))); + } + return $definition; + } + + /** + * {@inheritdoc} + */ + public function hasDefinition($plugin_id) { + return (bool) $this->getDefinition($plugin_id, FALSE); + } + + /** + * Returns a pre-configured menu link plugin instance. + * + * @param string $plugin_id + * The ID of the plugin being instantiated. + * @param array $configuration + * An array of configuration relevant to the plugin instance. + * + * @return \Drupal\Core\Menu\MenuLinkInterface + * A menu link instance. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * If the instance cannot be created, such as if the ID is invalid. + */ + public function createInstance($plugin_id, array $configuration = array()) { + return $this->factory->createInstance($plugin_id, $configuration); + } + + /** + * {@inheritdoc} + */ + public function getInstance(array $options) { + if (isset($options['id'])) { + return $this->createInstance($options['id']); + } + } + + /** + * {@inheritdoc} + */ + public function deleteLinksInMenu($menu_name) { + foreach ($this->treeStorage->loadByProperties(array('menu_name' => $menu_name)) as $plugin_id => $definition) { + $instance = $this->createInstance($plugin_id); + if ($instance->isDeletable()) { + $this->deleteInstance($instance, TRUE); + } + elseif ($instance->isResetable()) { + $new_instance = $this->resetInstance($instance); + $affected_menus[$new_instance->getMenuName()] = $new_instance->getMenuName(); + } + } + } + + /** + * Deletes a specific instance. + * + * @param \Drupal\Core\Menu\MenuLinkInterface $instance + * The plugin instance to be deleted. + * @param bool $persist + * If TRUE, calls MenuLinkInterface::deleteLink() on the instance. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * If the plugin instance does not support deletion. + */ + protected function deleteInstance(MenuLinkInterface $instance, $persist) { + $id = $instance->getPluginId(); + if ($instance->isDeletable()) { + if ($persist) { + $instance->deleteLink(); + } + } + else { + throw new PluginException(String::format('Menu link plugin with ID @id does not support deletion', array('@id' => $id))); + } + $this->treeStorage->delete($id); + } + + /** + * {@inheritdoc} + */ + public function removeDefinition($id, $persist = TRUE) { + $definition = $this->treeStorage->load($id); + // It's possible the definition has already been deleted, or doesn't exist. + if ($definition) { + $instance = $this->createInstance($id); + $this->deleteInstance($instance, $persist); + } + } + + /** + * {@inheritdoc} + */ + public function menuNameInUse($menu_name) { + $this->treeStorage->menuNameInUse($menu_name); + } + + /** + * {@inheritdoc} + */ + public function countMenuLinks($menu_name = NULL) { + return $this->treeStorage->countMenuLinks($menu_name); + } + + /** + * {@inheritdoc} + */ + public function getParentIds($id) { + if ($this->getDefinition($id, FALSE)) { + return $this->treeStorage->getRootPathIds($id); + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public function getChildIds($id) { + if ($this->getDefinition($id, FALSE)) { + return $this->treeStorage->getAllChildIds($id); + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public function loadLinksByRoute($route_name, array $route_parameters = array(), $menu_name = NULL) { + $instances = array(); + $loaded = $this->treeStorage->loadByRoute($route_name, $route_parameters, $menu_name); + foreach ($loaded as $plugin_id => $definition) { + $instances[$plugin_id] = $this->createInstance($plugin_id); + } + return $instances; + } + + /** + * {@inheritdoc} + */ + public function addDefinition($id, array $definition) { + if ($this->treeStorage->load($id) || $id === '') { + throw new PluginException(String::format('The ID @id already exists as a plugin definition or is not valid', array('@id' => $id))); + } + // Add defaults, so there is no requirement to specify everything. + $this->processDefinition($definition, $id); + // Store the new link in the tree. + $this->treeStorage->save($definition); + return $this->createInstance($id); + } + + /** + * {@inheritdoc} + */ + public function updateDefinition($id, array $new_definition_values, $persist = TRUE) { + $instance = $this->createInstance($id); + if ($instance) { + $new_definition_values['id'] = $id; + $changed_definition = $instance->updateLink($new_definition_values, $persist); + $this->treeStorage->save($changed_definition); + } + return $instance; + } + + /** + * {@inheritdoc} + */ + public function resetLink($id) { + $instance = $this->createInstance($id); + $new_instance = $this->resetInstance($instance); + return $new_instance; + } + + /** + * Resets the menu link to its default settings. + * + * @param \Drupal\Core\Menu\MenuLinkInterface $instance + * The menu link which should be reset. + * + * @return \Drupal\Core\Menu\MenuLinkInterface + * The reset menu link. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * Thrown when the menu link is not resettable. + */ + protected function resetInstance(MenuLinkInterface $instance) { + $id = $instance->getPluginId(); + + if (!$instance->isResetable()) { + throw new PluginException(String::format('Menu link %id is not resettable', array('%id' => $id))); + } + // Get the original data from disk, reset the override and re-save the menu + // tree for this link. + $definition = $this->getDefinitions()[$id]; + $this->overrides->deleteOverride($id); + $this->treeStorage->save($definition); + return $this->createInstance($id); + } + + /** + * {@inheritdoc} + */ + public function resetDefinitions() { + $this->treeStorage->resetDefinitions(); + } + +} diff --git a/core/lib/Drupal/Core/Menu/MenuLinkManagerInterface.php b/core/lib/Drupal/Core/Menu/MenuLinkManagerInterface.php new file mode 100644 index 000000000000..3507a9205bfc --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuLinkManagerInterface.php @@ -0,0 +1,200 @@ +<?php + +/** + * @file + * Contains \Drupal\Core\Menu\MenuLinkManagerInterface. + */ + +namespace Drupal\Core\Menu; + +use Drupal\Component\Plugin\PluginManagerInterface; + +/** + * Defines an interface for managing menu links and storing their definitions. + * + * Menu link managers support both automatic plugin definition discovery and + * manually maintaining plugin definitions. + * + * MenuLinkManagerInterface::updateDefinition() can be used to update a single + * menu link's definition and pass this onto the menu storage without requiring + * a full MenuLinkManagerInterface::rebuild(). + * + * Implementations that do not use automatic discovery should call + * MenuLinkManagerInterface::addDefinition() or + * MenuLinkManagerInterface::removeDefinition() when they add or remove links, + * and MenuLinkManagerInterface::updateDefinition() to update links they have + * already defined. + */ +interface MenuLinkManagerInterface extends PluginManagerInterface { + + /** + * Triggers discovery, save, and cleanup of discovered links. + */ + public function rebuild(); + + /** + * Deletes all links having a certain menu name. + * + * If a link is not deletable but is resettable, the link will be reset to have + * its original menu name, under the assumption that the original menu is not + * the one we are deleting it from. Note that when resetting, if the original + * menu name is the same as the menu name passed to this method, the link will + * not be moved or deleted. + * + * @param string $menu_name + * The name of the menu whose links will be deleted or reset. + */ + public function deleteLinksInMenu($menu_name); + + /** + * Removes a single link definition from the menu tree storage. + * + * This is used for plugins not found through discovery to remove definitions. + * + * @param string $id + * The menu link plugin ID. + * @param bool $persist + * If TRUE, this method will attempt to persist the deletion from any + * external storage by invoking MenuLinkInterface::deleteLink() on the + * plugin that is being deleted. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * Thrown if the $id is not a valid, existing, plugin ID or if the link + * cannot be deleted. + */ + public function removeDefinition($id, $persist = TRUE); + + /** + * Loads multiple plugin instances based on route. + * + * @param string $route_name + * The route name. + * @param array $route_parameters + * (optional) The route parameters. Defaults to an empty array. + * @param string $menu_name + * (optional) Restricts the found links to just those in the named menu. + * + * @return \Drupal\Core\Menu\MenuLinkInterface[] + * An array of instances keyed by plugin ID. + */ + public function loadLinksByRoute($route_name, array $route_parameters = array(), $menu_name = NULL); + + /** + * Adds a new menu link definition to the menu tree storage. + * + * Use this function when you know there is no entry in the tree. This is + * used for plugins not found through discovery to add new definitions. + * + * @param string $id + * The plugin ID for the new menu link definition that is being added. + * @param array $definition + * The values of the link definition. + * + * @return \Drupal\Core\Menu\MenuLinkInterface + * A plugin instance created using the newly added definition. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * Thrown when the $id is not valid or is an already existing plugin ID. + */ + public function addDefinition($id, array $definition); + + /** + * Updates the values for a menu link definition in the menu tree storage. + * + * This will update the definition for a discovered menu link without the + * need for a full rebuild. It is also used for plugins not found through + * discovery to update definitions. + * + * @param string $id + * The menu link plugin ID. + * @param array $new_definition_values + * The new values for the link definition. This will usually be just a + * subset of the plugin definition. + * @param bool $persist + * TRUE to also have the link instance itself persist the changed values to + * any additional storage by invoking MenuLinkInterface::updateDefinition() + * on the plugin that is being updated. + * + * @return \Drupal\Core\Menu\MenuLinkInterface + * A plugin instance created using the updated definition. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * Thrown if the $id is not a valid, existing, plugin ID. + */ + public function updateDefinition($id, array $new_definition_values, $persist = TRUE); + + /** + * Resets the values for a menu link based on the values found by discovery. + * + * @param string $id + * The menu link plugin ID. + * + * @return \Drupal\Core\Menu\MenuLinkInterface + * The menu link instance after being reset. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * Thrown if the $id is not a valid, existing, plugin ID or if the link + * cannot be reset. + */ + public function resetLink($id); + + /** + * Counts the total number of menu links. + * + * @param string $menu_name + * (optional) The menu name to count by. Defaults to all menus. + * + * @return int + * The number of menu links in the named menu, or in all menus if the + * menu name is NULL. + */ + public function countMenuLinks($menu_name = NULL); + + /** + * Loads all parent link IDs of a given menu link. + * + * This method is very similar to getActiveTrailIds() but allows the link to + * be specified rather than being discovered based on the menu name and + * request. This method is mostly useful for testing. + * + * @param string $id + * The menu link plugin ID. + * + * @return array + * An ordered array of IDs representing the path to the root of the tree. + * The first element of the array will be equal to $id, unless $id is not + * valid, in which case the return value will be NULL. + */ + public function getParentIds($id); + + /** + * Loads all child link IDs of a given menu link, regardless of visibility. + * + * This method is mostly useful for testing. + * + * @param string $id + * The menu link plugin ID. + * + * @return array + * An unordered array of IDs representing the IDs of all children, or NULL + * if the ID is invalid. + */ + public function getChildIds($id); + + /** + * Determines if any links use a given menu name. + * + * @param string $menu_name + * The menu name. + * + * @return bool + * TRUE if any links are present in the named menu, FALSE otherwise. + */ + public function menuNameInUse($menu_name); + + /** + * Resets any local definition cache. Used for testing. + */ + public function resetDefinitions(); + +} diff --git a/core/lib/Drupal/Core/Menu/MenuTreeParameters.php b/core/lib/Drupal/Core/Menu/MenuTreeParameters.php new file mode 100644 index 000000000000..23a0992dbd58 --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuTreeParameters.php @@ -0,0 +1,222 @@ +<?php + +/** + * @file + * Contains \Drupal\Core\Menu\MenuTreeParameters. + */ + +namespace Drupal\Core\Menu; + +/** + * Provides a value object to model menu tree parameters. + * + * Menu tree parameters are used to determine the set of definitions to be + * loaded from \Drupal\Core\Menu\MenuTreeStorageInterface. Hence they determine + * the shape and content of the tree: + * - which parent IDs should be used to restrict the tree, i.e. only links with + * a parent in the list will be included. + * - which menu links are omitted, i.e. minimum and maximum depth. + * + * @todo Add getter methods and make all properties protected and define an + * interface instead of using the concrete class to type hint. + * https://www.drupal.org/node/2302041 + */ +class MenuTreeParameters { + + /** + * A menu link plugin ID that should be used as the root. + * + * By default the root ID of empty string '' is used. However, when only the + * descendants (subtree) of a certain menu link are needed, a custom root can + * be specified. + * + * @var string + */ + public $root = ''; + + /** + * The minimum depth of menu links in the resulting tree relative to the root. + * + * Defaults to 1, which is the default to build a whole tree for a menu + * (excluding the root). + * + * @var int|null + */ + public $minDepth = NULL; + + /** + * The maximum depth of menu links in the resulting tree relative to the root. + * + * @var int|null + */ + public $maxDepth = NULL; + + /** + * An array of parent link IDs. + * + * This restricts the tree to only menu links that are at the top level or + * have a parent ID in this list. If empty, the whole menu tree is built. + * + * @var string[] + */ + public $expandedParents = array(); + + /** + * The IDs from the currently active menu link to the root of the whole tree. + * + * This is an array of menu link plugin IDs, representing the trail from the + * currently active menu link to the ("real") root of that menu link's menu. + * This does not affect the way the tree is built, it only is used to set the + * value of the inActiveTrail property for each tree element. + * + * @var string[] + */ + public $activeTrail = array(); + + /** + * The conditions used to restrict which links are loaded. + * + * An associative array of custom query condition key/value pairs. + * + * @var array + */ + public $conditions = array(); + + /** + * Sets a root; loads a menu tree with this menu link plugin ID as root. + * + * @param string $root + * A menu link plugin ID, or empty string '' to use the root of the whole + * tree. + * + * @return $this + * + * @codeCoverageIgnore + */ + public function setRoot($root) { + $this->root = (string) $root; + return $this; + } + + /** + * Sets a minimum depth; loads a menu tree from the given level. + * + * @param int $min_depth + * The (root-relative) minimum depth to apply. + * + * @return $this + */ + public function setMinDepth($min_depth) { + $this->minDepth = max(1, $min_depth); + return $this; + } + + /** + * Sets a minimum depth; loads a menu tree up to the given level. + * + * @param int $max_depth + * The (root-relative) maximum depth to apply. + * + * @return $this + * + * @codeCoverageIgnore + */ + public function setMaxDepth($max_depth) { + $this->maxDepth = $max_depth; + return $this; + } + + /** + * Adds parent menu links IDs to restrict the tree (only show children). + * + * @param string[] $parents + * An array containing the parent IDs to limit the tree. + * + * @return $this + */ + public function addExpandedParents(array $parents) { + $this->expandedParents = array_merge($this->expandedParents, $parents); + $this->expandedParents = array_unique($this->expandedParents); + return $this; + } + + /** + * Sets the active trail IDs used to set the inActiveTrail property. + * + * @param string[] $active_trail + * An array containing the active trail: a list of menu link plugin IDs. + * + * @return $this + * + * @see \Drupal\Core\Menu\MenuActiveTrail::getActiveTrailIds() + * + * @codeCoverageIgnore + */ + public function setActiveTrail(array $active_trail) { + $this->activeTrail = $active_trail; + return $this; + } + + /** + * Adds a custom query condition. + * + * @param string $definition_field + * Only conditions that are testing menu link definition fields are allowed. + * @param mixed $value + * The value to test the link definition field against. In most cases, this + * is a scalar. For more complex options, it is an array. The meaning of + * each element in the array is dependent on the $operator. + * @param string|null $operator + * (optional) The comparison operator, such as =, <, or >=. It also accepts + * more complex options such as IN, LIKE, or BETWEEN. If NULL, defaults to + * the = operator. + * + * @return $this + */ + public function addCondition($definition_field, $value, $operator = NULL) { + if (!isset($operator)) { + $this->conditions[$definition_field] = $value; + } + else { + $this->conditions[$definition_field] = array($value, $operator); + } + return $this; + } + + /** + * Excludes hidden links. + * + * @return $this + */ + public function excludeHiddenLinks() { + $this->addCondition('hidden', 0); + return $this; + } + + /** + * Ensures only the top level of the tree is loaded. + * + * @return $this + */ + public function topLevelOnly() { + $this->setMaxDepth(1); + return $this; + } + + /** + * Excludes the root menu link from the tree. + * + * Note that this is only necessary when you specified a custom root, because + * the normal root ID is the empty string, '', which does not correspond to an + * actual menu link. Hence when loading a menu link tree without specifying a + * custom root the tree will start at the children even if this method has not + * been called. + * + * @return $this + */ + public function excludeRoot() { + $this->setMinDepth(1); + return $this; + } + +} diff --git a/core/lib/Drupal/Core/Menu/MenuTreeStorage.php b/core/lib/Drupal/Core/Menu/MenuTreeStorage.php new file mode 100644 index 000000000000..6741d3540aba --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuTreeStorage.php @@ -0,0 +1,1441 @@ +<?php + +/** + * @file + * Contains \Drupal\Core\Menu\MenuTreeStorage. + */ + +namespace Drupal\Core\Menu; + +use Drupal\Component\Plugin\Exception\PluginException; +use Drupal\Component\Utility\String; +use Drupal\Component\Utility\UrlHelper; +use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Database\Connection; +use Drupal\Core\Database\Database; +use Drupal\Core\Database\Query\SelectInterface; +use Drupal\Core\Database\SchemaObjectExistsException; + +/** + * Provides a menu tree storage using the database. + */ +class MenuTreeStorage implements MenuTreeStorageInterface { + + /** + * The maximum depth of a menu links tree. + */ + const MAX_DEPTH = 9; + + /** + * The database connection. + * + * @var \Drupal\Core\Database\Connection + */ + protected $connection; + + /** + * Cache backend instance for the extracted tree data. + * + * @var \Drupal\Core\Cache\CacheBackendInterface + */ + protected $menuCacheBackend; + + /** + * The database table name. + * + * @var string + */ + protected $table; + + /** + * Additional database connection options to use in queries. + * + * @var array + */ + protected $options = array(); + + /** + * Stores definitions that have already been loaded for better performance. + * + * An array of plugin definition arrays, keyed by plugin ID. + * + * @var array + */ + protected $definitions = array(); + + /** + * List of serialized fields. + * + * @var array + */ + protected $serializedFields; + + /** + * List of plugin definition fields. + * + * @todo Decide how to keep these field definitions in sync. + * https://www.drupal.org/node/2302085 + * + * @see \Drupal\Core\Menu\MenuLinkManager::$defaults + * + * @var array + */ + protected $definitionFields = array( + 'menu_name', + 'route_name', + 'route_parameters', + 'url', + 'title', + 'title_arguments', + 'title_context', + 'description', + 'parent', + 'weight', + 'options', + 'expanded', + 'hidden', + 'provider', + 'metadata', + 'class', + 'form_class', + 'id', + ); + + /** + * Constructs a new \Drupal\Core\Menu\MenuTreeStorage. + * + * @param \Drupal\Core\Database\Connection $connection + * A Database connection to use for reading and writing configuration data. + * @param \Drupal\Core\Cache\CacheBackendInterface $menu_cache_backend + * Cache backend instance for the extracted tree data. + * @param string $table + * A database table name to store configuration data in. + * @param array $options + * (optional) Any additional database connection options to use in queries. + */ + public function __construct(Connection $connection, CacheBackendInterface $menu_cache_backend, $table, array $options = array()) { + $this->connection = $connection; + $this->menuCacheBackend = $menu_cache_backend; + $this->table = $table; + $this->options = $options; + } + + /** + * {@inheritdoc} + */ + public function maxDepth() { + return static::MAX_DEPTH; + } + + /** + * {@inheritdoc} + */ + public function resetDefinitions() { + $this->definitions = array(); + } + + /** + * {@inheritdoc} + */ + public function rebuild(array $definitions) { + $links = array(); + $children = array(); + $top_links = array(); + // Fetch the list of existing menus, in case some are not longer populated + // after the rebuild. + $before_menus = $this->getMenuNames(); + if ($definitions) { + foreach ($definitions as $id => $link) { + // Flag this link as discovered, i.e. saved via rebuild(). + $link['discovered'] = 1; + if (!empty($link['parent'])) { + $children[$link['parent']][$id] = $id; + } + else { + // A top level link - we need them to root our tree. + $top_links[$id] = $id; + $link['parent'] = ''; + } + $links[$id] = $link; + } + } + foreach ($top_links as $id) { + $this->saveRecursive($id, $children, $links); + } + // Handle any children we didn't find starting from top-level links. + foreach ($children as $orphan_links) { + foreach ($orphan_links as $id) { + // Force it to the top level. + $links[$id]['parent'] = ''; + $this->saveRecursive($id, $children, $links); + } + } + // Find any previously discovered menu links that no longer exist. + if ($definitions) { + $query = $this->connection->select($this->table, NULL, $this->options); + $query->addField($this->table, 'id'); + $query->condition('discovered', 1); + $query->condition('id', array_keys($definitions), 'NOT IN'); + // Starting from links with the greatest depth will minimize the amount + // of re-parenting done by the menu storage. + $query->orderBy('depth', 'DESC'); + $result = $query->execute()->fetchCol(); + } + else { + $result = array(); + } + + // Remove all such items. + if ($result) { + $this->purgeMultiple($result); + } + $this->resetDefinitions(); + $affected_menus = $this->getMenuNames() + $before_menus; + // Invalidate any cache tagged with any menu name. + Cache::invalidateTags(array('menu' => $affected_menus)); + $this->resetDefinitions(); + // Every item in the cache bin should have one of the menu cache tags but it + // is not guaranteed, so invalidate everything in the bin. + $this->menuCacheBackend->invalidateAll(); + } + + /** + * Purges multiple menu links that no longer exist. + * + * @param array $ids + * An array of menu link IDs. + */ + protected function purgeMultiple(array $ids) { + $loaded = $this->loadFullMultiple($ids); + foreach ($loaded as $id => $link) { + if ($link['has_children']) { + $children = $this->loadByProperties(array('parent' => $id)); + foreach ($children as $child) { + $child['parent'] = $link['parent']; + $this->save($child); + } + } + } + $query = $this->connection->delete($this->table, $this->options); + $query->condition('id', $ids, 'IN'); + $query->execute(); + } + + /** + * Executes a select query while making sure the database table exists. + * + * @param \Drupal\Core\Database\Query\SelectInterface $query + * The select object to be executed. + * + * @return \Drupal\Core\Database\StatementInterface|null + * A prepared statement, or NULL if the query is not valid. + * + * @throws \Exception + * Thrown if the table could not be created or the database connection + * failed. + */ + protected function safeExecuteSelect(SelectInterface $query) { + try { + return $query->execute(); + } + catch (\Exception $e) { + // If there was an exception, try to create the table. + if ($this->ensureTableExists()) { + return $query->execute(); + } + // Some other failure that we can not recover from. + throw $e; + } + } + + /** + * {@inheritdoc} + */ + public function save(array $link) { + $affected_menus = $this->doSave($link); + $this->resetDefinitions(); + Cache::invalidateTags(array('menu' => $affected_menus)); + return $affected_menus; + } + + /** + * Saves a link without clearing caches. + * + * @param array $link + * A definition, according to $definitionFields, for a + * \Drupal\Core\Menu\MenuLinkInterface plugin. + * + * @return array + * The menu names affected by the save operation. This will be one menu + * name if the link is saved to the sane menu, or two if it is saved to a + * new menu. + * + * @throws \Exception + * Thrown if the storage back-end does not exist and could not be created. + * @throws \Drupal\Component\Plugin\Exception\PluginException + * Thrown if the definition is invalid, for example, if the specified parent + * would cause the links children to be moved to greater than the maximum + * depth. + */ + protected function doSave(array $link) { + $original = $this->loadFull($link['id']); + // @todo Should we just return here if the link values match the original + // values completely? + // https://www.drupal.org/node/2302137 + $affected_menus = array(); + + $transaction = $this->connection->startTransaction(); + try { + if ($original) { + $link['mlid'] = $original['mlid']; + $link['has_children'] = $original['has_children']; + $affected_menus[$original['menu_name']] = $original['menu_name']; + } + else { + // Generate a new mlid. + $options = array('return' => Database::RETURN_INSERT_ID) + $this->options; + $link['mlid'] = $this->connection->insert($this->table, $options) + ->fields(array('id' => $link['id'], 'menu_name' => $link['menu_name'])) + ->execute(); + } + $fields = $this->preSave($link, $original); + // We may be moving the link to a new menu. + $affected_menus[$fields['menu_name']] = $fields['menu_name']; + $query = $this->connection->update($this->table, $this->options); + $query->condition('mlid', $link['mlid']); + $query->fields($fields) + ->execute(); + if ($original) { + $this->updateParentalStatus($original); + } + $this->updateParentalStatus($link); + } + catch (\Exception $e) { + $transaction->rollback(); + throw $e; + } + return $affected_menus; + } + + /** + * Fills in all the fields the database save needs, using the link definition. + * + * @param array $link + * The link definition to be updated. + * @param array $original + * The link definition before the changes. May be empty if not found. + * + * @return array + * The values which will be stored. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * Thrown when the specific depth exceeds the maximum. + */ + protected function preSave(array &$link, array $original) { + static $schema_fields, $schema_defaults; + if (empty($schema_fields)) { + $schema = static::schemaDefinition(); + $schema_fields = $schema['fields']; + foreach ($schema_fields as $name => $spec) { + if (isset($spec['default'])) { + $schema_defaults[$name] = $spec['default']; + } + } + } + + // Try to find a parent link. If found, assign it and derive its menu. + $parent = $this->findParent($link, $original); + if ($parent) { + $link['parent'] = $parent['id']; + $link['menu_name'] = $parent['menu_name']; + } + else { + $link['parent'] = ''; + } + + // If no corresponding parent link was found, move the link to the + // top-level. + foreach ($schema_defaults as $name => $default) { + if (!isset($link[$name])) { + $link[$name] = $default; + } + } + $fields = array_intersect_key($link, $schema_fields); + // Sort the route parameters so that the query string will be the same. + asort($fields['route_parameters']); + // Since this will be urlencoded, it's safe to store and match against a + // text field. + $fields['route_param_key'] = $fields['route_parameters'] ? UrlHelper::buildQuery($fields['route_parameters']) : ''; + + foreach ($this->serializedFields() as $name) { + $fields[$name] = serialize($fields[$name]); + } + + // Directly fill parents for top-level links. + if (empty($link['parent'])) { + $fields['p1'] = $link['mlid']; + for ($i = 2; $i <= $this->maxDepth(); $i++) { + $fields["p$i"] = 0; + } + $fields['depth'] = 1; + } + // Otherwise, ensure that this link's depth is not beyond the maximum depth + // and fill parents based on the parent link. + else { + // @todo We want to also check $original['has_children'] here, but that + // will be 0 even if there are children if those are hidden. + // has_children is really just the rendering hint. So, we either need + // to define another column (has_any_children), or do the extra query. + // https://www.drupal.org/node/2302149 + if ($original) { + $limit = $this->maxDepth() - $this->doFindChildrenRelativeDepth($original) - 1; + } + else { + $limit = $this->maxDepth() - 1; + } + if ($parent['depth'] > $limit) { + throw new PluginException(String::format('The link with ID @id or its children exceeded the maximum depth of @depth', array('@id' => $link['id'], '@depth' => $this->maxDepth()))); + } + $this->setParents($fields, $parent); + } + + // Need to check both parent and menu_name, since parent can be empty in any + // menu. + if ($original && ($link['parent'] != $original['parent'] || $link['menu_name'] != $original['menu_name'])) { + $this->moveChildren($fields, $original); + } + // We needed the mlid above, but not in the update query. + unset($fields['mlid']); + + // Cast Booleans to int, if needed. + $fields['hidden'] = (int) $fields['hidden']; + $fields['expanded'] = (int) $fields['expanded']; + return $fields; + } + + /** + * {@inheritdoc} + */ + public function delete($id) { + // Children get re-attached to the menu link's parent. + $item = $this->loadFull($id); + // It's possible the link is already deleted. + if ($item) { + $parent = $item['parent']; + $children = $this->loadByProperties(array('parent' => $id)); + foreach ($children as $child) { + $child['parent'] = $parent; + $this->save($child); + } + + $this->connection->delete($this->table, $this->options) + ->condition('id', $id) + ->execute(); + + $this->updateParentalStatus($item); + // Many children may have moved. + $this->resetDefinitions(); + Cache::invalidateTags(array('menu' => $item['menu_name'])); + } + } + + /** + * {@inheritdoc} + */ + public function getSubtreeHeight($id) { + $original = $this->loadFull($id); + return $original ? $this->doFindChildrenRelativeDepth($original) + 1 : 0; + } + + /** + * Finds the relative depth of this link's deepest child. + * + * @param array $original + * The parent definition used to find the depth. + * + * @return int + * Returns the relative depth. + */ + protected function doFindChildrenRelativeDepth(array $original) { + $query = $this->connection->select($this->table, $this->options); + $query->addField($this->table, 'depth'); + $query->condition('menu_name', $original['menu_name']); + $query->orderBy('depth', 'DESC'); + $query->range(0, 1); + + for ($i = 1; $i <= static::MAX_DEPTH && $original["p$i"]; $i++) { + $query->condition("p$i", $original["p$i"]); + } + + $max_depth = $this->safeExecuteSelect($query)->fetchField(); + + return ($max_depth > $original['depth']) ? $max_depth - $original['depth'] : 0; + } + + /** + * Sets the materialized path field values based on the parent. + * + * @param array $fields + * The menu link. + * @param array $parent + * The parent menu link. + */ + protected function setParents(array &$fields, array $parent) { + $fields['depth'] = $parent['depth'] + 1; + $i = 1; + while ($i < $fields['depth']) { + $p = 'p' . $i++; + $fields[$p] = $parent[$p]; + } + $p = 'p' . $i++; + // The parent (p1 - p9) corresponding to the depth always equals the mlid. + $fields[$p] = $fields['mlid']; + while ($i <= static::MAX_DEPTH) { + $p = 'p' . $i++; + $fields[$p] = 0; + } + } + + /** + * Re-parents a link's children when the link itself is moved. + * + * @param array $fields + * The changed menu link. + * @param array $original + * The original menu link. + */ + protected function moveChildren($fields, $original) { + $query = $this->connection->update($this->table, $this->options); + + $query->fields(array('menu_name' => $fields['menu_name'])); + + $expressions = array(); + for ($i = 1; $i <= $fields['depth']; $i++) { + $expressions[] = array("p$i", ":p_$i", array(":p_$i" => $fields["p$i"])); + } + $j = $original['depth'] + 1; + while ($i <= $this->maxDepth() && $j <= $this->maxDepth()) { + $expressions[] = array('p' . $i++, 'p' . $j++, array()); + } + while ($i <= $this->maxDepth()) { + $expressions[] = array('p' . $i++, 0, array()); + } + + $shift = $fields['depth'] - $original['depth']; + if ($shift > 0) { + // The order of expressions must be reversed so the new values don't + // overwrite the old ones before they can be used because "Single-table + // UPDATE assignments are generally evaluated from left to right". + // @see http://dev.mysql.com/doc/refman/5.0/en/update.html + $expressions = array_reverse($expressions); + } + foreach ($expressions as $expression) { + $query->expression($expression[0], $expression[1], $expression[2]); + } + + $query->expression('depth', 'depth + :depth', array(':depth' => $shift)); + $query->condition('menu_name', $original['menu_name']); + + for ($i = 1; $i <= $this->maxDepth() && $original["p$i"]; $i++) { + $query->condition("p$i", $original["p$i"]); + } + + $query->execute(); + } + + /** + * Loads the parent definition if it exists. + * + * @param array $link + * The link definition to find the parent of. + * @param array|false $original + * The original link that might be used to find the parent if the parent + * is not set on the $link, or FALSE if the original could not be loaded. + * + * @return array|false + * Returns a definition array, or FALSE if no parent was found. + */ + protected function findParent($link, $original) { + $parent = FALSE; + + // This item is explicitly top-level, skip the rest of the parenting. + if (isset($link['parent']) && empty($link['parent'])) { + return $parent; + } + + // If we have a parent link ID, try to use that. + $candidates = array(); + if (isset($link['parent'])) { + $candidates[] = $link['parent']; + } + elseif (!empty($original['parent']) && $link['menu_name'] == $original['menu_name']) { + // Otherwise, fall back to the original parent. + $candidates[] = $original['parent']; + } + + foreach ($candidates as $id) { + $parent = $this->loadFull($id); + if ($parent) { + break; + } + } + return $parent; + } + + /** + * Sets has_children for the link's parent if it has visible children. + * + * @param array $link + * The link to get a parent ID from. + */ + protected function updateParentalStatus(array $link) { + // If parent is empty, there is nothing to update. + if (!empty($link['parent'])) { + // Check if at least one visible child exists in the table. + $query = $this->connection->select($this->table, $this->options); + $query->addExpression('1'); + $query->range(0, 1); + $query + ->condition('menu_name', $link['menu_name']) + ->condition('parent', $link['parent']) + ->condition('hidden', 0); + + $parent_has_children = ((bool) $query->execute()->fetchField()) ? 1 : 0; + $this->connection->update($this->table, $this->options) + ->fields(array('has_children' => $parent_has_children)) + ->condition('id', $link['parent']) + ->execute(); + } + } + + /** + * Prepares a link by unserializing values and saving the definition. + * + * @param array $link + * The data loaded in the query. + * @param bool $intersect + * If TRUE, filter out values that are not part of the actual definition. + * + * @return array + * The prepared link data. + */ + protected function prepareLink(array $link, $intersect = FALSE) { + foreach ($this->serializedFields() as $name) { + $link[$name] = unserialize($link[$name]); + } + if ($intersect) { + $link = array_intersect_key($link, array_flip($this->definitionFields())); + } + $this->definitions[$link['id']] = $link; + return $link; + } + + /** + * {@inheritdoc} + */ + public function loadByProperties(array $properties) { + // @todo Only allow loading by plugin definition properties. + https://www.drupal.org/node/2302165 + $query = $this->connection->select($this->table, $this->options); + $query->fields($this->table, $this->definitionFields()); + foreach ($properties as $name => $value) { + $query->condition($name, $value); + } + $loaded = $this->safeExecuteSelect($query)->fetchAllAssoc('id', \PDO::FETCH_ASSOC); + foreach ($loaded as $id => $link) { + $loaded[$id] = $this->prepareLink($link); + } + return $loaded; + } + + /** + * {@inheritdoc} + */ + public function loadByRoute($route_name, array $route_parameters = array(), $menu_name = NULL) { + // Sort the route parameters so that the query string will be the same. + asort($route_parameters); + // Since this will be urlencoded, it's safe to store and match against a + // text field. + // @todo Standardize an efficient way to load by route name and parameters + // in place of system path. https://www.drupal.org/node/2302139 + $param_key = $route_parameters ? UrlHelper::buildQuery($route_parameters) : ''; + $query = $this->connection->select($this->table, $this->options); + $query->fields($this->table, $this->definitionFields()); + $query->condition('route_name', $route_name); + $query->condition('route_param_key', $param_key); + if ($menu_name) { + $query->condition('menu_name', $menu_name); + } + // Make the ordering deterministic. + $query->orderBy('depth'); + $query->orderBy('weight'); + $query->orderBy('id'); + $loaded = $this->safeExecuteSelect($query)->fetchAllAssoc('id', \PDO::FETCH_ASSOC); + foreach ($loaded as $id => $link) { + $loaded[$id] = $this->prepareLink($link); + } + return $loaded; + } + + /** + * {@inheritdoc} + */ + public function loadMultiple(array $ids) { + $missing_ids = array_diff($ids, array_keys($this->definitions)); + + if ($missing_ids) { + $query = $this->connection->select($this->table, $this->options); + $query->fields($this->table, $this->definitionFields()); + $query->condition('id', $missing_ids, 'IN'); + $loaded = $this->safeExecuteSelect($query)->fetchAllAssoc('id', \PDO::FETCH_ASSOC); + foreach ($loaded as $id => $link) { + $this->definitions[$id] = $this->prepareLink($link); + } + } + return array_intersect_key($this->definitions, array_flip($ids)); + } + + /** + * {@inheritdoc} + */ + public function load($id) { + if (isset($this->definitions[$id])) { + return $this->definitions[$id]; + } + $loaded = $this->loadMultiple(array($id)); + return isset($loaded[$id]) ? $loaded[$id] : FALSE; + } + + /** + * Loads all table fields, not just those that are in the plugin definition. + * + * @param string $id + * The menu link ID. + * + * @return array + * The loaded menu link definition or an empty array if not be found. + */ + protected function loadFull($id) { + $loaded = $this->loadFullMultiple(array($id)); + return isset($loaded[$id]) ? $loaded[$id] : array(); + } + + /** + * Loads all table fields for multiple menu link definitions by ID. + * + * @param array $ids + * The IDs to load. + * + * @return array + * The loaded menu link definitions. + */ + protected function loadFullMultiple(array $ids) { + $query = $this->connection->select($this->table, $this->options); + $query->fields($this->table); + $query->condition('id', $ids, 'IN'); + $loaded = $this->safeExecuteSelect($query)->fetchAllAssoc('id', \PDO::FETCH_ASSOC); + foreach ($loaded as &$link) { + foreach ($this->serializedFields() as $name) { + $link[$name] = unserialize($link[$name]); + } + } + return $loaded; + } + + /** + * {@inheritdoc} + */ + public function getRootPathIds($id) { + $subquery = $this->connection->select($this->table, $this->options); + // @todo Consider making this dynamic based on static::MAX_DEPTH or from the + // schema if that is generated using static::MAX_DEPTH. + // https://www.drupal.org/node/2302043 + $subquery->fields($this->table, array('p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8', 'p9')); + $subquery->condition('id', $id); + $result = current($subquery->execute()->fetchAll(\PDO::FETCH_ASSOC)); + $ids = array_filter($result); + if ($ids) { + $query = $this->connection->select($this->table, $this->options); + $query->fields($this->table, array('id')); + $query->orderBy('depth', 'DESC'); + $query->condition('mlid', $ids, 'IN'); + // @todo Cache this result in memory if we find it is being used more + // than once per page load. https://www.drupal.org/node/2302185 + return $this->safeExecuteSelect($query)->fetchAllKeyed(0, 0); + } + return array(); + } + + /** + * {@inheritdoc} + */ + public function getExpanded($menu_name, array $parents) { + // @todo Go back to tracking in state or some other way which menus have + // expanded links? https://www.drupal.org/node/2302187 + do { + $query = $this->connection->select($this->table, $this->options); + $query->fields($this->table, array('id')); + $query->condition('menu_name', $menu_name); + $query->condition('expanded', 1); + $query->condition('has_children', 1); + $query->condition('hidden', 0); + $query->condition('parent', $parents, 'IN'); + $query->condition('id', $parents, 'NOT IN'); + $result = $this->safeExecuteSelect($query)->fetchAllKeyed(0, 0); + $parents += $result; + } while (!empty($result)); + return $parents; + } + + /** + * Saves menu links recursively. + * + * @param string $id + * The definition ID. + * @param array $children + * An array of IDs of child links collected by parent ID. + * @param array $links + * An array of all definitions keyed by ID. + */ + protected function saveRecursive($id, &$children, &$links) { + if (!empty($links[$id]['parent']) && empty($links[$links[$id]['parent']])) { + // Invalid parent ID, so remove it. + $links[$id]['parent'] = ''; + } + $this->doSave($links[$id]); + + if (!empty($children[$id])) { + foreach ($children[$id] as $next_id) { + $this->saveRecursive($next_id, $children, $links); + } + } + // Remove processed link names so we can find stragglers. + unset($children[$id]); + } + + /** + * {@inheritdoc} + */ + public function loadTreeData($menu_name, MenuTreeParameters $parameters) { + // Build the cache ID; sort 'expanded' and 'conditions' to prevent duplicate + // cache items. + sort($parameters->expandedParents); + sort($parameters->conditions); + $tree_cid = "tree-data:$menu_name:" . serialize($parameters); + $cache = $this->menuCacheBackend->get($tree_cid); + if ($cache && isset($cache->data)) { + $data = $cache->data; + // Cache the definitions in memory so they don't need to be loaded again. + $this->definitions += $data['definitions']; + unset($data['definitions']); + } + else { + $links = $this->loadLinks($menu_name, $parameters); + $data['tree'] = $this->doBuildTreeData($links, $parameters->activeTrail, $parameters->minDepth); + $data['definitions'] = array(); + $data['route_names'] = $this->collectRoutesAndDefinitions($data['tree'], $data['definitions']); + $this->menuCacheBackend->set($tree_cid, $data, Cache::PERMANENT, array('menu' => $menu_name)); + // The definitions were already added to $this->definitions in + // $this->doBuildTreeData() + unset($data['definitions']); + } + return $data; + } + + /** + * Loads links in the given menu, according to the given tree parameters. + * + * @param string $menu_name + * A menu name. + * @param \Drupal\Core\Menu\MenuTreeParameters $parameters + * The parameters to determine which menu links to be loaded into a tree. + * This method will set the absolute minimum depth, which is used in + * MenuTreeStorage::doBuildTreeData(). + * + * @return array + * A flat array of menu links that are part of the menu. Each array element + * is an associative array of information about the menu link, containing + * the fields from the {menu_tree} table. This array must be ordered + * depth-first. + */ + protected function loadLinks($menu_name, MenuTreeParameters $parameters) { + $query = $this->connection->select($this->table, $this->options); + $query->fields($this->table); + + // Allow a custom root to be specified for loading a menu link tree. If + // omitted, the default root (i.e. the actual root, '') is used. + if ($parameters->root !== '') { + $root = $this->loadFull($parameters->root); + + // If the custom root does not exist, we cannot load the links below it. + if (!$root) { + return array(); + } + + // When specifying a custom root, we only want to find links whose + // parent IDs match that of the root; that's how we ignore the rest of the + // tree. In other words: we exclude everything unreachable from the + // custom root. + for ($i = 1; $i <= $root['depth']; $i++) { + $query->condition("p$i", $root["p$i"]); + } + + // When specifying a custom root, the menu is determined by that root. + $menu_name = $root['menu_name']; + + // If the custom root exists, then we must rewrite some of our + // parameters; parameters are relative to the root (default or custom), + // but the queries require absolute numbers, so adjust correspondingly. + if (isset($parameters->minDepth)) { + $parameters->minDepth += $root['depth']; + } + else { + $parameters->minDepth = $root['depth']; + } + if (isset($parameters->maxDepth)) { + $parameters->maxDepth += $root['depth']; + } + } + + // If no minimum depth is specified, then set the actual minimum depth, + // depending on the root. + if (!isset($parameters->minDepth)) { + if ($parameters->root !== '' && $root) { + $parameters->minDepth = $root['depth']; + } + else { + $parameters->minDepth = 1; + } + } + + for ($i = 1; $i <= $this->maxDepth(); $i++) { + $query->orderBy('p' . $i, 'ASC'); + } + + $query->condition('menu_name', $menu_name); + + if (!empty($parameters->expandedParents)) { + $query->condition('parent', $parameters->expandedParents, 'IN'); + } + if (isset($parameters->minDepth) && $parameters->minDepth > 1) { + $query->condition('depth', $parameters->minDepth, '>='); + } + if (isset($parameters->maxDepth)) { + $query->condition('depth', $parameters->maxDepth, '<='); + } + // Add custom query conditions, if any were passed. + if (!empty($parameters->conditions)) { + // Only allow conditions that are testing definition fields. + $parameters->conditions = array_intersect_key($parameters->conditions, array_flip($this->definitionFields())); + foreach ($parameters->conditions as $column => $value) { + if (!is_array($value)) { + $query->condition($column, $value); + } + else { + $operator = $value[1]; + $value = $value[0]; + $query->condition($column, $value, $operator); + } + } + } + + $links = $this->safeExecuteSelect($query)->fetchAllAssoc('id', \PDO::FETCH_ASSOC); + + return $links; + } + + /** + * Traverses the menu tree and collects all the route names and definitions. + * + * @param array $tree + * The menu tree you wish to operate on. + * @param array $definitions + * An array to accumulate definitions by reference. + * + * @return array + * Array of route names, with all values being unique. + */ + protected function collectRoutesAndDefinitions(array $tree, array &$definitions) { + return array_values($this->doCollectRoutesAndDefinitions($tree, $definitions)); + } + + /** + * Collects all the route names and definitions. + * + * @param array $tree + * A menu link tree from MenuTreeStorage::doBuildTreeData() + * @param array $definitions + * The collected definitions which are populated by reference. + * + * @return array + * The collected route names. + */ + protected function doCollectRoutesAndDefinitions(array $tree, array &$definitions) { + $route_names = array(); + foreach (array_keys($tree) as $id) { + $definitions[$id] = $this->definitions[$id]; + if (!empty($definition['route_name'])) { + $route_names[$definition['route_name']] = $definition['route_name']; + } + if ($tree[$id]['subtree']) { + $route_names += $this->doCollectRoutesAndDefinitions($tree[$id]['subtree'], $definitions); + } + } + return $route_names; + } + + /** + * {@inheritdoc} + */ + public function loadSubtreeData($id, $max_relative_depth = NULL) { + $tree = array(); + $root = $this->loadFull($id); + if (!$root) { + return $tree; + } + $parameters = new MenuTreeParameters(); + $parameters->setRoot($id)->excludeHiddenLinks(); + return $this->loadTreeData($root['menu_name'], $parameters); + } + + /** + * {@inheritdoc} + */ + public function menuNameInUse($menu_name) { + $query = $this->connection->select($this->table, $this->options); + $query->addField($this->table, 'mlid'); + $query->condition('menu_name', $menu_name); + $query->range(0, 1); + return (bool) $this->safeExecuteSelect($query); + } + + /** + * {@inheritdoc} + */ + public function getMenuNames() { + $query = $this->connection->select($this->table, $this->options); + $query->addField($this->table, 'menu_name'); + $query->distinct(); + return $this->safeExecuteSelect($query)->fetchAllKeyed(0, 0); + } + + /** + * {@inheritdoc} + */ + public function countMenuLinks($menu_name = NULL) { + $query = $this->connection->select($this->table, $this->options); + if ($menu_name) { + $query->condition('menu_name', $menu_name); + } + return $this->safeExecuteSelect($query->countQuery())->fetchField(); + } + + /** + * {@inheritdoc} + */ + public function getAllChildIds($id) { + $root = $this->loadFull($id); + if (!$root) { + return array(); + } + $query = $this->connection->select($this->table, $this->options); + $query->fields($this->table, array('id')); + $query->condition('menu_name', $root['menu_name']); + for ($i = 1; $i <= $root['depth']; $i++) { + $query->condition("p$i", $root["p$i"]); + } + // The next p column should not be empty. This excludes the root link. + $query->condition("p$i", 0, '>'); + return $this->safeExecuteSelect($query)->fetchAllKeyed(0, 0); + } + + /** + * {@inheritdoc} + */ + public function loadAllChildren($id, $max_relative_depth = NULL) { + $parameters = new MenuTreeParameters(); + $parameters->setRoot($id)->excludeRoot()->setMaxDepth($max_relative_depth)->excludeHiddenLinks(); + $links = $this->loadLinks(NULL, $parameters); + foreach ($links as $id => $link) { + $links[$id] = $this->prepareLink($link); + } + return $links; + } + + /** + * Prepares the data for calling $this->treeDataRecursive(). + */ + protected function doBuildTreeData(array $links, array $parents = array(), $depth = 1) { + // Reverse the array so we can use the more efficient array_pop() function. + $links = array_reverse($links); + return $this->treeDataRecursive($links, $parents, $depth); + } + + /** + * Builds the data representing a menu tree. + * + * The function is a bit complex because the rendering of a link depends on + * the next menu link. + * + * @param array $links + * A flat array of menu links that are part of the menu. Each array element + * is an associative array of information about the menu link, containing + * the fields from the $this->table. This array must be ordered + * depth-first. MenuTreeStorage::loadTreeData() includes a sample query. + * + * @param array $parents + * An array of the menu link ID values that are in the path from the current + * page to the root of the menu tree. + * @param int $depth + * The minimum depth to include in the returned menu tree. + * + * @return array + * The fully built tree. + * + * @see \Drupal\Core\Menu\MenuTreeStorage::loadTreeData() + */ + protected function treeDataRecursive(array &$links, array $parents, $depth) { + $tree = array(); + while ($tree_link_definition = array_pop($links)) { + $tree[$tree_link_definition['id']] = array( + 'definition' => $this->prepareLink($tree_link_definition, TRUE), + 'has_children' => $tree_link_definition['has_children'], + // We need to determine if we're on the path to root so we can later + // build the correct active trail. + 'in_active_trail' => in_array($tree_link_definition['id'], $parents), + 'subtree' => array(), + 'depth' => $tree_link_definition['depth'], + ); + // Look ahead to the next link, but leave it on the array so it's + // available to other recursive function calls if we return or build a + // sub-tree. + $next = end($links); + // Check whether the next link is the first in a new sub-tree. + if ($next && $next['depth'] > $depth) { + // Recursively call doBuildTreeData to build the sub-tree. + $tree[$tree_link_definition['id']]['subtree'] = $this->treeDataRecursive($links, $parents, $next['depth']); + // Fetch next link after filling the sub-tree. + $next = end($links); + } + // Determine if we should exit the loop and return. + if (!$next || $next['depth'] < $depth) { + break; + } + } + return $tree; + } + + /** + * Checks if the tree table exists and create it if not. + * + * @return bool + * TRUE if the table was created, FALSE otherwise. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * If a database error occurs. + */ + protected function ensureTableExists() { + try { + if (!$this->connection->schema()->tableExists($this->table)) { + $this->connection->schema()->createTable($this->table, static::schemaDefinition()); + return TRUE; + } + } + catch (SchemaObjectExistsException $e) { + // If another process has already created the config table, attempting to + // recreate it will throw an exception. In this case just catch the + // exception and do nothing. + return TRUE; + } + catch (\Exception $e) { + throw new PluginException($e->getMessage(), NULL, $e); + } + return FALSE; + } + + /** + * Determines serialized fields in the storage. + * + * @return array + * A list of fields that are serialized in the database. + */ + protected function serializedFields() { + if (empty($this->serializedFields)) { + $schema = static::schemaDefinition(); + foreach ($schema['fields'] as $name => $field) { + if (!empty($field['serialize'])) { + $this->serializedFields[] = $name; + } + } + } + return $this->serializedFields; + } + + /** + * Determines fields that are part of the plugin definition. + * + * @return array + * The list of the subset of fields that are part of the plugin definition. + */ + protected function definitionFields() { + return $this->definitionFields; + } + + /** + * Defines the schema for the tree table. + * + * @return array + * The schema API definition for the SQL storage table. + */ + protected static function schemaDefinition() { + $schema = array( + 'description' => 'Contains the menu tree hierarchy.', + 'fields' => array( + 'menu_name' => array( + 'description' => "The menu name. All links with the same menu name (such as 'tools') are part of the same menu.", + 'type' => 'varchar', + 'length' => 32, + 'not null' => TRUE, + 'default' => '', + ), + 'mlid' => array( + 'description' => 'The menu link ID (mlid) is the integer primary key.', + 'type' => 'serial', + 'unsigned' => TRUE, + 'not null' => TRUE, + ), + 'id' => array( + 'description' => 'Unique machine name: the plugin ID.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + ), + 'parent' => array( + 'description' => 'The plugin ID for the parent of this link.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'route_name' => array( + 'description' => 'The machine name of a defined Symfony Route this menu item represents.', + 'type' => 'varchar', + 'length' => 255, + ), + 'route_param_key' => array( + 'description' => 'An encoded string of route parameters for loading by route.', + 'type' => 'varchar', + 'length' => 255, + ), + 'route_parameters' => array( + 'description' => 'Serialized array of route parameters of this menu link.', + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'serialize' => TRUE, + ), + 'url' => array( + 'description' => 'The external path this link points to (when not using a route).', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'title' => array( + 'description' => 'The text displayed for the link.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'title_arguments' => array( + 'description' => 'A serialized array of arguments to be passed to t() (if this plugin uses it).', + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'serialize' => TRUE, + ), + 'title_context' => array( + 'description' => 'The translation context for the link title.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'description' => array( + 'description' => 'The description of this link - used for admin pages and title attribute.', + 'type' => 'text', + 'not null' => FALSE, + ), + 'class' => array( + 'description' => 'The class for this link plugin.', + 'type' => 'text', + 'not null' => FALSE, + ), + 'options' => array( + 'description' => 'A serialized array of options to be passed to the url() or l() function, such as a query string or HTML attributes.', + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'serialize' => TRUE, + ), + 'provider' => array( + 'description' => 'The name of the module that generated this link.', + 'type' => 'varchar', + 'length' => DRUPAL_EXTENSION_NAME_MAX_LENGTH, + 'not null' => TRUE, + 'default' => 'system', + ), + 'hidden' => array( + 'description' => 'A flag for whether the link should be rendered in menus. (1 = a disabled menu item that may be shown on admin screens, 0 = a normal, visible link)', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'small', + ), + 'discovered' => array( + 'description' => 'A flag for whether the link was discovered, so can be purged on rebuild', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'small', + ), + 'expanded' => array( + 'description' => 'Flag for whether this link should be rendered as expanded in menus - expanded links always have their child links displayed, instead of only when the link is in the active trail (1 = expanded, 0 = not expanded)', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'small', + ), + 'weight' => array( + 'description' => 'Link weight among links in the same menu at the same depth.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ), + 'metadata' => array( + 'description' => 'A serialized array of data that may be used by the plugin instance.', + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'serialize' => TRUE, + ), + 'has_children' => array( + 'description' => 'Flag indicating whether any non-hidden links have this link as a parent (1 = children exist, 0 = no children).', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'small', + ), + 'depth' => array( + 'description' => 'The depth relative to the top level. A link with empty parent will have depth == 1.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'small', + ), + 'p1' => array( + 'description' => 'The first mlid in the materialized path. If N = depth, then pN must equal the mlid. If depth > 1 then p(N-1) must equal the parent link mlid. All pX where X > depth must equal zero. The columns p1 .. p9 are also called the parents.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'p2' => array( + 'description' => 'The second mlid in the materialized path. See p1.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'p3' => array( + 'description' => 'The third mlid in the materialized path. See p1.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'p4' => array( + 'description' => 'The fourth mlid in the materialized path. See p1.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'p5' => array( + 'description' => 'The fifth mlid in the materialized path. See p1.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'p6' => array( + 'description' => 'The sixth mlid in the materialized path. See p1.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'p7' => array( + 'description' => 'The seventh mlid in the materialized path. See p1.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'p8' => array( + 'description' => 'The eighth mlid in the materialized path. See p1.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'p9' => array( + 'description' => 'The ninth mlid in the materialized path. See p1.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'form_class' => array( + 'description' => 'meh', + 'type' => 'varchar', + 'length' => 255, + ), + ), + 'indexes' => array( + 'menu_parents' => array( + 'menu_name', + 'p1', + 'p2', + 'p3', + 'p4', + 'p5', + 'p6', + 'p7', + 'p8', + 'p9', + ), + // @todo Test this index for effectiveness. + // https://www.drupal.org/node/2302197 + 'menu_parent_expand_child' => array( + 'menu_name', 'expanded', + 'has_children', + array('parent', 16), + ), + 'route_values' => array( + array('route_name', 32), + array('route_param_key', 16), + ), + ), + 'primary key' => array('mlid'), + 'unique keys' => array( + 'id' => array('id'), + ), + ); + + return $schema; + } + +} diff --git a/core/lib/Drupal/Core/Menu/MenuTreeStorageInterface.php b/core/lib/Drupal/Core/Menu/MenuTreeStorageInterface.php new file mode 100644 index 000000000000..a5d512d4ee5c --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuTreeStorageInterface.php @@ -0,0 +1,278 @@ +<?php + +/** + * @file + * Contains \Drupal\Core\Menu\MenuTreeStorageInterface. + */ + +namespace Drupal\Core\Menu; + +/** + * Defines an interface for storing a menu tree containing menu link IDs. + * + * The tree contains a hierarchy of menu links which have an ID as well as a + * route name or external URL. + */ +interface MenuTreeStorageInterface { + + /** + * The maximum depth of tree the storage implementation supports. + * + * @return int + * The maximum depth. + */ + public function maxDepth(); + + /** + * Clears all definitions cached in memory. + */ + public function resetDefinitions(); + + /** + * Rebuilds the stored menu link definitions. + * + * Links that saved by passing definitions into this method must be included + * on all future calls, or they will be purged. This allows for automatic + * cleanup e.g. when modules are uninstalled. + * + * @param array $definitions + * The new menu link definitions. + * + */ + public function rebuild(array $definitions); + + /** + * Loads a menu link plugin definition from the storage. + * + * @param string $id + * The menu link plugin ID. + * + * @return array|FALSE + * The plugin definition, or FALSE if no definition was found for the ID. + */ + public function load($id); + + /** + * Loads multiple plugin definitions from the storage. + * + * @param array $ids + * An array of plugin IDs. + * + * @return array + * An array of plugin definition arrays keyed by plugin ID, which are the + * actual definitions after the loadMultiple() including all those plugins + * from $ids. + */ + public function loadMultiple(array $ids); + + /** + * Loads multiple plugin definitions from the storage based on properties. + * + * @param array $properties + * The properties to filter by. + * + * @return array + * An array of menu link definition arrays. + */ + public function loadByProperties(array $properties); + + /** + * Loads multiple plugin definitions from the storage based on route. + * + * @param string $route_name + * The route name. + * @param array $route_parameters + * (optional) The route parameters. Defaults to an empty array. + * @param string $menu_name + * (optional) Restricts the found links to just those in the named menu. + * + * @return array + * An array of menu link definitions keyed by ID and ordered by depth. + */ + public function loadByRoute($route_name, array $route_parameters = array(), $menu_name = NULL); + + /** + * Saves a plugin definition to the storage. + * + * @param array $definition + * A definition for a \Drupal\Core\Menu\MenuLinkInterface plugin. + * + * @return array + * The menu names affected by the save operation. This will be one menu + * name if the link is saved to the sane menu, or two if it is saved to a + * new menu. + * + * @throws \Exception + * Thrown if the storage back-end does not exist and could not be created. + * @throws \Drupal\Component\Plugin\Exception\PluginException + * Thrown if the definition is invalid - for example, if the specified + * parent would cause the links children to be moved to greater than the + * maximum depth. + */ + public function save(array $definition); + + /** + * Deletes a menu link definition from the storage. + * + * @param string $id + * The menu link plugin ID. + */ + public function delete($id); + + /** + * Loads a menu link tree from the storage. + * + * This function may be used build the data for a menu tree only, for example + * to further massage the data manually before further processing happens. + * MenuLinkTree::checkAccess() needs to be invoked afterwards. + * + * The tree order is maintained using an optimized algorithm, for example by + * storing each parent in an individual field, see + * http://drupal.org/node/141866 for more details. However, any details of the + * storage should not be relied upon since it may be swapped with a different + * implementation. + * + * @param string $menu_name + * The name of the menu. + * @param \Drupal\Core\Menu\MenuTreeParameters $parameters + * The parameters to determine which menu links to be loaded into a tree. + * + * @return array + * An array with 2 elements: + * - tree: A fully built menu tree containing an array. + * @see static::treeDataRecursive() + * - route_names: An array of all route names used in the tree. + */ + public function loadTreeData($menu_name, MenuTreeParameters $parameters); + + /** + * Loads all the visible menu links that are below the given ID. + * + * The returned links are not ordered, and visible children will be included + * even if they have a hidden parent or ancestor so would not normally appear + * in a rendered tree. + * + * @param string $id + * The parent menu link ID. + * @param int $max_relative_depth + * The maximum relative depth of the children relative to the passed parent. + * + * @return array + * An array of visible (not hidden) link definitions, keyed by ID. + */ + public function loadAllChildren($id, $max_relative_depth = NULL); + + /** + * Loads all the IDs for menu links that are below the given ID. + * + * @param string $id + * The parent menu link ID. + * + * @return array + * An unordered array of plugin IDs corresponding to all children. + */ + public function getAllChildIds($id); + + /** + * Loads a subtree rooted by the given ID. + * + * The returned links are structured like those from loadTreeData(). + * + * @param string $id + * The menu link plugin ID. + * @param int $max_relative_depth + * (optional) The maximum depth of child menu links relative to the passed + * in. Defaults to NULL, in which case the full subtree will be returned. + * + * @return array + * An array with 2 elements: + * - subtree: A fully built menu tree element or FALSE. + * - route_names: An array of all route names used in the subtree. + */ + public function loadSubtreeData($id, $max_relative_depth = NULL); + + /** + * Returns all the IDs that represent the path to the root of the tree. + * + * @param string $id + * A menu link ID. + * + * @return array + * An associative array of IDs with keys equal to values that represents the + * path from the given ID to the root of the tree. If $id is an ID that + * exists, the returned array will at least include it. An empty array is + * returned if the ID does not exist in the storage. An example $id (8) with + * two parents (1, 6) looks like the following: + * @code + * array( + * 'p1' => 1, + * 'p2' => 6, + * 'p3' => 8, + * 'p4' => 0, + * 'p5' => 0, + * 'p6' => 0, + * 'p7' => 0, + * 'p8' => 0, + * 'p9' => 0 + * ) + * @endcode + */ + public function getRootPathIds($id); + + /** + * Finds expanded links in a menu given a set of possible parents. + * + * @param string $menu_name + * The menu name. + * @param array $parents + * One or more parent IDs to match. + * + * @return array + * The menu link IDs that are flagged as expanded in this menu. + */ + public function getExpanded($menu_name, array $parents); + + /** + * Finds the height of a subtree rooted by the given ID. + * + * @param string $id + * The ID of an item in the storage. + * + * @return int + * Returns the height of the subtree. This will be at least 1 if the ID + * exists, or 0 if the ID does not exist in the storage. + */ + public function getSubtreeHeight($id); + + /** + * Determines whether a specific menu name is used in the tree. + * + * @param string $menu_name + * The menu name. + * + * @return bool + * Returns TRUE if the given menu name is used, otherwise FALSE. + */ + public function menuNameInUse($menu_name); + + /** + * Returns the used menu names in the tree storage. + * + * @return array + * The menu names. + */ + public function getMenuNames(); + + /** + * Counts the total number of menu links in one menu or all menus. + * + * @param string $menu_name + * (optional) The menu name to count by. Defaults to all menus. + * + * @return int + * The number of menu links in the named menu, or in all menus if the menu + * name is NULL. + */ + public function countMenuLinks($menu_name = NULL); + +} diff --git a/core/lib/Drupal/Core/Menu/StaticMenuLinkOverrides.php b/core/lib/Drupal/Core/Menu/StaticMenuLinkOverrides.php new file mode 100644 index 000000000000..8b79bffc253c --- /dev/null +++ b/core/lib/Drupal/Core/Menu/StaticMenuLinkOverrides.php @@ -0,0 +1,163 @@ +<?php + +/** + * @file + * Contains \Drupal\Core\Menu\StaticMenuLinkOverrides. + */ + +namespace Drupal\Core\Menu; + +use Drupal\Core\Config\ConfigFactoryInterface; + +/** + * Defines an implementation of the menu link override using a config file. + */ +class StaticMenuLinkOverrides implements StaticMenuLinkOverridesInterface { + + /** + * The config name used to store the overrides. + * + * @var string + */ + protected $configName = 'menu_link.static.overrides'; + + /** + * The menu link overrides config object. + * + * @var \Drupal\Core\Config\Config + */ + protected $config; + + /** + * The config factory object. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $configFactory; + + /** + * Constructs a StaticMenuLinkOverrides object. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * A configuration factory instance. + */ + public function __construct(ConfigFactoryInterface $config_factory) { + $this->configFactory = $config_factory; + } + + /** + * Gets the configuration object when needed. + * + * Since this service is injected into all static menu link objects, but + * only used when updating one, avoid actually loading the config when it's + * not needed. + */ + protected function getConfig() { + if (empty($this->config)) { + $this->config = $this->configFactory->get($this->configName); + } + return $this->config; + } + + /** + * {@inheritdoc} + */ + public function reload() { + $this->config = NULL; + $this->configFactory->reset($this->configName); + } + + /** + * {@inheritdoc} + */ + public function loadOverride($id) { + $all_overrides = $this->getConfig()->get('definitions'); + $id = static::encodeId($id); + return $id && isset($all_overrides[$id]) ? $all_overrides[$id] : array(); + } + + /** + * {@inheritdoc} + */ + public function deleteMultipleOverrides(array $ids) { + $all_overrides = $this->getConfig()->get('definitions'); + $save = FALSE; + foreach ($ids as $id) { + $id = static::encodeId($id); + if (isset($all_overrides[$id])) { + unset($all_overrides[$id]); + $save = TRUE; + } + } + if ($save) { + $this->getConfig()->set('definitions', $all_overrides)->save(); + } + return $save; + } + + /** + * {@inheritdoc} + */ + public function deleteOverride($id) { + return $this->deleteMultipleOverrides(array($id)); + } + + /** + * {@inheritdoc} + */ + public function loadMultipleOverrides(array $ids) { + $result = array(); + if ($ids) { + $all_overrides = $this->getConfig()->get('definitions') ?: array(); + foreach ($ids as $id) { + $encoded_id = static::encodeId($id); + if (isset($all_overrides[$encoded_id])) { + $result[$id] = $all_overrides[$encoded_id]; + } + } + } + return $result; + } + + /** + * {@inheritdoc} + */ + public function saveOverride($id, array $definition) { + // Only allow to override a specific subset of the keys. + $expected = array( + 'menu_name' => 1, + 'parent' => 1, + 'weight' => 1, + 'expanded' => 1, + 'hidden' => 1, + ); + // Filter the overrides to only those that are expected. + $definition = array_intersect_key($definition, $expected); + if ($definition) { + $id = static::encodeId($id); + $all_overrides = $this->getConfig()->get('definitions'); + // Combine with any existing data. + $all_overrides[$id] = $definition + $this->loadOverride($id); + $this->getConfig()->set('definitions', $all_overrides)->save(); + } + return array_keys($definition); + } + + /** + * Encodes the ID by replacing dots with double underscores. + * + * This is done because config schema uses dots for its internal type + * hierarchy. Double underscores are converted to triple underscores to + * avoid accidental conflicts. + * + * @param string $id + * The menu plugin ID. + * + * @return string + * The menu plugin ID with double underscore instead of dots. + */ + protected static function encodeId($id) { + return strtr($id, array('.' => '__', '__' => '___')); + } + +} diff --git a/core/lib/Drupal/Core/Menu/StaticMenuLinkOverridesInterface.php b/core/lib/Drupal/Core/Menu/StaticMenuLinkOverridesInterface.php new file mode 100644 index 000000000000..0ffb21a0fb22 --- /dev/null +++ b/core/lib/Drupal/Core/Menu/StaticMenuLinkOverridesInterface.php @@ -0,0 +1,87 @@ +<?php + +/** + * @file + * Contains \Drupal\Core\Menu\StaticMenuLinkOverridesInterface. + */ + +namespace Drupal\Core\Menu; + +/** + * Defines an interface for objects which overrides menu links defined in YAML. + */ +interface StaticMenuLinkOverridesInterface { + + /** + * Reloads the overrides from config. + * + * Forces all overrides to be reloaded from config storage to compare the + * override value with the value submitted during test form submission. + */ + public function reload(); + + /** + * Loads any overrides to the definition of a static (YAML-defined) link. + * + * @param string $id + * A menu link plugin ID. + * + * @return array|NULL + * An override with following supported keys: + * - parent + * - weight + * - menu_name + * - expanded + * - hidden + * or NULL if there is no override for the given ID. + */ + public function loadOverride($id); + + /** + * Deletes any overrides to the definition of a static (YAML-defined) link. + * + * @param string $id + * A menu link plugin ID. + */ + public function deleteOverride($id); + + /** + * Deletes multiple overrides to definitions of static (YAML-defined) links. + * + * @param array $ids + * Array of menu link plugin IDs. + */ + public function deleteMultipleOverrides(array $ids); + + /** + * Loads overrides to multiple definitions of a static (YAML-defined) link. + * + * @param array $ids + * Array of menu link plugin IDs. + * + * @return array + * One or override keys by plugin ID. + * + * @see \Drupal\Core\Menu\StaticMenuLinkOverridesInterface + */ + public function loadMultipleOverrides(array $ids); + + /** + * Saves the override. + * + * @param string $id + * A menu link plugin ID. + * @param array $definition + * The definition values to override. Supported keys: + * - menu_name + * - parent + * - weight + * - expanded + * - hidden + * + * @return array + * A list of properties which got saved. + */ + public function saveOverride($id, array $definition); + +} diff --git a/core/lib/Drupal/Core/Plugin/CachedDiscoveryClearer.php b/core/lib/Drupal/Core/Plugin/CachedDiscoveryClearer.php index 5febcd3e66d3..9ab429f2ac17 100644 --- a/core/lib/Drupal/Core/Plugin/CachedDiscoveryClearer.php +++ b/core/lib/Drupal/Core/Plugin/CachedDiscoveryClearer.php @@ -21,7 +21,7 @@ class CachedDiscoveryClearer { * * @var \Drupal\Component\Plugin\Discovery\CachedDiscoveryInterface[] */ - protected $cachedDiscoveries; + protected $cachedDiscoveries = array(); /** * Adds a plugin manager to the active list. diff --git a/core/lib/Drupal/Core/Plugin/PluginManagerPass.php b/core/lib/Drupal/Core/Plugin/PluginManagerPass.php index 8bfde35c6197..9efdcf2c22f1 100644 --- a/core/lib/Drupal/Core/Plugin/PluginManagerPass.php +++ b/core/lib/Drupal/Core/Plugin/PluginManagerPass.php @@ -23,7 +23,9 @@ public function process(ContainerBuilder $container) { $cache_clearer_definition = $container->getDefinition('plugin.cache_clearer'); foreach ($container->getDefinitions() as $service_id => $definition) { if (strpos($service_id, 'plugin.manager.') === 0 || $definition->hasTag('plugin_manager_cache_clear')) { - $cache_clearer_definition->addMethodCall('addCachedDiscovery', array(new Reference($service_id))); + if (is_subclass_of($definition->getClass(), '\Drupal\Component\Plugin\Discovery\CachedDiscoveryInterface')) { + $cache_clearer_definition->addMethodCall('addCachedDiscovery', array(new Reference($service_id))); + } } } } diff --git a/core/modules/menu_link_content/menu_link_content.info.yml b/core/modules/menu_link_content/menu_link_content.info.yml new file mode 100644 index 000000000000..5ea5cc71bf2b --- /dev/null +++ b/core/modules/menu_link_content/menu_link_content.info.yml @@ -0,0 +1,6 @@ +name: 'Custom Menu Links' +type: module +description: 'Allows administrators to create custom menu links.' +package: Core +version: VERSION +core: 8.x diff --git a/core/modules/menu_link_content/menu_link_content.module b/core/modules/menu_link_content/menu_link_content.module new file mode 100644 index 000000000000..25dfb2692151 --- /dev/null +++ b/core/modules/menu_link_content/menu_link_content.module @@ -0,0 +1,17 @@ +<?php + +/** + * @file + * Allows administrators to create custom menu links. + */ + +use Drupal\system\MenuInterface; + +/** + * Implements hook_menu_delete(). + */ +function menu_link_content_menu_delete(MenuInterface $menu) { + $storage = \Drupal::entityManager()->getStorage('menu_link_content'); + $menu_links = $storage->loadByProperties(array('menu_name' => $menu->id())); + $storage->delete($menu_links); +} diff --git a/core/modules/menu_link_content/src/Entity/MenuLinkContent.php b/core/modules/menu_link_content/src/Entity/MenuLinkContent.php new file mode 100644 index 000000000000..411adf126ae1 --- /dev/null +++ b/core/modules/menu_link_content/src/Entity/MenuLinkContent.php @@ -0,0 +1,385 @@ +<?php + +/** + * @file + * Contains \Drupal\menu_link_content\Entity\MenuLinkContent. + */ + +namespace Drupal\menu_link_content\Entity; + +use Drupal\Core\Entity\ContentEntityBase; +use Drupal\Core\Entity\EntityStorageInterface; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Field\FieldDefinition; +use Drupal\Core\Url; + +/** + * Defines the menu link content entity class. + * + * @ContentEntityType( + * id = "menu_link_content", + * label = @Translation("Custom menu link"), + * controllers = { + * "storage" = "Drupal\Core\Entity\ContentEntityDatabaseStorage", + * "access" = "Drupal\menu_link_content\MenuLinkContentAccessController", + * "form" = { + * "default" = "Drupal\menu_link_content\Form\MenuLinkContentForm", + * "delete" = "Drupal\menu_link_content\Form\MenuLinkContentDeleteForm" + * } + * }, + * admin_permission = "administer menu", + * base_table = "menu_link_content", + * data_table = "menu_link_content_data", + * fieldable = TRUE, + * translatable = TRUE, + * entity_keys = { + * "id" = "id", + * "label" = "title", + * "uuid" = "uuid", + * "bundle" = "bundle" + * }, + * links = { + * "canonical" = "menu_link_content.link_edit", + * "edit-form" = "menu_link_content.link_edit", + * } + * ) + */ +class MenuLinkContent extends ContentEntityBase implements MenuLinkContentInterface { + + /** + * A flag for whether this entity is wrapped in a plugin instance. + * + * @var bool + */ + protected $insidePlugin = FALSE; + + /** + * {@inheritdoc} + */ + public function setInsidePlugin() { + $this->insidePlugin = TRUE; + } + + /** + * {@inheritdoc} + */ + public function getTitle() { + return $this->get('title')->value; + } + + /** + * {@inheritdoc} + */ + public function getRouteName() { + return $this->get('route_name')->value; + } + + /** + * {@inheritdoc} + */ + public function getRouteParameters() { + return $this->get('route_parameters')->first()->getValue(); + } + + /** + * {@inheritdoc} + */ + public function setRouteParameters(array $route_parameters) { + $this->set('route_parameters', array($route_parameters)); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getUrl() { + return $this->get('url')->value ?: NULL; + } + + /** + * {@inheritdoc} + */ + public function getUrlObject() { + if ($route_name = $this->getRouteName()) { + $url = new Url($route_name, $this->getRouteParameters(), $this->getOptions()); + } + else { + $path = $this->getUrl(); + if (isset($path)) { + $url = Url::createFromPath($path); + } + else { + $url = new Url('<front>'); + } + } + + return $url; + } + + /** + * {@inheritdoc} + */ + public function getMenuName() { + return $this->get('menu_name')->value; + } + + /** + * {@inheritdoc} + */ + public function getOptions() { + return $this->get('options')->first()->getValue(); + } + + /** + * {@inheritdoc} + */ + public function setOptions(array $options) { + $this->set('options', array($options)); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + return $this->get('description')->value; + } + + /** + * {@inheritdoc} + */ + public function getPluginId() { + return 'menu_link_content:' . $this->uuid(); + } + + /** + * {@inheritdoc} + */ + public function isHidden() { + return (bool) $this->get('hidden')->value; + } + + /** + * {@inheritdoc} + */ + public function isExpanded() { + return (bool) $this->get('expanded')->value; + } + + /** + * {@inheritdoc} + */ + public function getParentId() { + return $this->get('parent')->value; + } + + /** + * {@inheritdoc} + */ + public function getWeight() { + return (int) $this->get('weight')->value; + } + + /** + * Builds up the menu link plugin definition for this entity. + * + * @return array + * The plugin definition corresponding to this entity. + * + * @see \Drupal\Core\Menu\MenuLinkTree::$defaults + */ + protected function getPluginDefinition() { + $definition = array(); + $definition['class'] = 'Drupal\menu_link_content\Plugin\Menu\MenuLinkContent'; + $definition['menu_name'] = $this->getMenuName(); + $definition['route_name'] = $this->getRouteName(); + $definition['route_parameters'] = $this->getRouteParameters(); + $definition['url'] = $this->getUrl(); + $definition['options'] = $this->getOptions(); + $definition['title'] = $this->getTitle(); + $definition['description'] = $this->getDescription(); + $definition['weight'] = $this->getWeight(); + $definition['id'] = $this->getPluginId(); + $definition['metadata'] = array('entity_id' => $this->id()); + $definition['form_class'] = '\Drupal\menu_link_content\Form\MenuLinkContentForm'; + $definition['hidden'] = $this->isHidden() ? 1 : 0; + $definition['expanded'] = $this->isExpanded() ? 1 : 0; + $definition['provider'] = 'menu_link_content'; + $definition['discovered'] = 0; + $definition['parent'] = $this->getParentId(); + + return $definition; + } + + /** + * {@inheritdoc} + */ + public function postSave(EntityStorageInterface $storage, $update = TRUE) { + parent::postSave($storage, $update); + + /** @var \Drupal\Core\Menu\MenuLinkManagerInterface $menu_link_manager */ + $menu_link_manager = \Drupal::service('plugin.manager.menu.link'); + + // The menu link can just be updated if there is already an menu link entry + // on both entity and menu link plugin level. + if ($update && $menu_link_manager->getDefinition($this->getPluginId())) { + // When the entity is saved via a plugin instance, we should not call + // the menu tree manager to update the definition a second time. + if (!$this->insidePlugin) { + $menu_link_manager->updateDefinition($this->getPluginId(), $this->getPluginDefinition(), FALSE); + } + } + else { + $menu_link_manager->addDefinition($this->getPluginId(), $this->getPluginDefinition()); + } + } + + /** + * {@inheritdoc} + */ + public static function preDelete(EntityStorageInterface $storage, array $entities) { + parent::preDelete($storage, $entities); + + /** @var \Drupal\Core\Menu\MenuLinkManagerInterface $menu_link_manager */ + $menu_link_manager = \Drupal::service('plugin.manager.menu.link'); + + foreach ($entities as $menu_link) { + /** @var \Drupal\menu_link_content\Entity\MenuLinkContent $menu_link */ + $menu_link_manager->removeDefinition($menu_link->getPluginId(), FALSE); + } + } + + /** + * {@inheritdoc} + */ + public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { + $fields['id'] = FieldDefinition::create('integer') + ->setLabel(t('Entity ID')) + ->setDescription(t('The entity ID for this menu link content entity.')) + ->setReadOnly(TRUE) + ->setSetting('unsigned', TRUE); + + $fields['uuid'] = FieldDefinition::create('uuid') + ->setLabel(t('UUID')) + ->setDescription(t('The content menu link UUID.')) + ->setReadOnly(TRUE); + + $fields['bundle'] = FieldDefinition::create('string') + ->setLabel(t('Bundle')) + ->setDescription(t('The content menu link bundle.')) + ->setSetting('max_length', EntityTypeInterface::BUNDLE_MAX_LENGTH) + ->setReadOnly(TRUE); + + $fields['title'] = FieldDefinition::create('string') + ->setLabel(t('Menu link title')) + ->setDescription(t('The text to be used for this link in the menu.')) + ->setRequired(TRUE) + ->setTranslatable(TRUE) + ->setSettings(array( + 'default_value' => '', + 'max_length' => 255, + )) + ->setDisplayOptions('view', array( + 'label' => 'hidden', + 'type' => 'string', + 'weight' => -5, + )) + ->setDisplayOptions('form', array( + 'type' => 'string', + 'weight' => -5, + )) + ->setDisplayConfigurable('form', TRUE); + + $fields['description'] = FieldDefinition::create('string') + ->setLabel(t('Description')) + ->setDescription(t('Shown when hovering over the menu link.')) + ->setTranslatable(TRUE) + ->setSettings(array( + 'default_value' => '', + 'max_length' => 255, + )) + ->setDisplayOptions('view', array( + 'label' => 'hidden', + 'type' => 'string', + 'weight' => 0, + )) + ->setDisplayOptions('form', array( + 'type' => 'string', + 'weight' => 0, + )); + + $fields['menu_name'] = FieldDefinition::create('string') + ->setLabel(t('Menu name')) + ->setDescription(t('The menu name. All links with the same menu name (such as "tools") are part of the same menu.')) + ->setSetting('default_value', 'tools'); + + // @todo Use a link field https://www.drupal.org/node/2302205. + $fields['route_name'] = FieldDefinition::create('string') + ->setLabel(t('Route name')) + ->setDescription(t('The machine name of a defined Symfony Route this menu item represents.')); + + $fields['route_parameters'] = FieldDefinition::create('map') + ->setLabel(t('Route parameters')) + ->setDescription(t('A serialized array of route parameters of this menu link.')); + + $fields['url'] = FieldDefinition::create('uri') + ->setLabel(t('External link url')) + ->setDescription(t('The url of the link, in case you have an external link.')); + + $fields['options'] = FieldDefinition::create('map') + ->setLabel(t('Options')) + ->setDescription(t('A serialized array of options to be passed to the url() or l() function, such as a query string or HTML attributes.')) + ->setSetting('default_value', array()); + + $fields['external'] = FieldDefinition::create('boolean') + ->setLabel(t('External')) + ->setDescription(t('A flag to indicate if the link points to a full URL starting with a protocol, like http:// (1 = external, 0 = internal).')) + ->setSetting('default_value', FALSE); + + // The form widget doesn't work yet for core fields, so we skip the + // for display and manually create form elements for the boolean fields. + // @see https://drupal.org/node/2226493 + // @see https://drupal.org/node/2150511 + $fields['expanded'] = FieldDefinition::create('boolean') + ->setLabel(t('Expanded')) + ->setDescription(t('Flag for whether this link should be rendered as expanded in menus - expanded links always have their child links displayed, instead of only when the link is in the active trail (1 = expanded, 0 = not expanded).')) + ->setSetting('default_value', FALSE) + ->setDisplayOptions('view', array( + 'label' => 'hidden', + 'type' => 'boolean', + 'weight' => 0, + )); + + // We manually create a form element for this, since the form logic is + // is inverted to show enabled. + $fields['hidden'] = FieldDefinition::create('boolean') + ->setLabel(t('Hidden')) + ->setDescription(t('A flag for whether the link should be hidden in menus or rendered normally.')) + ->setSetting('default_value', FALSE); + + $fields['weight'] = FieldDefinition::create('integer') + ->setLabel(t('Weight')) + ->setDescription(t('Link weight among links in the same menu at the same depth.')) + ->setSetting('default_value', 0) + ->setDisplayOptions('view', array( + 'label' => 'hidden', + 'type' => 'integer', + 'weight' => 0, + )) + ->setDisplayOptions('form', array( + 'type' => 'integer', + 'weight' => 0, + )); + + $fields['langcode'] = FieldDefinition::create('language') + ->setLabel(t('Language code')) + ->setDescription(t('The node language code.')); + + $fields['parent'] = FieldDefinition::create('string') + ->setLabel(t('Parent plugin ID')) + ->setDescription(t('The ID of the parent menu link plugin, or empty string when at the top level of the hierarchy.')); + + return $fields; + } + +} diff --git a/core/modules/menu_link_content/src/Entity/MenuLinkContentInterface.php b/core/modules/menu_link_content/src/Entity/MenuLinkContentInterface.php new file mode 100644 index 000000000000..d64bab9854e7 --- /dev/null +++ b/core/modules/menu_link_content/src/Entity/MenuLinkContentInterface.php @@ -0,0 +1,152 @@ +<?php + +/** + * @file + * Contains \Drupal\menu_link_content\Entity\MenuLinkContentInterface. + */ + +namespace Drupal\menu_link_content\Entity; + +use Drupal\Core\Entity\ContentEntityInterface; + +/** + * Defines an interface for custom menu links. + */ +interface MenuLinkContentInterface extends ContentEntityInterface { + + /** + * Flags this instance as being wrapped in a menu link plugin instance. + */ + public function setInsidePlugin(); + + /** + * Gets the title of the menu link. + * + * @return string + * The title of the link. + */ + public function getTitle(); + + /** + * Gets the route name of the menu link. + * + * @return string|NULL + * Returns the route name, or NULL if it is an external link. + */ + public function getRouteName(); + + /** + * Gets the route parameters of the menu link content entity. + * + * @return array + * The route parameters, or an empty array. + */ + public function getRouteParameters(); + + /** + * Sets the route parameters of the custom menu link. + * + * @param array $route_parameters + * The route parameters, usually derived from the path entered by the + * administrator. For example, for a link to a node with route 'node.view' + * the route needs the node ID as a parameter: + * @code + * array('node' => 2) + * @endcode + * + * @return $this + */ + public function setRouteParameters(array $route_parameters); + + /** + * Gets the external URL. + * + * @return string|NULL + * Returns the external URL if the menu link points to an external URL, + * otherwise NULL. + */ + public function getUrl(); + + /** + * Gets the url object pointing to the URL of the menu link content entity. + * + * @return \Drupal\Core\Url + * A Url object instance. + */ + public function getUrlObject(); + + /** + * Gets the menu name of the custom menu link. + * + * @return string + * The menu ID. + */ + public function getMenuName(); + + /** + * Gets the options for the menu link content entity. + * + * @return array + * The options that may be passed to the URL generator. + */ + public function getOptions(); + + /** + * Sets the query options of the menu link content entity. + * + * @param array $options + * The new option. + * + * @return $this + */ + public function setOptions(array $options); + + /** + * Gets the description of the menu link for the UI. + * + * @return string + * The description to use on admin pages or as a title attribute. + */ + public function getDescription(); + + /** + * Gets the menu plugin ID associated with this entity. + * + * @return string + * The plugin ID. + */ + public function getPluginId(); + + /** + * Returns whether the menu link is marked as hidden. + * + * @return bool + * TRUE if is not enabled, otherwise FALSE. + */ + public function isHidden(); + + /** + * Returns whether the menu link is marked as always expanded. + * + * @return bool + * TRUE for expanded, FALSE otherwise. + */ + public function isExpanded(); + + /** + * Gets the plugin ID of the parent menu link. + * + * @return string + * A plugin ID, or empty string if this link is at the top level. + */ + public function getParentId(); + + /** + * Returns the weight of the menu link content entity. + * + * @return int + * A weight for use when ordering links. + */ + public function getWeight(); + +} diff --git a/core/modules/menu_link_content/src/MenuLinkContentAccessController.php b/core/modules/menu_link_content/src/MenuLinkContentAccessController.php new file mode 100644 index 000000000000..c99109e40621 --- /dev/null +++ b/core/modules/menu_link_content/src/MenuLinkContentAccessController.php @@ -0,0 +1,68 @@ +<?php +/** + * @file + * Contains \Drupal\menu_link_content\MenuLinkContentAccessController. + */ + +namespace Drupal\menu_link_content; + +use Drupal\Core\Access\AccessManager; +use Drupal\Core\Entity\EntityControllerInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityAccessController; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Session\AccountInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Defines the access controller for the user entity type. + */ +class MenuLinkContentAccessController extends EntityAccessController implements EntityControllerInterface { + + /** + * The access manager to check routes by name. + * + * @var \Drupal\Core\Access\AccessManager + */ + protected $accessManager; + + /** + * Creates a new MenuLinkContentAccessController. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type definition. + * @param \Drupal\Core\Access\AccessManager $access_manager + * The access manager to check routes by name. + */ + public function __construct(EntityTypeInterface $entity_type, AccessManager $access_manager) { + parent::__construct($entity_type); + + $this->accessManager = $access_manager; + } + + /** + * {@inheritdoc} + */ + public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) { + return new static($entity_type, $container->get('access_manager')); + } + + /** + * {@inheritdoc} + */ + protected function checkAccess(EntityInterface $entity, $operation, $langcode, AccountInterface $account) { + switch ($operation) { + case 'view': + // There is no direct view. + return FALSE; + + case 'update': + // If there is a URL, this is an external link so always accessible. + return $account->hasPermission('administer menu') && ($entity->getUrl() || $this->accessManager->checkNamedRoute($entity->getRouteName(), $entity->getRouteParameters(), $account)); + + case 'delete': + return !$entity->isNew() && $account->hasPermission('administer menu'); + } + } + +} diff --git a/core/modules/menu_link_content/src/Plugin/Menu/MenuLinkContent.php b/core/modules/menu_link_content/src/Plugin/Menu/MenuLinkContent.php new file mode 100644 index 000000000000..a55dc9b168c2 --- /dev/null +++ b/core/modules/menu_link_content/src/Plugin/Menu/MenuLinkContent.php @@ -0,0 +1,250 @@ +<?php + +/** + * @file + * Contains \Drupal\menu_link_content\Plugin\Menu\MenuLinkContent. + */ + +namespace Drupal\menu_link_content\Plugin\Menu; + +use Drupal\Component\Plugin\Exception\PluginException; +use Drupal\Component\Utility\String; +use Drupal\Core\Entity\EntityManagerInterface; +use Drupal\Core\Language\LanguageManagerInterface; +use Drupal\Core\Menu\MenuLinkBase; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides the menu link plugin for content menu links. + */ +class MenuLinkContent extends MenuLinkBase implements ContainerFactoryPluginInterface { + + /** + * Entities IDs to load. + * + * It is an array of entity IDs keyed by entity IDs. + * + * @var array + */ + protected static $entityIdsToLoad = array(); + + /** + * {@inheritdoc} + */ + protected $overrideAllowed = array( + 'menu_name' => 1, + 'parent' => 1, + 'weight' => 1, + 'expanded' => 1, + 'hidden' => 1, + 'title' => 1, + 'description' => 1, + 'route_name' => 1, + 'route_parameters' => 1, + 'url' => 1, + 'options' => 1, + ); + + /** + * The menu link content entity connected to this plugin instance. + * + * @var \Drupal\menu_link_content\Entity\MenuLinkContentInterface + */ + protected $entity; + + /** + * The entity manager. + * + * @var \Drupal\Core\Entity\EntityManagerInterface + */ + protected $entityManager; + + /** + * The language manager. + * + * @var \Drupal\Core\Language\LanguageManagerInterface + */ + protected $languageManager; + + /** + * Constructs a new MenuLinkContent. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager + * The entity manager. + * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager + * The language manager. + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityManagerInterface $entity_manager, LanguageManagerInterface $language_manager) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + + if (!empty($this->pluginDefinition['metadata']['entity_id'])) { + $entity_id = $this->pluginDefinition['metadata']['entity_id']; + // Builds a list of entity IDs to take advantage of the more efficient + // EntityStorageInterface::loadMultiple() in getEntity() at render time. + static::$entityIdsToLoad[$entity_id] = $entity_id; + } + + $this->entityManager = $entity_manager; + $this->langaugeManager = $language_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('entity.manager'), + $container->get('language_manager') + ); + } + + /** + * Loads the entity associated with this menu link. + * + * @return \Drupal\menu_link_content\Entity\MenuLinkContentInterface + * The menu link content entity. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * If the entity ID and UUID are both invalid or missing. + */ + protected function getEntity() { + if (empty($this->entity)) { + $entity = NULL; + $storage = $this->entityManager->getStorage('menu_link_content'); + if (!empty($this->pluginDefinition['metadata']['entity_id'])) { + $entity_id = $this->pluginDefinition['metadata']['entity_id']; + // Make sure the current ID is in the list, since each plugin empties + // the list after calling loadMultple(). Note that the list may include + // multiple IDs added earlier in each plugin's constructor. + static::$entityIdsToLoad[$entity_id] = $entity_id; + $entities = $storage->loadMultiple(array_values(static::$entityIdsToLoad)); + $entity = isset($entities[$entity_id]) ? $entities[$entity_id] : NULL; + static::$entityIdsToLoad = array(); + } + if (!$entity) { + // Fallback to the loading by the UUID. + $uuid = $this->getDerivativeId(); + $loaded_entities = $storage->loadByProperties(array('uuid' => $uuid)); + $entity = reset($loaded_entities); + } + if (!$entity) { + throw new PluginException(String::format('Entity not found through the menu link plugin definition and could not fallback on UUID @uuid', array('@uuid' => $uuid))); + } + // Clone the entity object to avoid tampering with the static cache. + $this->entity = clone $entity; + $the_entity = $this->entityManager->getTranslationFromContext($this->entity); + /** @var \Drupal\menu_link_content\Entity\MenuLinkContentInterface $the_entity */ + $this->entity = $the_entity; + $this->entity->setInsidePlugin(); + } + return $this->entity; + } + + /** + * {@inheritdoc} + */ + public function getTitle() { + // We only need to get the title from the actual entity if it may be a + // translation based on the current language context. This can only happen + // if the site is configured to be multilingual. + if ($this->langaugeManager->isMultilingual()) { + return $this->getEntity()->getTitle(); + } + return $this->pluginDefinition['title']; + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + // We only need to get the description from the actual entity if it may be a + // translation based on the current language context. This can only happen + // if the site is configured to be multilingual. + if ($this->langaugeManager->isMultilingual()) { + return $this->getEntity()->getDescription(); + } + return $this->pluginDefinition['description']; + } + + /** + * {@inheritdoc} + */ + public function getDeleteRoute() { + return array( + 'route_name' => 'menu_link_content.link_delete', + 'route_parameters' => array('menu_link_content' => $this->getEntity()->id()), + ); + } + + /** + * {@inheritdoc} + */ + public function getEditRoute() { + return array( + 'route_name' => 'menu_link_content.link_edit', + 'route_parameters' => array('menu_link_content' => $this->getEntity()->id()), + ); + } + + /** + * {@inheritdoc} + */ + public function getTranslateRoute() { + $entity_type = 'menu_link_content'; + return array( + 'route_name' => 'content_translation.translation_overview_' . $entity_type, + 'route_parameters' => array( $entity_type => $this->getEntity()->id()), + ); + } + + /** + * {@inheritdoc} + */ + public function updateLink(array $new_definition_values, $persist) { + // Filter the list of updates to only those that are allowed. + $overrides = array_intersect_key($new_definition_values, $this->overrideAllowed); + // Update the definition. + $this->pluginDefinition = $overrides + $this->getPluginDefinition(); + if ($persist) { + $entity = $this->getEntity(); + foreach ($overrides as $key => $value) { + $entity->{$key}->value = $value; + } + $this->entityManager->getStorage('menu_link_content')->save($entity); + } + + return $this->pluginDefinition; + } + + /** + * {@inheritdoc} + */ + public function isDeletable() { + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function isTranslatable() { + return $this->getEntity()->isTranslatable(); + } + + /** + * {@inheritdoc} + */ + public function deleteLink() { + $this->getEntity()->delete(); + } + +} diff --git a/core/modules/system/src/Tests/Menu/MenuTreeStorageTest.php b/core/modules/system/src/Tests/Menu/MenuTreeStorageTest.php new file mode 100644 index 000000000000..75b4f1e225fc --- /dev/null +++ b/core/modules/system/src/Tests/Menu/MenuTreeStorageTest.php @@ -0,0 +1,389 @@ +<?php + +/** + * @file + * Contains \Drupal\system\Tests\Menu\MenuTreeStorageTest. + */ + +namespace Drupal\system\Tests\Menu; + +use Drupal\Component\Plugin\Exception\PluginException; +use Drupal\Core\Menu\MenuTreeParameters; +use Drupal\Core\Menu\MenuTreeStorage; +use Drupal\simpletest\KernelTestBase; + +/** + * Tests the menu tree storage. + * + * @group Menu + * + * @see \Drupal\Core\Menu\MenuTreeStorage + */ +class MenuTreeStorageTest extends KernelTestBase { + + /** + * The tested tree storage. + * + * @var \Drupal\Core\Menu\MenuTreeStorage + */ + protected $treeStorage; + + /** + * The database connection. + * + * @var \Drupal\Core\Database\Connection + */ + protected $connection; + + /** + * Modules to enable. + * + * @var array + */ + public static $modules = array('system', 'menu_link_content'); + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->treeStorage = new MenuTreeStorage($this->container->get('database'), $this->container->get('cache.menu'), 'menu_tree'); + $this->connection = $this->container->get('database'); + $this->installEntitySchema('menu_link_content'); + } + + /** + * Tests the tree storage when no tree was built yet. + */ + public function testBasicMethods() { + $this->doTestEmptyStorage(); + $this->doTestTable(); + } + + /** + * Ensures that there are no menu links by default. + */ + protected function doTestEmptyStorage() { + $this->assertEqual(0, $this->treeStorage->countMenuLinks()); + } + + /** + * Ensures that table gets created on the fly. + */ + protected function doTestTable() { + // Test that we can create a tree storage with an arbitrary table name and + // that selecting from the storage creates the table. + $tree_storage = new MenuTreeStorage($this->container->get('database'), $this->container->get('cache.menu'), 'test_menu_tree'); + $this->assertFalse($this->connection->schema()->tableExists('test_menu_tree'), 'Test table is not yet created'); + $tree_storage->countMenuLinks(); + $this->assertTrue($this->connection->schema()->tableExists('test_menu_tree'), 'Test table was created'); + } + + /** + * Tests with a simple linear hierarchy. + */ + public function testSimpleHierarchy() { + // Add some links with parent on the previous one and test some values. + // <tools> + // - test1 + // -- test2 + // --- test3 + $this->addMenuLink('test1', ''); + $this->assertMenuLink('test1', array('has_children' => 0, 'depth' => 1)); + + $this->addMenuLink('test2', 'test1'); + $this->assertMenuLink('test1', array('has_children' => 1, 'depth' => 1), array(), array('test2')); + $this->assertMenuLink('test2', array('has_children' => 0, 'depth' => 2), array('test1')); + + $this->addMenuLink('test3', 'test2'); + $this->assertMenuLink('test1', array('has_children' => 1, 'depth' => 1), array(), array('test2', 'test3')); + $this->assertMenuLink('test2', array('has_children' => 1, 'depth' => 2), array('test1'), array('test3')); + $this->assertMenuLink('test3', array('has_children' => 0, 'depth' => 3), array('test2', 'test1')); + } + + /** + * Tests the tree with moving links inside the hierarchy. + */ + public function testMenuLinkMoving() { + // Before the move. + // <tools> + // - test1 + // -- test2 + // --- test3 + // - test4 + // -- test5 + // --- test6 + + $this->addMenuLink('test1', ''); + $this->addMenuLink('test2', 'test1'); + $this->addMenuLink('test3', 'test2'); + $this->addMenuLink('test4', ''); + $this->addMenuLink('test5', 'test4'); + $this->addMenuLink('test6', 'test5'); + + $this->assertMenuLink('test1', array('has_children' => 1, 'depth' => 1), array(), array('test2', 'test3')); + $this->assertMenuLink('test2', array('has_children' => 1, 'depth' => 2), array('test1'), array('test3')); + $this->assertMenuLink('test4', array('has_children' => 1, 'depth' => 1), array(), array('test5', 'test6')); + $this->assertMenuLink('test5', array('has_children' => 1, 'depth' => 2), array('test4'), array('test6')); + $this->assertMenuLink('test6', array('has_children' => 0, 'depth' => 3), array('test5', 'test4')); + + $this->moveMenuLink('test2', 'test5'); + // After the 1st move. + // <tools> + // - test1 + // - test4 + // -- test5 + // --- test2 + // ---- test3 + // --- test6 + + $this->assertMenuLink('test1', array('has_children' => 0, 'depth' => 1)); + $this->assertMenuLink('test2', array('has_children' => 1, 'depth' => 3), array('test5', 'test4'), array('test3')); + $this->assertMenuLink('test3', array('has_children' => 0, 'depth' => 4), array('test2', 'test5', 'test4')); + $this->assertMenuLink('test4', array('has_children' => 1, 'depth' => 1), array(), array('test5', 'test2', 'test3', 'test6')); + $this->assertMenuLink('test5', array('has_children' => 1, 'depth' => 2), array('test4'), array('test2', 'test3', 'test6')); + $this->assertMenuLink('test6', array('has_children' => 0, 'depth' => 3), array('test5', 'test4')); + + $this->moveMenuLink('test4', 'test1'); + $this->moveMenuLink('test3', 'test1'); + // After the next 2 moves. + // <tools> + // - test1 + // -- test3 + // -- test4 + // --- test5 + // ---- test2 + // ---- test6 + + $this->assertMenuLink('test1', array('has_children' => 1, 'depth' => 1), array(), array('test4', 'test5', 'test2', 'test3', 'test6')); + $this->assertMenuLink('test2', array('has_children' => 0, 'depth' => 4), array('test5', 'test4', 'test1')); + $this->assertMenuLink('test3', array('has_children' => 0, 'depth' => 2), array('test1')); + $this->assertMenuLink('test4', array('has_children' => 1, 'depth' => 2), array('test1'), array('test2', 'test5', 'test6')); + $this->assertMenuLink('test5', array('has_children' => 1, 'depth' => 3), array('test4', 'test1'), array('test2', 'test6')); + $this->assertMenuLink('test6', array('has_children' => 0, 'depth' => 4), array('test5', 'test4', 'test1')); + + // Deleting a link in the middle should re-attach child links to the parent. + $this->treeStorage->delete('test4'); + // After the delete. + // <tools> + // - test1 + // -- test3 + // -- test5 + // --- test2 + // --- test6 + $this->assertMenuLink('test1', array('has_children' => 1, 'depth' => 1), array(), array('test5', 'test2', 'test3', 'test6')); + $this->assertMenuLink('test2', array('has_children' => 0, 'depth' => 3), array('test5', 'test1')); + $this->assertMenuLink('test3', array('has_children' => 0, 'depth' => 2), array('test1')); + $this->assertFalse($this->treeStorage->load('test4')); + $this->assertMenuLink('test5', array('has_children' => 1, 'depth' => 2), array('test1'), array('test2', 'test6')); + $this->assertMenuLink('test6', array('has_children' => 0, 'depth' => 3), array('test5', 'test1')); + } + + /** + * Tests with hidden child links. + */ + public function testMenuHiddenChildLinks() { + // Add some links with parent on the previous one and test some values. + // <tools> + // - test1 + // -- test2 (hidden) + + $this->addMenuLink('test1', ''); + $this->assertMenuLink('test1', array('has_children' => 0, 'depth' => 1)); + + $this->addMenuLink('test2', 'test1', '<front>', array(), 'tools', array('hidden' => 1)); + // The 1st link does not have any visible children, so has_children is 0. + $this->assertMenuLink('test1', array('has_children' => 0, 'depth' => 1)); + $this->assertMenuLink('test2', array('has_children' => 0, 'depth' => 2, 'hidden' => 1), array('test1')); + + // Add more links with parent on the previous one. + // <footer> + // - footerA + // =============== + // <tools> + // - test1 + // -- test2 (hidden) + // --- test3 + // ---- test4 + // ----- test5 + // ------ test6 + // ------- test7 + // -------- test8 + // --------- test9 + $this->addMenuLink('footerA', '', '<front>', array(), 'footer'); + $visible_children = array(); + for ($i = 3; $i <= $this->treeStorage->maxDepth(); $i++) { + $parent = $i - 1; + $this->addMenuLink("test$i", "test$parent"); + $visible_children[] = "test$i"; + } + // The 1st link does not have any visible children, so has_children is still + // 0. However, it has visible links below it that will be found. + $this->assertMenuLink('test1', array('has_children' => 0, 'depth' => 1), array(), $visible_children); + // This should fail since test9 would end up at greater than max depth. + try { + $this->moveMenuLink('test1', 'footerA'); + $this->fail('Exception was not thrown'); + } + catch (PluginException $e) { + $this->pass($e->getMessage()); + } + // The opposite move should work, and change the has_children flag. + $this->moveMenuLink('footerA', 'test1'); + $visible_children[] = 'footerA'; + $this->assertMenuLink('test1', array('has_children' => 1, 'depth' => 1), array(), $visible_children); + } + + /** + * Tests the loadTreeData method. + */ + public function testLoadTree() { + $this->addMenuLink('test1', ''); + $this->addMenuLink('test2', 'test1'); + $this->addMenuLink('test3', 'test2'); + $this->addMenuLink('test4'); + $this->addMenuLink('test5', 'test4'); + + $data = $this->treeStorage->loadTreeData('tools', new MenuTreeParameters()); + $tree = $data['tree']; + $this->assertEqual(count($tree['test1']['subtree']), 1); + $this->assertEqual(count($tree['test1']['subtree']['test2']['subtree']), 1); + $this->assertEqual(count($tree['test1']['subtree']['test2']['subtree']['test3']['subtree']), 0); + $this->assertEqual(count($tree['test4']['subtree']), 1); + $this->assertEqual(count($tree['test4']['subtree']['test5']['subtree']), 0); + + $parameters = new MenuTreeParameters(); + $parameters->setActiveTrail(array('test4', 'test5')); + $data = $this->treeStorage->loadTreeData('tools', $parameters); + $tree = $data['tree']; + $this->assertEqual(count($tree['test1']['subtree']), 1); + $this->assertFalse($tree['test1']['in_active_trail']); + $this->assertEqual(count($tree['test1']['subtree']['test2']['subtree']), 1); + $this->assertFalse($tree['test1']['subtree']['test2']['in_active_trail']); + $this->assertEqual(count($tree['test1']['subtree']['test2']['subtree']['test3']['subtree']), 0); + $this->assertFalse($tree['test1']['subtree']['test2']['subtree']['test3']['in_active_trail']); + $this->assertEqual(count($tree['test4']['subtree']), 1); + $this->assertTrue($tree['test4']['in_active_trail']); + $this->assertEqual(count($tree['test4']['subtree']['test5']['subtree']), 0); + $this->assertTrue($tree['test4']['subtree']['test5']['in_active_trail']); + } + + /** + * Tests finding the subtree height with content menu links. + */ + public function testSubtreeHeight() { + + $storage = \Drupal::entityManager()->getStorage('menu_link_content'); + + // root + // - child1 + // -- child2 + // --- child3 + // ---- child4 + $root = $storage->create(array('route_name' => 'menu_test.menu_name_test', 'menu_name' => 'menu1', 'bundle' => 'menu_link_content')); + $root->save(); + $child1 = $storage->create(array('route_name' => 'menu_test.menu_name_test', 'menu_name' => 'menu1', 'bundle' => 'menu_link_content', 'parent' => $root->getPluginId())); + $child1->save(); + $child2 = $storage->create(array('route_name' => 'menu_test.menu_name_test', 'menu_name' => 'menu1', 'bundle' => 'menu_link_content', 'parent' => $child1->getPluginId())); + $child2->save(); + $child3 = $storage->create(array('route_name' => 'menu_test.menu_name_test', 'menu_name' => 'menu1', 'bundle' => 'menu_link_content', 'parent' => $child2->getPluginId())); + $child3->save(); + $child4 = $storage->create(array('route_name' => 'menu_test.menu_name_test', 'menu_name' => 'menu1', 'bundle' => 'menu_link_content', 'parent' => $child3->getPluginId())); + $child4->save(); + + $this->assertEqual($this->treeStorage->getSubtreeHeight($root->getPluginId()), 5); + $this->assertEqual($this->treeStorage->getSubtreeHeight($child1->getPluginId()), 4); + $this->assertEqual($this->treeStorage->getSubtreeHeight($child2->getPluginId()), 3); + $this->assertEqual($this->treeStorage->getSubtreeHeight($child3->getPluginId()), 2); + $this->assertEqual($this->treeStorage->getSubtreeHeight($child4->getPluginId()), 1); + } + + /** + * Adds a link with the given ID and supply defaults. + */ + protected function addMenuLink($id, $parent = '', $route_name = 'test', $route_parameters = array(), $menu_name = 'tools', $extra = array()) { + $link = array( + 'id' => $id, + 'menu_name' => $menu_name, + 'route_name' => $route_name, + 'route_parameters' => $route_parameters, + 'title_arguments' => array(), + 'title' => 'test', + 'parent' => $parent, + 'options' => array(), + 'metadata' => array(), + ) + $extra; + $this->treeStorage->save($link); + } + + /** + * Moves the link with the given ID so it's under a new parent. + * + * @param string $id + * The ID of the menu link to move. + * @param string $new_parent + * The ID of the new parent link. + */ + protected function moveMenuLink($id, $new_parent) { + $menu_link = $this->treeStorage->load($id); + $menu_link['parent'] = $new_parent; + $this->treeStorage->save($menu_link); + } + + /** + * Tests that a link's stored representation matches the expected values. + * + * @param string $id + * The ID of the menu link to test + * @param array $expected_properties + * A keyed array of column names and values like has_children and depth. + * @param array $parents + * An ordered array of the IDs of the menu links that are the parents. + * @param array $children + * Array of child IDs that are visible (hidden == 0). + */ + protected function assertMenuLink($id, array $expected_properties, array $parents = array(), array $children = array()) { + $query = $this->connection->select('menu_tree'); + $query->fields('menu_tree'); + $query->condition('id', $id); + foreach ($expected_properties as $field => $value) { + $query->condition($field, $value); + } + $all = $query->execute()->fetchAll(\PDO::FETCH_ASSOC); + $this->assertEqual(count($all), 1, "Found link $id matching all the expected properties"); + $raw = reset($all); + + // Put the current link onto the front. + array_unshift($parents, $raw['id']); + + $query = $this->connection->select('menu_tree'); + $query->fields('menu_tree', array('id', 'mlid')); + $query->condition('id', $parents, 'IN'); + $found_parents = $query->execute()->fetchAllKeyed(0, 1); + + $this->assertEqual(count($parents), count($found_parents), 'Found expected number of parents'); + $this->assertEqual($raw['depth'], count($found_parents), 'Number of parents is the same as the depth'); + + $materialized_path = $this->treeStorage->getRootPathIds($id); + $this->assertEqual(array_values($materialized_path), array_values($parents), 'Parents match the materialized path'); + // Check that the selected mlid values of the parents are in the correct + // column, including the link's own. + for ($i = $raw['depth']; $i >= 1; $i--) { + $parent_id = array_shift($parents); + $this->assertEqual($raw["p$i"], $found_parents[$parent_id], "mlid of parent matches at column p$i"); + } + for ($i = $raw['depth'] + 1; $i <= $this->treeStorage->maxDepth(); $i++) { + $this->assertEqual($raw["p$i"], 0, "parent is 0 at column p$i greater than depth"); + } + if ($parents) { + $this->assertEqual($raw['parent'], end($parents), 'Ensure that the parent field is set properly'); + } + $found_children = array_keys($this->treeStorage->loadAllChildren($id)); + // We need both these checks since the 2nd will pass if there are extra + // IDs loaded in $found_children. + $this->assertEqual(count($children), count($found_children), "Found expected number of children for $id"); + $this->assertEqual(array_intersect($children, $found_children), $children, 'Child IDs match'); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Menu/StaticMenuLinkOverridesTest.php b/core/tests/Drupal/Tests/Core/Menu/StaticMenuLinkOverridesTest.php new file mode 100644 index 000000000000..c436d6f391c1 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Menu/StaticMenuLinkOverridesTest.php @@ -0,0 +1,219 @@ +<?php + +/** + * @file + * Contains \Drupal\Tests\Core\Menu\StaticMenuLinkOverridesTest. + */ + +namespace Drupal\Tests\Core\Menu; + +use Drupal\Core\Menu\StaticMenuLinkOverrides; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\Core\Menu\StaticMenuLinkOverrides + * @group Menu + */ +class StaticMenuLinkOverridesTest extends UnitTestCase { + + /** + * Tests the constructor. + * + * @covers ::__construct + */ + public function testConstruct() { + $config_factory = $this->getConfigFactoryStub(array('menu_link.static.overrides' => array())); + $static_override = new StaticMenuLinkOverrides($config_factory); + + $this->assertAttributeEquals($config_factory, 'configFactory', $static_override); + } + + /** + * Tests the reload method. + * + * @covers ::reload + */ + public function testReload() { + $config_factory = $this->getMock('Drupal\Core\Config\ConfigFactoryInterface'); + $config_factory->expects($this->at(0)) + ->method('reset') + ->with('menu_link.static.overrides'); + + $static_override = new StaticMenuLinkOverrides($config_factory); + + $static_override->reload(); + } + + /** + * Tests the loadOverride method. + * + * @dataProvider providerTestLoadOverride + * + * @covers ::loadOverride + * @covers ::getConfig + */ + public function testLoadOverride($overrides, $id, $expected) { + $config_factory = $this->getConfigFactoryStub(array('menu_link.static.overrides' => array('definitions' => $overrides))); + $static_override = new StaticMenuLinkOverrides($config_factory); + + $this->assertEquals($expected, $static_override->loadOverride($id)); + } + + /** + * Provides test data for testLoadOverride. + */ + public function providerTestLoadOverride() { + $data = array(); + // No specified ID. + $data[] = array(array('test1' => array('parent' => 'test0')), NULL, array()); + // Valid ID. + $data[] = array(array('test1' => array('parent' => 'test0')), 'test1', array('parent' => 'test0')); + // Non existing ID. + $data[] = array(array('test1' => array('parent' => 'test0')), 'test2', array()); + // Ensure that the ID is encoded properly + $data[] = array(array('test1__la___ma' => array('parent' => 'test0')), 'test1.la__ma', array('parent' => 'test0')); + + return $data; + } + + /** + * Tests the loadMultipleOverrides method. + * + * @covers ::loadMultipleOverrides + * @covers ::getConfig + */ + public function testLoadMultipleOverrides() { + $overrides = array(); + $overrides['test1'] = array('parent' => 'test0'); + $overrides['test2'] = array('parent' => 'test1'); + $overrides['test1__la___ma'] = array('parent' => 'test2'); + + $config_factory = $this->getConfigFactoryStub(array('menu_link.static.overrides' => array('definitions' => $overrides))); + $static_override = new StaticMenuLinkOverrides($config_factory); + + $this->assertEquals(array('test1' => array('parent' => 'test0'), 'test1.la__ma' => array('parent' => 'test2')), $static_override->loadMultipleOverrides(array('test1', 'test1.la__ma'))); + } + + /** + * Tests the saveOverride method. + * + * @covers ::saveOverride + * @covers ::loadOverride + * @covers ::getConfig + */ + public function testSaveOverride() { + $config = $this->getMockBuilder('Drupal\Core\Config\Config') + ->disableOriginalConstructor() + ->getMock(); + $config->expects($this->at(0)) + ->method('get') + ->with('definitions') + ->will($this->returnValue(array())); + $config->expects($this->at(1)) + ->method('get') + ->with('definitions') + ->will($this->returnValue(array())); + + $definition_save_1 = array('definitions' => array('test1' => array('parent' => 'test0'))); + $definitions_save_2 = array( + 'definitions' => array( + 'test1' => array('parent' => 'test0'), + 'test1__la___ma' => array('parent' => 'test1') + ) + ); + $config->expects($this->at(2)) + ->method('set') + ->with('definitions', $definition_save_1['definitions']) + ->will($this->returnSelf()); + $config->expects($this->at(3)) + ->method('save'); + $config->expects($this->at(4)) + ->method('get') + ->with('definitions') + ->will($this->returnValue($definition_save_1['definitions'])); + $config->expects($this->at(5)) + ->method('get') + ->with('definitions') + ->will($this->returnValue($definition_save_1['definitions'])); + $config->expects($this->at(6)) + ->method('set') + ->with('definitions', $definitions_save_2['definitions']) + ->will($this->returnSelf()); + $config->expects($this->at(7)) + ->method('save'); + + $config_factory = $this->getMock('Drupal\Core\Config\ConfigFactoryInterface'); + $config_factory->expects($this->once()) + ->method('get') + ->will($this->returnValue($config)); + + $static_override = new StaticMenuLinkOverrides($config_factory); + + $static_override->saveOverride('test1', array('parent' => 'test0')); + $static_override->saveOverride('test1.la__ma', array('parent' => 'test1')); + } + + /** + * Tests the deleteOverride and deleteOverrides method. + * + * @param array|string $ids + * Either a single ID or multiple ones as array. + * @param array $old_definitions + * The definitions before the deleting + * @param array $new_definitions + * The definitions after the deleting. + * + * @dataProvider providerTestDeleteOverrides + */ + public function testDeleteOverrides($ids, array $old_definitions, array $new_definitions) { + $config = $this->getMockBuilder('Drupal\Core\Config\Config') + ->disableOriginalConstructor() + ->getMock(); + $config->expects($this->at(0)) + ->method('get') + ->with('definitions') + ->will($this->returnValue($old_definitions)); + + // Only save if the definitions changes. + if ($old_definitions != $new_definitions) { + $config->expects($this->at(1)) + ->method('set') + ->with('definitions', $new_definitions) + ->will($this->returnSelf()); + $config->expects($this->at(2)) + ->method('save'); + } + + $config_factory = $this->getMock('Drupal\Core\Config\ConfigFactoryInterface'); + $config_factory->expects($this->once()) + ->method('get') + ->will($this->returnValue($config)); + + $static_override = new StaticMenuLinkOverrides($config_factory); + + if (is_array($ids)) { + $static_override->deleteMultipleOverrides($ids); + } + else { + $static_override->deleteOverride($ids); + } + } + + /** + * Provides test data for testDeleteOverrides. + */ + public function providerTestDeleteOverrides() { + $data = array(); + // Delete a non existing ID. + $data[] = array('test0', array(), array()); + // Delete an existing ID. + $data[] = array('test1', array('test1' => array('parent' => 'test0')), array()); + // Delete an existing ID with a special ID. + $data[] = array('test1.la__ma', array('test1__la___ma' => array('parent' => 'test0')), array()); + // Delete multiple IDs. + $data[] = array(array('test1.la__ma', 'test1'), array('test1' => array('parent' => 'test0'), 'test1__la___ma' => array('parent' => 'test0')), array()); + + return $data; + } + +} -- GitLab