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