From 5ad8f598e5a36f9913485804c117b3fa5e41bf6f Mon Sep 17 00:00:00 2001
From: Francesco Placella <plach@183211.no-reply.drupal.org>
Date: Mon, 11 Mar 2019 14:38:31 +0100
Subject: [PATCH] Issue #2880152 by amateescu, blazey, plach, larowlan,
 vijaycs85: Convert custom menu links to be revisionable

---
 .../menu_link_content.module                  |  10 ++
 .../menu_link_content.post_update.php         | 103 ++++++++++++++++
 .../src/Entity/MenuLinkContent.php            |  45 +++++--
 .../src/Form/MenuLinkContentForm.php          |  18 +--
 .../src/MenuLinkContentInterface.php          |   3 +-
 .../src/MenuLinkContentStorage.php            |  44 +++++++
 .../src/MenuLinkContentStorageInterface.php   |  23 ++++
 .../MenuTreeHierarchyConstraint.php           |  31 +++++
 .../MenuTreeHierarchyConstraintValidator.php  |  66 +++++++++++
 .../Rest/MenuLinkContentResourceTestBase.php  |  15 +++
 .../Update/MenuLinkContentUpdateTest.php      |  43 +++++++
 .../src/Kernel/MenuLinkContentDeriverTest.php |   3 +-
 .../tests/src/Kernel/MenuLinksTest.php        | 110 ++++++++++++++++++
 .../Kernel/PathAliasMenuLinkContentTest.php   |   3 +-
 core/modules/menu_ui/src/MenuForm.php         |  64 +++++++++-
 .../tests/src/Functional/MenuUiTest.php       |  44 +++++++
 .../src/Tests/KernelTestBaseTest.php          |   2 +-
 .../KernelTests/Core/Command/DbDumpTest.php   |   2 +
 .../Core/Menu/MenuLinkTreeTest.php            |   2 +
 19 files changed, 598 insertions(+), 33 deletions(-)
 create mode 100644 core/modules/menu_link_content/menu_link_content.post_update.php
 create mode 100644 core/modules/menu_link_content/src/MenuLinkContentStorage.php
 create mode 100644 core/modules/menu_link_content/src/MenuLinkContentStorageInterface.php
 create mode 100644 core/modules/menu_link_content/src/Plugin/Validation/Constraint/MenuTreeHierarchyConstraint.php
 create mode 100644 core/modules/menu_link_content/src/Plugin/Validation/Constraint/MenuTreeHierarchyConstraintValidator.php

diff --git a/core/modules/menu_link_content/menu_link_content.module b/core/modules/menu_link_content/menu_link_content.module
index a5541cda2619..2960834a5c6e 100644
--- a/core/modules/menu_link_content/menu_link_content.module
+++ b/core/modules/menu_link_content/menu_link_content.module
@@ -29,6 +29,16 @@ function menu_link_content_help($route_name, RouteMatchInterface $route_match) {
   }
 }
 
+/**
+ * Implements hook_entity_type_alter().
+ */
+function menu_link_content_entity_type_alter(array &$entity_types) {
+  // @todo Moderation is disabled for custom menu links until when we have an UI
+  //   for them.
+  //   @see https://www.drupal.org/project/drupal/issues/2350939
+  $entity_types['menu_link_content']->setHandlerClass('moderation', '');
+}
+
 /**
  * Implements hook_menu_delete().
  */
diff --git a/core/modules/menu_link_content/menu_link_content.post_update.php b/core/modules/menu_link_content/menu_link_content.post_update.php
new file mode 100644
index 000000000000..0c0a64388e57
--- /dev/null
+++ b/core/modules/menu_link_content/menu_link_content.post_update.php
@@ -0,0 +1,103 @@
+<?php
+
+/**
+ * @file
+ * Post update functions for the Menu link content module.
+ */
+
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+
+/**
+ * Update custom menu links to be revisionable.
+ */
+function menu_link_content_post_update_make_menu_link_content_revisionable(&$sandbox) {
+  $definition_update_manager = \Drupal::entityDefinitionUpdateManager();
+  /** @var \Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface $last_installed_schema_repository */
+  $last_installed_schema_repository = \Drupal::service('entity.last_installed_schema.repository');
+
+  $entity_type = $definition_update_manager->getEntityType('menu_link_content');
+  $field_storage_definitions = $last_installed_schema_repository->getLastInstalledFieldStorageDefinitions('menu_link_content');
+
+  // Update the entity type definition.
+  $entity_keys = $entity_type->getKeys();
+  $entity_keys['revision'] = 'revision_id';
+  $entity_keys['revision_translation_affected'] = 'revision_translation_affected';
+  $entity_type->set('entity_keys', $entity_keys);
+  $entity_type->set('revision_table', 'menu_link_content_revision');
+  $entity_type->set('revision_data_table', 'menu_link_content_field_revision');
+  $revision_metadata_keys = [
+    'revision_default' => 'revision_default',
+    'revision_user' => 'revision_user',
+    'revision_created' => 'revision_created',
+    'revision_log_message' => 'revision_log_message',
+  ];
+  $entity_type->set('revision_metadata_keys', $revision_metadata_keys);
+
+  // Update the field storage definitions and add the new ones required by a
+  // revisionable entity type.
+  $field_storage_definitions['langcode']->setRevisionable(TRUE);
+  $field_storage_definitions['title']->setRevisionable(TRUE);
+  $field_storage_definitions['description']->setRevisionable(TRUE);
+  $field_storage_definitions['link']->setRevisionable(TRUE);
+  $field_storage_definitions['external']->setRevisionable(TRUE);
+  $field_storage_definitions['enabled']->setRevisionable(TRUE);
+  $field_storage_definitions['changed']->setRevisionable(TRUE);
+
+  $field_storage_definitions['revision_id'] = BaseFieldDefinition::create('integer')
+    ->setName('revision_id')
+    ->setTargetEntityTypeId('menu_link_content')
+    ->setTargetBundle(NULL)
+    ->setLabel(new TranslatableMarkup('Revision ID'))
+    ->setReadOnly(TRUE)
+    ->setSetting('unsigned', TRUE);
+
+  $field_storage_definitions['revision_default'] = BaseFieldDefinition::create('boolean')
+    ->setName('revision_default')
+    ->setTargetEntityTypeId('menu_link_content')
+    ->setTargetBundle(NULL)
+    ->setLabel(new TranslatableMarkup('Default revision'))
+    ->setDescription(new TranslatableMarkup('A flag indicating whether this was a default revision when it was saved.'))
+    ->setStorageRequired(TRUE)
+    ->setInternal(TRUE)
+    ->setTranslatable(FALSE)
+    ->setRevisionable(TRUE);
+
+  $field_storage_definitions['revision_translation_affected'] = BaseFieldDefinition::create('boolean')
+    ->setName('revision_translation_affected')
+    ->setTargetEntityTypeId('menu_link_content')
+    ->setTargetBundle(NULL)
+    ->setLabel(new TranslatableMarkup('Revision translation affected'))
+    ->setDescription(new TranslatableMarkup('Indicates if the last edit of a translation belongs to current revision.'))
+    ->setReadOnly(TRUE)
+    ->setRevisionable(TRUE)
+    ->setTranslatable(TRUE);
+
+  $field_storage_definitions['revision_created'] = BaseFieldDefinition::create('created')
+    ->setName('revision_created')
+    ->setTargetEntityTypeId('menu_link_content')
+    ->setTargetBundle(NULL)
+    ->setLabel(new TranslatableMarkup('Revision create time'))
+    ->setDescription(new TranslatableMarkup('The time that the current revision was created.'))
+    ->setRevisionable(TRUE);
+  $field_storage_definitions['revision_user'] = BaseFieldDefinition::create('entity_reference')
+    ->setName('revision_user')
+    ->setTargetEntityTypeId('menu_link_content')
+    ->setTargetBundle(NULL)
+    ->setLabel(new TranslatableMarkup('Revision user'))
+    ->setDescription(new TranslatableMarkup('The user ID of the author of the current revision.'))
+    ->setSetting('target_type', 'user')
+    ->setRevisionable(TRUE);
+  $field_storage_definitions['revision_log_message'] = BaseFieldDefinition::create('string_long')
+    ->setName('revision_log_message')
+    ->setTargetEntityTypeId('menu_link_content')
+    ->setTargetBundle(NULL)
+    ->setLabel(new TranslatableMarkup('Revision log message'))
+    ->setDescription(new TranslatableMarkup('Briefly describe the changes you have made.'))
+    ->setRevisionable(TRUE)
+    ->setDefaultValue('');
+
+  $definition_update_manager->updateFieldableEntityType($entity_type, $field_storage_definitions, $sandbox);
+
+  return t('Custom menu links have been converted to be revisionable.');
+}
diff --git a/core/modules/menu_link_content/src/Entity/MenuLinkContent.php b/core/modules/menu_link_content/src/Entity/MenuLinkContent.php
index 3f637ecd4878..90ff62eaea1b 100644
--- a/core/modules/menu_link_content/src/Entity/MenuLinkContent.php
+++ b/core/modules/menu_link_content/src/Entity/MenuLinkContent.php
@@ -2,9 +2,7 @@
 
 namespace Drupal\menu_link_content\Entity;
 
-use Drupal\Core\Entity\ContentEntityBase;
-use Drupal\Core\Entity\EntityChangedTrait;
-use Drupal\Core\Entity\EntityPublishedTrait;
+use Drupal\Core\Entity\EditorialContentEntityBase;
 use Drupal\Core\Entity\EntityStorageInterface;
 use Drupal\Core\Entity\EntityTypeInterface;
 use Drupal\Core\Field\BaseFieldDefinition;
@@ -28,7 +26,7 @@
  *     plural = "@count custom menu links",
  *   ),
  *   handlers = {
- *     "storage" = "Drupal\Core\Entity\Sql\SqlContentEntityStorage",
+ *     "storage" = "\Drupal\menu_link_content\MenuLinkContentStorage",
  *     "storage_schema" = "Drupal\menu_link_content\MenuLinkContentStorageSchema",
  *     "access" = "Drupal\menu_link_content\MenuLinkContentAccessControlHandler",
  *     "form" = {
@@ -39,26 +37,34 @@
  *   admin_permission = "administer menu",
  *   base_table = "menu_link_content",
  *   data_table = "menu_link_content_data",
+ *   revision_table = "menu_link_content_revision",
+ *   revision_data_table = "menu_link_content_field_revision",
  *   translatable = TRUE,
  *   entity_keys = {
  *     "id" = "id",
+ *     "revision" = "revision_id",
  *     "label" = "title",
  *     "langcode" = "langcode",
  *     "uuid" = "uuid",
  *     "bundle" = "bundle",
  *     "published" = "enabled",
  *   },
+ *   revision_metadata_keys = {
+ *     "revision_user" = "revision_user",
+ *     "revision_created" = "revision_created",
+ *     "revision_log_message" = "revision_log_message",
+ *   },
  *   links = {
  *     "canonical" = "/admin/structure/menu/item/{menu_link_content}/edit",
  *     "edit-form" = "/admin/structure/menu/item/{menu_link_content}/edit",
  *     "delete-form" = "/admin/structure/menu/item/{menu_link_content}/delete",
- *   }
+ *   },
+ *   constraints = {
+ *     "MenuTreeHierarchy" = {}
+ *   },
  * )
  */
-class MenuLinkContent extends ContentEntityBase implements MenuLinkContentInterface {
-
-  use EntityChangedTrait;
-  use EntityPublishedTrait;
+class MenuLinkContent extends EditorialContentEntityBase implements MenuLinkContentInterface {
 
   /**
    * A flag for whether this entity is wrapped in a plugin instance.
@@ -203,6 +209,11 @@ public function preSave(EntityStorageInterface $storage) {
   public function postSave(EntityStorageInterface $storage, $update = TRUE) {
     parent::postSave($storage, $update);
 
+    // Don't update the menu tree if a pending revision was saved.
+    if (!$this->isDefaultRevision()) {
+      return;
+    }
+
     /** @var \Drupal\Core\Menu\MenuLinkManagerInterface $menu_link_manager */
     $menu_link_manager = \Drupal::service('plugin.manager.menu.link');
 
@@ -277,6 +288,7 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
       ->setDescription(t('The text to be used for this link in the menu.'))
       ->setRequired(TRUE)
       ->setTranslatable(TRUE)
+      ->setRevisionable(TRUE)
       ->setSetting('max_length', 255)
       ->setDisplayOptions('view', [
         'label' => 'hidden',
@@ -293,6 +305,7 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
       ->setLabel(t('Description'))
       ->setDescription(t('Shown when hovering over the menu link.'))
       ->setTranslatable(TRUE)
+      ->setRevisionable(TRUE)
       ->setSetting('max_length', 255)
       ->setDisplayOptions('view', [
         'label' => 'hidden',
@@ -313,6 +326,7 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
     $fields['link'] = BaseFieldDefinition::create('link')
       ->setLabel(t('Link'))
       ->setDescription(t('The location this menu link points to.'))
+      ->setRevisionable(TRUE)
       ->setRequired(TRUE)
       ->setSettings([
         'link_type' => LinkItemInterface::LINK_GENERIC,
@@ -326,7 +340,8 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
     $fields['external'] = BaseFieldDefinition::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).'))
-      ->setDefaultValue(FALSE);
+      ->setDefaultValue(FALSE)
+      ->setRevisionable(TRUE);
 
     $fields['rediscover'] = BaseFieldDefinition::create('boolean')
       ->setLabel(t('Indicates whether the menu link should be rediscovered'))
@@ -365,7 +380,6 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
     $fields['enabled']->setLabel(t('Enabled'));
     $fields['enabled']->setDescription(t('A flag for whether the link should be enabled in menus or hidden.'));
     $fields['enabled']->setTranslatable(FALSE);
-    $fields['enabled']->setRevisionable(FALSE);
     $fields['enabled']->setDisplayOptions('view', [
       'label' => 'hidden',
       'type' => 'boolean',
@@ -383,7 +397,14 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
     $fields['changed'] = BaseFieldDefinition::create('changed')
       ->setLabel(t('Changed'))
       ->setDescription(t('The time that the menu link was last edited.'))
-      ->setTranslatable(TRUE);
+      ->setTranslatable(TRUE)
+      ->setRevisionable(TRUE);
+
+    // @todo Keep this field hidden until we have a revision UI for menu links.
+    //   @see https://www.drupal.org/project/drupal/issues/2350939
+    $fields['revision_log_message']->setDisplayOptions('form', [
+      'region' => 'hidden',
+    ]);
 
     return $fields;
   }
diff --git a/core/modules/menu_link_content/src/Form/MenuLinkContentForm.php b/core/modules/menu_link_content/src/Form/MenuLinkContentForm.php
index 1e0ecd9aea9c..a7525c0ddc3d 100644
--- a/core/modules/menu_link_content/src/Form/MenuLinkContentForm.php
+++ b/core/modules/menu_link_content/src/Form/MenuLinkContentForm.php
@@ -127,19 +127,11 @@ public function buildEntity(array $form, FormStateInterface $form_state) {
   public function save(array $form, FormStateInterface $form_state) {
     // The entity is rebuilt in parent::submit().
     $menu_link = $this->entity;
-    $saved = $menu_link->save();
-
-    if ($saved) {
-      $this->messenger()->addStatus($this->t('The menu link has been saved.'));
-      $form_state->setRedirect(
-        'entity.menu_link_content.canonical',
-        ['menu_link_content' => $menu_link->id()]
-      );
-    }
-    else {
-      $this->messenger()->addError($this->t('There was an error saving the menu link.'));
-      $form_state->setRebuild();
-    }
+    $menu_link->save();
+
+    $this->messenger()->addStatus($this->t('The menu link has been saved.'));
+
+    $form_state->setRedirectUrl($menu_link->toUrl('canonical'));
   }
 
 }
diff --git a/core/modules/menu_link_content/src/MenuLinkContentInterface.php b/core/modules/menu_link_content/src/MenuLinkContentInterface.php
index 1d01522dca33..583d16209731 100644
--- a/core/modules/menu_link_content/src/MenuLinkContentInterface.php
+++ b/core/modules/menu_link_content/src/MenuLinkContentInterface.php
@@ -5,11 +5,12 @@
 use Drupal\Core\Entity\EntityChangedInterface;
 use Drupal\Core\Entity\ContentEntityInterface;
 use Drupal\Core\Entity\EntityPublishedInterface;
+use Drupal\Core\Entity\RevisionLogInterface;
 
 /**
  * Defines an interface for custom menu links.
  */
-interface MenuLinkContentInterface extends ContentEntityInterface, EntityChangedInterface, EntityPublishedInterface {
+interface MenuLinkContentInterface extends ContentEntityInterface, EntityChangedInterface, EntityPublishedInterface, RevisionLogInterface {
 
   /**
    * Flags this instance as being wrapped in a menu link plugin instance.
diff --git a/core/modules/menu_link_content/src/MenuLinkContentStorage.php b/core/modules/menu_link_content/src/MenuLinkContentStorage.php
new file mode 100644
index 000000000000..a5c59a77cc4b
--- /dev/null
+++ b/core/modules/menu_link_content/src/MenuLinkContentStorage.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\menu_link_content;
+
+use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
+
+/**
+ * Storage handler for menu_link_content entities.
+ */
+class MenuLinkContentStorage extends SqlContentEntityStorage implements MenuLinkContentStorageInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getMenuLinkIdsWithPendingRevisions() {
+    $table_mapping = $this->getTableMapping();
+    $id_field = $table_mapping->getColumnNames($this->entityType->getKey('id'))['value'];
+    $revision_field = $table_mapping->getColumnNames($this->entityType->getKey('revision'))['value'];
+    $rta_field = $table_mapping->getColumnNames($this->entityType->getKey('revision_translation_affected'))['value'];
+    $langcode_field = $table_mapping->getColumnNames($this->entityType->getKey('langcode'))['value'];
+    $revision_default_field = $table_mapping->getColumnNames($this->entityType->getRevisionMetadataKey('revision_default'))['value'];
+
+    $query = $this->database->select($this->getRevisionDataTable(), 'mlfr');
+    $query->fields('mlfr', [$id_field]);
+    $query->addExpression("MAX(mlfr.$revision_field)", $revision_field);
+
+    $query->join($this->getRevisionTable(), 'mlr', "mlfr.$revision_field = mlr.$revision_field AND mlr.$revision_default_field = 0");
+
+    $inner_select = $this->database->select($this->getRevisionDataTable(), 't');
+    $inner_select->condition("t.$rta_field", '1');
+    $inner_select->fields('t', [$id_field, $langcode_field]);
+    $inner_select->addExpression("MAX(t.$revision_field)", $revision_field);
+    $inner_select
+      ->groupBy("t.$id_field")
+      ->groupBy("t.$langcode_field");
+
+    $query->join($inner_select, 'mr', "mlfr.$revision_field = mr.$revision_field AND mlfr.$langcode_field = mr.$langcode_field");
+
+    $query->groupBy("mlfr.$id_field");
+
+    return $query->execute()->fetchAllKeyed(1, 0);
+  }
+
+}
diff --git a/core/modules/menu_link_content/src/MenuLinkContentStorageInterface.php b/core/modules/menu_link_content/src/MenuLinkContentStorageInterface.php
new file mode 100644
index 000000000000..ffd9fbd7df77
--- /dev/null
+++ b/core/modules/menu_link_content/src/MenuLinkContentStorageInterface.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Drupal\menu_link_content;
+
+use Drupal\Core\Entity\ContentEntityStorageInterface;
+
+/**
+ * Defines an interface for menu_link_content entity storage classes.
+ */
+interface MenuLinkContentStorageInterface extends ContentEntityStorageInterface {
+
+  /**
+   * Gets a list of menu link IDs with pending revisions.
+   *
+   * @return int[]
+   *   An array of menu link IDs which have pending revisions, keyed by their
+   *   revision IDs.
+   *
+   * @internal
+   */
+  public function getMenuLinkIdsWithPendingRevisions();
+
+}
diff --git a/core/modules/menu_link_content/src/Plugin/Validation/Constraint/MenuTreeHierarchyConstraint.php b/core/modules/menu_link_content/src/Plugin/Validation/Constraint/MenuTreeHierarchyConstraint.php
new file mode 100644
index 000000000000..4c2b7b2d7133
--- /dev/null
+++ b/core/modules/menu_link_content/src/Plugin/Validation/Constraint/MenuTreeHierarchyConstraint.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\menu_link_content\Plugin\Validation\Constraint;
+
+use Drupal\Core\Entity\Plugin\Validation\Constraint\CompositeConstraintBase;
+
+/**
+ * Validation constraint for changing the menu hierarchy in pending revisions.
+ *
+ * @Constraint(
+ *   id = "MenuTreeHierarchy",
+ *   label = @Translation("Menu tree hierarchy.", context = "Validation"),
+ * )
+ */
+class MenuTreeHierarchyConstraint extends CompositeConstraintBase {
+
+  /**
+   * The default violation message.
+   *
+   * @var string
+   */
+  public $message = 'You can only change the hierarchy for the <em>published</em> version of this menu link.';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function coversFields() {
+    return ['parent', 'weight'];
+  }
+
+}
diff --git a/core/modules/menu_link_content/src/Plugin/Validation/Constraint/MenuTreeHierarchyConstraintValidator.php b/core/modules/menu_link_content/src/Plugin/Validation/Constraint/MenuTreeHierarchyConstraintValidator.php
new file mode 100644
index 000000000000..5a4c3da849a0
--- /dev/null
+++ b/core/modules/menu_link_content/src/Plugin/Validation/Constraint/MenuTreeHierarchyConstraintValidator.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\menu_link_content\Plugin\Validation\Constraint;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\ConstraintValidator;
+
+/**
+ * Constraint validator for changing menu link parents in pending revisions.
+ */
+class MenuTreeHierarchyConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  private $entityTypeManager;
+
+  /**
+   * Creates a new MenuTreeHierarchyConstraintValidator instance.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity_type.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate($entity, Constraint $constraint) {
+    if ($entity && !$entity->isNew() && !$entity->isDefaultRevision()) {
+      $original = $this->entityTypeManager->getStorage($entity->getEntityTypeId())->loadUnchanged($entity->id());
+
+      // Ensure that empty items do not affect the comparison checks below.
+      // @todo Remove this filtering when
+      //   https://www.drupal.org/project/drupal/issues/3039031 is fixed.
+      $entity->parent->filterEmptyItems();
+      if (($entity->parent->isEmpty() !== $original->parent->isEmpty()) || !$entity->parent->equals($original->parent)) {
+        $this->context->buildViolation($constraint->message)
+          ->atPath('menu_parent')
+          ->addViolation();
+      }
+      if (!$entity->weight->equals($original->weight)) {
+        $this->context->buildViolation($constraint->message)
+          ->atPath('weight')
+          ->addViolation();
+      }
+    }
+  }
+
+}
diff --git a/core/modules/menu_link_content/tests/src/Functional/Rest/MenuLinkContentResourceTestBase.php b/core/modules/menu_link_content/tests/src/Functional/Rest/MenuLinkContentResourceTestBase.php
index 6c076e4ce69f..7e3a86ec2091 100644
--- a/core/modules/menu_link_content/tests/src/Functional/Rest/MenuLinkContentResourceTestBase.php
+++ b/core/modules/menu_link_content/tests/src/Functional/Rest/MenuLinkContentResourceTestBase.php
@@ -120,6 +120,11 @@ protected function getExpectedNormalizedEntity() {
           'value' => 1,
         ],
       ],
+      'revision_id' => [
+        [
+          'value' => 1,
+        ],
+      ],
       'title' => [
         [
           'value' => 'Llama Gabilondo',
@@ -191,6 +196,16 @@ protected function getExpectedNormalizedEntity() {
         ],
       ],
       'parent' => [],
+      'revision_created' => [
+        $this->formatExpectedTimestampItemValues((int) $this->entity->getRevisionCreationTime()),
+      ],
+      'revision_user' => [],
+      'revision_log_message' => [],
+      'revision_translation_affected' => [
+        [
+          'value' => TRUE,
+        ],
+      ],
     ];
   }
 
diff --git a/core/modules/menu_link_content/tests/src/Functional/Update/MenuLinkContentUpdateTest.php b/core/modules/menu_link_content/tests/src/Functional/Update/MenuLinkContentUpdateTest.php
index 145863790e0c..29df303f6a7c 100644
--- a/core/modules/menu_link_content/tests/src/Functional/Update/MenuLinkContentUpdateTest.php
+++ b/core/modules/menu_link_content/tests/src/Functional/Update/MenuLinkContentUpdateTest.php
@@ -59,6 +59,49 @@ public function testPublishedEntityKeyAddition() {
     $this->assertTrue($menu_link->isPublished());
   }
 
+  /**
+   * Tests the conversion of custom menu links to be revisionable.
+   *
+   * @see menu_link_content_post_update_make_menu_link_content_revisionable()
+   */
+  public function testConversionToRevisionable() {
+    $entity_type = \Drupal::entityDefinitionUpdateManager()->getEntityType('menu_link_content');
+    $this->assertFalse($entity_type->isRevisionable());
+
+    $this->runUpdates();
+
+    $entity_type = \Drupal::entityDefinitionUpdateManager()->getEntityType('menu_link_content');
+    $this->assertTrue($entity_type->isRevisionable());
+
+    // Log in as user 1.
+    $account = User::load(1);
+    $account->passRaw = 'drupal';
+    $this->drupalLogin($account);
+
+    // Make sure our custom menu link exists.
+    $assert_session = $this->assertSession();
+    $this->drupalGet('admin/structure/menu/item/1/edit');
+    $assert_session->checkboxChecked('edit-enabled-value');
+
+    // Check that custom menu links can be created, saved and then loaded.
+    $storage = \Drupal::entityTypeManager()->getStorage('menu_link_content');
+    /** @var \Drupal\menu_link_content\Entity\MenuLinkContent $menu_link */
+    $menu_link = $storage->create([
+      'menu_name' => 'main',
+      'link' => 'route:user.page',
+      'title' => 'Pineapple',
+    ]);
+    $menu_link->save();
+
+    $storage->resetCache();
+    $menu_link = $storage->loadRevision($menu_link->getRevisionId());
+
+    $this->assertEquals('main', $menu_link->getMenuName());
+    $this->assertEquals('Pineapple', $menu_link->label());
+    $this->assertEquals('route:user.page', $menu_link->link->uri);
+    $this->assertTrue($menu_link->isPublished());
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/core/modules/menu_link_content/tests/src/Kernel/MenuLinkContentDeriverTest.php b/core/modules/menu_link_content/tests/src/Kernel/MenuLinkContentDeriverTest.php
index 12d8d35afd16..a5202f31747f 100644
--- a/core/modules/menu_link_content/tests/src/Kernel/MenuLinkContentDeriverTest.php
+++ b/core/modules/menu_link_content/tests/src/Kernel/MenuLinkContentDeriverTest.php
@@ -18,7 +18,7 @@ class MenuLinkContentDeriverTest extends KernelTestBase {
   /**
    * {@inheritdoc}
    */
-  public static $modules = ['menu_link_content', 'link', 'system', 'menu_link_content_dynamic_route'];
+  public static $modules = ['menu_link_content', 'link', 'system', 'menu_link_content_dynamic_route', 'user'];
 
   /**
    * {@inheritdoc}
@@ -26,6 +26,7 @@ class MenuLinkContentDeriverTest extends KernelTestBase {
   protected function setUp() {
     parent::setUp();
 
+    $this->installEntitySchema('user');
     $this->installEntitySchema('menu_link_content');
   }
 
diff --git a/core/modules/menu_link_content/tests/src/Kernel/MenuLinksTest.php b/core/modules/menu_link_content/tests/src/Kernel/MenuLinksTest.php
index b5e5c274842b..c5bf428eb7f1 100644
--- a/core/modules/menu_link_content/tests/src/Kernel/MenuLinksTest.php
+++ b/core/modules/menu_link_content/tests/src/Kernel/MenuLinksTest.php
@@ -310,4 +310,114 @@ public function testModuleUninstalledMenuLinks() {
     $this->assertEqual(count($menu_links), 0);
   }
 
+  /**
+   * Tests handling of pending revisions.
+   *
+   * @coversDefaultClass \Drupal\menu_link_content\Plugin\Validation\Constraint\MenuTreeHierarchyConstraintValidator
+   */
+  public function testPendingRevisions() {
+    /** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
+    $storage = \Drupal::entityTypeManager()->getStorage('menu_link_content');
+
+    // Add new menu items in a hierarchy.
+    $default_root_1_title = $this->randomMachineName(8);
+    $root_1 = $storage->create([
+      'title' => $default_root_1_title,
+      'link' => [['uri' => 'internal:/#root_1']],
+      'menu_name' => 'menu_test',
+    ]);
+    $root_1->save();
+    $default_child1_title = $this->randomMachineName(8);
+    $child1 = $storage->create([
+      'title' => $default_child1_title,
+      'link' => [['uri' => 'internal:/#child1']],
+      'menu_name' => 'menu_test',
+      'parent' => 'menu_link_content:' . $root_1->uuid(),
+    ]);
+    $child1->save();
+    $default_child2_title = $this->randomMachineName(8);
+    $child2 = $storage->create([
+      'title' => $default_child2_title,
+      'link' => [['uri' => 'internal:/#child2']],
+      'menu_name' => 'menu_test',
+      'parent' => 'menu_link_content:' . $child1->uuid(),
+    ]);
+    $child2->save();
+    $default_root_2_title = $this->randomMachineName(8);
+    $root_2 = $storage->create([
+      'title' => $default_root_2_title,
+      'link' => [['uri' => 'internal:/#root_2']],
+      'menu_name' => 'menu_test',
+    ]);
+    $root_2->save();
+
+    // Check that changing the title and the link in a pending revision is
+    // allowed.
+    $pending_child1_title = $this->randomMachineName(8);
+    $child1_pending_revision = $storage->createRevision($child1, FALSE);
+    $child1_pending_revision->set('title', $pending_child1_title);
+    $child1_pending_revision->set('link', [['uri' => 'internal:/#test']]);
+
+    $violations = $child1_pending_revision->validate();
+    $this->assertEmpty($violations);
+    $child1_pending_revision->save();
+
+    $storage->resetCache();
+    $child1_pending_revision = $storage->loadRevision($child1_pending_revision->getRevisionId());
+    $this->assertFalse($child1_pending_revision->isDefaultRevision());
+    $this->assertEquals($pending_child1_title, $child1_pending_revision->getTitle());
+    $this->assertEquals('/#test', $child1_pending_revision->getUrlObject()->toString());
+
+    // Check that saving a pending revision does not affect the menu tree.
+    $menu_tree = \Drupal::menuTree()->load('menu_test', new MenuTreeParameters());
+    $parent_link = reset($menu_tree);
+    $this->assertEquals($default_root_1_title, $parent_link->link->getTitle());
+    $this->assertEquals('/#root_1', $parent_link->link->getUrlObject()->toString());
+
+    $child1_link = reset($parent_link->subtree);
+    $this->assertEquals($default_child1_title, $child1_link->link->getTitle());
+    $this->assertEquals('/#child1', $child1_link->link->getUrlObject()->toString());
+
+    $child2_link = reset($child1_link->subtree);
+    $this->assertEquals($default_child2_title, $child2_link->link->getTitle());
+    $this->assertEquals('/#child2', $child2_link->link->getUrlObject()->toString());
+
+    // Check that changing the parent in a pending revision is not allowed.
+    $child2_pending_revision = $storage->createRevision($child2, FALSE);
+    $child2_pending_revision->set('parent', $child1->id());
+    $violations = $child2_pending_revision->validate();
+    $this->assertCount(1, $violations);
+    $this->assertEquals('You can only change the hierarchy for the <em>published</em> version of this menu link.', $violations[0]->getMessage());
+    $this->assertEquals('menu_parent', $violations[0]->getPropertyPath());
+
+    // Check that changing the weight in a pending revision is not allowed.
+    $child2_pending_revision = $storage->createRevision($child2, FALSE);
+    $child2_pending_revision->set('weight', 500);
+    $violations = $child2_pending_revision->validate();
+    $this->assertCount(1, $violations);
+    $this->assertEquals('You can only change the hierarchy for the <em>published</em> version of this menu link.', $violations[0]->getMessage());
+    $this->assertEquals('weight', $violations[0]->getPropertyPath());
+
+    // Check that changing both the parent and the weight in a pending revision
+    // is not allowed.
+    $child2_pending_revision = $storage->createRevision($child2, FALSE);
+    $child2_pending_revision->set('parent', $child1->id());
+    $child2_pending_revision->set('weight', 500);
+    $violations = $child2_pending_revision->validate();
+    $this->assertCount(2, $violations);
+    $this->assertEquals('You can only change the hierarchy for the <em>published</em> version of this menu link.', $violations[0]->getMessage());
+    $this->assertEquals('You can only change the hierarchy for the <em>published</em> version of this menu link.', $violations[1]->getMessage());
+    $this->assertEquals('menu_parent', $violations[0]->getPropertyPath());
+    $this->assertEquals('weight', $violations[1]->getPropertyPath());
+
+    // Check that changing the parent of a term which didn't have a parent
+    // initially is not allowed in a pending revision.
+    $root_2_pending_revision = $storage->createRevision($root_2, FALSE);
+    $root_2_pending_revision->set('parent', $root_1->id());
+    $violations = $root_2_pending_revision->validate();
+    $this->assertCount(1, $violations);
+    $this->assertEquals('You can only change the hierarchy for the <em>published</em> version of this menu link.', $violations[0]->getMessage());
+    $this->assertEquals('menu_parent', $violations[0]->getPropertyPath());
+  }
+
 }
diff --git a/core/modules/menu_link_content/tests/src/Kernel/PathAliasMenuLinkContentTest.php b/core/modules/menu_link_content/tests/src/Kernel/PathAliasMenuLinkContentTest.php
index 6a7cf4a363d1..104f356cf6ac 100644
--- a/core/modules/menu_link_content/tests/src/Kernel/PathAliasMenuLinkContentTest.php
+++ b/core/modules/menu_link_content/tests/src/Kernel/PathAliasMenuLinkContentTest.php
@@ -18,7 +18,7 @@ class PathAliasMenuLinkContentTest extends KernelTestBase {
   /**
    * {@inheritdoc}
    */
-  public static $modules = ['menu_link_content', 'system', 'link', 'test_page_test'];
+  public static $modules = ['menu_link_content', 'system', 'link', 'test_page_test', 'user'];
 
   /**
    * {@inheritdoc}
@@ -26,6 +26,7 @@ class PathAliasMenuLinkContentTest extends KernelTestBase {
   protected function setUp() {
     parent::setUp();
 
+    $this->installEntitySchema('user');
     $this->installEntitySchema('menu_link_content');
 
     // Ensure that the weight of module_link_content is higher than system.
diff --git a/core/modules/menu_ui/src/MenuForm.php b/core/modules/menu_ui/src/MenuForm.php
index 15bf106794f7..74ec5fbb419a 100644
--- a/core/modules/menu_ui/src/MenuForm.php
+++ b/core/modules/menu_ui/src/MenuForm.php
@@ -15,6 +15,8 @@
 use Drupal\Core\Render\Element;
 use Drupal\Core\Url;
 use Drupal\Core\Utility\LinkGeneratorInterface;
+use Drupal\menu_link_content\MenuLinkContentStorageInterface;
+use Drupal\menu_link_content\Plugin\Menu\MenuLinkContent;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
@@ -45,6 +47,13 @@ class MenuForm extends EntityForm {
    */
   protected $linkGenerator;
 
+  /**
+   * The menu_link_content storage handler.
+   *
+   * @var \Drupal\menu_link_content\MenuLinkContentStorageInterface
+   */
+  protected $menuLinkContentStorage;
+
   /**
    * The overview tree form.
    *
@@ -61,11 +70,14 @@ class MenuForm extends EntityForm {
    *   The menu tree service.
    * @param \Drupal\Core\Utility\LinkGeneratorInterface $link_generator
    *   The link generator.
+   * @param \Drupal\menu_link_content\MenuLinkContentStorageInterface $menu_link_content_storage
+   *   The menu link content storage handler.
    */
-  public function __construct(MenuLinkManagerInterface $menu_link_manager, MenuLinkTreeInterface $menu_tree, LinkGeneratorInterface $link_generator) {
+  public function __construct(MenuLinkManagerInterface $menu_link_manager, MenuLinkTreeInterface $menu_tree, LinkGeneratorInterface $link_generator, MenuLinkContentStorageInterface $menu_link_content_storage) {
     $this->menuLinkManager = $menu_link_manager;
     $this->menuTree = $menu_tree;
     $this->linkGenerator = $link_generator;
+    $this->menuLinkContentStorage = $menu_link_content_storage;
   }
 
   /**
@@ -75,7 +87,8 @@ public static function create(ContainerInterface $container) {
     return new static(
       $container->get('plugin.manager.menu.link'),
       $container->get('menu.link_tree'),
-      $container->get('link_generator')
+      $container->get('link_generator'),
+      $container->get('entity_type.manager')->getStorage('menu_link_content')
     );
   }
 
@@ -273,16 +286,52 @@ protected function buildOverviewForm(array &$form, FormStateInterface $form_stat
       ]),
     ]);
     $links = $this->buildOverviewTreeForm($tree, $delta);
+
+    // Get the menu links which have pending revisions, and disable the
+    // tabledrag if there are any.
+    $edited_ids = array_filter(array_map(function ($element) {
+      return is_array($element) && isset($element['#item']) && $element['#item']->link instanceof MenuLinkContent ? $element['#item']->link->getMetaData()['entity_id'] : NULL;
+    }, $links));
+    $pending_menu_link_ids = array_intersect($this->menuLinkContentStorage->getMenuLinkIdsWithPendingRevisions(), $edited_ids);
+    if ($pending_menu_link_ids) {
+      $form['help'] = [
+        '#type' => 'container',
+        'message' => [
+          '#markup' => $this->formatPlural(
+            count($pending_menu_link_ids),
+            '%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.',
+            '%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.',
+            [
+              '%capital_name' => $this->entity->label(),
+            ]
+          ),
+        ],
+        '#attributes' => ['class' => ['messages', 'messages--warning']],
+        '#weight' => -10,
+      ];
+
+      unset($form['links']['#tabledrag']);
+      unset($form['links']['#header'][2]);
+    }
+
     foreach (Element::children($links) as $id) {
       if (isset($links[$id]['#item'])) {
         $element = $links[$id];
 
+        $is_pending_menu_link = isset($element['#item']->link->getMetaData()['entity_id'])
+          && in_array($element['#item']->link->getMetaData()['entity_id'], $pending_menu_link_ids);
+
         $form['links'][$id]['#item'] = $element['#item'];
 
         // TableDrag: Mark the table row as draggable.
         $form['links'][$id]['#attributes'] = $element['#attributes'];
         $form['links'][$id]['#attributes']['class'][] = 'draggable';
 
+        if ($is_pending_menu_link) {
+          $form['links'][$id]['#attributes']['class'][] = 'color-warning';
+          $form['links'][$id]['#attributes']['class'][] = 'menu-link-content--pending-revision';
+        }
+
         // TableDrag: Sort the table row according to its existing/configured weight.
         $form['links'][$id]['#weight'] = $element['#item']->link->getWeight();
 
@@ -301,7 +350,14 @@ protected function buildOverviewForm(array &$form, FormStateInterface $form_stat
         $form['links'][$id]['enabled'] = $element['enabled'];
         $form['links'][$id]['enabled']['#wrapper_attributes']['class'] = ['checkbox', 'menu-enabled'];
 
-        $form['links'][$id]['weight'] = $element['weight'];
+        // Disallow changing the publishing status of a pending revision.
+        if ($is_pending_menu_link) {
+          $form['links'][$id]['enabled']['#access'] = FALSE;
+        }
+
+        if (!$pending_menu_link_ids) {
+          $form['links'][$id]['weight'] = $element['weight'];
+        }
 
         // Operations (dropbutton) column.
         $form['links'][$id]['operations'] = $element['operations'];
@@ -463,7 +519,7 @@ protected function submitOverviewForm(array $complete_form, FormStateInterface $
         $updated_values = [];
         // Update any fields that have changed in this menu item.
         foreach ($fields as $field) {
-          if ($element[$field]['#value'] != $element[$field]['#default_value']) {
+          if (isset($element[$field]['#value']) && $element[$field]['#value'] != $element[$field]['#default_value']) {
             $updated_values[$field] = $element[$field]['#value'];
           }
         }
diff --git a/core/modules/menu_ui/tests/src/Functional/MenuUiTest.php b/core/modules/menu_ui/tests/src/Functional/MenuUiTest.php
index f97a9650113d..e5c9f96464c5 100644
--- a/core/modules/menu_ui/tests/src/Functional/MenuUiTest.php
+++ b/core/modules/menu_ui/tests/src/Functional/MenuUiTest.php
@@ -987,4 +987,48 @@ protected function doTestMenuBlock() {
     $block->save();
   }
 
+  /**
+   * Test that menu links with pending revisions can not be re-parented.
+   */
+  public function testMenuUiWithPendingRevisions() {
+    $this->drupalLogin($this->adminUser);
+    $assert_session = $this->assertSession();
+
+    // Add four menu links in two separate menus.
+    $menu_1 = $this->addCustomMenu();
+    $root_1 = $this->addMenuLink('', '/', $menu_1->id());
+    $this->addMenuLink($root_1->getPluginId(), '/', $menu_1->id());
+
+    $menu_2 = $this->addCustomMenu();
+    $root_2 = $this->addMenuLink('', '/', $menu_2->id());
+    $child_2 = $this->addMenuLink($root_2->getPluginId(), '/', $menu_2->id());
+
+    $this->drupalGet('admin/structure/menu/manage/' . $menu_2->id());
+    $assert_session->pageTextNotContains($menu_2->label() . ' 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.');
+
+    $this->drupalGet('admin/structure/menu/manage/' . $menu_1->id());
+    $assert_session->pageTextNotContains($menu_1->label() . ' 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.');
+
+    // Create a pending revision for one of the menu links and check that it can
+    // no longer be re-parented in the UI. We can not create pending revisions
+    // through the UI yet so we have to use API calls.
+    \Drupal::entityTypeManager()->getStorage('menu_link_content')->createRevision($child_2, FALSE)->save();
+
+    $this->drupalGet('admin/structure/menu/manage/' . $menu_2->id());
+    $assert_session->pageTextContains($menu_2->label() . ' 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.');
+
+    // Check that the 'Enabled' checkbox is hidden for a pending revision.
+    $this->assertNotEmpty($this->cssSelect('input[name="links[menu_plugin_id:' . $root_2->getPluginId() . '][enabled]"]'), 'The publishing status of a default revision can be changed.');
+    $this->assertEmpty($this->cssSelect('input[name="links[menu_plugin_id:' . $child_2->getPluginId() . '][enabled]"]'), 'The publishing status of a pending revision can not be changed.');
+
+    $this->drupalGet('admin/structure/menu/manage/' . $menu_1->id());
+    $assert_session->pageTextNotContains($menu_1->label() . ' 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.');
+
+    // Check that the menu overview form can be saved without errors when there
+    // are pending revisions.
+    $this->drupalPostForm('admin/structure/menu/manage/' . $menu_2->id(), [], 'Save');
+    $errors = $this->xpath('//div[contains(@class, "messages--error")]');
+    $this->assertFalse($errors, 'Menu overview form saved without errors.');
+  }
+
 }
diff --git a/core/modules/simpletest/src/Tests/KernelTestBaseTest.php b/core/modules/simpletest/src/Tests/KernelTestBaseTest.php
index 2be141b4076d..c92381fab8dc 100644
--- a/core/modules/simpletest/src/Tests/KernelTestBaseTest.php
+++ b/core/modules/simpletest/src/Tests/KernelTestBaseTest.php
@@ -239,7 +239,7 @@ public function testInstallConfig() {
    */
   public function testEnableModulesFixedList() {
     // Install system module.
-    $this->container->get('module_installer')->install(['system', 'menu_link_content']);
+    $this->container->get('module_installer')->install(['system', 'user', 'menu_link_content']);
     $entity_manager = \Drupal::entityManager();
 
     // entity_test is loaded via $modules; its entity type should exist.
diff --git a/core/tests/Drupal/KernelTests/Core/Command/DbDumpTest.php b/core/tests/Drupal/KernelTests/Core/Command/DbDumpTest.php
index 16070c0e2e56..d9bc931d65f9 100644
--- a/core/tests/Drupal/KernelTests/Core/Command/DbDumpTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Command/DbDumpTest.php
@@ -130,6 +130,8 @@ protected function setUp() {
       'key_value_expire',
       'menu_link_content',
       'menu_link_content_data',
+      'menu_link_content_revision',
+      'menu_link_content_field_revision',
       'sequences',
       'sessions',
       'url_alias',
diff --git a/core/tests/Drupal/KernelTests/Core/Menu/MenuLinkTreeTest.php b/core/tests/Drupal/KernelTests/Core/Menu/MenuLinkTreeTest.php
index 04c04aa3e3f4..a998f0fea85d 100644
--- a/core/tests/Drupal/KernelTests/Core/Menu/MenuLinkTreeTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Menu/MenuLinkTreeTest.php
@@ -41,6 +41,7 @@ class MenuLinkTreeTest extends KernelTestBase {
     'menu_link_content',
     'field',
     'link',
+    'user',
   ];
 
   /**
@@ -49,6 +50,7 @@ class MenuLinkTreeTest extends KernelTestBase {
   protected function setUp() {
     parent::setUp();
     \Drupal::service('router.builder')->rebuild();
+    $this->installEntitySchema('user');
     $this->installEntitySchema('menu_link_content');
 
     $this->linkTree = $this->container->get('menu.link_tree');
-- 
GitLab