diff --git a/core/lib/Drupal/Core/Menu/Form/MenuLinkDefaultForm.php b/core/lib/Drupal/Core/Menu/Form/MenuLinkDefaultForm.php new file mode 100644 index 0000000..16f0b43 --- /dev/null +++ b/core/lib/Drupal/Core/Menu/Form/MenuLinkDefaultForm.php @@ -0,0 +1,162 @@ +menuTree = $menu_tree; + $this->stringTranslation = $string_translation; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('menu.link_tree'), + $container->get('string_translation') + ); + } + + /** + * Injects the menu link plugin. + * + * @param MenuLinkInterface $menu_link + * A menu link plugin instance. + */ + public function setMenuLinkInstance(MenuLinkInterface $menu_link) { + $this->menuLink = $menu_link; + } + + /** + * {@inheritdoc} + */ + public function buildEditForm(array &$form, array &$form_state) { + $form['#title'] = $this->t('Edit menu link %title', array('%title' => $this->menuLink->getTitle())); + + $form['info'] = array( + '#type' => 'item', + '#title' => $this->t('This is a module-provided link. The label and path cannot be changed.'), + ); + $form['path'] = array( + 'link' => $this->menuLink->build(), + '#type' => 'item', + '#title' => $this->t('Link'), + ); + + $form['enabled'] = array( + '#type' => 'checkbox', + '#title' => $this->t('Enable'), + '#description' => $this->t('Menu links that are not enabled will not be listed in any menu.'), + '#default_value' => !$this->menuLink->isHidden(), + ); + $form['expanded'] = array( + '#type' => 'checkbox', + '#title' => t('Show as expanded'), + '#description' => $this->t('If selected and this menu link has children, the menu will always appear expanded.'), + '#default_value' => $this->menuLink->isExpanded(), + ); + $delta = max(abs($this->menuLink->getWeight()), 50); + $form['weight'] = array( + '#type' => 'weight', + '#delta' => $delta, + '#default_value' => $this->menuLink->getWeight(), + '#title' => $this->t('Weight'), + '#description' => $this->t('Link weight among links in the same menu at the same depth.'), + ); + + $options = $this->menuTree->getParentSelectOptions($this->menuLink->getPluginId()); + $menu_parent = $this->menuLink->getMenuName() . ':' . $this->menuLink->getParent(); + + if (!isset($options[$menu_parent])) { + // Put it at the top level in the current menu. + $menu_parent = $this->menuLink->getMenuName() . ':'; + } + $form['menu_parent'] = array( + '#type' => 'select', + '#title' => $this->t('Parent link'), + '#options' => $options, + '#default_value' => $menu_parent, + '#description' => $this->t('The maximum depth for a link and all its children is fixed at !maxdepth. Some menu links may not be available as parents if selecting them would exceed this limit.', array('!maxdepth' => $this->menuTree->maxDepth())), + '#attributes' => array('class' => array('menu-title-select')), + ); + + return $form; + } + + /** + * {@inheritdoc} + */ + public function extractFormValues(array &$form, array &$form_state) { + $new_definition = array(); + $new_definition['hidden'] = $form_state['values']['enabled'] ? 0 : 1; + $new_definition['weight'] = (int) $form_state['values']['weight']; + $new_definition['expanded'] = $form_state['values']['expanded'] ? 1 : 0; + list($menu_name, $parent) = explode(':', $form_state['values']['menu_parent'], 2); + if (!empty($menu_name)) { + $new_definition['menu_name'] = $menu_name; + } + if (isset($parent)) { + $new_definition['parent'] = $parent; + } + return $new_definition; + } + + /** + * {@inheritdoc} + */ + public function validateEditForm(array &$form, array &$form_state) { + } + + /** + * {@inheritdoc} + */ + public function submitEditForm(array &$form, array &$form_state) { + $new_definition = $this->extractFormValues($form, $form_state); + + return $this->menuTree->updateLink($this->menuLink->getPluginId(), $new_definition); + } + +} diff --git a/core/lib/Drupal/Core/Menu/Form/MenuLinkFormInterface.php b/core/lib/Drupal/Core/Menu/Form/MenuLinkFormInterface.php new file mode 100644 index 0000000..27d3cb0 --- /dev/null +++ b/core/lib/Drupal/Core/Menu/Form/MenuLinkFormInterface.php @@ -0,0 +1,77 @@ +getOptions(); + $description = $this->getDescription(); + if ($title_attribute && $description) { + $options['attributes']['title'] = $description; + } + $build = array( + '#type' => 'link', + '#route_name' => $this->pluginDefinition['route_name'], + '#route_parameters' => $this->pluginDefinition['route_parameters'], + '#title' => $this->getTitle(), + '#options' => $options, + ); + return $build; + } + + /** + * Returns the weight of the menu link. + * + * @return int + * The weight of the menu link, 0 by default. + */ + 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 isDiscovered() { + return (bool) $this->pluginDefinition['discovered']; + } + + /** + * {@inheritdoc} + */ + public function isResetable() { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function isTranslatable() { + return FALSE; + } + + /** + * {@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 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(sprintf("Menu link plugin with ID %s does not support deletion", $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 0000000..170ea19 --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuLinkDefault.php @@ -0,0 +1,96 @@ + 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 was discovered and has an override. + return $this->pluginDefinition['discovered'] && $this->staticOverride->loadOverride($this->getPluginId()); + } + + /** + * {@inheritdoc} + */ + public function updateLink(array $new_definition_values, $persist) { + $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; + } + + /** + * {@inheritdoc} + */ + public function persistLinkDeletion() { + // @todo - what should this do by default? + } + +} diff --git a/core/lib/Drupal/Core/Menu/MenuLinkInterface.php b/core/lib/Drupal/Core/Menu/MenuLinkInterface.php new file mode 100644 index 0000000..2e42d52 --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuLinkInterface.php @@ -0,0 +1,205 @@ + '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. + 'title' => '', + 'title_arguments' => array(), + '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, + // Flag for whether this plugin was discovered. Should be set to 0 or NULL + // for definitions that are added via a direct save. + 'discovered' => 0, + '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 language manager. + * + * @var \Drupal\Core\Language\LanguageManagerInterface + */ + protected $languageManager; + + /** + * Cache backend instance for the extracted tree data. + * + * @var \Drupal\Core\Cache\CacheBackendInterface + */ + protected $treeCacheBackend; + + /** + * 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 request stack. + * + * @var \Symfony\Component\HttpFoundation\RequestStack + */ + protected $requestStack; + + /** + * The plugin instances. + * + * @var array + */ + protected $instances = array(); + + /** + * The statically cached definitions. + * + * @var array + */ + protected $definitions = array(); + + /** + * The route provider to load routes by name. + * + * @var \Drupal\Core\Routing\RouteProviderInterface + */ + protected $routeProvider; + + /** + * The access manager. + * + * @var \Drupal\Core\Access\AccessManager + */ + protected $accessManager; + + /** + * The current user. + * + * @var \Drupal\Core\Session\AccountInterface + */ + protected $account; + + /** + * The entity manager. + * + * @var \Drupal\Core\Entity\EntityManagerInterface + */ + protected $entityManager; + + /** + * The module handler. + * + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ + protected $moduleHandler; + + /** + * The configuration factory. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $configFactory; + + /** + * Stores the menu tree used by the doBuildTree method, keyed by a cache ID. + * + * This cache ID is built using the $menu_name, the current language and + * some parameters passed into an entity query. + */ + protected $menuTree; + + /** + * Stores the menu tree data on the current page keyed by a cache ID. + * + * This contains less information than a tree built with buildAllData. + * + * @var array + */ + protected $menuPageTrees; + + /** + * Stores the preferred menu link keyed by route_name + parameters. + * + * @var array + */ + protected $preferredLinks = array(); + + /** + * Stores the active menu names. + * + * @var array + */ + protected $activeMenus = array(); + + /** + * Stores the parameters for buildAllData keyed by cached ID. + * + * @var array + */ + protected $buildAllDataParameters = array(); + + /** + * Constructs a \Drupal\Core\Menu\MenuLinkTree object. + * + * @param \Drupal\Core\Menu\MenuTreeStorageInterface $tree_storage + * The menu link tree storage. + * @param \Drupal\Core\Menu\StaticMenuLinkOverridesInterface $overrides + * Service providing overrides for static links + * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack + * A request object for the controller resolver and finding the preferred + * menu and link for the current page. + * @param \Drupal\Core\Routing\RouteProviderInterface $route_provider + * The route provider to load routes by name. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler. + * @param \Drupal\Core\Cache\CacheBackendInterface $tree_cache_backend + * Cache backend instance for the extracted tree data. + * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager + * The language manager. + * @param \Drupal\Core\Access\AccessManager $access_manager + * The access manager. + * @param \Drupal\Core\Session\AccountInterface $account + * The current user. + * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager + * The entity manager. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * Configuration factory. + */ + public function __construct(MenuTreeStorageInterface $tree_storage, StaticMenuLinkOverridesInterface $overrides, RequestStack $request_stack, RouteProviderInterface $route_provider, ModuleHandlerInterface $module_handler, CacheBackendInterface $tree_cache_backend, LanguageManagerInterface $language_manager, AccessManager $access_manager, AccountInterface $account, EntityManagerInterface $entity_manager, ConfigFactoryInterface $config_factory) { + $this->treeStorage = $tree_storage; + $this->overrides = $overrides; + $this->factory = new ContainerFactory($this); + $this->requestStack = $request_stack; + $this->routeProvider = $route_provider; + $this->accessManager = $access_manager; + $this->account = $account; + $this->moduleHandler = $module_handler; + $this->treeCacheBackend = $tree_cache_backend; + $this->languageManager = $language_manager; + $this->entityManager = $entity_manager; + $this->configFactory = $config_factory; + } + + /** + * Performs extra processing on plugin definitions. + * + * By default we add defaults for the type to the definition. If a type has + * additional processing logic they can do that by replacing or extending the + * method. + */ + protected function processDefinition(&$definition, $plugin_id) { + $definition = NestedArray::mergeDeep($this->defaults, $definition); + $definition['parent'] = (string) $definition['parent']; + $definition['id'] = $plugin_id; + } + + /** + * Instanciates the discovery. + */ + 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', $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. + foreach ($definitions as $plugin_id => $plugin_definition) { + if (!empty($plugin_definition['provider']) && !$this->moduleHandler->moduleExists($plugin_definition['provider'])) { + unset($definitions[$plugin_id]); + } + else { + // Any link found here is flagged as discovered, so it can be purged + // if it does exit in the future. + $definitions[$plugin_id]['discovered'] = 1; + } + } + return $definitions; + } + + /** + * {@inheritdoc} + */ + public function rebuild() { + // Fetch the list of existing menus, in case some are not longer populated + // after the rebuild. + $before_menus = $this->treeStorage->getMenuNames(); + $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); + $this->treeCacheBackend->deleteAll(); + $affected_menus = $this->treeStorage->getMenuNames() + $before_menus; + Cache::invalidateTags(array('menu' => $affected_menus)); + } + + /** + * {@inheritdoc} + */ + public function getDefinition($plugin_id, $exception_on_invalid = TRUE) { + // When building tress, we will usually have the definitions already loaded. + // This makes the call to $this->factory->createInstance() faster. + // @todo Normal plugin managers throw an exception in case it doesn't exist. + if (!isset($this->definitions[$plugin_id])) { + $this->definitions[$plugin_id] = $this->treeStorage->load($plugin_id); + } + if (empty($this->definitions[$plugin_id]) && $exception_on_invalid) { + throw new PluginNotFoundException("$plugin_id could not be found."); + } + return $this->definitions[$plugin_id]; + } + + /** + * {@inheritdoc} + */ + public function hasDefinition($plugin_id) { + return (bool) $this->getDefinition($plugin_id, FALSE); + } + + /** + * {@inheritdoc} + * + * @return \Drupal\Core\Menu\MenuLinkInterface + * A menu link instance. + */ + 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']); + } + } + + /** + * Returns an array containing all links for a menu. + * + * @param string $menu_name + * The name of the menu whose links should be returned. + * + * @return \Drupal\Core\Menu\MenuLinkInterface[] + * An array of menu link plugin instances keyed by ID. + */ + public function loadLinks($menu_name) { + $instances = array(); + $loaded = $this->treeStorage->loadByProperties(array('menu_name' => $menu_name)); + foreach ($loaded as $plugin_id => $definition) { + // Setting the definition here means it will be used by getDefinition() + // which is called by createInstance() from the factory. + $this->definitions[$plugin_id] = $definition; + $instances[$plugin_id] = $this->createInstance($plugin_id); + } + return $instances; + } + + /** + * Deletes all links for a menu. + * + * @todo - this should really only be called as part of the flow of + * deleting a menu entity, so maybe we should load it and make sure it's + * not locked? + * + * @param string $menu_name + * The name of the menu whose links will be deleted. + */ + public function deleteLinksInMenu($menu_name) { + $affected_menus = array($menu_name => $menu_name); + foreach ($this->treeStorage->loadByProperties(array('menu_name' => $menu_name)) as $plugin_id => $definition) { + // Setting the definition here means it will be used by getDefinition() + // which is called by createInstance() from the factory. + $this->definitions[$plugin_id] = $definition; + $instance = $this->createInstance($plugin_id); + if ($instance->isResetable()) { + $new_instance = $this->resetInstance($instance); + $affected_menus[$new_instance->getMenuName()] = $new_instance->getMenuName(); + } + elseif ($instance->isDeletable()) { + $this->deleteInstance($instance, TRUE); + } + } + Cache::invalidateTags(array('menu' => $affected_menus)); + } + + /** + * Helper function to delete a specific instance. + */ + protected function deleteInstance(MenuLinkInterface $instance, $persist) { + $id = $instance->getPluginId(); + if ($instance->isDeletable()) { + if ($persist) { + $instance->deleteLink(); + } + } + else { + throw new PluginException(sprintf("Menu link plugin with ID %s does not support deletion", $id)); + } + $this->treeStorage->delete($id); + } + + /** + * {@inheritdoc} + */ + public function deleteLink($id, $persist = TRUE) { + $definition = $this->treeStorage->load($id); + // It's possible the definition has already been deleted, or doesn't exist. + if ($definition) { + // Setting the definition here means it will be used by getDefinition() + // which is called by createInstance() from the factory. + $this->definitions[$id] = $definition; + $instance = $this->createInstance($id); + $this->deleteInstance($instance, $persist); + // Many children may have moved. + $this->resetDefinitions(); + Cache::invalidateTags(array('menu' => array($definition['menu_name']))); + } + $this->resetDefinition($id); + } + + /** + * {@inheritdoc} + */ + public function countMenuLinks($menu_name = NULL) { + return $this->treeStorage->countMenuLinks($menu_name); + } + + /** + * {@inheritdoc} + */ + public function loadLinksByRoute($route_name, array $route_parameters = array(), $include_hidden = FALSE) { + $instances = array(); + $loaded = $this->treeStorage->loadByRoute($route_name, $route_parameters, $include_hidden); + foreach ($loaded as $plugin_id => $definition) { + // Setting the definition here means it will be used by getDefinition() + // which is called by createInstance() from the factory. + $this->definitions[$plugin_id] = $definition; + $instances[$plugin_id] = $this->createInstance($plugin_id); + } + return $instances; + } + + /** + * {@inheritdoc} + */ + public function maxDepth() { + return $this->treeStorage->maxDepth(); + } + + /** + * {@inheritdoc} + */ + public function buildRenderTree($tree) { + $build = array(); + + foreach ($tree as $data) { + $class = array(); + /** @var \Drupal\Core\Menu\MenuLinkInterface $link */ + $link = $data['link']; + // Generally we only deal with visible links, but just in case. + if ($link->isHidden()) { + continue; + } + // Set a class for the
  • -tag. Only set 'expanded' class if the link + // also has visible children within the current tree. + if ($data['has_children'] && $data['below']) { + $class[] = 'expanded'; + } + elseif ($data['has_children']) { + $class[] = 'collapsed'; + } + else { + $class[] = 'leaf'; + } + // Set a class if the link is in the active trail. + if ($data['in_active_trail']) { + $class[] = 'active-trail'; + } + + // Allow menu-specific theme overrides. + $element['#theme'] = 'menu_link__' . strtr($link->getMenuName(), '-', '_'); + $element['#attributes']['class'] = $class; + $element['#title'] = $link->getTitle(); + $element['#url'] = $link->getUrlObject(); + $element['#below'] = $data['below'] ? $this->buildRenderTree($data['below']) : array(); + $element['#original_link'] = $link; + // Index using the link's unique ID. + $build[$link->getPluginId()] = $element; + } + if ($build) { + // Make sure drupal_render() does not re-order the links. + $build['#sorted'] = TRUE; + // Get the menu name from the last link. + $menu_name = $link->getMenuName(); + // Add the theme wrapper for outer markup. + // Allow menu-specific theme overrides. + $build['#theme_wrappers'][] = 'menu_tree__' . strtr($menu_name, '-', '_'); + // Set cache tag. + $build['#cache']['tags']['menu'][$menu_name] = $menu_name; + } + + return $build; + } + + /** + * {@inheritdoc} + */ + public function getActiveTrailIds($menu_name) { + // Parent ids; used both as key and value to ensure uniqueness. + // We always want all the top-level links with parent == ''. + $active_trail = array('' => ''); + + $request = $this->requestStack->getCurrentRequest(); + + if ($route_name = $request->attributes->get(RouteObjectInterface::ROUTE_NAME)) { + $route_parameters = $request->attributes->get('_raw_variables')->all(); + $page_is_403 = $request->attributes->get('_exception_statuscode') == 403; + // Find a menu link corresponding to the current path. If + // $active_path is NULL, let $this->menuLinkGetPreferred() determine the + // path. + if (!$page_is_403) { + $active_link = $this->menuLinkGetPreferred($route_name, $route_parameters, $menu_name); + if ($active_link && $active_link->getMenuName() == $menu_name) { + $active_trail += $this->treeStorage->getRootPathIds($active_link->getPluginId()); + } + } + } + return $active_trail; + } + + /** + * {@inheritdoc} + */ + public function menuLinkGetPreferred($route_name = NULL, array $route_parameters = array(), $selected_menu = NULL) { + if (!isset($route_name)) { + $request = $this->requestStack->getCurrentRequest(); + + $route_name = $request->attributes->get(RouteObjectInterface::ROUTE_NAME); + $route_parameters = $request->attributes->get('_raw_variables')->all(); + } + + $access = $this->accessManager->checkNamedRoute($route_name, $route_parameters, $this->account); + if (!$access) { + return NULL; + } + asort($route_parameters); + $route_key = $route_name . serialize($route_parameters); + + if (empty($selected_menu)) { + // Use an illegal menu name as the key for the preferred menu link. + $selected_menu = '%'; + } + + if (!isset($this->preferredLinks[$route_key])) { + // Retrieve a list of menu names, ordered by preference. + $menu_names = $this->getActiveMenuNames(); + // Put the selected menu at the front of the list. + array_unshift($menu_names, $selected_menu); + // If this menu name is not fond later, we want to just get NULL. + $this->preferredLinks[$route_key][$selected_menu] = NULL; + + // Only load non-hidden links. + $definitions = $this->treeStorage->loadByRoute($route_name, $route_parameters); + // Sort candidates by menu name. + $candidates = array(); + foreach ($definitions as $candidate) { + $candidates[$candidate['menu_name']] = $candidate; + $menu_names[] = $candidate['menu_name']; + } + foreach ($menu_names as $menu_name) { + if (isset($candidates[$menu_name]) && !isset($this->preferredLinks[$route_key][$menu_name])) { + $candidate = $candidates[$menu_name]; + $this->definitions[$candidate['id']] = $candidate; + $instance = $this->createInstance($candidate['id']); + $this->preferredLinks[$route_key][$menu_name] = $instance; + if (!isset($this->preferredLinks[$route_key]['%'])) { + $this->preferredLinks[$route_key]['%'] = $instance; + } + } + } + + } + return isset($this->preferredLinks[$route_key][$selected_menu]) ? $this->preferredLinks[$route_key][$selected_menu] : NULL; + } + + /** + * {@inheritdoc} + */ + public function getActiveMenuNames() { + return $this->activeMenus; + } + + /** + * {@inheritdoc} + */ + public function setActiveMenuNames(array $menu_names) { + + if (isset($menu_names) && is_array($menu_names)) { + $this->activeMenus = $menu_names; + } + elseif (!isset($this->activeMenus)) { + $config = $this->configFactory->get('system.menu'); + $this->activeMenus = $config->get('active_menus_default') ?: array_keys($this->listSystemMenus()); + } + } + + /** + * Returns an array containing the names of system-defined (default) menus. + */ + protected function listSystemMenus() { + // For simplicity and performance, this is simply a hard-coded list copied + // from menu_list_system_menus() which is simply the list of all Menu config + // entities that are shipped with system module. + return array( + 'tools' => 'Tools', + 'admin' => 'Administration', + 'account' => 'User account menu', + 'main' => 'Main navigation', + 'footer' => 'Footer menu', + ); + } + + /** + * {@inheritdoc} + */ + public function buildPageData($menu_name, $max_depth = NULL, $only_active_trail = FALSE) { + $language_interface = $this->languageManager->getCurrentLanguage(); + + // Load the request corresponding to the current page. + $request = $this->requestStack->getCurrentRequest(); + $page_is_403 = FALSE; + $system_path = NULL; + if ($route_name = $request->attributes->get(RouteObjectInterface::ROUTE_NAME)) { + $system_path = $request->attributes->get('_system_path'); + $page_is_403 = $request->attributes->get('_exception_statuscode') == 403; + } + + if (isset($max_depth)) { + $max_depth = min($max_depth, $this->treeStorage->maxDepth()); + } + // Generate a cache ID (cid) specific for this page. + $cid = 'links:' . $menu_name . ':page:' . $system_path . ':' . $language_interface->id . ':' . $page_is_403 . ':' . (int) $max_depth; + // If we are asked for the active trail only, and $menu_name has not been + // built and cached for this page yet, then this likely means that it + // won't be built anymore, as this function is invoked from + // template_preprocess_page(). So in order to not build a giant menu tree + // that needs to be checked for access on all levels, we simply check + // whether we have the menu already in cache, or otherwise, build a + // minimum tree containing the active trail only. + // @see menu_set_active_trail() + if (!isset($this->menuPageTrees[$cid]) && $only_active_trail) { + $cid .= ':trail'; + } + + // @todo Decide whether it makes sense to static cache page menu trees. + if (!isset($this->menuPageTrees[$cid])) { + // If the static variable doesn't have the data, check {cache_menu}. + $cache = $this->treeCacheBackend->get($cid); + if ($cache && isset($cache->data)) { + // If the cache entry exists, it contains the parameters for + // menu_build_tree(). + $tree_parameters = $cache->data; + } + else { + $tree_parameters = $this->doBuildPageDataTreeParameters($menu_name, $max_depth, $only_active_trail, $page_is_403); + + // Cache the tree building parameters using the page-specific cid. + $this->treeCacheBackend->set($cid, $tree_parameters, Cache::PERMANENT, array('menu' => $menu_name)); + } + + // Build the tree using the parameters; the resulting tree will be cached + // by $this->buildTree()). + $this->menuPageTrees[$cid] = $this->buildTree($menu_name, $tree_parameters); + } + return $this->menuPageTrees[$cid]; + } + + /** + * Determines the required tree parameters used for the page menu tree. + * + * This method takes into account the active trail of the current page. + * + * @param string $menu_name + * The menu name. + * @param int $max_depth + * The maximum allowed depth of menus. + * @param bool $only_active_trail + * If TRUE, just load level 0 plus the active trail, otherwise load the full + * menu tree. + * @param bool $page_is_403 + * Is the current request happening on a 403 subrequest. + * + * @return array + * An array of tree parameters. + */ + protected function doBuildPageDataTreeParameters($menu_name, $max_depth, $only_active_trail, $page_is_403) { + $tree_parameters = array( + 'min_depth' => 1, + 'max_depth' => $max_depth, + ); + + // If this page is accessible to the current user, build the tree + // parameters accordingly. + if (!$page_is_403) { + $active_trail = $this->getActiveTrailIds($menu_name); + // The active trail contains more than only array(0 => 0). + if (count($active_trail) > 1) { + // If we are asked to build links for the active trail only,skip + // the entire 'expanded' handling. + if ($only_active_trail) { + $tree_parameters['only_active_trail'] = TRUE; + } + } + $parents = $active_trail; + + if (!$only_active_trail) { + // Collect all the links set to be expanded, and then add all of + // their children to the list as well. + $parents = $this->treeStorage->getExpanded($menu_name, $parents); + } + } + else { + // If access is denied, we only show top-level links in menus. + $active_trail = array('' => ''); + $parents = $active_trail; + } + $tree_parameters['expanded'] = $parents; + $tree_parameters['active_trail'] = $active_trail; + return $tree_parameters; + } + + /** + * {@inheritdoc} + * + * @todo should this accept a menu link instance or just the ID? + */ + public function buildAllData($menu_name, $id = NULL, $max_depth = NULL) { + $language_interface = $this->languageManager->getCurrentLanguage(); + + // Use ID as a flag for whether the data being loaded is for the whole + // tree. + $id = isset($id) ? $id : '%'; + // Generate a cache ID (cid) specific for this $menu_name, $link, $language, + // and depth. + $cid = 'links:' . $menu_name . ':all:' . $id . ':' . $language_interface->id . ':' . (int) $max_depth; + if (!isset($this->buildAllDataParameters[$cid])) { + $tree_parameters = array( + 'min_depth' => 1, + 'max_depth' => $max_depth, + ); + if ($id != '%') { + // The tree is for a single item, so we need to match the values in + // of all the IDs on the path to root. + $tree_parameters['active_trail'] = $this->treeStorage->getRootPathIds($id); + $tree_parameters['expanded'] = $tree_parameters['active_trail']; + // Include top-level links. + $tree_parameters['expanded'][''] = ''; + } + $this->buildAllDataParameters[$cid] = $tree_parameters; + } + // Build the tree using the parameters; the resulting tree will be cached + // by buildTree(). + return $this->buildTree($menu_name, $this->buildAllDataParameters[$cid]); + } + + /** + * {@inheritdoc} + */ + public function getChildLinks($id, $max_relative_depth = NULL) { + $links = array(); + $definitions = $this->treeStorage->loadAllChildLinks($id, $max_relative_depth); + foreach ($definitions as $id => $definition) { + $instance = $this->menuLinkCheckAccess($definition); + if ($instance) { + $links[$id] = $instance; + } + } + return $links; + } + + /** + * {@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 buildSubtree($id, $max_relative_depth = NULL) { + $subtree = $this->treeStorage->loadSubtree($id, $max_relative_depth); + if ($subtree) { + // Check access and instantiate. @todo rename these methods. + $instance = $this->menuLinkCheckAccess($subtree['definition']); + if ($instance) { + $subtree['link'] = $instance; + $route_names = $this->collectRoutes($subtree['below']); + // Pre-load all the route objects in the tree for access checks. + if ($route_names) { + $this->routeProvider->getRoutesByNames($route_names); + } + $this->treeCheckAccess($subtree['below']); + return $subtree; + } + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public function buildTree($menu_name, array $parameters = array()) { + $language_interface = $this->languageManager->getCurrentLanguage(); + + // Build the cache id; sort parents to prevent duplicate storage and remove + // default parameter values. + asort($parameters); + if (isset($parameters['expanded'])) { + sort($parameters['expanded']); + } + $tree_cid = 'links:' . $menu_name . ':tree-data:' . $language_interface->id . ':' . hash('sha256', serialize($parameters)); + + // If we do not have this tree in the static cache, check cache.menu. + if (!isset($this->menuTree[$tree_cid])) { + $cache = $this->treeCacheBackend->get($tree_cid); + if ($cache && isset($cache->data)) { + $this->menuTree[$tree_cid] = $cache->data; + } + } + + if (!isset($this->menuTree[$tree_cid])) { + // Rebuild the links which are stored. + $data['tree'] = $this->treeStorage->loadTree($menu_name, $parameters); + $data['route_names'] = $this->collectRoutes($data['tree']); + // Cache the data, if it is not already in the cache. + $this->treeCacheBackend->set($tree_cid, $data, Cache::PERMANENT, array('menu' => $menu_name)); + $this->menuTree[$tree_cid] = $data; + } + else { + $data = $this->menuTree[$tree_cid]; + } + + // Pre-load all the route objects in the tree for access checks. + if ($data['route_names']) { + $this->routeProvider->getRoutesByNames($data['route_names']); + } + $tree = $data['tree']; + $this->treeCheckAccess($tree); + return $tree; + } + + /** + * Traverses the menu tree and collects all the route names. + * + * @param array $tree + * The menu tree you wish to operate on. + * + * @return array + * Array of route names, with all values being unique. + */ + protected function collectRoutes($tree) { + return array_values($this->doCollectRoutes($tree)); + } + + /** + * Recursive helper function to collect all the route names. + */ + protected function doCollectRoutes($tree) { + $route_names = array(); + foreach ($tree as $key => $v) { + $definition = $tree[$key]['definition']; + if (!empty($definition['route_name'])) { + $route_names[$definition['route_name']] = $definition['route_name']; + } + if ($tree[$key]['below']) { + $route_names += $this->doCollectRoutes($tree[$key]['below']); + } + } + return $route_names; + } + + /** + * Sorts the menu tree and recursively checks access for each item. + * + * @param array $tree + * The menu tree you wish to operate on. + */ + protected function treeCheckAccess(&$tree) { + $this->doTreeCheckAccess($tree); + $this->sortTree($tree); + } + + /** + * Helper function that recursively checks access for each item. + */ + protected function doTreeCheckAccess(&$tree) { + foreach ($tree as $key => $v) { + $definition = $tree[$key]['definition']; + // Setting the definition here means it will be used by getDefinition() + // which is called by createInstance() from the factory. + $this->definitions[$definition['id']] = $definition; + $instance = $this->menuLinkCheckAccess($definition); + if ($instance) { + $tree[$key]['link'] = $instance; + if ($tree[$key]['below']) { + $this->doTreeCheckAccess($tree[$key]['below']); + } + unset($tree[$key]['definition']); + } + else { + unset($tree[$key]); + } + } + } + + /** + * Sorts the menu tree and recursively using the weight and title. + * + * @param array $tree + * The menu tree you wish to operate on. + */ + protected function sortTree(&$tree) { + $new_tree = array(); + foreach ($tree as $key => $v) { + if ($tree[$key]['below']) { + $this->sortTree($tree[$key]['below']); + } + $instance = $tree[$key]['link']; + // The weights are made a uniform 5 digits by adding 50000 as an offset. + // After $this->menuLinkCheckAccess(), $instance->getTitle() has the + // localized or translated title. Adding the plugin id to the end of the + // index insures that it is unique. + $new_tree[(50000 + $instance->getWeight()) . ' ' . $instance->getTitle() . ' ' . $instance->getPluginId()] = $tree[$key]; + } + // Sort siblings in the tree based on the weights and localized titles. + ksort($new_tree); + $tree = $new_tree; + } + + /** + * Check access for the item and create an instance if it is accessible. + * + * @param array $definition + * The menu link definition. + * + * @return \Drupal\Core\Menu\MenuLinkInterface|NULL + * A plugin instance or NULL if the current user can not access its route. + */ + protected function menuLinkCheckAccess(array $definition) { + // 'url' should only be populated for external links. + if (!empty($definition['url']) && empty($definition['route_name'])) { + $access = TRUE; + } + else { + $access = $this->accessManager->checkNamedRoute($definition['route_name'], $definition['route_parameters'], $this->account); + } + // For performance, don't instantiate a link the user can't access. + if ($access) { + return $this->createInstance($definition['id']); + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public function createLink($id, array $definition) { + // Add defaults and other stuff, so there is no requirement to specify + // everything. + $this->processDefinition($definition, $id); + + // Store the new link in the tree and invalidate some caches. + $affected_menus = $this->treeStorage->save($definition); + Cache::invalidateTags(array('menu' => $affected_menus)); + $this->resetDefinition($id); + return $this->createInstance($id); + } + + /** + * {@inheritdoc} + */ + public function updateLink($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); + $affected_menus = $this->treeStorage->save($changed_definition); + $this->moduleHandler->invokeAll('menu_link_update', array($changed_definition)); + $this->resetDefinitions(); + Cache::invalidateTags(array('menu' => $affected_menus)); + } + return $instance; + } + + /** + * {@inheritdoc} + */ + public function getPluginForm(MenuLinkInterface $menu_link) { + $class_name = $menu_link->getFormClass(); + /** @var \Drupal\Core\Menu\Form\MenuLinkFormInterface $form */ + if (in_array('Drupal\Core\DependencyInjection\ContainerInjectionInterface', class_implements($class_name))) { + $form = $class_name::create(\Drupal::getContainer()); + } + else { + $form = new $class_name(); + } + $form->setMenuLinkInstance($menu_link); + return $form; + } + + /** + * {@inheritdoc} + */ + public function getParentSelectOptions($id = '', array $menus = array()) { + // @todo: Core allows you to replace the select element ... this is a sign + // that we might want to write a form element as well, which can be swapped. + if (empty($menus)) { + $menus = $this->getMenuOptions(); + } + + $options = array(); + $depth_limit = $this->getParentDepthLimit($id); + foreach ($menus as $menu_name => $menu_title) { + $options[$menu_name . ':'] = '<' . $menu_title . '>'; + + $tree = $this->buildAllData($menu_name, NULL, $depth_limit); + $this->parentSelectOptionsTreeWalk($tree, $menu_name, '--', $options, $id, $depth_limit); + } + return $options; + } + + /** + * {@inheritdoc} + */ + public function getParentDepthLimit($id) { + if ($id) { + $limit = $this->treeStorage->maxDepth() - $this->treeStorage->getSubtreeHeight($id); + } + else { + $limit = $this->treeStorage->maxDepth() - 1; + } + return $limit; + } + + /** + * Iterates over all items in the tree to prepare the parents select options. + * + * @param array $tree + * The menu tree. + * @param string $menu_name + * The menu name. + * @param string $indent + * The indentation string used for the label. + * @param array $options + * The select options. + * @param string $exclude + * An excluded menu link. + * @param int $depth_limit + * The maximum depth of menu links considered for the select options. + */ + protected function parentSelectOptionsTreeWalk(array $tree, $menu_name, $indent, array &$options, $exclude, $depth_limit) { + foreach ($tree as $data) { + if ($data['depth'] > $depth_limit) { + // Don't iterate through any links on this level. + break; + } + /** @var \Drupal\Core\Menu\MenuLinkInterface $link */ + $link = $data['link']; + if ($link->getPluginId() != $exclude) { + $title = $indent . ' ' . Unicode::truncate($link->getTitle(), 30, TRUE, FALSE); + if ($link->isHidden()) { + $title .= ' (' . t('disabled') . ')'; + } + $options[$menu_name . ':' . $link->getPluginId()] = $title; + if ($data['below']) { + $this->parentSelectOptionsTreeWalk($data['below'], $menu_name, $indent . '--', $options, $exclude, $depth_limit); + } + } + } + } + + /** + * {@inheritdoc} + */ + public function getMenuOptions(array $menu_names = NULL) { + $menus = $this->entityManager->getStorage('menu')->loadMultiple($menu_names); + $options = array(); + foreach ($menus as $menu) { + $options[$menu->id()] = $menu->label(); + } + return $options; + } + + /** + * {@inheritdoc} + */ + public function menuNameInUse($menu_name) { + $this->treeStorage->menuNameInUse($menu_name); + } + + /** + * {@inheritdoc} + */ + public function resetLink($id) { + $instance = $this->createInstance($id); + $affected_menus[$instance->getMenuName()] = $instance->getMenuName(); + $new_instance = $this->resetInstance($instance); + $affected_menus[$new_instance->getMenuName()] = $new_instance->getMenuName(); + Cache::invalidateTags(array('menu' => $affected_menus)); + 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 resetable. + */ + protected function resetInstance(MenuLinkInterface $instance) { + $id = $instance->getPluginId(); + + if (!$instance->isResetable()) { + throw new PluginException(String::format('Menu link %id is not resetable', 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->resetDefinition($id, $definition); + $this->treeStorage->save($definition); + return $this->createInstance($id); + } + + /** + * {@inheritdoc} + */ + public function resetDefinitions() { + $this->definitions = array(); + $this->menuTree = array(); + $this->buildAllDataParameters = array(); + $this->menuPageTrees = array(); + } + + /** + * Resets the local definition cache for one plugin. + * + * @param string $id + * The menu link plugin ID. + * @param array $definition + * Optional new definition for the given plugin ID. + */ + protected function resetDefinition($id, $definition = NULL) { + $this->definitions[$id] = $definition; + } + +} diff --git a/core/lib/Drupal/Core/Menu/MenuLinkTreeInterface.php b/core/lib/Drupal/Core/Menu/MenuLinkTreeInterface.php new file mode 100644 index 0000000..a75e284 --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuLinkTreeInterface.php @@ -0,0 +1,397 @@ +connection = $connection; + $this->urlGenerator = $url_generator; + $this->table = $table; + $this->options = $options; + } + + /** + * {@inheritdoc} + */ + public function maxDepth() { + return static::MAX_DEPTH; + } + + /** + * {@inheritdoc} + */ + public function rebuild(array $definitions) { + $links = array(); + $children = array(); + $top_links = array(); + if ($definitions) { + foreach ($definitions as $id => $link) { + 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'); + $query->orderBy('depth', 'DESC'); + $result = $query->execute()->fetchCol(); + } + else { + $result = array(); + } + + // Remove all such items. Starting from those with the greatest depth will + // minimize the amount of re-parenting done by the menu link controller. + if ($result) { + $this->purgeMultiple($result); + } + } + + /** + * Purges multiple menu links that no longer exist. + * + * @param array $ids + * An array of menu link IDs. + * @param bool $prevent_reparenting + * (optional) Disables the re-parenting logic from the deletion process. + * Defaults to FALSE. + */ + protected function purgeMultiple(array $ids, $prevent_reparenting = FALSE) { + if (!$prevent_reparenting) { + $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 + * 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) { + $original = $this->loadFull($link['id']); + // @todo - should we just return here if the links values match the original + // values completely?. + + $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); + // Ignore slave server temporarily. + db_ignore_slave(); + } + catch (\Exception $e) { + $transaction->rollback(); + throw $e; + } + return $affected_menus; + } + + /** + * Using the link definition, but up all the fields needed for database save. + * + * @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); + 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. + if ($original) { + $limit = $this->maxDepth() - $this->doFindChildrenRelativeDepth($original) - 1; + } + else { + $limit = $this->maxDepth() - 1; + } + if ($parent['depth'] > $limit) { + throw new PluginException(sprintf('The link with ID %s or its children exceeded the maximum depth of %d', $link['id'], $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); + } + } + + /** + * {@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; + } + } + + /** + * Moves the link's children using the query fields value and original values. + * + * @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 check. + * @param array|FALSE $original + * The original link, or FALSE. + * + * @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']) { + $candidates[] = $original['parent']; + } + + // Else, if we have a link hierarchy try to find a valid parent in there. + // @todo - why does this make sense to do at all? + + foreach ($candidates as $id) { + $parent = $this->loadFull($id); + if ($parent) { + break; + } + } + return $parent; + } + + /** + * Set the has_children flag 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(); + } + } + + /** + * {@inheritdoc} + */ + public function loadByProperties(array $properties) { + // @todo - only allow loading by plugin definition properties. + $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 &$link) { + foreach ($this->serializedFields() as $name) { + $link[$name] = unserialize($link[$name]); + } + } + return $loaded; + } + + /** + * {@inheritdoc} + */ + public function loadByRoute($route_name, array $route_parameters = array(), $include_hidden = FALSE) { + asort($route_parameters); + // Since this will be urlencoded, it's safe to store and match against a + // text field. + // @todo - does this make more sense than using the system path? + $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 (!$include_hidden) { + $query->condition('hidden', 0); + } + $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 loadMultiple(array $ids) { + $query = $this->connection->select($this->table, $this->options); + $query->fields($this->table, $this->definitionFields()); + $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 load($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 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. + $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's being used more + // than once per page load. + 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? + 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. + */ + 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->save($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 loadTree($menu_name, array $parameters = array()) { + $query = $this->connection->select($this->table, $this->options); + $query->fields($this->table); + for ($i = 1; $i <= $this->maxDepth(); $i++) { + $query->orderBy('p' . $i, 'ASC'); + } + + $query->condition('menu_name', $menu_name); + + if (!empty($parameters['expanded'])) { + $query->condition('parent', $parameters['expanded'], 'IN'); + } + elseif (!empty($parameters['only_active_trail'])) { + $query->condition('id', $parameters['active_trail'], 'IN'); + } + $min_depth = (isset($parameters['min_depth']) ? $parameters['min_depth'] : NULL); + if ($min_depth) { + $query->condition('depth', $min_depth, '>='); + } + if (isset($parameters['max_depth'])) { + $query->condition('depth', $parameters['max_depth'], '<='); + } + // 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) { + $query->condition($column, $value); + } + } + $active_trail = (isset($parameters['active_trail']) ? $parameters['active_trail'] : array()); + $links = $this->safeExecuteSelect($query)->fetchAll(\PDO::FETCH_ASSOC); + if (!isset($min_depth)) { + $first_link = reset($links); + if ($first_link) { + $min_depth = $first_link['depth']; + } + } + $tree = $this->doBuildTreeData($links, $active_trail, $min_depth); + return $tree; + } + + /** + * {@inheritdoc} + */ + public function loadSubtree($id, $max_relative_depth = NULL) { + $tree = array(); + $root = $this->loadFull($id); + if (!$root) { + return $tree; + } + $query = $this->connection->select($this->table, $this->options); + $query->fields($this->table); + for ($i = 1; $i <= $this->maxDepth(); $i++) { + $query->orderBy('p' . $i, 'ASC'); + } + $query->condition('hidden', 0); + $query->condition('menu_name', $root['menu_name']); + for ($i = 1; $i <= $root['depth']; $i++) { + $query->condition("p$i", $root["p$i"]); + } + if (!empty($max_relative_depth)) { + $query->condition('depth', (int) $root['depth'] + $max_relative_depth, '<='); + } + $links = $this->safeExecuteSelect($query)->fetchAll(\PDO::FETCH_ASSOC); + $tree = $this->doBuildTreeData($links, array(), $root['depth']); + $subtree = current($tree); + return $subtree; + } + + /** + * {@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 loadAllChildLinks($id, $max_relative_depth = NULL) { + $tree = array(); + $root = $this->loadFull($id); + if (!$root || $root['depth'] == $this->maxDepth()) { + return $tree; + } + $query = $this->connection->select($this->table, $this->options); + $query->fields($this->table, $this->definitionFields()); + $query->condition('hidden', 0); + $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, '>'); + if (!empty($max_relative_depth)) { + $query->condition('depth', (int) $root['depth'] + $max_relative_depth, '<='); + } + $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; + } + + /** + * 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 {menu_links} table, and optionally additional + * information from the {menu_router} table, if the menu item appears in + * both tables. This array must be ordered depth-first. + * See _menu_build_tree() for 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 build tree. + */ + protected function treeDataRecursive(&$links, $parents, $depth) { + $tree = array(); + while ($item = array_pop($links)) { + // We need to determine if we're on the path to root so we can later build + // the correct active trail. + foreach ($this->serializedFields() as $name) { + $item[$name] = unserialize($item[$name]); + } + // Add the current link to the tree. + $tree[$item['id']] = array( + 'definition' => array_intersect_key($item, array_flip($this->definitionFields())), + 'has_children' => $item['has_children'], + 'in_active_trail' => in_array($item['id'], $parents), + 'below' => array(), + 'depth' => $item['depth'], + ); + for ($i = 1; $i <= $this->maxDepth(); $i++) { + $tree[$item['id']]['p' . $i] = $item['p' . $i]; + } + // 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[$item['id']]['below'] = $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; + } + + /** + * Helper function to determine serialized fields. + */ + protected function serializedFields() { + // For now, build the list from the schema since it's in active development. + if (empty($this->serializedFields)) { + $schema = static::schemaDefinition(); + foreach ($schema['fields'] as $name => $field) { + if (!empty($field['serialize'])) { + $this->serializedFields[] = $name; + } + } + } + return $this->serializedFields; + } + + /** + * Helper function to determine fields that are part of the plugin definition. + */ + protected function definitionFields() { + return $this->definitionFields; + } + + /** + * Defines the schema for the tree 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. + '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 0000000..d90a767 --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuTreeStorageInterface.php @@ -0,0 +1,253 @@ +configFactory = $config_factory; + } + + /** + * Helper function to get the config object. + * + * 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) { + // Remove unexpected keys. + $expected = array( + 'menu_name' => 1, + 'parent' => 1, + 'weight' => 1, + 'expanded' => 1, + 'hidden' => 1, + ); + $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. + * + * @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 str_replace('.', '__', $id); + } + +} diff --git a/core/lib/Drupal/Core/Menu/StaticMenuLinkOverridesInterface.php b/core/lib/Drupal/Core/Menu/StaticMenuLinkOverridesInterface.php new file mode 100644 index 0000000..5f0549b --- /dev/null +++ b/core/lib/Drupal/Core/Menu/StaticMenuLinkOverridesInterface.php @@ -0,0 +1,83 @@ +menuTree = $menu_tree; + } + + /** + * {@inheritdoc} + */ + public function convert($value, $definition, $name, array $defaults, Request $request) { + if ($value) { + try { + return $this->menuTree->createInstance($value); + } + catch (PluginException $e) { + // Suppress the error. + } + } + } + + /** + * {@inheritdoc} + */ + public function applies($definition, $name, Route $route) { + return (!empty($definition['type']) && $definition['type'] === 'menu_link_plugin'); + } + +} 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 0000000..4c69873 --- /dev/null +++ b/core/modules/menu_link_content/menu_link_content.info.yml @@ -0,0 +1,6 @@ +name: 'Menu Links Content' +type: module +description: 'Allows administrators to create custom links' +package: Core +version: VERSION +core: 8.x diff --git a/core/modules/menu_link_content/menu_link_content.install b/core/modules/menu_link_content/menu_link_content.install new file mode 100644 index 0000000..606ca6a --- /dev/null +++ b/core/modules/menu_link_content/menu_link_content.install @@ -0,0 +1,19 @@ +fetchCol(); + $menu_tree = \Drupal::menuTree(); + foreach ($uuids as $uuid) { + // Manually build the plugin ID, and remove it from the menu tree. + $menu_tree->deleteLink("menu_link_content:$uuid", FALSE); + } +} diff --git a/core/modules/menu_link_content/menu_link_content.local_tasks.yml b/core/modules/menu_link_content/menu_link_content.local_tasks.yml new file mode 100644 index 0000000..8195236 --- /dev/null +++ b/core/modules/menu_link_content/menu_link_content.local_tasks.yml @@ -0,0 +1,4 @@ +menu_link_content.link_edit: + route_name: menu_link_content.link_edit + base_route: menu_link_content.link_edit + title: Edit 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 0000000..6648280 --- /dev/null +++ b/core/modules/menu_link_content/menu_link_content.module @@ -0,0 +1,17 @@ +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/menu_link_content.routing.yml b/core/modules/menu_link_content/menu_link_content.routing.yml new file mode 100644 index 0000000..f96b22d --- /dev/null +++ b/core/modules/menu_link_content/menu_link_content.routing.yml @@ -0,0 +1,23 @@ +menu_link_content.link_add: + path: '/admin/structure/menu/manage/{menu}/add' + defaults: + _content: '\Drupal\menu_link_content\Controller\MenuController::addLink' + _title: 'Add menu link' + requirements: + _entity_create_access: 'menu_link_content' + +menu_link_content.link_edit: + path: '/admin/structure/menu/item/{menu_link_content}/edit' + defaults: + _entity_form: 'menu_link_content.default' + _title: 'Edit menu link' + requirements: + _entity_access: 'menu_link_content.update' + +menu_link_content.link_delete: + path: '/admin/structure/menu/item/{menu_link_content}/delete' + defaults: + _entity_form: 'menu_link_content.delete' + _title: 'Delete menu link' + requirements: + _entity_access: 'menu_link_content.delete' diff --git a/core/modules/menu_link_content/src/Controller/MenuController.php b/core/modules/menu_link_content/src/Controller/MenuController.php new file mode 100644 index 0000000..7ffa9f8 --- /dev/null +++ b/core/modules/menu_link_content/src/Controller/MenuController.php @@ -0,0 +1,34 @@ +entityManager()->getStorage('menu_link_content')->create(array( + 'id' => '', + 'parent' => '', + 'menu_name' => $menu->id(), + 'bundle' => 'menu_link_content', + )); + return $this->entityFormBuilder()->getForm($menu_link); + } + +} 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 0000000..c98ef42 --- /dev/null +++ b/core/modules/menu_link_content/src/Entity/MenuLinkContent.php @@ -0,0 +1,382 @@ +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(''); + } + } + + 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 getMenuDefinition() { + $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(); + // Don't bother saving title and description strings, since they are never + // used. + $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\MenuLinkTreeInterface $menu_tree */ + $menu_tree = \Drupal::menuTree(); + + // The menu link can just be updated if there is already an menu link entry + // on both entity and menu tree level. + if ($update && $menu_tree->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_tree->updateLink($this->getPluginId(), $this->getMenuDefinition(), FALSE); + } + } + else { + $menu_tree->createLink($this->getPluginId(), $this->getMenuDefinition()); + } + } + + /** + * {@inheritdoc} + */ + public static function preDelete(EntityStorageInterface $storage, array $entities) { + parent::preDelete($storage, $entities); + + foreach ($entities as $menu_link) { + /** @var \Drupal\menu_link_content\Entity\MenuLinkContent $menu_link */ + \Drupal::menuTree()->deleteLink($menu_link->getPluginId(), FALSE); + } + } + + /** + * {@inheritdoc} + */ + public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { + $fields['id'] = FieldDefinition::create('integer') + ->setLabel(t('Content menu link ID')) + ->setDescription(t('The menu link ID.')) + ->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 in the end? see https://drupal.org/node/2235457 + $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('string') + ->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', 0); + + $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', 0) + ->setDisplayOptions('view', array( + 'label' => 'hidden', + 'type' => 'boolean', + 'weight' => 0, + )) + ->setDisplayOptions('form', array( + 'type' => 'options_onoff', + 'weight' => 0, + )); + + $fields['hidden'] = FieldDefinition::create('boolean') + ->setLabel(t('Hidden')) + ->setDescription(t('A flag for whether the link should be rendered in menus. (1 = a disabled menu item that may be shown on admin screens, -1 = a menu callback, 0 = a normal, visible link).')) + ->setSetting('default_value', 0); + + $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 menu link ID')) + ->setDescription(t('The parent menu link ID.')); + + 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 0000000..35a24a7 --- /dev/null +++ b/core/modules/menu_link_content/src/Entity/MenuLinkContentInterface.php @@ -0,0 +1,147 @@ +t('Are you sure you want to delete the custom menu link %item?', array('%item' => $this->entity->getTitle())); + } + + /** + * {@inheritdoc} + */ + public function getCancelRoute() { + return new Url('menu_ui.menu_edit', array('menu' => $this->entity->getMenuName())); + } + + /** + * {@inheritdoc} + */ + public function submit(array $form, array &$form_state) { + $storage = $this->entityManager->getStorage('menu_link_content'); + $storage->delete(array($this->entity)); + $t_args = array('%title' => $this->entity->getTitle()); + drupal_set_message($this->t('The menu link %title has been deleted.', $t_args)); + watchdog('menu', 'Deleted menu link %title.', $t_args, WATCHDOG_NOTICE); + $form_state['redirect_route'] = array( + 'route_name' => '', + ); + } + +} diff --git a/core/modules/menu_link_content/src/Form/MenuLinkContentForm.php b/core/modules/menu_link_content/src/Form/MenuLinkContentForm.php new file mode 100644 index 0000000..272ac64 --- /dev/null +++ b/core/modules/menu_link_content/src/Form/MenuLinkContentForm.php @@ -0,0 +1,400 @@ +menuTree = $menu_tree; + $this->pathAliasManager = $alias_manager; + $this->moduleHandler = $module_handler; + $this->requestContext = $request_context; + $this->languageManager = $language_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity.manager'), + $container->get('menu.link_tree'), + $container->get('path.alias_manager'), + $container->get('module_handler'), + $container->get('router.request_context'), + $container->get('language_manager') + ); + } + + /** + * {@inheritdoc} + */ + public function setMenuLinkInstance(MenuLinkInterface $menu_link) { + // Load the entity for the entity form. + $metadata = $menu_link->getMetaData(); + if (!empty($metadata['entity_id'])) { + $this->entity = $this->entityManager->getStorage('menu_link_content')->load($metadata['entity_id']); + } + else { + // Fallback to the loading by the uuid. + $links = $this->entityManager->getStorage('menu_link_content')->loadByProperties(array('uuid' => $menu_link->getDerivativeId())); + $this->entity = reset($links); + } + } + + /** + * {@inheritdoc} + */ + public function buildEditForm(array &$form, array &$form_state) { + $this->setOperation('default'); + $this->init($form_state); + + return $this->form($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function validateEditForm(array &$form, array &$form_state) { + $this->doValidate($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitEditForm(array &$form, array &$form_state) { + // Remove button and internal Form API values from submitted values. + form_state_values_clean($form_state); + $this->entity = $this->buildEntity($form, $form_state); + $this->entity->save(); + return $this->menuTree->createInstance($this->entity->getPluginId()); + } + + /** + * Break up a user-entered URL or path into all the relevant parts. + * + * @param string $url + * The user-entered URL or path. + * + * @return array + * The extracted parts. + */ + protected function extractUrl($url) { + $extracted = UrlHelper::parse($url); + $external = UrlHelper::isExternal($url); + if ($external) { + $extracted['url'] = $extracted['path']; + $extracted['route_name'] = NULL; + $extracted['route_parameters'] = array(); + } + else { + $extracted['url'] = ''; + // If the path doesn't match a Drupal path, the route should end up empty. + $extracted['route_name'] = NULL; + $extracted['route_parameters'] = array(); + try { + // Find the route_name. + $normal_path = $this->pathAliasManager->getPathByAlias($extracted['path']); + $url_obj = Url::createFromPath($normal_path); + $extracted['route_name'] = $url_obj->getRouteName(); + $extracted['route_parameters'] = $url_obj->getRouteParameters(); + } + catch (MatchingRouteNotFoundException $e) { + // The path doesn't match a Drupal path. + } + catch (ParamNotConvertedException $e) { + // A path like node/99 matched a route, but the route parameter was + // invalid (e.g. node with ID 99 does not exist). + } + } + return $extracted; + } + + /** + * {@inheritdoc} + */ + public function extractFormValues(array &$form, array &$form_state) { + + $new_definition = array(); + $new_definition['expanded'] = !empty($form_state['values']['expanded']) ? 1 : 0; + $new_definition['hidden'] = empty($form_state['values']['enabled']) ? 1 : 0; + list($menu_name, $parent) = explode(':', $form_state['values']['menu_parent'], 2); + if (!empty($menu_name)) { + $new_definition['menu_name'] = $menu_name; + } + $new_definition['parent'] = isset($parent) ? $parent : ''; + + $extracted = $this->extractUrl($form_state['values']['url']); + $new_definition['url'] = $extracted['url']; + $new_definition['route_name'] = $extracted['route_name']; + $new_definition['route_parameters'] = $extracted['route_parameters']; + $new_definition['options'] = array(); + if ($extracted['query']) { + $new_definition['options']['query'] = $extracted['query']; + } + if ($extracted['fragment']) { + $new_definition['options']['fragment'] = $extracted['fragment']; + } + $new_definition['title'] = $form_state['values']['title'][0]['value']; + $new_definition['description'] = $form_state['values']['description'][0]['value']; + $new_definition['weight'] = (int) $form_state['values']['weight'][0]['value']; + + return $new_definition; + } + + /** + * {@inheritdoc} + */ + public function form(array $form, array &$form_state) { + + $form = parent::form($form, $form_state); + + $language_configuration = $this->moduleHandler->invoke('language', 'get_default_configuration', array('menu_link_content', 'menu_link_content')); + if ($this->entity->isNew()) { + $default_language = isset($language_configuration['langcode']) ? $language_configuration['langcode'] : $this->languageManager->getDefaultLanguage()->id; + } + else { + $default_language = $this->entity->getUntranslated()->language()->id; + } + $form['langcode'] = array( + '#title' => t('Language'), + '#type' => 'language_select', + '#default_value' => $default_language, + '#languages' => Language::STATE_ALL, + '#access' => !empty($language_configuration['language_show']), + ); + + $form['enabled'] = array( + '#type' => 'checkbox', + '#title' => $this->t('Enable menu link'), + '#description' => $this->t('Menu links that are not enabled will not be listed in any menu.'), + '#default_value' => !$this->entity->isHidden(), + ); + + // @TODO For whatever reason the expanded widget is not autogenerated. + $form['expanded'] = array( + '#type' => 'checkbox', + '#title' => $this->t('Show as expanded'), + '#description' => $this->t('If selected and this menu link has children, the menu will always appear expanded.'), + '#default_value' => $this->entity->isExpanded(), + ); + + // We always show the internal path here. + $url = $this->getEntity()->getUrlObject(); + if ($url->isExternal()) { + $default_value = $url->toString(); + } + elseif ($url->getRouteName() == '') { + // The default route for new entities is , but we just want an + // empty form field. + $default_value = $this->getEntity()->isNew() ? '' : ''; + } + else { + // @TODO Maybe support options in + // \Drupal\Core\Routing\UrlGeneratorInterface::getInternalPath(). + // or a helper method to render just options? + $default_value = $url->getInternalPath(); + $options = $url->getOptions(); + if (isset($options['query'])) { + $default_value .= $options['query'] ? ('?' . UrlHelper::buildQuery($options['query'])) : ''; + } + if (isset($options['fragment']) && $options['fragment'] !== '') { + $default_value .= '#' . $options['fragment']; + } + } + $form['url'] = array( + '#title' => $this->t('Link path'), + '#type' => 'textfield', + '#description' => $this->t('The path for this menu link. This can be an internal Drupal path such as %add-node or an external URL such as %drupal. Enter %front to link to the front page.', array('%front' => '', '%add-node' => 'node/add', '%drupal' => 'http://drupal.org')), + '#default_value' => $default_value, + '#required' => TRUE, + ); + + $options = $this->menuTree->getParentSelectOptions($this->entity->getPluginId()); + $menu_parent = $this->entity->getMenuName() . ':' . $this->entity->getParentId(); + + if (!isset($options[$menu_parent])) { + // Put it at the top level in the current menu. + $menu_parent = $this->entity->getMenuName() . ':'; + } + $form['menu_parent'] = array( + '#type' => 'select', + '#title' => $this->t('Parent link'), + '#options' => $options, + '#default_value' => $menu_parent, + '#description' => $this->t('The maximum depth for a link and all its children is fixed at !maxdepth. Some menu links may not be available as parents if selecting them would exceed this limit.', array('!maxdepth' => $this->menuTree->maxDepth())), + '#attributes' => array('class' => array('menu-title-select')), + ); + + return $form; + } + + /** + * {@inheritdoc} + */ + protected function actions(array $form, array &$form_state) { + $element = parent::actions($form, $form_state); + $element['submit']['#button_type'] = 'primary'; + $element['delete']['#access'] = $this->entity->access('delete'); + + return $element; + } + + /** + * {@inheritdoc} + */ + public function validate(array $form, array &$form_state) { + $this->doValidate($form, $form_state); + + parent::validate($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function buildEntity(array $form, array &$form_state) { + /** @var \Drupal\menu_link_content\Entity\MenuLinkContent $entity */ + $entity = parent::buildEntity($form, $form_state); + $new_definition = $this->extractFormValues($form, $form_state); + + $entity->parent->value = $new_definition['parent']; + $entity->menu_name->value = $new_definition['menu_name']; + $entity->hidden->value = (bool) $new_definition['hidden']; + $entity->expanded->value = $new_definition['expanded']; + + $entity->url->value = $new_definition['url']; + $entity->route_name->value = $new_definition['route_name']; + $entity->setRouteParameters($new_definition['route_parameters']); + $entity->setOptions($new_definition['options']); + + return $entity; + } + + /** + * {@inheritdoc} + */ + public function save(array $form, array &$form_state) { + // The entity is rebuilt in parent::submit(). + $menu_link = $this->entity; + $saved = $menu_link->save(); + + if ($saved) { + drupal_set_message(t('The menu link has been saved.')); + $form_state['redirect_route'] = array( + 'route_name' => 'menu_link_content.link_edit', + 'route_parameters' => array( + 'menu_link_content' => $menu_link->id(), + ), + ); + } + else { + drupal_set_message(t('There was an error saving the menu link.'), 'error'); + $form_state['rebuild'] = TRUE; + } + } + + /** + * Validates the form, both on the menu link edit and content menu link form. + */ + protected function doValidate(array $form, array &$form_state) { + $extracted = $this->extractUrl($form_state['values']['url']); + + // If both url and route_nae are empty, the entered value is not valid. + $valid = FALSE; + if ($extracted['url']) { + // This is an external link. + $valid = TRUE; + } + elseif ($extracted['route_name']) { + // Users are not allowed to add a link to a page they cannot access. + $valid = \Drupal::service('access_manager')->checkNamedRoute($extracted['route_name'], $extracted['route_parameters'], \Drupal::currentUser()); + } + if (!$valid) { + $this->setFormError('url', $form_state, $this->t("The path '@link_path' is either invalid or you do not have access to it.", array('@link_path' => $form_state['values']['url']))); + } + elseif ($extracted['route_name']) { + // The user entered a Drupal path. + $normal_path = $this->pathAliasManager->getPathByAlias($extracted['path']); + if ($extracted['path'] != $normal_path) { + drupal_set_message($this->t('The menu system stores system paths only, but will use the URL alias for display. %link_path has been stored as %normal_path', array( + '%link_path' => $extracted['path'], + '%normal_path' => $normal_path, + ))); + } + } + } + +} 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 0000000..d895478 --- /dev/null +++ b/core/modules/menu_link_content/src/MenuLinkContentAccessController.php @@ -0,0 +1,55 @@ +hasPermission('administer menu') && ($entity->getUrl() || $this->accessManager()->checkNamedRoute($entity->getRouteName(), $entity->getRouteParameters(), $account)); + + case 'delete': + return !$entity->isNew() && $account->hasPermission('administer menu'); + } + } + + /** + * Returns the access manager. + * + * @return \Drupal\Core\Access\AccessManager + * The route provider. + */ + protected function accessManager() { + if (!$this->accessManager) { + $this->accessManager = \Drupal::service('access_manager'); + } + return $this->accessManager; + } +} 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 0000000..15c5370 --- /dev/null +++ b/core/modules/menu_link_content/src/Plugin/Menu/MenuLinkContent.php @@ -0,0 +1,236 @@ + 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']; + 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']; + 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(); + } + else { + // Fallback to the loading by the uuid. + $uuid = $this->getDerivativeId(); + $links = $storage->loadByProperties(array('uuid' => $uuid)); + $entity = reset($links); + } + if (!$entity) { + throw new PluginException("Invalid entity ID or uuid"); + } + // Clone the entity object to avoid tampering with the static cache. + $this->entity = clone $entity; + $this->entity = $this->entityManager->getTranslationFromContext($this->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 configured to be multilingual. + if ($this->langaugeManager->isMultilingual()) { + return $this->getEntity()->getTitle(); + } + return $this->pluginDefinition['title']; + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + 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 = $this->getEntity()->getEntityType()->id(); + 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) { + $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() { + // @todo: Flag this call if possible so we don't call the menu tree manager. + $this->getEntity()->delete(); + } + +} diff --git a/core/modules/menu_link_content/src/Tests/MenuLinkContentUITest.php b/core/modules/menu_link_content/src/Tests/MenuLinkContentUITest.php new file mode 100644 index 0000000..6d2bed0 --- /dev/null +++ b/core/modules/menu_link_content/src/Tests/MenuLinkContentUITest.php @@ -0,0 +1,63 @@ + 'Menu link content translation UI', + 'description' => 'Tests the basic menu link content translation UI.', + 'group' => 'Menu', + ); + } + + /** + * {@inheritdoc} + */ + public function setUp() { + $this->entityTypeId = 'menu_link_content'; + $this->bundle = 'menu_link_content'; + $this->fieldName = 'title'; + parent::setUp(); + } + + /** + * {@inheritdoc} + */ + protected function getTranslatorPermissions() { + return array_merge(parent::getTranslatorPermissions(), array('administer menu')); + } + + /** + * {@inheritdoc} + */ + protected function createEntity($values, $langcode, $bundle_name = NULL) { + $values['menu_name'] = 'tools'; + $values['route_name'] = 'menu_ui.overview_page'; + $values['title'] = 'Test title'; + + return parent::createEntity($values, $langcode, $bundle_name); + } + +} diff --git a/core/modules/menu_ui/src/Form/MenuLinkEditForm.php b/core/modules/menu_ui/src/Form/MenuLinkEditForm.php new file mode 100644 index 0000000..7ac65ac --- /dev/null +++ b/core/modules/menu_ui/src/Form/MenuLinkEditForm.php @@ -0,0 +1,93 @@ +menuTree = $menu_tree; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('menu.link_tree') + ); + } + + public function getFormId() { + return 'menu_link_edit'; + } + + /** + * {@inheritdoc} + * + * @param \Drupal\Core\Menu\MenuLinkInterface $menu_link_plugin + * The plugin instance to use for this form. + */ + public function buildForm(array $form, array &$form_state, MenuLinkInterface $menu_link_plugin = NULL) { + + $form['menu_link_id'] = array( + '#type' => 'value', + '#value' => $menu_link_plugin->getPluginId(), + ); + + $form['#plugin_form'] = $this->menuTree->getPluginForm($menu_link_plugin); + + $form += $form['#plugin_form']->buildEditForm($form, $form_state); + + $form['actions'] = array('#type' => 'actions'); + $form['actions']['submit'] = array( + '#type' => 'submit', + '#value' => t('Save'), + '#button_type' => 'primary', + ); + return $form; + } + + public function validateForm(array &$form, array &$form_state) { + $form['#plugin_form']->validateEditForm($form, $form_state); + } + + public function submitForm(array &$form, array &$form_state) { + $link = $form['#plugin_form']->submitEditForm($form, $form_state); + + drupal_set_message($this->t('The menu link has been saved.')); + $form_state['redirect_route'] = array( + 'route_name' => 'menu_ui.menu_edit', + 'route_parameters' => array( + 'menu' => $link->getMenuName(), + ), + ); + } + +} + diff --git a/core/modules/menu_ui/src/Plugin/Menu/LocalAction/MenuLinkAdd.php b/core/modules/menu_ui/src/Plugin/Menu/LocalAction/MenuLinkAdd.php new file mode 100644 index 0000000..88e1898 --- /dev/null +++ b/core/modules/menu_ui/src/Plugin/Menu/LocalAction/MenuLinkAdd.php @@ -0,0 +1,32 @@ +attributes->has('_system_path')) { + // @todo: is there a better value to get from the request? + $options['query']['destination'] = $request->attributes->get('_system_path'); + } + return $options; + } + +} diff --git a/core/modules/system/config/install/menu_link.static.overrides.yml b/core/modules/system/config/install/menu_link.static.overrides.yml new file mode 100644 index 0000000..ca4ba7f --- /dev/null +++ b/core/modules/system/config/install/menu_link.static.overrides.yml @@ -0,0 +1 @@ +definitions: [] diff --git a/core/modules/system/src/Tests/Menu/MenuLinkTreeTest.php b/core/modules/system/src/Tests/Menu/MenuLinkTreeTest.php new file mode 100644 index 0000000..4665d7a --- /dev/null +++ b/core/modules/system/src/Tests/Menu/MenuLinkTreeTest.php @@ -0,0 +1,109 @@ + 'Tests \Drupal\Core\Menu\MenuLinkTree', + 'description' => '', + 'group' => 'Menu' + ); + } + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + $this->installSchema('system', array('router')); + $this->installEntitySchema('menu_link_content'); + + $this->linkTree = \Drupal::menuTree(); + } + + public function testDeleteLinksInMenu() { + \Drupal::service('router.builder')->rebuild(); + + \Drupal::entityManager()->getStorage('menu')->create(array('id' => 'menu1'))->save(); + \Drupal::entityManager()->getStorage('menu')->create(array('id' => 'menu2'))->save(); + + \Drupal::entityManager()->getStorage('menu_link_content')->create(array('route_name' => 'menu_test.menu_name_test', 'menu_name' => 'menu1', 'bundle' => 'menu_link_content'))->save(); + \Drupal::entityManager()->getStorage('menu_link_content')->create(array('route_name' => 'menu_test.menu_name_test', 'menu_name' => 'menu1', 'bundle' => 'menu_link_content'))->save(); + \Drupal::entityManager()->getStorage('menu_link_content')->create(array('route_name' => 'menu_test.menu_name_test', 'menu_name' => 'menu2', 'bundle' => 'menu_link_content'))->save(); + + $output = $this->linkTree->buildTree('menu1'); + $this->assertEqual(count($output), 2); + $output = $this->linkTree->buildTree('menu2'); + $this->assertEqual(count($output), 1); + + $this->linkTree->deleteLinksInMenu('menu1'); + $this->linkTree->resetDefinitions(); + + $output = $this->linkTree->buildTree('menu1'); + $this->assertEqual(count($output), 0); + + $output = $this->linkTree->buildTree('menu2'); + $this->assertEqual(count($output), 1); + } + + public function testGetParentDepthLimit() { + \Drupal::service('router.builder')->rebuild(); + + $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->linkTree->getParentDepthLimit($root->getPluginId()), 4); + $this->assertEqual($this->linkTree->getParentDepthLimit($child1->getPluginId()), 5); + $this->assertEqual($this->linkTree->getParentDepthLimit($child2->getPluginId()), 6); + $this->assertEqual($this->linkTree->getParentDepthLimit($child3->getPluginId()), 7); + $this->assertEqual($this->linkTree->getParentDepthLimit($child4->getPluginId()), 8); + } + +} + 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 0000000..d336099 --- /dev/null +++ b/core/modules/system/src/Tests/Menu/MenuTreeStorageTest.php @@ -0,0 +1,359 @@ + 'Menu tree storage tests', + 'description' => 'Tests menu tree storage tests', + 'group' => 'Menu' + ); + } + + protected function setUp() { + parent::setUp(); + + $this->treeStorage = new MenuTreeStorage($this->container->get('database'), $this->container->get('url_generator'), 'menu_tree'); + $this->connection = $this->container->get('database'); + } + + /** + * 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('url_generator'), '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. + // + // - 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. + // + // - 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. + // + // - 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. + // + // - 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. + // + // - 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. + // + // - test1 + // -- test2 (hidden) + + $this->addMenuLink('test1', ''); + $this->assertMenuLink('test1', array('has_children' => 0, 'depth' => 1)); + + $this->addMenuLink('test2', 'test1', '', array(), 'tools', array('hidden' => 1)); + // The 1st link does not have any visible children, so has_children is still 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. + //