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