1<?php 2 3namespace Drupal\menu_ui; 4 5use Drupal\Component\Utility\NestedArray; 6use Drupal\Core\Cache\CacheableMetadata; 7use Drupal\Core\Entity\EntityForm; 8use Drupal\Core\Form\FormStateInterface; 9use Drupal\Core\Language\LanguageInterface; 10use Drupal\Core\Link; 11use Drupal\Core\Menu\MenuLinkManagerInterface; 12use Drupal\Core\Menu\MenuLinkTreeElement; 13use Drupal\Core\Menu\MenuLinkTreeInterface; 14use Drupal\Core\Menu\MenuTreeParameters; 15use Drupal\Core\Render\Element; 16use Drupal\Core\Url; 17use Drupal\Core\Utility\LinkGeneratorInterface; 18use Drupal\menu_link_content\MenuLinkContentStorageInterface; 19use Drupal\menu_link_content\Plugin\Menu\MenuLinkContent; 20use Drupal\system\MenuStorage; 21use Symfony\Component\DependencyInjection\ContainerInterface; 22 23/** 24 * Base form for menu edit forms. 25 * 26 * @internal 27 */ 28class MenuForm extends EntityForm { 29 30 /** 31 * The menu link manager. 32 * 33 * @var \Drupal\Core\Menu\MenuLinkManagerInterface 34 */ 35 protected $menuLinkManager; 36 37 /** 38 * The menu tree service. 39 * 40 * @var \Drupal\Core\Menu\MenuLinkTreeInterface 41 */ 42 protected $menuTree; 43 44 /** 45 * The link generator. 46 * 47 * @var \Drupal\Core\Utility\LinkGeneratorInterface 48 */ 49 protected $linkGenerator; 50 51 /** 52 * The menu_link_content storage handler. 53 * 54 * @var \Drupal\menu_link_content\MenuLinkContentStorageInterface 55 */ 56 protected $menuLinkContentStorage; 57 58 /** 59 * The overview tree form. 60 * 61 * @var array 62 */ 63 protected $overviewTreeForm = ['#tree' => TRUE]; 64 65 /** 66 * Constructs a MenuForm object. 67 * 68 * @param \Drupal\Core\Menu\MenuLinkManagerInterface $menu_link_manager 69 * The menu link manager. 70 * @param \Drupal\Core\Menu\MenuLinkTreeInterface $menu_tree 71 * The menu tree service. 72 * @param \Drupal\Core\Utility\LinkGeneratorInterface $link_generator 73 * The link generator. 74 * @param \Drupal\menu_link_content\MenuLinkContentStorageInterface $menu_link_content_storage 75 * The menu link content storage handler. 76 */ 77 public function __construct(MenuLinkManagerInterface $menu_link_manager, MenuLinkTreeInterface $menu_tree, LinkGeneratorInterface $link_generator, MenuLinkContentStorageInterface $menu_link_content_storage) { 78 $this->menuLinkManager = $menu_link_manager; 79 $this->menuTree = $menu_tree; 80 $this->linkGenerator = $link_generator; 81 $this->menuLinkContentStorage = $menu_link_content_storage; 82 } 83 84 /** 85 * {@inheritdoc} 86 */ 87 public static function create(ContainerInterface $container) { 88 return new static( 89 $container->get('plugin.manager.menu.link'), 90 $container->get('menu.link_tree'), 91 $container->get('link_generator'), 92 $container->get('entity_type.manager')->getStorage('menu_link_content') 93 ); 94 } 95 96 /** 97 * {@inheritdoc} 98 */ 99 public function form(array $form, FormStateInterface $form_state) { 100 $menu = $this->entity; 101 102 if ($this->operation == 'edit') { 103 $form['#title'] = $this->t('Edit menu %label', ['%label' => $menu->label()]); 104 } 105 106 $form['label'] = [ 107 '#type' => 'textfield', 108 '#title' => $this->t('Title'), 109 '#default_value' => $menu->label(), 110 '#required' => TRUE, 111 ]; 112 $form['id'] = [ 113 '#type' => 'machine_name', 114 '#title' => $this->t('Menu name'), 115 '#default_value' => $menu->id(), 116 '#maxlength' => MenuStorage::MAX_ID_LENGTH, 117 '#description' => $this->t('A unique name to construct the URL for the menu. It must only contain lowercase letters, numbers and hyphens.'), 118 '#machine_name' => [ 119 'exists' => [$this, 'menuNameExists'], 120 'source' => ['label'], 121 'replace_pattern' => '[^a-z0-9-]+', 122 'replace' => '-', 123 ], 124 // A menu's machine name cannot be changed. 125 '#disabled' => !$menu->isNew() || $menu->isLocked(), 126 ]; 127 $form['description'] = [ 128 '#type' => 'textfield', 129 '#title' => $this->t('Administrative summary'), 130 '#maxlength' => 512, 131 '#default_value' => $menu->getDescription(), 132 ]; 133 134 $form['langcode'] = [ 135 '#type' => 'language_select', 136 '#title' => $this->t('Menu language'), 137 '#languages' => LanguageInterface::STATE_ALL, 138 '#default_value' => $menu->language()->getId(), 139 ]; 140 141 // Add menu links administration form for existing menus. 142 if (!$menu->isNew() || $menu->isLocked()) { 143 // Form API supports constructing and validating self-contained sections 144 // within forms, but does not allow handling the form section's submission 145 // equally separated yet. Therefore, we use a $form_state key to point to 146 // the parents of the form section. 147 // @see self::submitOverviewForm() 148 $form_state->set('menu_overview_form_parents', ['links']); 149 $form['links'] = []; 150 $form['links'] = $this->buildOverviewForm($form['links'], $form_state); 151 } 152 153 return parent::form($form, $form_state); 154 } 155 156 /** 157 * Returns whether a menu name already exists. 158 * 159 * @param string $value 160 * The name of the menu. 161 * 162 * @return bool 163 * Returns TRUE if the menu already exists, FALSE otherwise. 164 */ 165 public function menuNameExists($value) { 166 // Check first to see if a menu with this ID exists. 167 if ($this->entityTypeManager->getStorage('menu')->getQuery()->condition('id', $value)->range(0, 1)->count()->execute()) { 168 return TRUE; 169 } 170 171 // Check for a link assigned to this menu. 172 return $this->menuLinkManager->menuNameInUse($value); 173 } 174 175 /** 176 * {@inheritdoc} 177 */ 178 public function save(array $form, FormStateInterface $form_state) { 179 $menu = $this->entity; 180 $status = $menu->save(); 181 $edit_link = $this->entity->toLink($this->t('Edit'), 'edit-form')->toString(); 182 if ($status == SAVED_UPDATED) { 183 $this->messenger()->addStatus($this->t('Menu %label has been updated.', ['%label' => $menu->label()])); 184 $this->logger('menu')->notice('Menu %label has been updated.', ['%label' => $menu->label(), 'link' => $edit_link]); 185 } 186 else { 187 $this->messenger()->addStatus($this->t('Menu %label has been added.', ['%label' => $menu->label()])); 188 $this->logger('menu')->notice('Menu %label has been added.', ['%label' => $menu->label(), 'link' => $edit_link]); 189 } 190 191 $form_state->setRedirectUrl($this->entity->toUrl('edit-form')); 192 } 193 194 /** 195 * {@inheritdoc} 196 */ 197 public function submitForm(array &$form, FormStateInterface $form_state) { 198 parent::submitForm($form, $form_state); 199 200 if (!$this->entity->isNew() || $this->entity->isLocked()) { 201 $this->submitOverviewForm($form, $form_state); 202 } 203 } 204 205 /** 206 * Form constructor to edit an entire menu tree at once. 207 * 208 * Shows for one menu the menu links accessible to the current user and 209 * relevant operations. 210 * 211 * This form constructor can be integrated as a section into another form. It 212 * relies on the following keys in $form_state: 213 * - menu: A menu entity. 214 * - menu_overview_form_parents: An array containing the parent keys to this 215 * form. 216 * Forms integrating this section should call menu_overview_form_submit() from 217 * their form submit handler. 218 */ 219 protected function buildOverviewForm(array &$form, FormStateInterface $form_state) { 220 // Ensure that menu_overview_form_submit() knows the parents of this form 221 // section. 222 if (!$form_state->has('menu_overview_form_parents')) { 223 $form_state->set('menu_overview_form_parents', []); 224 } 225 226 $form['#attached']['library'][] = 'menu_ui/drupal.menu_ui.adminforms'; 227 228 $tree = $this->menuTree->load($this->entity->id(), new MenuTreeParameters()); 229 230 // We indicate that a menu administrator is running the menu access check. 231 $this->getRequest()->attributes->set('_menu_admin', TRUE); 232 $manipulators = [ 233 ['callable' => 'menu.default_tree_manipulators:checkAccess'], 234 ['callable' => 'menu.default_tree_manipulators:generateIndexAndSort'], 235 ]; 236 $tree = $this->menuTree->transform($tree, $manipulators); 237 $this->getRequest()->attributes->set('_menu_admin', FALSE); 238 239 // Determine the delta; the number of weights to be made available. 240 $count = function (array $tree) { 241 $sum = function ($carry, MenuLinkTreeElement $item) { 242 return $carry + $item->count(); 243 }; 244 return array_reduce($tree, $sum); 245 }; 246 $delta = max($count($tree), 50); 247 248 $form['links'] = [ 249 '#type' => 'table', 250 '#theme' => 'table__menu_overview', 251 '#header' => [ 252 $this->t('Menu link'), 253 [ 254 'data' => $this->t('Enabled'), 255 'class' => ['checkbox'], 256 ], 257 $this->t('Weight'), 258 [ 259 'data' => $this->t('Operations'), 260 'colspan' => 3, 261 ], 262 ], 263 '#attributes' => [ 264 'id' => 'menu-overview', 265 ], 266 '#tabledrag' => [ 267 [ 268 'action' => 'match', 269 'relationship' => 'parent', 270 'group' => 'menu-parent', 271 'subgroup' => 'menu-parent', 272 'source' => 'menu-id', 273 'hidden' => TRUE, 274 'limit' => $this->menuTree->maxDepth() - 1, 275 ], 276 [ 277 'action' => 'order', 278 'relationship' => 'sibling', 279 'group' => 'menu-weight', 280 ], 281 ], 282 ]; 283 284 $form['links']['#empty'] = $this->t('There are no menu links yet. <a href=":url">Add link</a>.', [ 285 ':url' => Url::fromRoute('entity.menu.add_link_form', ['menu' => $this->entity->id()], [ 286 'query' => ['destination' => $this->entity->toUrl('edit-form')->toString()], 287 ])->toString(), 288 ]); 289 $links = $this->buildOverviewTreeForm($tree, $delta); 290 291 // Get the menu links which have pending revisions, and disable the 292 // tabledrag if there are any. 293 $edited_ids = array_filter(array_map(function ($element) { 294 return is_array($element) && isset($element['#item']) && $element['#item']->link instanceof MenuLinkContent ? $element['#item']->link->getMetaData()['entity_id'] : NULL; 295 }, $links)); 296 $pending_menu_link_ids = array_intersect($this->menuLinkContentStorage->getMenuLinkIdsWithPendingRevisions(), $edited_ids); 297 if ($pending_menu_link_ids) { 298 $form['help'] = [ 299 '#type' => 'container', 300 'message' => [ 301 '#markup' => $this->formatPlural( 302 count($pending_menu_link_ids), 303 '%capital_name contains 1 menu link with pending revisions. Manipulation of a menu tree having links with pending revisions is not supported, but you can re-enable manipulation by getting each menu link to a published state.', 304 '%capital_name contains @count menu links with pending revisions. Manipulation of a menu tree having links with pending revisions is not supported, but you can re-enable manipulation by getting each menu link to a published state.', 305 [ 306 '%capital_name' => $this->entity->label(), 307 ] 308 ), 309 ], 310 '#attributes' => ['class' => ['messages', 'messages--warning']], 311 '#weight' => -10, 312 ]; 313 314 unset($form['links']['#tabledrag']); 315 unset($form['links']['#header'][2]); 316 } 317 318 foreach (Element::children($links) as $id) { 319 if (isset($links[$id]['#item'])) { 320 $element = $links[$id]; 321 322 $is_pending_menu_link = isset($element['#item']->link->getMetaData()['entity_id']) 323 && in_array($element['#item']->link->getMetaData()['entity_id'], $pending_menu_link_ids); 324 325 $form['links'][$id]['#item'] = $element['#item']; 326 327 // TableDrag: Mark the table row as draggable. 328 $form['links'][$id]['#attributes'] = $element['#attributes']; 329 $form['links'][$id]['#attributes']['class'][] = 'draggable'; 330 331 if ($is_pending_menu_link) { 332 $form['links'][$id]['#attributes']['class'][] = 'color-warning'; 333 $form['links'][$id]['#attributes']['class'][] = 'menu-link-content--pending-revision'; 334 } 335 336 // TableDrag: Sort the table row according to its existing/configured weight. 337 $form['links'][$id]['#weight'] = $element['#item']->link->getWeight(); 338 339 // Add special classes to be used for tabledrag.js. 340 $element['parent']['#attributes']['class'] = ['menu-parent']; 341 $element['weight']['#attributes']['class'] = ['menu-weight']; 342 $element['id']['#attributes']['class'] = ['menu-id']; 343 344 $form['links'][$id]['title'] = [ 345 [ 346 '#theme' => 'indentation', 347 '#size' => $element['#item']->depth - 1, 348 ], 349 $element['title'], 350 ]; 351 $form['links'][$id]['enabled'] = $element['enabled']; 352 $form['links'][$id]['enabled']['#wrapper_attributes']['class'] = ['checkbox', 'menu-enabled']; 353 354 // Disallow changing the publishing status of a pending revision. 355 if ($is_pending_menu_link) { 356 $form['links'][$id]['enabled']['#access'] = FALSE; 357 } 358 359 if (!$pending_menu_link_ids) { 360 $form['links'][$id]['weight'] = $element['weight']; 361 } 362 363 // Operations (dropbutton) column. 364 $form['links'][$id]['operations'] = $element['operations']; 365 366 $form['links'][$id]['id'] = $element['id']; 367 $form['links'][$id]['parent'] = $element['parent']; 368 } 369 } 370 371 return $form; 372 } 373 374 /** 375 * Recursive helper function for buildOverviewForm(). 376 * 377 * @param \Drupal\Core\Menu\MenuLinkTreeElement[] $tree 378 * The tree retrieved by \Drupal\Core\Menu\MenuLinkTreeInterface::load(). 379 * @param int $delta 380 * The default number of menu items used in the menu weight selector is 50. 381 * 382 * @return array 383 * The overview tree form. 384 */ 385 protected function buildOverviewTreeForm($tree, $delta) { 386 $form = &$this->overviewTreeForm; 387 $tree_access_cacheability = new CacheableMetadata(); 388 foreach ($tree as $element) { 389 $tree_access_cacheability = $tree_access_cacheability->merge(CacheableMetadata::createFromObject($element->access)); 390 391 // Only render accessible links. 392 if (!$element->access->isAllowed()) { 393 continue; 394 } 395 396 /** @var \Drupal\Core\Menu\MenuLinkInterface $link */ 397 $link = $element->link; 398 if ($link) { 399 $id = 'menu_plugin_id:' . $link->getPluginId(); 400 $form[$id]['#item'] = $element; 401 $form[$id]['#attributes'] = $link->isEnabled() ? ['class' => ['menu-enabled']] : ['class' => ['menu-disabled']]; 402 $form[$id]['title'] = Link::fromTextAndUrl($link->getTitle(), $link->getUrlObject())->toRenderable(); 403 if (!$link->isEnabled()) { 404 $form[$id]['title']['#suffix'] = ' (' . $this->t('disabled') . ')'; 405 } 406 // @todo Remove this in https://www.drupal.org/node/2568785. 407 elseif ($id === 'menu_plugin_id:user.logout') { 408 $form[$id]['title']['#suffix'] = ' (' . $this->t('<q>Log in</q> for anonymous users') . ')'; 409 } 410 // @todo Remove this in https://www.drupal.org/node/2568785. 411 elseif (($url = $link->getUrlObject()) && $url->isRouted() && $url->getRouteName() == 'user.page') { 412 $form[$id]['title']['#suffix'] = ' (' . $this->t('logged in users only') . ')'; 413 } 414 415 $form[$id]['enabled'] = [ 416 '#type' => 'checkbox', 417 '#title' => $this->t('Enable @title menu link', ['@title' => $link->getTitle()]), 418 '#title_display' => 'invisible', 419 '#default_value' => $link->isEnabled(), 420 ]; 421 $form[$id]['weight'] = [ 422 '#type' => 'weight', 423 '#delta' => $delta, 424 '#default_value' => $link->getWeight(), 425 '#title' => $this->t('Weight for @title', ['@title' => $link->getTitle()]), 426 '#title_display' => 'invisible', 427 ]; 428 $form[$id]['id'] = [ 429 '#type' => 'hidden', 430 '#value' => $link->getPluginId(), 431 ]; 432 $form[$id]['parent'] = [ 433 '#type' => 'hidden', 434 '#default_value' => $link->getParent(), 435 ]; 436 // Build a list of operations. 437 $operations = []; 438 $operations['edit'] = [ 439 'title' => $this->t('Edit'), 440 ]; 441 // Allow for a custom edit link per plugin. 442 $edit_route = $link->getEditRoute(); 443 if ($edit_route) { 444 $operations['edit']['url'] = $edit_route; 445 // Bring the user back to the menu overview. 446 $operations['edit']['query'] = $this->getDestinationArray(); 447 } 448 else { 449 // Fall back to the standard edit link. 450 $operations['edit'] += [ 451 'url' => Url::fromRoute('menu_ui.link_edit', ['menu_link_plugin' => $link->getPluginId()]), 452 ]; 453 } 454 // Links can either be reset or deleted, not both. 455 if ($link->isResettable()) { 456 $operations['reset'] = [ 457 'title' => $this->t('Reset'), 458 'url' => Url::fromRoute('menu_ui.link_reset', ['menu_link_plugin' => $link->getPluginId()]), 459 ]; 460 } 461 elseif ($delete_link = $link->getDeleteRoute()) { 462 $operations['delete']['url'] = $delete_link; 463 $operations['delete']['query'] = $this->getDestinationArray(); 464 $operations['delete']['title'] = $this->t('Delete'); 465 } 466 if ($link->isTranslatable()) { 467 $operations['translate'] = [ 468 'title' => $this->t('Translate'), 469 'url' => $link->getTranslateRoute(), 470 ]; 471 } 472 $form[$id]['operations'] = [ 473 '#type' => 'operations', 474 '#links' => $operations, 475 ]; 476 } 477 478 if ($element->subtree) { 479 $this->buildOverviewTreeForm($element->subtree, $delta); 480 } 481 } 482 483 $tree_access_cacheability 484 ->merge(CacheableMetadata::createFromRenderArray($form)) 485 ->applyTo($form); 486 487 return $form; 488 } 489 490 /** 491 * Submit handler for the menu overview form. 492 * 493 * This function takes great care in saving parent items first, then items 494 * underneath them. Saving items in the incorrect order can break the tree. 495 */ 496 protected function submitOverviewForm(array $complete_form, FormStateInterface $form_state) { 497 // Form API supports constructing and validating self-contained sections 498 // within forms, but does not allow to handle the form section's submission 499 // equally separated yet. Therefore, we use a $form_state key to point to 500 // the parents of the form section. 501 $parents = $form_state->get('menu_overview_form_parents'); 502 $input = NestedArray::getValue($form_state->getUserInput(), $parents); 503 $form = &NestedArray::getValue($complete_form, $parents); 504 505 // When dealing with saving menu items, the order in which these items are 506 // saved is critical. If a changed child item is saved before its parent, 507 // the child item could be saved with an invalid path past its immediate 508 // parent. To prevent this, save items in the form in the same order they 509 // are sent, ensuring parents are saved first, then their children. 510 // See https://www.drupal.org/node/181126#comment-632270. 511 $order = is_array($input) ? array_flip(array_keys($input)) : []; 512 // Update our original form with the new order. 513 $form = array_intersect_key(array_merge($order, $form), $form); 514 515 $fields = ['weight', 'parent', 'enabled']; 516 $form_links = $form['links']; 517 foreach (Element::children($form_links) as $id) { 518 if (isset($form_links[$id]['#item'])) { 519 $element = $form_links[$id]; 520 $updated_values = []; 521 // Update any fields that have changed in this menu item. 522 foreach ($fields as $field) { 523 if (isset($element[$field]['#value']) && $element[$field]['#value'] != $element[$field]['#default_value']) { 524 $updated_values[$field] = $element[$field]['#value']; 525 } 526 } 527 if ($updated_values) { 528 // Use the ID from the actual plugin instance since the hidden value 529 // in the form could be tampered with. 530 $this->menuLinkManager->updateDefinition($element['#item']->link->getPLuginId(), $updated_values); 531 } 532 } 533 } 534 } 535 536} 537