Commit 131d5772 authored by catch's avatar catch

Issue #2453153 by mkalkbrenner, plach: Node revisions cannot be reverted per translation

parent 97202a70
......@@ -219,7 +219,6 @@ public function postCreate(EntityStorageInterface $storage) {
* {@inheritdoc}
*/
public function setNewRevision($value = TRUE) {
if (!$this->getEntityType()->hasKey('revision')) {
throw new \LogicException(SafeMarkup::format('Entity type @entity_type does not support revisions.', ['@entity_type' => $this->getEntityTypeId()]));
}
......@@ -228,7 +227,17 @@ public function setNewRevision($value = TRUE) {
// When saving a new revision, set any existing revision ID to NULL so as
// to ensure that a new revision will actually be created.
$this->set($this->getEntityType()->getKey('revision'), NULL);
// Make sure that the flag tracking which translations are affected by the
// current revision is reset.
foreach ($this->translations as $langcode => $data) {
// But skip removed translations.
if ($this->hasTranslation($langcode)) {
$this->getTranslation($langcode)->setRevisionTranslationAffected(NULL);
}
}
}
$this->newRevision = $value;
}
......@@ -250,6 +259,25 @@ public function isDefaultRevision($new_value = NULL) {
return $return;
}
/**
* {@inheritdoc}
*/
public function isRevisionTranslationAffected() {
$field_name = 'revision_translation_affected';
return $this->hasField($field_name) ? $this->get($field_name)->value : TRUE;
}
/**
* {@inheritdoc}
*/
public function setRevisionTranslationAffected($affected) {
$field_name = 'revision_translation_affected';
if ($this->hasField($field_name)) {
$this->set($field_name, $affected);
}
return $this;
}
/**
* {@inheritdoc}
*/
......@@ -680,6 +708,7 @@ protected function initializeTranslation($langcode) {
$translation->fields = &$this->fields;
$translation->translations = &$this->translations;
$translation->enforceIsNew = &$this->enforceIsNew;
$translation->newRevision = &$this->newRevision;
$translation->translationInitialize = FALSE;
// Reset language-dependent properties.
unset($translation->entityKeys['label']);
......@@ -1012,4 +1041,55 @@ public static function bundleFieldDefinitions(EntityTypeInterface $entity_type,
return array();
}
/**
* {@inheritdoc}
*/
public function hasTranslationChanges() {
if ($this->isNew()) {
return TRUE;
}
// $this->original only exists during save. See
// \Drupal\Core\Entity\EntityStorageBase::save(). If it exists we re-use it
// here for performance reasons.
/** @var \Drupal\Core\Entity\ContentEntityBase $original */
$original = $this->original ? $this->original : NULL;
if (!$original) {
$id = $this->getOriginalId() !== NULL ? $this->getOriginalId() : $this->id();
$original = $this->entityManager()->getStorage($this->getEntityTypeId())->loadUnchanged($id);
}
// If the current translation has just been added, we have a change.
$translated = count($this->translations) > 1;
if ($translated && !$original->hasTranslation($this->activeLangcode)) {
return TRUE;
}
// Compare field item current values with the original ones to determine
// whether we have changes. If a field is not translatable and the entity is
// translated we skip it because, depending on the use case, it would make
// sense to mark all translations as changed or none of them. We skip also
// computed fields as comparing them with their original values might not be
// possible or be meaningless.
/** @var \Drupal\Core\Entity\ContentEntityBase $translation */
$translation = $original->getTranslation($this->activeLangcode);
foreach ($this->getFieldDefinitions() as $field_name => $definition) {
// @todo Avoid special-casing the following fields. See
// https://www.drupal.org/node/2329253.
if ($field_name == 'revision_translation_affected' || $field_name == 'revision_id') {
continue;
}
if (!$definition->isComputed() && (!$translated || $definition->isTranslatable())) {
$items = $this->get($field_name)->filterEmptyItems();
$original_items = $translation->get($field_name)->filterEmptyItems();
if (!$items->equals($original_items)) {
return TRUE;
}
}
}
return FALSE;
}
}
......@@ -26,4 +26,36 @@
* @ingroup entity_api
*/
interface ContentEntityInterface extends \Traversable, FieldableEntityInterface, RevisionableInterface, TranslatableInterface {
/**
* Determines if the current translation of the entity has unsaved changes.
*
* If the entity is translatable only translatable fields will be checked for
* changes.
*
* @return bool
* TRUE if the current translation of the entity has changes.
*/
public function hasTranslationChanges();
/**
* Marks the current revision translation as affected.
*
* @param bool|null $affected
* The flag value. A NULL value can be specified to reset the current value
* and make sure a new value will be computed by the system.
*
* @return $this
*/
public function setRevisionTranslationAffected($affected);
/**
* Checks whether the current translation is affected by the current revision.
*
* @return bool
* TRUE if the entity object is affected by the current revision, FALSE
* otherwise.
*/
public function isRevisionTranslationAffected();
}
......@@ -238,4 +238,24 @@ protected function hasFieldValueChanged(FieldDefinitionInterface $field_definiti
return FALSE;
}
/**
* Populates the affected flag for all the revision translations.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* An entity object being saved.
*/
protected function populateAffectedRevisionTranslations(ContentEntityInterface $entity) {
if ($this->entityType->isTranslatable() && $this->entityType->isRevisionable()) {
$languages = $entity->getTranslationLanguages();
foreach ($languages as $langcode => $language) {
$translation = $entity->getTranslation($langcode);
// Avoid populating the value if it was already manually set.
$affected = $translation->isRevisionTranslationAffected();
if (!isset($affected) && $translation->hasTranslationChanges()) {
$translation->setRevisionTranslationAffected(TRUE);
}
}
}
}
}
......@@ -957,6 +957,7 @@ protected function doSave($id, EntityInterface $entity) {
$entity->{$this->revisionKey}->value = $this->saveRevision($entity);
}
if ($this->dataTable) {
$this->populateAffectedRevisionTranslations($entity);
$this->saveToSharedTables($entity);
}
if ($this->revisionDataTable) {
......@@ -984,6 +985,7 @@ protected function doSave($id, EntityInterface $entity) {
$record->{$this->revisionKey} = $this->saveRevision($entity);
}
if ($this->dataTable) {
$this->populateAffectedRevisionTranslations($entity);
$this->saveToSharedTables($entity);
}
if ($this->revisionDataTable) {
......
......@@ -36,37 +36,21 @@ public function preSave() {
$this->value = REQUEST_TIME;
}
else {
// On an existing entity the changed timestamp will only be set to request
// time automatically if at least one other field value of the entity has
// changed. This detection doesn't run on new entities and will be turned
// off if the changed timestamp is set manually before save, for example
// during migrations or using
// On an existing entity translation, the changed timestamp will only be
// set to the request time automatically if at least one other field value
// of the entity has changed. This detection does not run on new entities
// and will be turned off if the changed timestamp is set manually before
// save, for example during migrations or by using
// \Drupal\content_translation\ContentTranslationMetadataWrapperInterface::setChangedTime().
// @todo Knowing if the current translation was modified or not is
// generally useful. There's a follow-up issue to reduce the nesting
// here and to offer an accessor for this information. See
// https://www.drupal.org/node/2453153
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $this->getEntity();
if (!$entity->isNew()) {
$field_name = $this->getFieldDefinition()->getName();
// Clone $entity->original to avoid modifying it when calling
// getTranslation().
$original = clone $entity->original;
$translatable = $this->getFieldDefinition()->isTranslatable();
if ($translatable) {
$original = $original->getTranslation($entity->language()->getId());
}
if ($this->value == $original->get($field_name)->value) {
foreach ($entity->getFieldDefinitions() as $other_field_name => $other_field_definition) {
if ($other_field_name != $field_name && !$other_field_definition->isComputed() && (!$translatable || $other_field_definition->isTranslatable())) {
$items = $entity->get($other_field_name)->filterEmptyItems();
$original_items = $original->get($other_field_name)->filterEmptyItems();
if (!$items->equals($original_items)) {
$this->value = REQUEST_TIME;
break;
}
}
}
/** @var \Drupal\Core\Entity\ContentEntityInterface $original */
$original = $entity->original;
$langcode = $entity->language()->getId();
if (!$entity->isNew() && $original->hasTranslation($langcode)) {
$original_value = $original->getTranslation($langcode)->get($this->getFieldDefinition()->getName())->value;
if ($this->value == $original_value && $entity->hasTranslationChanges()) {
$this->value = REQUEST_TIME;
}
}
}
......
......@@ -207,6 +207,13 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
->setTranslatable(TRUE)
->setRevisionable(TRUE);
$fields['revision_translation_affected'] = BaseFieldDefinition::create('boolean')
->setLabel(t('Revision translation affected'))
->setDescription(t('Indicates if the last edit of a translation belongs to current revision.'))
->setReadOnly(TRUE)
->setRevisionable(TRUE)
->setTranslatable(TRUE);
return $fields;
}
......
......@@ -8,7 +8,9 @@
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Core\Config\Entity\ThirdPartySettingsInterface;
use Drupal\Core\Entity\ContentEntityTypeInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Render\Element;
......@@ -102,9 +104,7 @@ function _content_translation_form_language_content_settings_form_alter(array &$
$fields = $entity_manager->getFieldDefinitions($entity_type_id, $bundle);
if ($fields) {
foreach ($fields as $field_name => $definition) {
// Allow to configure only fields supporting multilingual storage.
// We skip our own fields as they are always translatable.
if (!empty($storage_definitions[$field_name]) && $storage_definitions[$field_name]->isTranslatable() && $storage_definitions[$field_name]->getProvider() != 'content_translation' && $field_name != $entity_type->getKey('langcode') && $field_name != $entity_type->getKey('default_langcode')) {
if (!empty($storage_definitions[$field_name]) && _content_translation_is_field_translatability_configurable($entity_type, $storage_definitions[$field_name])) {
$form['settings'][$entity_type_id][$bundle]['fields'][$field_name] = array(
'#label' => $definition->getLabel(),
'#type' => 'checkbox',
......@@ -138,6 +138,28 @@ function _content_translation_form_language_content_settings_form_alter(array &$
$form['#validate'][] = 'content_translation_form_language_content_settings_validate';
$form['#submit'][] = 'content_translation_form_language_content_settings_submit';
}
/**
* Checks whether translatability should be configurable for a field.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition.
* @param \Drupal\Core\Field\FieldStorageDefinitionInterface $definition
* The field storage definition.
*
* @return bool
* TRUE if field translatability can be configured, FALSE otherwise.
*
* @internal
*/
function _content_translation_is_field_translatability_configurable(EntityTypeInterface $entity_type, FieldStorageDefinitionInterface $definition) {
// Allow to configure only fields supporting multilingual storage. We skip our
// own fields as they are always translatable. Additionally we skip a set of
// well-known fields implementing entity system business logic.
return
$definition->isTranslatable() &&
$definition->getProvider() != 'content_translation' &&
!in_array($definition->getName(), [$entity_type->getKey('langcode'), $entity_type->getKey('default_langcode'), 'revision_translation_affected']);
}
/**
* (proxied) Implements hook_preprocess_HOOK();
......
......@@ -59,6 +59,11 @@ public function prepareTranslation(ContentEntityInterface $entity, LanguageInter
$source_translation = $entity->getTranslation($source->getId());
$target_translation = $entity->addTranslation($target->getId(), $source_translation->toArray());
// Make sure we do not inherit the affected status from the source values.
if ($entity->getEntityType()->isRevisionable()) {
$target_translation->setRevisionTranslationAffected(NULL);
}
/** @var \Drupal\user\UserInterface $user */
$user = $this->entityManager()->getStorage('user')->load($this->currentUser()->id());
$metadata = $this->manager->getTranslationMetadata($target_translation);
......
......@@ -494,6 +494,13 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
),
));
$fields['revision_translation_affected'] = BaseFieldDefinition::create('boolean')
->setLabel(t('Revision translation affected'))
->setDescription(t('Indicates if the last edit of a translation belongs to current revision.'))
->setReadOnly(TRUE)
->setRevisionable(TRUE)
->setTranslatable(TRUE);
return $fields;
}
......
......@@ -101,17 +101,14 @@ public function buildForm(array $form, FormStateInterface $form_state, $node_rev
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->revision->setNewRevision();
// Make this the new default revision for the node.
$this->revision->isDefaultRevision(TRUE);
$revision = $this->prepareRevertedRevision($this->revision);
// The revision timestamp will be updated when the revision is saved. Keep the
// original one for the confirmation message.
$original_revision_timestamp = $this->revision->getRevisionCreationTime();
$original_revision_timestamp = $revision->getRevisionCreationTime();
$revision->revision_log = t('Copy of the revision from %date.', array('%date' => format_date($original_revision_timestamp)));
$this->revision->revision_log = t('Copy of the revision from %date.', array('%date' => format_date($original_revision_timestamp)));
$this->revision->save();
$revision->save();
$this->logger('content')->notice('@type: reverted %title revision %revision.', array('@type' => $this->revision->bundle(), '%title' => $this->revision->label(), '%revision' => $this->revision->getRevisionId()));
drupal_set_message(t('@type %title has been reverted to the revision from %revision-date.', array('@type' => node_get_type_label($this->revision), '%title' => $this->revision->label(), '%revision-date' => format_date($original_revision_timestamp))));
......@@ -121,4 +118,43 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
);
}
/**
* Prepares a revision to be reverted.
*
* @param \Drupal\node\NodeInterface $revision
* The revision to be reverted.
*
* @return \Drupal\node\NodeInterface
* The prepared revision ready to be stored.
*/
protected function prepareRevertedRevision(NodeInterface $revision) {
/** @var \Drupal\node\NodeInterface $default_revision */
$default_revision = $this->nodeStorage->load($revision->id());
// If the entity is translated, make sure only translations affected by the
// specified revision are reverted.
$languages = $default_revision->getTranslationLanguages();
if (count($languages) > 1) {
// @todo Instead of processing all the available translations, we should
// let the user decide which translations should be reverted. See
// https://www.drupal.org/node/2465907.
foreach ($languages as $langcode => $language) {
if ($revision->hasTranslation($langcode) && !$revision->getTranslation($langcode)->isRevisionTranslationAffected()) {
$revision_translation = $revision->getTranslation($langcode);
$default_translation = $default_revision->getTranslation($langcode);
foreach ($default_revision->getFieldDefinitions() as $field_name => $definition) {
if ($definition->isTranslatable()) {
$revision_translation->set($field_name, $default_translation->get($field_name)->getValue());
}
}
}
}
}
$revision->setNewRevision();
$revision->isDefaultRevision(TRUE);
return $revision;
}
}
......@@ -7,7 +7,9 @@
namespace Drupal\node\Tests;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\node\Entity\Node;
use Drupal\node\NodeInterface;
/**
* Create a node with revisions and test viewing, saving, reverting, and
......@@ -19,9 +21,23 @@ class NodeRevisionsTest extends NodeTestBase {
protected $nodes;
protected $revisionLogs;
/**
* {@inheritdoc}
*/
public static $modules = array('node', 'datetime', 'language', 'content_translation');
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
ConfigurableLanguage::createFromLangcode('it')->save();
/** @var \Drupal\content_translation\ContentTranslationManagerInterface $manager */
$manager = \Drupal::service('content_translation.manager');
$manager->setEnabled('node', 'article', TRUE);
// Create and log in user.
$web_user = $this->drupalCreateUser(
array(
......@@ -29,7 +45,8 @@ protected function setUp() {
'revert page revisions',
'delete page revisions',
'edit any page content',
'delete any page content'
'delete any page content',
'translate any entity',
)
);
......@@ -203,4 +220,58 @@ function testNodeRevisionWithoutLogMessage() {
$node_revision = $node_storage->load($node->id());
$this->assertTrue(empty($node_revision->revision_log->value), 'After a new node revision is saved with an empty log message, the log message for the node is empty.');
}
/**
* Tests the revision translations are correctly reverted.
*/
public function testRevisionTranslationRevert() {
// Create a node and a few revisions.
$node = $this->drupalCreateNode(['langcode' => 'en']);
$this->createRevisions($node, 2);
// Translate the node and create a few translation revisions.
$translation = $node->addTranslation('it');
$this->createRevisions($translation, 3);
$revert_id = $node->getRevisionId();
$translated_title = $translation->label();
// Create a new revision for the default translation in-between a series of
// translation revisions.
$this->createRevisions($node, 1);
$default_translation_title = $node->label();
// And create a few more translation revisions.
$this->createRevisions($translation, 2);
$translation_revision_id = $translation->getRevisionId();
// Now revert the a translation revision preceding the last default
// translation revision, and check that the desired value was reverted but
// the default translation value was preserved.
$this->drupalPostForm("node/" . $node->id() . "/revisions/" . $revert_id . "/revert", [], t('Revert'));
/** @var \Drupal\node\NodeStorage $node_storage */
$node_storage = $this->container->get('entity.manager')->getStorage('node');
$node_storage->resetCache();
/** @var \Drupal\node\NodeInterface $node */
$node = $node_storage->load($node->id());
$this->assertTrue($node->getRevisionId() > $translation_revision_id);
$this->assertEqual($node->label(), $default_translation_title);
$this->assertEqual($node->getTranslation('it')->label(), $translated_title);
}
/**
* Creates a series of revisions for the specified node.
*
* @param \Drupal\node\NodeInterface $node
* The node object.
* @param $count
* The number of revisions to be created.
*/
protected function createRevisions(NodeInterface $node, $count) {
for ($i = 0; $i < $count; $i++) {
$node->title = $this->randomString();
$node->setNewRevision(TRUE);
$node->save();
}
}
}
......@@ -61,6 +61,13 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
->setReadOnly(TRUE)
->setSetting('unsigned', TRUE);
$fields['revision_translation_affected'] = BaseFieldDefinition::create('boolean')
->setLabel(t('Revision translation affected'))
->setDescription(t('Indicates if the last edit of a translation belongs to current revision.'))
->setReadOnly(TRUE)
->setRevisionable(TRUE)
->setTranslatable(TRUE);
$fields['langcode']->setRevisionable(TRUE);
$fields['name']->setRevisionable(TRUE);
$fields['user_id']->setRevisionable(TRUE);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment