Unverified Commit 76cfa872 authored by Daniel Sipos's avatar Daniel Sipos Committed by Daniel Sipos
Browse files

Issue #3292852 by Upchuk, sinn, joevagyok: Handle content moderation state (translations)

parent 5140fa15
Loading
Loading
Loading
Loading
+110 −10
Original line number Diff line number Diff line
@@ -3,6 +3,9 @@
namespace Drupal\entity_clone\EntityClone\Content;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\content_moderation\Entity\ContentModerationState;
use Drupal\content_moderation\Plugin\Field\ModerationStateFieldItemList;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityHandlerInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
@@ -35,18 +38,18 @@ class ContentEntityCloneBase implements EntityHandlerInterface, EntityCloneInter
  protected $entityTypeId;

  /**
   * The current user.
   * A service for obtaining the system's time.
   *
   * @var \Drupal\Core\Session\AccountProxyInterface
   * @var \Drupal\Component\Datetime\TimeInterface
   */
  protected $currentUser;
  protected $timeService;

  /**
   * A service for obtaining the system's time.
   * The current user.
   *
   * @var \Drupal\Component\Datetime\TimeInterface
   * @var \Drupal\Core\Session\AccountProxyInterface
   */
  protected $timeService;
  protected $currentUser;

  /**
   * Constructs a new ContentEntityCloneBase.
@@ -55,16 +58,18 @@ class ContentEntityCloneBase implements EntityHandlerInterface, EntityCloneInter
   *   The entity type manager.
   * @param string $entity_type_id
   *   The entity type ID.
   * @param \Drupal\Core\Session\AccountProxyInterface $currentUser
   *  The current user.
   * @param \Drupal\Component\Datetime\TimeInterface $time_service
   *   A service for obtaining the system's time.
   * @param \Drupal\Core\Session\AccountProxyInterface $current_user
   *   The current user.
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   The module handler.
   */
  public function __construct(EntityTypeManagerInterface $entity_type_manager, $entity_type_id, TimeInterface $time_service, AccountProxyInterface $currentUser) {
  public function __construct(EntityTypeManagerInterface $entity_type_manager, $entity_type_id, TimeInterface $time_service, AccountProxyInterface $current_user) {
    $this->entityTypeManager = $entity_type_manager;
    $this->entityTypeId = $entity_type_id;
    $this->timeService = $time_service;
    $this->currentUser = $currentUser;
    $this->currentUser = $current_user;
  }

  /**
@@ -111,7 +116,28 @@ class ContentEntityCloneBase implements EntityHandlerInterface, EntityCloneInter
      $cloned_entity->setCreatedTime($this->timeService->getRequestTime());
    }

    if ($this->hasTranslatableModerationState($cloned_entity)) {
      // If we are using moderation state, ensure that each translation gets
      // the same moderation state BEFORE we save so that upon save, each
      // translation gets its publishing status updated according to the
      // moderation state. After the entity is saved, we kick in the creation
      // of translations of created moderation state entity.
      foreach ($cloned_entity->getTranslationLanguages(TRUE) as $language) {
        $translation = $cloned_entity->getTranslation($language->getId());
        $translation->set('moderation_state', $cloned_entity->get('moderation_state')->value);
      }
    }

    $cloned_entity->save();

    // If we are using content moderation, make sure the moderation state
    // entity gets translated to reflect the available translations on the
    // source entity. Thus, we call this after the save because we need the
    // original moderation state entity to have been created.
    if ($this->hasTranslatableModerationState($cloned_entity)) {
      $this->setTranslationModerationState($entity, $cloned_entity);
    }

    return $cloned_entity;
  }

@@ -134,6 +160,7 @@ class ContentEntityCloneBase implements EntityHandlerInterface, EntityCloneInter
    if (($field_definition instanceof FieldConfigInterface) && $type_is_clonable) {
      return TRUE;
    }

    return FALSE;
  }

@@ -199,6 +226,7 @@ class ContentEntityCloneBase implements EntityHandlerInterface, EntityCloneInter
        $referenced_entities[] = $referenced_entity;
      }
    }

    return $referenced_entities;
  }

@@ -223,7 +251,79 @@ class ContentEntityCloneBase implements EntityHandlerInterface, EntityCloneInter
    if (!isset($child_properties['children'])) {
      $child_properties['children'] = [];
    }

    return $child_properties;
  }

  /**
   * Create moderation_state translations for the cloned entities.
   *
   * When a new translation is saved, content moderation creates a corresponding
   * translation to the moderation_state entity as well. However, for this to
   * happen, the translation itself needs to be saved. When we clone, this
   * doesn't happen as the original entity gets cloned together with the
   * translations and a save is called on the original language being cloned. So
   * we have to do this manually.
   *
   * This is doing essentially what
   * Drupal\content_moderation\EntityOperations::updateOrCreateFromEntity but
   * we had to replicate it because if a user clones a node translation
   * directly, updateOrCreateFromEntity() would not create a translation for
   * the original language but would override the language when passing the
   * original entity translation.
   */
  protected function setTranslationModerationState(ContentEntityInterface $entity, ContentEntityInterface $cloned_entity) {
    $languages = $cloned_entity->getTranslationLanguages();

    // Load the existing moderation state entity for the cloned entity. This
    // should exist and have only 1 translation.
    $needs_save = FALSE;
    $moderation_state = ContentModerationState::loadFromModeratedEntity($cloned_entity);
    $original_translation = $cloned_entity->getUntranslated();
    if ($moderation_state->language()->getId() !== $original_translation->language()->getId()) {
      // If we are cloning a node while not being in the original translation
      // language, Drupal core will set the default language of the moderation
      // state to that language whereas the node is simply duplicated and will
      // keep the original default language. So we need to change it to that
      // also in the moderation state to keep things consistent.
      $moderation_state->set($moderation_state->getEntityType()->getKey('langcode'), $original_translation->language()->getId());
      $needs_save = TRUE;
    }

    foreach ($languages as $language) {
      $translation = $cloned_entity->getTranslation($language->getId());
      if (!$moderation_state->hasTranslation($translation->language()->getId())) {
        // We make a 1 to 1 copy of the moderation state entity from the
        // original created already by the content_moderation module. This is ok
        // because even if translations can be in different moderation states,
        // when cloning, the moderation state is reset to whatever the workflow
        // default is configured to be. So we anyway should end up with the
        // same state across all languages.
        $moderation_state->addTranslation($translation->language()->getId(), $moderation_state->toArray());
        $needs_save = TRUE;
      }
    }

    if ($needs_save) {
      ContentModerationState::updateOrCreateFromEntity($moderation_state);
    }
  }

  /**
   * Checks if the entity has the moderation state field and can be moderated.
   *
   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
   *   The entity.
   *
   * @return bool
   *   Whether it can be moderated.
   */
  protected function hasTranslatableModerationState(ContentEntityInterface $entity): bool {
    if (!$entity->hasField('moderation_state') || !$entity->get('moderation_state') instanceof ModerationStateFieldItemList) {
      return FALSE;
    }

    return !empty($entity->getTranslationLanguages(FALSE));
  }

}
+274 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Tests\entity_clone\Functional;

use Drupal\content_moderation\Entity\ContentModerationState as ContentModerationStateEntity;
use Drupal\Core\Url;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\node\Entity\Node;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
use Drupal\Tests\node\Functional\NodeTestBase;

/**
 * Create a moderated content and test the clone of its moderation state.
 *
 * @group entity_clone
 */
class EntityCloneContentModerationTest extends NodeTestBase {

  use ContentModerationTestTrait;

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'entity_clone',
    'content_moderation',
    'language',
    'content_translation',
    'block',
  ];

  /**
   * {@inheritdoc}
   */
  protected $defaultTheme = 'classy';

  /**
   * Permissions to grant admin user.
   *
   * @var array
   */
  protected $permissions = [
    'bypass node access',
    'administer nodes',
    'clone node entity',
    'use editorial transition create_new_draft',
    'use editorial transition publish',
    'use editorial transition archive',
    'use editorial transition archived_draft',
    'use editorial transition archived_published',
  ];

  /**
   * A user with permission to bypass content access checks.
   *
   * @var \Drupal\user\UserInterface
   */
  protected $adminUser;

  /**
   * Sets the test up.
   */
  protected function setUp(): void {
    parent::setUp();

    ConfigurableLanguage::createFromLangcode('fr')->save();
    \Drupal::service('content_translation.manager')->setEnabled('node', 'page', TRUE);
    $workflow = $this->createEditorialWorkflow();
    $this->addEntityTypeAndBundleToWorkflow($workflow, 'node', 'page');

    $this->adminUser = $this->drupalCreateUser($this->permissions);
    $this->drupalLogin($this->adminUser);
  }

  /**
   * Test content entity clone.
   */
  public function testContentModerationEntityClone() {
    $node = Node::create([
      'type' => 'page',
      'title' => 'My node',
    ]);

    $node->save();
    $translation = $node->addTranslation('fr', $node->toArray());
    // Unfortunately content moderation only creates translations to the
    // moderation state entities when the actual translation of the source
    // entity gets saved (as opposed to an original node with multiple
    // translations).
    $translation->save();

    // Assert that we have a moderation state translation for each language.
    $node = Node::load($node->id());
    $this->assertCount(2, $node->getTranslationLanguages());
    $moderation_state = ContentModerationStateEntity::loadFromModeratedEntity($node);
    $this->assertFalse($moderation_state->isNew());
    $this->assertCount(2, $moderation_state->getTranslationLanguages());
    foreach ($moderation_state->getTranslationLanguages() as $language) {
      $this->assertEquals('draft', $moderation_state->getTranslation($language->getId())->get('moderation_state')->value);
    }
    $moderation_state_id = $moderation_state->id();

    // Clone the node and assert that the moderation state is cloned and has
    // a translation for each language.
    $this->drupalGet(Url::fromUserInput('/entity_clone/node/' . $node->id()));
    $this->submitForm([], t('Clone'));

    $nodes = \Drupal::entityTypeManager()
      ->getStorage('node')
      ->loadByProperties([
        'title' => 'My node - Cloned',
      ]);
    $clone = reset($nodes);
    $this->assertInstanceOf(Node::class, $clone, 'Test node cloned found in database.');

    $this->assertCount(2, $clone->getTranslationLanguages());
    $clone_moderation_state = ContentModerationStateEntity::loadFromModeratedEntity($clone);
    $this->assertNotEquals($moderation_state_id, $clone_moderation_state->id());
    $this->assertFalse($clone_moderation_state->isNew());
    $this->assertCount(2, $clone_moderation_state->getTranslationLanguages());
    foreach ($clone_moderation_state->getTranslationLanguages() as $language) {
      $this->assertEquals('draft', $clone_moderation_state->getTranslation($language->getId())->get('moderation_state')->value);
    }

    // Create another node, but this time, move the state to published.
    $node = Node::create([
      'type' => 'page',
      'title' => 'My second node',
    ]);
    $node->save();
    $node->set('moderation_state', 'published');
    $node->setNewRevision();
    $node->save();
    $translation = $node->addTranslation('fr', $node->toArray());
    $translation->save();
    $moderation_state = ContentModerationStateEntity::loadFromModeratedEntity($node);
    $this->assertFalse($moderation_state->isNew());
    $this->assertCount(2, $moderation_state->getTranslationLanguages());
    foreach ($moderation_state->getTranslationLanguages() as $language) {
      $this->assertEquals('published', $moderation_state->getTranslation($language->getId())->get('moderation_state')->value);
    }

    // Clone the node and assert that the moderation state is cloned and has
    // a translation for each language.
    $this->drupalGet(Url::fromUserInput('/entity_clone/node/' . $node->id()));
    $this->submitForm([], t('Clone'));

    $nodes = \Drupal::entityTypeManager()
      ->getStorage('node')
      ->loadByProperties([
        'title' => 'My second node - Cloned',
      ]);
    $clone = reset($nodes);
    $this->assertInstanceOf(Node::class, $clone, 'Test node cloned found in database.');

    $this->assertCount(2, $clone->getTranslationLanguages());
    $clone_moderation_state = ContentModerationStateEntity::loadFromModeratedEntity($clone);
    $this->assertFalse($clone_moderation_state->isNew());
    $this->assertCount(2, $clone_moderation_state->getTranslationLanguages());
    foreach ($clone_moderation_state->getTranslationLanguages() as $language) {
      // When we clone, the default moderation state is set on the clone for
      // both languages (draft), even if the cloned content was published.
      $this->assertEquals('draft', $clone_moderation_state->getTranslation($language->getId())->get('moderation_state')->value);
    }

    // Create another node, but this time the original should be published but
    // the translation should be draft.
    $node = Node::create([
      'type' => 'page',
      'title' => 'My third node',
    ]);
    $node->save();
    $translation = $node->addTranslation('fr', $node->toArray());
    $translation->save();
    $node->set('moderation_state', 'published');
    $node->setNewRevision();
    $node->save();

    $moderation_state = ContentModerationStateEntity::loadFromModeratedEntity($node);
    $this->assertFalse($moderation_state->isNew());
    $this->assertCount(2, $moderation_state->getTranslationLanguages());
    $expected_map = [
      'en' => 'published',
      'fr' => 'draft',
    ];
    foreach ($moderation_state->getTranslationLanguages() as $language) {
      $this->assertEquals($expected_map[$language->getId()], $moderation_state->getTranslation($language->getId())->get('moderation_state')->value);
    }
    $this->assertTrue($node->getTranslation('en')->isPublished());
    $this->assertFalse($node->getTranslation('fr')->isPublished());

    // Clone the node and assert that the moderation state is reset to draft
    // for both languages.
    $this->drupalGet(Url::fromUserInput('/entity_clone/node/' . $node->id()));
    $this->submitForm([], t('Clone'));

    $nodes = \Drupal::entityTypeManager()
      ->getStorage('node')
      ->loadByProperties([
        'title' => 'My third node - Cloned',
      ]);
    $clone = reset($nodes);
    $this->assertInstanceOf(Node::class, $clone, 'Test node cloned found in database.');

    $this->assertCount(2, $clone->getTranslationLanguages());
    $this->assertFalse($clone->getTranslation('en')->isPublished());
    $this->assertFalse($clone->getTranslation('fr')->isPublished());
    $clone_moderation_state = ContentModerationStateEntity::loadFromModeratedEntity($clone);
    $this->assertFalse($clone_moderation_state->isNew());
    $this->assertCount(2, $clone_moderation_state->getTranslationLanguages());
    foreach ($clone_moderation_state->getTranslationLanguages() as $language) {
      $this->assertEquals('draft', $clone_moderation_state->getTranslation($language->getId())->get('moderation_state')->value);
    }

    // Create another node but this time clone while on the French and assert
    // that the moderation state default language is the same as of the node.
    $node = Node::create([
      'type' => 'page',
      'title' => 'My fourth node',
    ]);

    $node->save();
    $translation = $node->addTranslation('fr', ['title' => 'My fourth node FR'] + $node->toArray());
    $translation->save();
    $node = Node::load($node->id());
    $this->assertCount(2, $node->getTranslationLanguages());
    $this->drupalGet(Url::fromUserInput('/fr/entity_clone/node/' . $node->id()));
    $this->submitForm([], t('Clone'));

    $clone = Node::load($node->id() + 1);
    $this->assertInstanceOf(Node::class, $clone, 'Test node cloned found in database.');

    $this->assertCount(2, $clone->getTranslationLanguages());
    $this->assertEquals('My fourth node FR - Cloned', $clone->getTranslation('fr')->label());
    $clone_moderation_state = ContentModerationStateEntity::loadFromModeratedEntity($clone);
    $this->assertFalse($clone_moderation_state->isNew());
    $this->assertCount(2, $clone_moderation_state->getTranslationLanguages());
    foreach ($clone_moderation_state->getTranslationLanguages() as $language) {
      $this->assertEquals('draft', $clone_moderation_state->getTranslation($language->getId())->get('moderation_state')->value);
    }
    $this->assertTrue($clone_moderation_state->isDefaultTranslation());
    $this->assertEquals('en', $clone_moderation_state->language()->getId());

    // Create another node, published, translated and assert that upon cloning
    // the node status is reset to 0 to match the fact that it's a draft.
    $node = Node::create([
      'type' => 'page',
      'title' => 'My fifth node',
      'moderation_state' => 'published',
    ]);
    $node->save();
    $translation = $node->addTranslation('fr', $node->toArray());
    $translation->save();
    $node = Node::load($node->id());
    $this->assertCount(2, $node->getTranslationLanguages());
    $this->assertTrue($node->getTranslation('en')->isPublished());
    $this->assertTrue($node->getTranslation('fr')->isPublished());
    $this->drupalGet(Url::fromUserInput('/entity_clone/node/' . $node->id()));
    $this->submitForm([], t('Clone'));

    $nodes = \Drupal::entityTypeManager()
      ->getStorage('node')
      ->loadByProperties([
        'title' => 'My fifth node - Cloned',
      ]);
    $clone = reset($nodes);
    $this->assertInstanceOf(Node::class, $clone, 'Test node cloned found in database.');
    $this->assertCount(2, $clone->getTranslationLanguages());
    $this->assertFalse($clone->getTranslation('en')->isPublished());
    $this->assertFalse($clone->getTranslation('fr')->isPublished());

  }

}