diff --git a/core/modules/content_moderation/content_moderation.module b/core/modules/content_moderation/content_moderation.module index 985f91f08b8a37cae246e0cea80bf69c576b0747..df1c2225bb5a21f8009217c1852e4556fd5e30a8 100644 --- a/core/modules/content_moderation/content_moderation.module +++ b/core/modules/content_moderation/content_moderation.module @@ -127,6 +127,15 @@ function content_moderation_entity_translation_delete(EntityInterface $translati ->entityTranslationDelete($translation); } +/** + * Implements hook_entity_prepare_form(). + */ +function content_moderation_entity_prepare_form(EntityInterface $entity, $operation, FormStateInterface $form_state) { + \Drupal::service('class_resolver') + ->getInstanceFromDefinition(EntityTypeInfo::class) + ->entityPrepareForm($entity, $operation, $form_state); +} + /** * Implements hook_form_alter(). */ @@ -252,6 +261,7 @@ function content_moderation_action_info_alter(&$definitions) { * Implements hook_entity_bundle_info_alter(). */ function content_moderation_entity_bundle_info_alter(&$bundles) { + $translatable = FALSE; /** @var \Drupal\workflows\WorkflowInterface $workflow */ foreach (Workflow::loadMultipleByType('content_moderation') as $workflow) { /** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $plugin */ @@ -260,10 +270,18 @@ function content_moderation_entity_bundle_info_alter(&$bundles) { foreach ($plugin->getBundlesForEntityType($entity_type_id) as $bundle_id) { if (isset($bundles[$entity_type_id][$bundle_id])) { $bundles[$entity_type_id][$bundle_id]['workflow'] = $workflow->id(); + // If we have even one moderation-enabled translatable bundle, we need + // to make the moderation state bundle translatable as well, to enable + // the revision translation merge logic also for content moderation + // state revisions. + if (!empty($bundles[$entity_type_id][$bundle_id]['translatable'])) { + $translatable = TRUE; + } } } } } + $bundles['content_moderation_state']['content_moderation_state']['translatable'] = $translatable; } /** diff --git a/core/modules/content_moderation/src/Entity/ContentModerationState.php b/core/modules/content_moderation/src/Entity/ContentModerationState.php index e54fbbe2a8203e5d835815eea94997dfcaa987bc..b56f0d8115b0b4e0727d8798f83940e0c741f7cf 100644 --- a/core/modules/content_moderation/src/Entity/ContentModerationState.php +++ b/core/modules/content_moderation/src/Entity/ContentModerationState.php @@ -221,4 +221,16 @@ protected function realSave() { return parent::save(); } + /** + * {@inheritdoc} + */ + protected function getFieldsToSkipFromTranslationChangesCheck() { + $field_names = parent::getFieldsToSkipFromTranslationChangesCheck(); + // We need to skip the parent entity revision ID, since that will always + // change on every save, otherwise every translation would be marked as + // affected regardless of actual changes. + $field_names[] = 'content_entity_revision_id'; + return $field_names; + } + } diff --git a/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php b/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php index f2c69178188146b7f523c78a1cb507f5773e919c..c44ab098413f4cd15c64fd8d76808ea7a04dc212 100644 --- a/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php +++ b/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php @@ -35,11 +35,6 @@ public function onPresave(ContentEntityInterface $entity, $default_revision, $pu // This is probably not necessary if configuration is setup correctly. $entity->setNewRevision(TRUE); $entity->isDefaultRevision($default_revision); - if ($entity->hasField('revision_translation_affected')) { - // @todo remove this when revision and translation issues have been - // resolved. https://www.drupal.org/node/2860097 - $entity->set('revision_translation_affected', TRUE); - } // Update publishing status if it can be updated and if it needs updating. if (($entity instanceof EntityPublishedInterface) && $entity->isPublished() !== $published_state) { diff --git a/core/modules/content_moderation/src/EntityOperations.php b/core/modules/content_moderation/src/EntityOperations.php index 0d25d2abd2763679c13bea58200e0ef0abed543d..fa76bf0dd7c0990060d76eac36bedc06e7c91d1c 100644 --- a/core/modules/content_moderation/src/EntityOperations.php +++ b/core/modules/content_moderation/src/EntityOperations.php @@ -98,10 +98,9 @@ public function entityPresave(EntityInterface $entity) { $current_state = $workflow->getTypePlugin() ->getState($entity->moderation_state->value); - // This entity is default if it is new, a new translation, the default - // revision, or the default revision is not published. + // This entity is default if it is new, the default revision, or the + // default revision is not published. $update_default_revision = $entity->isNew() - || $entity->isNewTranslation() || $current_state->isDefaultRevisionState() || !$this->moderationInfo->isDefaultRevisionPublished($entity); @@ -247,27 +246,28 @@ public function entityTranslationDelete(EntityInterface $translation) { * @see EntityFieldManagerInterface::getExtraFields() */ public function entityView(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ if (!$this->moderationInfo->isModeratedEntity($entity)) { return; } - if (!$this->moderationInfo->isLatestRevision($entity)) { + // If the component is not defined for this display, we have nothing to do. + if (!$display->getComponent('content_moderation_control')) { return; } - if ($this->moderationInfo->isLiveRevision($entity)) { + // The moderation form should be displayed only when viewing the latest + // (translation-affecting) revision, unless it was created as published + // default revision. + if (!$entity->isLatestRevision() && !$entity->isLatestTranslationAffectedRevision()) { return; } - // Don't display the moderation form when when: - // - The revision is not translation affected. - // - There are more than one translation languages. - // - The entity has pending revisions. - if (!$this->moderationInfo->isPendingRevisionAllowed($entity)) { - return; + if (($entity->isDefaultRevision() || $entity->wasDefaultRevision()) && ($moderation_state = $entity->get('moderation_state')->value)) { + $workflow = $this->moderationInfo->getWorkflowForEntity($entity); + if ($workflow->getTypePlugin()->getState($moderation_state)->isPublishedState()) { + return; + } } - $component = $display->getComponent('content_moderation_control'); - if ($component) { - $build['content_moderation_control'] = $this->formBuilder->getForm(EntityModerationForm::class, $entity); - } + $build['content_moderation_control'] = $this->formBuilder->getForm(EntityModerationForm::class, $entity); } } diff --git a/core/modules/content_moderation/src/EntityTypeInfo.php b/core/modules/content_moderation/src/EntityTypeInfo.php index 5c7cda929b29b7c9856954a2f2fef388e9af0704..c03cf4922036a6bf5afdb619aaf66924b41e7c2b 100644 --- a/core/modules/content_moderation/src/EntityTypeInfo.php +++ b/core/modules/content_moderation/src/EntityTypeInfo.php @@ -7,10 +7,12 @@ use Drupal\Core\Entity\ContentEntityFormInterface; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Entity\ContentEntityTypeInterface; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\Core\Form\FormInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -268,6 +270,40 @@ public function entityBaseFieldInfo(EntityTypeInterface $entity_type) { return $fields; } + /** + * Replaces the entity form entity object with a proper revision object. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being edited. + * @param string $operation + * The entity form operation. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @see hook_entity_prepare_form() + */ + public function entityPrepareForm(EntityInterface $entity, $operation, FormStateInterface $form_state) { + /** @var \Drupal\Core\Entity\EntityFormInterface $form_object */ + $form_object = $form_state->getFormObject(); + + if ($this->isModeratedEntityEditForm($form_object) && !$entity->isNew()) { + // Generate a proper revision object for the current entity. This allows + // to correctly handle translatable entities having pending revisions. + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId()); + /** @var \Drupal\Core\Entity\ContentEntityInterface $new_revision */ + $new_revision = $storage->createRevision($entity, FALSE); + + // Restore the revision ID as other modules may expect to find it still + // populated. This will reset the "new revision" flag, however the entity + // object will be marked as a new revision again on submit. + // @see \Drupal\Core\Entity\ContentEntityForm::buildEntity() + $revision_key = $new_revision->getEntityType()->getKey('revision'); + $new_revision->set($revision_key, $new_revision->getLoadedRevisionId()); + $form_object->setEntity($new_revision); + } + } + /** * Alters bundle forms to enforce revision handling. * @@ -291,57 +327,15 @@ public function formAlter(array &$form, FormStateInterface $form_state, $form_id $this->entityTypeManager->getHandler($config_entity_type->getBundleOf(), 'moderation')->enforceRevisionsBundleFormAlter($form, $form_state, $form_id); } } - elseif ($form_object instanceof ContentEntityFormInterface && in_array($form_object->getOperation(), ['edit', 'default'])) { + elseif ($this->isModeratedEntityEditForm($form_object)) { + /** @var \Drupal\Core\Entity\ContentEntityFormInterface $form_object */ + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ $entity = $form_object->getEntity(); if ($this->moderationInfo->isModeratedEntity($entity)) { $this->entityTypeManager ->getHandler($entity->getEntityTypeId(), 'moderation') ->enforceRevisionsEntityFormAlter($form, $form_state, $form_id); - if (!$this->moderationInfo->isPendingRevisionAllowed($entity)) { - $latest_revision = $this->moderationInfo->getLatestRevision($entity->getEntityTypeId(), $entity->id()); - if ($entity->bundle()) { - $bundle_type_id = $entity->getEntityType()->getBundleEntityType(); - $bundle = $this->entityTypeManager->getStorage($bundle_type_id)->load($entity->bundle()); - $type_label = $bundle->label(); - } - else { - $type_label = $entity->getEntityType()->getLabel(); - } - - $translation = $this->moderationInfo->getAffectedRevisionTranslation($latest_revision); - $args = [ - '@type_label' => $type_label, - '@latest_revision_edit_url' => $translation->toUrl('edit-form', ['language' => $translation->language()])->toString(), - '@latest_revision_delete_url' => $translation->toUrl('delete-form', ['language' => $translation->language()])->toString(), - ]; - $label = $this->t('Unable to save this @type_label.', $args); - $message = $this->t('Publish or delete the latest revision to allow all workflow transitions.', $args); - $full_message = $this->t('Unable to save this @type_label. Publish or delete the latest revision to allow all workflow transitions.', $args); - drupal_set_message($full_message, 'error'); - - $form['moderation_state']['#access'] = FALSE; - $form['actions']['#access'] = FALSE; - $form['invalid_transitions'] = [ - 'label' => [ - '#type' => 'item', - '#prefix' => '', - '#markup' => $label, - '#suffix' => '', - ], - 'message' => [ - '#type' => 'item', - '#markup' => $message, - ], - '#weight' => 999, - '#no_valid_transitions' => TRUE, - ]; - - if ($form['footer']) { - $form['invalid_transitions']['#group'] = 'footer'; - } - } - // Submit handler to redirect to the latest version, if available. $form['actions']['submit']['#submit'][] = [EntityTypeInfo::class, 'bundleFormRedirect']; @@ -360,6 +354,21 @@ public function formAlter(array &$form, FormStateInterface $form_state, $form_id } } + /** + * Checks whether the specified form allows to edit a moderated entity. + * + * @param \Drupal\Core\Form\FormInterface $form_object + * The form object. + * + * @return bool + * TRUE if the form should get form moderation, FALSE otherwise. + */ + protected function isModeratedEntityEditForm(FormInterface $form_object) { + return $form_object instanceof ContentEntityFormInterface && + in_array($form_object->getOperation(), ['edit', 'default'], TRUE) && + $this->moderationInfo->isModeratedEntity($form_object->getEntity()); + } + /** * Redirect content entity edit forms on save, if there is a pending revision. * diff --git a/core/modules/content_moderation/src/Form/EntityModerationForm.php b/core/modules/content_moderation/src/Form/EntityModerationForm.php index 507ef54485155f1558ab71b4380a543fc4c1696e..98f6fdf2b9a09f47d997177efd2c143d98ebcebc 100644 --- a/core/modules/content_moderation/src/Form/EntityModerationForm.php +++ b/core/modules/content_moderation/src/Form/EntityModerationForm.php @@ -138,6 +138,9 @@ public function buildForm(array $form, FormStateInterface $form_state, ContentEn public function submitForm(array &$form, FormStateInterface $form_state) { /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ $entity = $form_state->get('entity'); + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = \Drupal::entityTypeManager()->getStorage($entity->getEntityTypeId()); + $entity = $storage->createRevision($entity, $entity->isDefaultRevision()); $new_state = $form_state->getValue('new_state'); diff --git a/core/modules/content_moderation/src/ModerationInformation.php b/core/modules/content_moderation/src/ModerationInformation.php index 7e3e513307fe0772d2b0b18a0f109a7445345e0e..f42ec335586fc5da8360cf13292d16d3653796dc 100644 --- a/core/modules/content_moderation/src/ModerationInformation.php +++ b/core/modules/content_moderation/src/ModerationInformation.php @@ -127,13 +127,6 @@ public function getAffectedRevisionTranslation(ContentEntityInterface $entity) { } } - /** - * {@inheritdoc} - */ - public function isPendingRevisionAllowed(ContentEntityInterface $entity) { - return !(!$entity->isRevisionTranslationAffected() && count($entity->getTranslationLanguages()) > 1 && $this->hasPendingRevision($entity)); - } - /** * {@inheritdoc} */ @@ -145,8 +138,20 @@ public function isLatestRevision(ContentEntityInterface $entity) { * {@inheritdoc} */ public function hasPendingRevision(ContentEntityInterface $entity) { - return $this->isModeratedEntity($entity) - && !($this->getLatestRevisionId($entity->getEntityTypeId(), $entity->id()) == $this->getDefaultRevisionId($entity->getEntityTypeId(), $entity->id())); + $result = FALSE; + if ($this->isModeratedEntity($entity)) { + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId()); + $latest_revision_id = $storage->getLatestTranslationAffectedRevisionId($entity->id(), $entity->language()->getId()); + $default_revision_id = $entity->isDefaultRevision() && !$entity->isNewRevision() && ($revision_id = $entity->getRevisionId()) ? + $revision_id : $this->getDefaultRevisionId($entity->getEntityTypeId(), $entity->id()); + if ($latest_revision_id != $default_revision_id) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $latest_revision */ + $latest_revision = $storage->loadRevision($latest_revision_id); + $result = !$latest_revision->wasDefaultRevision(); + } + } + return $result; } /** @@ -172,9 +177,15 @@ public function isDefaultRevisionPublished(ContentEntityInterface $entity) { // Loop through each language that has a translation. foreach ($default_revision->getTranslationLanguages() as $language) { // Load the translated revision. - $language_revision = $default_revision->getTranslation($language->getId()); + $translation = $default_revision->getTranslation($language->getId()); + // If the moderation state is empty, it was not stored yet so no point + // in doing further work. + $moderation_state = $translation->moderation_state->value; + if (!$moderation_state) { + continue; + } // Return TRUE if a translation with a published state is found. - if ($workflow->getTypePlugin()->getState($language_revision->moderation_state->value)->isPublishedState()) { + if ($workflow->getTypePlugin()->getState($moderation_state)->isPublishedState()) { return TRUE; } } diff --git a/core/modules/content_moderation/src/ModerationInformationInterface.php b/core/modules/content_moderation/src/ModerationInformationInterface.php index 1dafb3f71c5d65edd5dc9917ec3ef493764e1958..739c16b842bce895cac2a6f8c331a62b3370b653 100644 --- a/core/modules/content_moderation/src/ModerationInformationInterface.php +++ b/core/modules/content_moderation/src/ModerationInformationInterface.php @@ -100,19 +100,6 @@ public function getDefaultRevisionId($entity_type_id, $entity_id); */ public function getAffectedRevisionTranslation(ContentEntityInterface $entity); - /** - * Determines if pending revisions are allowed. - * - * @internal - * - * @param \Drupal\Core\Entity\ContentEntityInterface $entity - * The content entity. - * - * @return bool - * If pending revisions are allowed. - */ - public function isPendingRevisionAllowed(ContentEntityInterface $entity); - /** * Determines if an entity is a latest revision. * diff --git a/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php b/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php index 4c72e9a2988e2cf3b64cb82a5d39da56e5eb99f2..1256bf27f4c83ad3413d647857caad8b53888abd 100644 --- a/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php +++ b/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php @@ -166,10 +166,9 @@ protected function updateModeratedEntity($moderation_state_id) { /** @var \Drupal\content_moderation\ContentModerationState $current_state */ $current_state = $workflow->getTypePlugin()->getState($moderation_state_id); - // This entity is default if it is new, a new translation, the default - // revision state, or the default revision is not published. + // This entity is default if it is new, the default revision state, or the + // default revision is not published. $update_default_revision = $entity->isNew() - || $entity->isNewTranslation() || $current_state->isDefaultRevisionState() || !$content_moderation_info->isDefaultRevisionPublished($entity); diff --git a/core/modules/content_moderation/src/Plugin/views/filter/ModerationStateFilter.php b/core/modules/content_moderation/src/Plugin/views/filter/ModerationStateFilter.php index 035ad3ea27c20b70933252897e7dbda898092628..c658ef8759e79eb1c092845ffc8d8a61b001d021 100644 --- a/core/modules/content_moderation/src/Plugin/views/filter/ModerationStateFilter.php +++ b/core/modules/content_moderation/src/Plugin/views/filter/ModerationStateFilter.php @@ -176,8 +176,8 @@ protected function opSimple() { $entity_base_table_alias = $this->table; // The bundle field of an entity type is not revisionable so we need to - // join the data table. - $entity_base_table = $entity_type->isTranslatable() ? $entity_type->getDataTable() : $entity_type->getBaseTable(); + // join the base table. + $entity_base_table = $entity_type->getBaseTable(); $entity_revision_base_table = $entity_type->isTranslatable() ? $entity_type->getRevisionDataTable() : $entity_type->getRevisionTable(); if ($this->table === $entity_revision_base_table) { $configuration = [ @@ -187,12 +187,6 @@ protected function opSimple() { 'left_field' => $entity_type->getKey('id'), 'type' => 'INNER', ]; - if ($entity_type->isTranslatable()) { - $configuration['extra'][] = [ - 'field' => $entity_type->getKey('langcode'), - 'left_field' => $entity_type->getKey('langcode'), - ]; - } $join = Views::pluginManager('join')->createInstance('standard', $configuration); $entity_base_table_alias = $this->query->addRelationship($entity_base_table, $join, $entity_revision_base_table); diff --git a/core/modules/content_moderation/tests/src/Functional/ModerationFormTest.php b/core/modules/content_moderation/tests/src/Functional/ModerationFormTest.php index b474762b57212ddcc319b3855478395372006fb0..4b483584e492bd4bb9fb28f23003f7a544252281 100644 --- a/core/modules/content_moderation/tests/src/Functional/ModerationFormTest.php +++ b/core/modules/content_moderation/tests/src/Functional/ModerationFormTest.php @@ -296,14 +296,6 @@ public function testContentTranslationNodeForm() { $this->drupalGet($latest_version_path, ['language' => $french]); $this->assertTrue($this->xpath('//ul[@class="entity-moderation-form"]')); - // It should not be possible to add a new english revision. - $this->drupalGet($edit_path); - $this->assertSession()->fieldNotExists('moderation_state[0][state]'); - $this->assertSession()->pageTextContains('Unable to save this Moderated content.'); - - $this->clickLink('Publish'); - $this->assertSession()->fieldValueEquals('body[0][value]', 'Third version of the content.'); - $this->drupalGet($edit_path); $this->clickLink('Delete'); $this->assertSession()->buttonExists('Delete'); @@ -324,7 +316,7 @@ public function testContentTranslationNodeForm() { $this->drupalGet($latest_version_path, ['language' => $french]); $this->assertFalse($this->xpath('//ul[@class="entity-moderation-form"]')); - // Now we can publish the english (revision 5). + // Publish the English pending revision (revision 5). $this->drupalGet($edit_path); $this->assertSession()->optionExists('moderation_state[0][state]', 'draft'); $this->assertSession()->optionExists('moderation_state[0][state]', 'published'); @@ -337,13 +329,13 @@ public function testContentTranslationNodeForm() { $this->drupalGet($latest_version_path); $this->assertFalse($this->xpath('//ul[@class="entity-moderation-form"]')); - // Make sure we're allowed to create a pending french revision. + // Make sure we are allowed to create a pending French revision. $this->drupalGet($edit_path, ['language' => $french]); $this->assertSession()->optionExists('moderation_state[0][state]', 'draft'); $this->assertSession()->optionExists('moderation_state[0][state]', 'published'); $this->assertSession()->optionExists('moderation_state[0][state]', 'archived'); - // Add a english pending revision (revision 6). + // Add an English pending revision (revision 6). $this->drupalGet($edit_path); $this->assertSession()->optionExists('moderation_state[0][state]', 'draft'); $this->assertSession()->optionExists('moderation_state[0][state]', 'published'); @@ -355,16 +347,10 @@ public function testContentTranslationNodeForm() { $this->drupalGet($latest_version_path); $this->assertTrue($this->xpath('//ul[@class="entity-moderation-form"]')); - - // Make sure we're not allowed to create a pending french revision. - $this->drupalGet($edit_path, ['language' => $french]); - $this->assertSession()->fieldNotExists('moderation_state[0][state]'); - $this->assertSession()->pageTextContains('Unable to save this Moderated content.'); - $this->drupalGet($latest_version_path, ['language' => $french]); $this->assertFalse($this->xpath('//ul[@class="entity-moderation-form"]')); - // We should be able to publish the english pending revision (revision 7) + // Publish the English pending revision (revision 7) $this->drupalGet($edit_path); $this->assertSession()->optionExists('moderation_state[0][state]', 'draft'); $this->assertSession()->optionExists('moderation_state[0][state]', 'published'); @@ -377,44 +363,17 @@ public function testContentTranslationNodeForm() { $this->drupalGet($latest_version_path); $this->assertFalse($this->xpath('//ul[@class="entity-moderation-form"]')); - // Make sure we're allowed to create a pending french revision. + // Make sure we are allowed to create a pending French revision. $this->drupalGet($edit_path, ['language' => $french]); $this->assertSession()->optionExists('moderation_state[0][state]', 'draft'); $this->assertSession()->optionExists('moderation_state[0][state]', 'published'); $this->assertSession()->optionExists('moderation_state[0][state]', 'archived'); - // Make sure we're allowed to create a pending english revision. - $this->drupalGet($edit_path); - $this->assertSession()->optionExists('moderation_state[0][state]', 'draft'); - $this->assertSession()->optionExists('moderation_state[0][state]', 'published'); - $this->assertSession()->optionExists('moderation_state[0][state]', 'archived'); - - // Create new moderated content. (revision 1). - $this->drupalPostForm('node/add/moderated_content', [ - 'title[0][value]' => 'Second moderated content', - 'body[0][value]' => 'First version of the content.', - 'moderation_state[0][state]' => 'published', - ], t('Save')); - - $node = $this->drupalGetNodeByTitle('Second moderated content'); - $this->assertTrue($node->language(), 'en'); - $edit_path = sprintf('node/%d/edit', $node->id()); - $translate_path = sprintf('node/%d/translations/add/en/fr', $node->id()); - - // Add a pending revision (revision 2). + // Make sure we are allowed to create a pending English revision. $this->drupalGet($edit_path); $this->assertSession()->optionExists('moderation_state[0][state]', 'draft'); $this->assertSession()->optionExists('moderation_state[0][state]', 'published'); $this->assertSession()->optionExists('moderation_state[0][state]', 'archived'); - $this->drupalPostForm(NULL, [ - 'body[0][value]' => 'Second version of the content.', - 'moderation_state[0][state]' => 'draft', - ], t('Save')); - - // It shouldn't be possible to translate as we have a pending revision. - $this->drupalGet($translate_path); - $this->assertSession()->fieldNotExists('moderation_state[0][state]'); - $this->assertSession()->pageTextContains('Unable to save this Moderated content.'); // Create new moderated content (revision 1). $this->drupalPostForm('node/add/moderated_content', [ @@ -445,11 +404,6 @@ public function testContentTranslationNodeForm() { 'moderation_state[0][state]' => 'draft', ], t('Save (this translation)')); - // Editing the original translation should not be possible. - $this->drupalGet($edit_path); - $this->assertSession()->fieldNotExists('moderation_state[0][state]'); - $this->assertSession()->pageTextContains('Unable to save this Moderated content.'); - // Updating and publishing the french translation is still possible. $this->drupalGet($edit_path, ['language' => $french]); $this->assertSession()->optionExists('moderation_state[0][state]', 'draft'); diff --git a/core/modules/content_moderation/tests/src/Functional/ModerationLocaleTest.php b/core/modules/content_moderation/tests/src/Functional/ModerationLocaleTest.php index 3104450fcef441ff929d611c0839011826b36df4..28de50c535490c93bfdb4b65681f484a82c548b3 100644 --- a/core/modules/content_moderation/tests/src/Functional/ModerationLocaleTest.php +++ b/core/modules/content_moderation/tests/src/Functional/ModerationLocaleTest.php @@ -2,6 +2,8 @@ namespace Drupal\Tests\content_moderation\Functional; +use Drupal\node\NodeInterface; + /** * Test content_moderation functionality with localization and translation. * @@ -22,19 +24,23 @@ class ModerationLocaleTest extends ModerationStateTestBase { ]; /** - * Tests article translations can be moderated separately. + * {@inheritdoc} */ - public function testTranslateModeratedContent() { + protected function setUp() { + parent::setUp(); + $this->drupalLogin($this->rootUser); // Enable moderation on Article node type. $this->createContentTypeFromUi('Article', 'article', TRUE); - // Add French language. - $edit = [ - 'predefined_langcode' => 'fr', - ]; - $this->drupalPostForm('admin/config/regional/language/add', $edit, t('Add language')); + // Add French and Italian languages. + foreach (['fr', 'it'] as $langcode) { + $edit = [ + 'predefined_langcode' => $langcode, + ]; + $this->drupalPostForm('admin/config/regional/language/add', $edit, t('Add language')); + } // Enable content translation on articles. $this->drupalGet('admin/config/regional/content-language'); @@ -48,7 +54,12 @@ public function testTranslateModeratedContent() { // Adding languages requires a container rebuild in the test running // environment so that multilingual services are used. $this->rebuildContainer(); + } + /** + * Tests article translations can be moderated separately. + */ + public function testTranslateModeratedContent() { // Create a published article in English. $edit = [ 'title[0][value]' => 'Published English node', @@ -191,4 +202,358 @@ public function testTranslateModeratedContent() { $this->assertFalse($french_node->isPublished()); } + /** + * Tests that individual translations can be moderated independently. + */ + public function testLanguageIndependentContentModeration() { + // Create a published article in English (revision 1). + $this->drupalGet('node/add/article'); + $node = $this->submitNodeForm('Test 1.1 EN', 'published'); + $this->assertNotLatestVersionPage($node); + + $edit_path = $node->toUrl('edit-form'); + $translate_path = $node->toUrl('drupal:content-translation-overview'); + + // Create a new English draft (revision 2). + $this->drupalGet($edit_path); + $this->submitNodeForm('Test 1.2 EN', 'draft', TRUE); + $this->assertLatestVersionPage($node); + + // Add a French translation draft (revision 3). + $this->drupalGet($translate_path); + $this->clickLink(t('Add')); + $this->submitNodeForm('Test 1.3 FR', 'draft'); + $fr_node = $this->loadTranslation($node, 'fr'); + $this->assertLatestVersionPage($fr_node); + $this->assertModerationForm($node); + + // Add an Italian translation draft (revision 4). + $this->drupalGet($translate_path); + $this->clickLink(t('Add')); + $this->submitNodeForm('Test 1.4 IT', 'draft'); + $it_node = $this->loadTranslation($node, 'it'); + $this->assertLatestVersionPage($it_node); + $this->assertModerationForm($node); + $this->assertModerationForm($fr_node); + + // Publish the English draft (revision 5). + $this->drupalGet($edit_path); + $this->submitNodeForm('Test 1.5 EN', 'published', TRUE); + $this->assertNotLatestVersionPage($node); + $this->assertModerationForm($fr_node); + $this->assertModerationForm($it_node); + + // Publish the Italian draft (revision 6). + $this->drupalGet($translate_path); + $this->clickLink(t('Edit'), 2); + $this->submitNodeForm('Test 1.6 IT', 'published'); + $this->assertNotLatestVersionPage($it_node); + $this->assertNoModerationForm($node); + $this->assertModerationForm($fr_node); + + // Publish the French draft (revision 7). + $this->drupalGet($translate_path); + $this->clickLink(t('Edit'), 1); + $this->submitNodeForm('Test 1.7 FR', 'published'); + $this->assertNotLatestVersionPage($fr_node); + $this->assertNoModerationForm($node); + $this->assertNoModerationForm($it_node); + + // Create an Italian draft (revision 8). + $this->drupalGet($translate_path); + $this->clickLink(t('Edit'), 2); + $this->submitNodeForm('Test 1.8 IT', 'draft'); + $this->assertLatestVersionPage($it_node); + $this->assertNoModerationForm($node); + $this->assertNoModerationForm($fr_node); + + // Create a French draft (revision 9). + $this->drupalGet($translate_path); + $this->clickLink(t('Edit'), 1); + $this->submitNodeForm('Test 1.9 FR', 'draft'); + $this->assertLatestVersionPage($fr_node); + $this->assertNoModerationForm($node); + $this->assertModerationForm($it_node); + + // Create an English draft (revision 10). + $this->drupalGet($edit_path); + $this->submitNodeForm('Test 1.10 EN', 'draft'); + $this->assertLatestVersionPage($node); + $this->assertModerationForm($fr_node); + $this->assertModerationForm($it_node); + + // Now start from a draft article in English (revision 1). + $this->drupalGet('node/add/article'); + $node2 = $this->submitNodeForm('Test 2.1 EN', 'draft', TRUE); + $this->assertNotLatestVersionPage($node2, TRUE); + + $edit_path = $node2->toUrl('edit-form'); + $translate_path = $node2->toUrl('drupal:content-translation-overview'); + + // Add a French translation (revision 2). + $this->drupalGet($translate_path); + $this->clickLink(t('Add')); + $this->submitNodeForm('Test 2.2 FR', 'draft'); + $fr_node2 = $this->loadTranslation($node2, 'fr'); + $this->assertNotLatestVersionPage($fr_node2, TRUE); + $this->assertModerationForm($node2, FALSE); + + // Add an Italian translation (revision 3). + $this->drupalGet($translate_path); + $this->clickLink(t('Add')); + $this->submitNodeForm('Test 2.3 IT', 'draft'); + $it_node2 = $this->loadTranslation($node2, 'it'); + $this->assertNotLatestVersionPage($it_node2, TRUE); + $this->assertModerationForm($node2, FALSE); + $this->assertModerationForm($fr_node2, FALSE); + + // Publish the English draft (revision 4). + $this->drupalGet($edit_path); + $this->submitNodeForm('Test 2.4 EN', 'published', TRUE); + $this->assertNotLatestVersionPage($node2); + $this->assertModerationForm($fr_node2, FALSE); + $this->assertModerationForm($it_node2, FALSE); + + // Publish the Italian draft (revision 5). + $this->drupalGet($translate_path); + $this->clickLink(t('Edit'), 2); + $this->submitNodeForm('Test 2.5 IT', 'published'); + $this->assertNotLatestVersionPage($it_node2); + $this->assertNoModerationForm($node2); + $this->assertModerationForm($fr_node2, FALSE); + + // Publish the French draft (revision 6). + $this->drupalGet($translate_path); + $this->clickLink(t('Edit'), 1); + $this->submitNodeForm('Test 2.6 FR', 'published'); + $this->assertNotLatestVersionPage($fr_node2); + $this->assertNoModerationForm($node2); + $this->assertNoModerationForm($it_node2); + + // Now that all revision translations are published, verify that the + // moderation form is never displayed on revision pages. + /** @var \Drupal\node\NodeStorageInterface $storage */ + $storage = $this->container->get('entity_type.manager')->getStorage('node'); + foreach (range(11, 16) as $revision_id) { + /** @var \Drupal\node\NodeInterface $revision */ + $revision = $storage->loadRevision($revision_id); + foreach ($revision->getTranslationLanguages() as $langcode => $language) { + if ($revision->isRevisionTranslationAffected()) { + $this->drupalGet($revision->toUrl('revision')); + $this->assertFalse($this->hasModerationForm(), 'Moderation form is not displayed correctly for revision ' . $revision_id); + break; + } + } + } + + // Create an Italian draft (revision 7). + $this->drupalGet($translate_path); + $this->clickLink(t('Edit'), 2); + $this->submitNodeForm('Test 2.7 IT', 'draft'); + $this->assertLatestVersionPage($it_node2); + $this->assertNoModerationForm($node2); + $this->assertNoModerationForm($fr_node2); + + // Create a French draft (revision 8). + $this->drupalGet($translate_path); + $this->clickLink(t('Edit'), 1); + $this->submitNodeForm('Test 2.8 FR', 'draft'); + $this->assertLatestVersionPage($fr_node2); + $this->assertNoModerationForm($node2); + $this->assertModerationForm($it_node2); + + // Create an English draft (revision 9). + $this->drupalGet($edit_path); + $this->submitNodeForm('Test 2.9 EN', 'draft', TRUE); + $this->assertLatestVersionPage($node2); + $this->assertModerationForm($fr_node2); + $this->assertModerationForm($it_node2); + + // Now publish a draft in another language first and verify that the + // moderation form is not displayed on the English node view page. + $this->drupalGet('node/add/article'); + $node3 = $this->submitNodeForm('Test 3.1 EN', 'published'); + $this->assertNotLatestVersionPage($node3); + + $edit_path = $node3->toUrl('edit-form'); + $translate_path = $node3->toUrl('drupal:content-translation-overview'); + + // Create an English draft (revision 2). + $this->drupalGet($edit_path); + $this->submitNodeForm('Test 3.2 EN', 'draft', TRUE); + $this->assertLatestVersionPage($node3); + + // Add a French translation (revision 3). + $this->drupalGet($translate_path); + $this->clickLink(t('Add')); + $this->submitNodeForm('Test 3.3 FR', 'draft'); + $fr_node3 = $this->loadTranslation($node3, 'fr'); + $this->assertLatestVersionPage($fr_node3); + $this->assertModerationForm($node3); + + // Publish the French draft (revision 4). + $this->drupalGet($translate_path); + $this->clickLink(t('Edit'), 1); + $this->submitNodeForm('Test 3.4 FR', 'published'); + $this->assertNotLatestVersionPage($fr_node3); + $this->assertModerationForm($node3); + } + + /** + * Checks that new translation values are populated properly. + */ + public function testNewTranslationSourceValues() { + // Create a published article in Italian (revision 1). + $this->drupalGet('node/add/article'); + $node = $this->submitNodeForm('Test 1.1 IT', 'published', TRUE, 'it'); + $this->assertNotLatestVersionPage($node); + + // Create a new draft (revision 2). + $this->drupalGet($node->toUrl('edit-form')); + $this->submitNodeForm('Test 1.2 IT', 'draft', TRUE); + $this->assertLatestVersionPage($node); + + // Create an English draft (revision 3) and verify that the Italian draft + // values are used as source values. + $url = $node->toUrl('drupal:content-translation-add'); + $url->setRouteParameter('source', 'it'); + $url->setRouteParameter('target', 'en'); + $this->drupalGet($url); + $this->assertSession()->pageTextContains('Test 1.2 IT'); + $this->submitNodeForm('Test 1.3 EN', 'draft'); + $this->assertLatestVersionPage($node); + + // Create a French draft (without saving) and verify that the Italian draft + // values are used as source values. + $url->setRouteParameter('target', 'fr'); + $this->drupalGet($url); + $this->assertSession()->pageTextContains('Test 1.2 IT'); + + // Now switch source language and verify that the English draft values are + // used as source values. + $url->setRouteParameter('source', 'en'); + $this->drupalGet($url); + $this->assertSession()->pageTextContains('Test 1.3 EN'); + } + + /** + * Submits the node form at the current URL with the specified values. + * + * @param string $title + * The node title. + * @param string $moderation_state + * The moderation state. + * @param bool $default_translation + * (optional) Whether we are editing the default translation. + * @param string|null $langcode + * (optional) The node language. Defaults to English. + * + * @return \Drupal\node\NodeInterface|null + * A node object if a new one is being created, NULL otherwise. + */ + protected function submitNodeForm($title, $moderation_state, $default_translation = FALSE, $langcode = 'en') { + $is_new = strpos($this->getSession()->getCurrentUrl(), '/node/add/') !== FALSE; + $edit = [ + 'title[0][value]' => $title, + 'moderation_state[0][state]' => $moderation_state, + ]; + if ($is_new) { + $default_translation = TRUE; + $edit['langcode[0][value]'] = $langcode; + } + $submit = $default_translation ? t('Save') : t('Save (this translation)'); + $this->drupalPostForm(NULL, $edit, $submit); + $message = $is_new ? "Article $title has been created." : "Article $title has been updated."; + $this->assertSession()->pageTextContains($message); + return $is_new ? $this->drupalGetNodeByTitle($title) : NULL; + } + + /** + * Loads the node translation for the specified language. + * + * @param \Drupal\node\NodeInterface $node + * A node object. + * @param string $langcode + * The translation language code. + * + * @return \Drupal\node\NodeInterface + * The node translation object. + */ + protected function loadTranslation(NodeInterface $node, $langcode) { + /** @var \Drupal\node\NodeStorageInterface $storage */ + $storage = $this->container->get('entity_type.manager')->getStorage('node'); + /** @var \Drupal\node\NodeInterface $node */ + $node = $storage->loadRevision($storage->getLatestRevisionId($node->id())); + return $node->getTranslation($langcode); + } + + /** + * Asserts that this is the "latest version" page for the specified node. + * + * @param \Drupal\node\NodeInterface $node + * A node object. + */ + public function assertLatestVersionPage(NodeInterface $node) { + $this->assertEquals($node->toUrl('latest-version')->setAbsolute()->toString(), $this->getSession()->getCurrentUrl()); + $this->assertModerationForm($node); + } + + /** + * Asserts that this is not the "latest version" page for the specified node. + * + * @param \Drupal\node\NodeInterface $node + * A node object. + * @param bool $moderation_form + * (optional) Whether the page should contain the moderation form. Defaults + * to FALSE. + */ + public function assertNotLatestVersionPage(NodeInterface $node, $moderation_form = FALSE) { + $this->assertNotEquals($node->toUrl('latest-version')->setAbsolute()->toString(), $this->getSession()->getCurrentUrl()); + if ($moderation_form) { + $this->assertModerationForm($node, FALSE); + } + else { + $this->assertNoModerationForm($node); + } + } + + /** + * Asserts that the moderation form is displayed for the specified node. + * + * @param \Drupal\node\NodeInterface $node + * A node object. + * @param bool $latest_tab + * (optional) Whether the node form is expected to be displayed on the + * latest version page or on the node view page. Defaults to the former. + */ + public function assertModerationForm(NodeInterface $node, $latest_tab = TRUE) { + $this->drupalGet($node->toUrl()); + $this->assertEquals(!$latest_tab, $this->hasModerationForm()); + $this->drupalGet($node->toUrl('latest-version')); + $this->assertEquals($latest_tab, $this->hasModerationForm()); + } + + /** + * Asserts that the moderation form is not displayed for the specified node. + * + * @param \Drupal\node\NodeInterface $node + * A node object. + */ + public function assertNoModerationForm(NodeInterface $node) { + $this->drupalGet($node->toUrl()); + $this->assertFalse($this->hasModerationForm()); + $this->drupalGet($node->toUrl('latest-version')); + $this->assertEquals(403, $this->getSession()->getStatusCode()); + } + + /** + * Checks whether the page contains the moderation form. + * + * @return bool + * TRUE if the moderation form could be find in the page, FALSE otherwise. + */ + public function hasModerationForm() { + return (bool) $this->xpath('//ul[@class="entity-moderation-form"]'); + } + } diff --git a/core/modules/content_moderation/tests/src/Kernel/ViewsModerationStateFilterTest.php b/core/modules/content_moderation/tests/src/Kernel/ViewsModerationStateFilterTest.php index 637e70a99fee8b854dc6c04a15b4194550a5b519..da7ac9299d7a278000327f797b37b17bad050f30 100644 --- a/core/modules/content_moderation/tests/src/Kernel/ViewsModerationStateFilterTest.php +++ b/core/modules/content_moderation/tests/src/Kernel/ViewsModerationStateFilterTest.php @@ -121,10 +121,10 @@ public function testStateFilterViewsRelationship() { $translated_forward_revision->moderation_state = 'translated_draft'; $translated_forward_revision->save(); - // Four revisions for the nodes when no filter. - $this->assertNodesWithFilters([$node, $second_node, $third_node, $third_node], []); + // The three default revisions are listed when no filter is specified. + $this->assertNodesWithFilters([$node, $second_node, $third_node], []); - // The default revision of node one and three is published. + // The default revision of node one and three are published. $this->assertNodesWithFilters([$node, $third_node], [ 'default_revision_state' => 'editorial-published', ]); diff --git a/core/modules/content_translation/src/ContentTranslationManager.php b/core/modules/content_translation/src/ContentTranslationManager.php index 8b3831a251a1c614b3c2c67d6d6db1ee9a013df4..3a21f9499f91ba330b0bce3274922226a81a87a7 100644 --- a/core/modules/content_translation/src/ContentTranslationManager.php +++ b/core/modules/content_translation/src/ContentTranslationManager.php @@ -145,4 +145,21 @@ protected function loadContentLanguageSettings($entity_type_id, $bundle) { return $config; } + /** + * Checks whether support for pending revisions should be enabled. + * + * @return bool + * TRUE if pending revisions should be enabled, FALSE otherwise. + * + * @internal + * There is ongoing discussion about how pending revisions should behave. + * The logic enabling pending revision support is likely to change once a + * decision is made. + * + * @see https://www.drupal.org/node/2940575 + */ + public static function isPendingRevisionSupportEnabled() { + return \Drupal::moduleHandler()->moduleExists('content_moderation'); + } + } diff --git a/core/modules/content_translation/src/Controller/ContentTranslationController.php b/core/modules/content_translation/src/Controller/ContentTranslationController.php index 190778d42c4bfdd819f771afbbbcdda845e39e7a..e556154f2a0dbdfd61eb2de7a23a894f87678814 100644 --- a/core/modules/content_translation/src/Controller/ContentTranslationController.php +++ b/core/modules/content_translation/src/Controller/ContentTranslationController.php @@ -2,6 +2,7 @@ namespace Drupal\content_translation\Controller; +use Drupal\content_translation\ContentTranslationManager; use Drupal\content_translation\ContentTranslationManagerInterface; use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Controller\ControllerBase; @@ -87,6 +88,7 @@ public function overview(RouteMatchInterface $route_match, $entity_type_id = NUL $handler = $this->entityManager()->getHandler($entity_type_id, 'translation'); $manager = $this->manager; $entity_type = $entity->getEntityType(); + $use_latest_revisions = $entity_type->isRevisionable() && ContentTranslationManager::isPendingRevisionSupportEnabled(); // Start collecting the cacheability metadata, starting with the entity and // later merge in the access result cacheability metadata. @@ -99,6 +101,9 @@ public function overview(RouteMatchInterface $route_match, $entity_type_id = NUL $rows = []; $show_source_column = FALSE; + $default_revision = $entity; + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = $this->entityTypeManager()->getStorage($entity_type_id); if ($this->languageManager()->isMultilingual()) { // Determine whether the current entity is translatable. @@ -121,6 +126,16 @@ public function overview(RouteMatchInterface $route_match, $entity_type_id = NUL $language_name = $language->getName(); $langcode = $language->getId(); + // If the entity type is revisionable, we may have pending revisions + // with translations not available yet in the default revision. Thus we + // need to load the latest translation-affecting revision for each + // language to be sure we are listing all available translations. + if ($use_latest_revisions) { + $latest_revision_id = $storage->getLatestTranslationAffectedRevisionId($entity->id(), $langcode); + $entity = $latest_revision_id ? $storage->loadRevision($latest_revision_id) : $default_revision; + $translations = $entity->getTranslationLanguages(); + } + $add_url = new Url( "entity.$entity_type_id.content_translation_add", [ @@ -330,8 +345,21 @@ public function overview(RouteMatchInterface $route_match, $entity_type_id = NUL * A processed form array ready to be rendered. */ public function add(LanguageInterface $source, LanguageInterface $target, RouteMatchInterface $route_match, $entity_type_id = NULL) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ $entity = $route_match->getParameter($entity_type_id); + // In case of a pending revision, make sure we load the latest + // translation-affecting revision for the source language, otherwise the + // initial form values may not be up-to-date. + if (!$entity->isDefaultRevision() && ContentTranslationManager::isPendingRevisionSupportEnabled()) { + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = $this->entityTypeManager()->getStorage($entity->getEntityTypeId()); + $revision_id = $storage->getLatestTranslationAffectedRevisionId($entity->id(), $source->getId()); + if ($revision_id != $entity->getRevisionId()) { + $entity = $storage->loadRevision($revision_id); + } + } + // @todo Exploit the upcoming hook_entity_prepare() when available. // See https://www.drupal.org/node/1810394. $this->prepareTranslation($entity, $source, $target); diff --git a/core/modules/content_translation/src/Routing/ContentTranslationRouteSubscriber.php b/core/modules/content_translation/src/Routing/ContentTranslationRouteSubscriber.php index 6aae426abe31116bcf24c8aa7265178f3c3d0e92..ac979d32056993b2deca1fcc7c6b3f340cd0161b 100644 --- a/core/modules/content_translation/src/Routing/ContentTranslationRouteSubscriber.php +++ b/core/modules/content_translation/src/Routing/ContentTranslationRouteSubscriber.php @@ -2,6 +2,7 @@ namespace Drupal\content_translation\Routing; +use Drupal\content_translation\ContentTranslationManager; use Drupal\content_translation\ContentTranslationManagerInterface; use Drupal\Core\Routing\RouteSubscriberBase; use Drupal\Core\Routing\RoutingEvents; @@ -55,6 +56,7 @@ protected function alterRoutes(RouteCollection $collection) { } $path = $base_path . '/translations'; + $load_latest_revision = ContentTranslationManager::isPendingRevisionSupportEnabled(); $route = new Route( $path, @@ -70,6 +72,7 @@ protected function alterRoutes(RouteCollection $collection) { 'parameters' => [ $entity_type_id => [ 'type' => 'entity:' . $entity_type_id, + 'load_latest_revision' => $load_latest_revision, ], ], '_admin_route' => $is_admin, @@ -102,6 +105,7 @@ protected function alterRoutes(RouteCollection $collection) { ], $entity_type_id => [ 'type' => 'entity:' . $entity_type_id, + 'load_latest_revision' => $load_latest_revision, ], ], '_admin_route' => $is_admin, @@ -127,6 +131,7 @@ protected function alterRoutes(RouteCollection $collection) { ], $entity_type_id => [ 'type' => 'entity:' . $entity_type_id, + 'load_latest_revision' => $load_latest_revision, ], ], '_admin_route' => $is_admin, @@ -152,6 +157,7 @@ protected function alterRoutes(RouteCollection $collection) { ], $entity_type_id => [ 'type' => 'entity:' . $entity_type_id, + 'load_latest_revision' => $load_latest_revision, ], ], '_admin_route' => $is_admin,