diff --git a/core/lib/Drupal/Core/Entity/ContentEntityBase.php b/core/lib/Drupal/Core/Entity/ContentEntityBase.php index 40da4b8..146379a 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityBase.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php @@ -52,6 +52,13 @@ protected $values = array(); /** + * The list of field names of any fields that have changed. + * + * @var string[] + */ + protected $changedFields = array(); + + /** * The array of fields, each being an instance of FieldItemListInterface. * * @var array @@ -428,6 +435,23 @@ public function getFields($include_computed = TRUE) { /** * {@inheritdoc} */ + public function getChangedFields() { + return array_filter($this->getFields(), function ($field) { + return isset($this->changedFields[$field->getName()]); + }); + } + + /** + * {@inheritdoc} + */ + public function resetChangedFieldList() { + $this->changedFields = array(); + return $this; + } + + /** + * {@inheritdoc} + */ public function getIterator() { return new \ArrayIterator($this->getFields()); } @@ -592,6 +616,38 @@ public function onChange($name) { } break; } + + if (!isset($this->changedFields[$name][$this->activeLangcode]) && isset($this->values[$name][$this->activeLangcode])) { + $items = $this->get($name)->filterEmptyItems(); + $value = $this->values[$name][$this->activeLangcode]; + $originalItems = \Drupal::service('plugin.manager.field.field_type')->createFieldItemList($this, $name, $value)->filterEmptyItems(); + // If the field items are not equal, we mark them changed. + if (!$items->equals($originalItems)) { + $this->changedFields[$name][$this->activeLangcode] = TRUE; + } + } + } + + /** + * {@inheritdoc} + */ + public function hasChanges() { + if ($this->hasChangesAcrossTranslations()) { + foreach ($this->changedFields as $langcodes) { + if (isset($langcodes[$this->activeLangcode])) { + return TRUE; + } + } + } + + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function hasChangesAcrossTranslations() { + return (bool) count($this->changedFields); } /** @@ -728,6 +784,8 @@ public function addTranslation($langcode, array $values = array()) { $values[$this->defaultLangcodeKey] = FALSE; $this->translations[$langcode]['status'] = static::TRANSLATION_CREATED; + // Todo Review + $this->changedFields['__dummy'][$langcode] = TRUE; $translation = $this->getTranslation($langcode); $definitions = $translation->getFieldDefinitions(); @@ -752,6 +810,12 @@ public function removeTranslation($langcode) { } } $this->translations[$langcode]['status'] = static::TRANSLATION_REMOVED; + foreach (array_keys($this->changedFields) as $name) { + unset($this->changedFields[$name][$langcode]); + if (empty($this->changedFields[$name])) { + unset($this->changedFields[$name]); + } + } } else { $message = 'The specified translation (@langcode) cannot be removed.'; @@ -1020,4 +1084,42 @@ public static function bundleFieldDefinitions(EntityTypeInterface $entity_type, return array(); } + /** + * {@inheritdoc} + */ + public function postSave(EntityStorageInterface $storage, $update = TRUE) { + parent::postSave($storage, $update); + $this->resetChangedFieldList(); + } + + /** + * {@inheritdoc} + */ + public function getChangedTime() { + if ($this->hasField('changed')) { + return $this->get('changed')->value; + } + else { + throw new EntityChangedException( + sprintf('The entity type %s has no changed field.', + $this->getEntityTypeId() + ) + ); + } + } + + /** + * {@inheritdoc} + */ + public function getChangedTimeAcrossTranslations() { + $changed = $this->getUntranslated()->getChangedTime(); + foreach ($this->getTranslationLanguages(FALSE) as $language) { + $translation_changed = $this->getTranslation($language->getId())->getChangedTime(); + if ($translation_changed > $changed) { + $changed = $translation_changed; + } + } + return $changed; + } + } diff --git a/core/lib/Drupal/Core/Entity/EntityChangedInterface.php b/core/lib/Drupal/Core/Entity/EntityChangedInterface.php index f5ee395..f8fef5f 100644 --- a/core/lib/Drupal/Core/Entity/EntityChangedInterface.php +++ b/core/lib/Drupal/Core/Entity/EntityChangedInterface.php @@ -22,11 +22,20 @@ interface EntityChangedInterface { /** - * Returns the timestamp of the last entity change. + * Returns the timestamp of the last entity change of the current + * translations. * * @return int * The timestamp of the last entity save operation. */ public function getChangedTime(); + /** + * Returns the timestamp of the last entity change across all translations. + * + * @return int + * The timestamp of the last entity save operation across all + * translations. + */ + public function getChangedTimeAcrossTranslations(); } diff --git a/core/lib/Drupal/Core/Entity/Exception/EntityChangedException.php b/core/lib/Drupal/Core/Entity/Exception/EntityChangedException.php new file mode 100644 index 0000000..0bc81fd --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Exception/EntityChangedException.php @@ -0,0 +1,14 @@ +isNew()) { $saved_entity = \Drupal::entityManager()->getStorage($entity->getEntityTypeId())->loadUnchanged($entity->id()); - if ($saved_entity && $saved_entity->getChangedTime() > $entity->getChangedTime()) { + if ($saved_entity && $saved_entity->getChangedTimeAcrossTranslations() > $entity->getChangedTime()) { $this->context->addViolation($constraint->message); } } diff --git a/core/lib/Drupal/Core/Field/Plugin/Field/FieldType/ChangedItem.php b/core/lib/Drupal/Core/Field/Plugin/Field/FieldType/ChangedItem.php index 81625d4..ddf99db 100644 --- a/core/lib/Drupal/Core/Field/Plugin/Field/FieldType/ChangedItem.php +++ b/core/lib/Drupal/Core/Field/Plugin/Field/FieldType/ChangedItem.php @@ -7,6 +7,8 @@ namespace Drupal\Core\Field\Plugin\Field\FieldType; +use Drupal\Core\Entity\FieldableEntityInterface; + /** * Defines the 'changed' entity field type. * @@ -30,7 +32,35 @@ class ChangedItem extends CreatedItem { */ public function preSave() { parent::preSave(); - $this->value = REQUEST_TIME; + + $entity = $this->getEntity(); + if ($entity->isNew() || !$this->getFieldDefinition()->isTranslatable()) { + $this->value = REQUEST_TIME; + } + else { + $field_name = $this->getFieldDefinition()->getName(); + $original = clone $entity->original; + if ($this->getFieldDefinition()->isTranslatable()) { + $original = $original->getTranslation($entity->language()->getId()); + } + // If the timestamp has not been set explicitly auto detect a modification + // of the current translation and set the timestamp if needed. + // An example of setting the timestamp explicitly is + // \Drupal\content_translation\ContentTranslationMetadataWrapperInterface + if ($this->value == $original->{$field_name}->value) { + if ($entity instanceof FieldableEntityInterface) { + if ($entity->hasChanges()) { + $this->value = REQUEST_TIME; + } + } + else { + // @todo is this field or its code re-usable for something else than + // ContentEntities? However the default behavior is to set the current + // REQUEST_TIME as value on save. + $this->value = REQUEST_TIME; + } + } + } } } diff --git a/core/modules/block_content/src/Entity/BlockContent.php b/core/modules/block_content/src/Entity/BlockContent.php index 046640c..9ee2620 100644 --- a/core/modules/block_content/src/Entity/BlockContent.php +++ b/core/modules/block_content/src/Entity/BlockContent.php @@ -210,13 +210,6 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { /** * {@inheritdoc} */ - public function getChangedTime() { - return $this->get('changed')->value; - } - - /** - * {@inheritdoc} - */ public function getRevisionLog() { return $this->get('revision_log')->value; } diff --git a/core/modules/comment/src/CommentInterface.php b/core/modules/comment/src/CommentInterface.php index c141706..0d558b5 100644 --- a/core/modules/comment/src/CommentInterface.php +++ b/core/modules/comment/src/CommentInterface.php @@ -197,14 +197,6 @@ public function getCreatedTime(); public function setCreatedTime($created); /** - * Returns the timestamp of when the comment was updated. - * - * @return int - * The timestamp of when the comment was updated. - */ - public function getChangedTime(); - - /** * Checks if the comment is published. * * @return bool diff --git a/core/modules/comment/src/CommentStatistics.php b/core/modules/comment/src/CommentStatistics.php index 588c831..09575a3 100644 --- a/core/modules/comment/src/CommentStatistics.php +++ b/core/modules/comment/src/CommentStatistics.php @@ -127,7 +127,7 @@ public function create(FieldableEntityInterface $entity, $fields) { // Default to REQUEST_TIME when entity does not have a changed property. $last_comment_timestamp = REQUEST_TIME; if ($entity instanceof EntityChangedInterface) { - $last_comment_timestamp = $entity->getChangedTime(); + $last_comment_timestamp = $entity->getChangedTimeAcrossTranslations(); } $query->values(array( 'entity_id' => $entity->id(), @@ -243,9 +243,9 @@ public function update(CommentInterface $comment) { ->fields(array( 'cid' => 0, 'comment_count' => 0, - // Use the created date of the entity if it's set, or default to + // Use the changed date of the entity if it's set, or default to // REQUEST_TIME. - 'last_comment_timestamp' => ($entity instanceof EntityChangedInterface) ? $entity->getChangedTime() : REQUEST_TIME, + 'last_comment_timestamp' => ($entity instanceof EntityChangedInterface) ? $entity->getChangedTimeAcrossTranslations() : REQUEST_TIME, 'last_comment_name' => '', 'last_comment_uid' => $last_comment_uid, )) diff --git a/core/modules/comment/src/Entity/Comment.php b/core/modules/comment/src/Entity/Comment.php index 798a69f..96b1901 100644 --- a/core/modules/comment/src/Entity/Comment.php +++ b/core/modules/comment/src/Entity/Comment.php @@ -517,13 +517,6 @@ public function setThread($thread) { /** * {@inheritdoc} */ - public function getChangedTime() { - return $this->get('changed')->value; - } - - /** - * {@inheritdoc} - */ public static function preCreate(EntityStorageInterface $storage, array &$values) { if (empty($values['comment_type']) && !empty($values['field_name']) && !empty($values['entity_type'])) { $field_storage = FieldStorageConfig::loadByName($values['entity_type'], $values['field_name']); diff --git a/core/modules/comment/src/Form/CommentAdminOverview.php b/core/modules/comment/src/Form/CommentAdminOverview.php index 9205de8..c01e32f 100644 --- a/core/modules/comment/src/Form/CommentAdminOverview.php +++ b/core/modules/comment/src/Form/CommentAdminOverview.php @@ -213,7 +213,7 @@ public function buildForm(array $form, FormStateInterface $form_state, $type = ' '#url' => $commented_entity->urlInfo(), ), ), - 'changed' => $this->dateFormatter->format($comment->getChangedTime(), 'short'), + 'changed' => $this->dateFormatter->format($comment->getChangedTimeAcrossTranslations(), 'short'), ); $comment_uri_options = $comment->urlInfo()->getOptions(); $links = array(); diff --git a/core/modules/comment/src/Tests/CommentTokenReplaceTest.php b/core/modules/comment/src/Tests/CommentTokenReplaceTest.php index a5eaad8..c45c0f7 100644 --- a/core/modules/comment/src/Tests/CommentTokenReplaceTest.php +++ b/core/modules/comment/src/Tests/CommentTokenReplaceTest.php @@ -61,7 +61,7 @@ function testCommentTokenReplacement() { $tests['[comment:url]'] = $comment->url('canonical', $url_options + array('fragment' => 'comment-' . $comment->id())); $tests['[comment:edit-url]'] = $comment->url('edit-form', $url_options); $tests['[comment:created:since]'] = \Drupal::service('date.formatter')->formatInterval(REQUEST_TIME - $comment->getCreatedTime(), 2, $language_interface->getId()); - $tests['[comment:changed:since]'] = \Drupal::service('date.formatter')->formatInterval(REQUEST_TIME - $comment->getChangedTime(), 2, $language_interface->getId()); + $tests['[comment:changed:since]'] = \Drupal::service('date.formatter')->formatInterval(REQUEST_TIME - $comment->getChangedTimeAcrossTranslations(), 2, $language_interface->getId()); $tests['[comment:parent:cid]'] = $comment->hasParentComment() ? $comment->getParentComment()->id() : NULL; $tests['[comment:parent:title]'] = SafeMarkup::checkPlain($parent_comment->getSubject()); $tests['[comment:entity]'] = SafeMarkup::checkPlain($node->getTitle()); diff --git a/core/modules/content_translation/src/ContentTranslationMetadataWrapperInterface.php b/core/modules/content_translation/src/ContentTranslationMetadataWrapperInterface.php index 4a0a65b..955da4e 100644 --- a/core/modules/content_translation/src/ContentTranslationMetadataWrapperInterface.php +++ b/core/modules/content_translation/src/ContentTranslationMetadataWrapperInterface.php @@ -7,8 +7,6 @@ namespace Drupal\content_translation; -use Drupal\Core\Entity\EntityChangedInterface; -use Drupal\Core\Entity\EntityInterface; use Drupal\user\UserInterface; /** @@ -17,7 +15,7 @@ * This acts as a wrapper for an entity translation object, encapsulating the * logic needed to retrieve translation metadata. */ -interface ContentTranslationMetadataWrapperInterface extends EntityChangedInterface { +interface ContentTranslationMetadataWrapperInterface { /** * Retrieves the source language for this translation. @@ -110,6 +108,15 @@ public function getCreatedTime(); public function setCreatedTime($timestamp); /** + * Returns the timestamp of the last entity change of the current + * translations. + * + * @return int + * The timestamp of the last entity save operation. + */ + public function getChangedTime(); + + /** * Sets the translation modification timestamp. * * @param int $timestamp diff --git a/core/modules/file/src/Entity/File.php b/core/modules/file/src/Entity/File.php index 42de0ae..934df62 100644 --- a/core/modules/file/src/Entity/File.php +++ b/core/modules/file/src/Entity/File.php @@ -11,7 +11,6 @@ use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\BaseFieldDefinition; -use Drupal\Core\Language\LanguageInterface; use Drupal\file\FileInterface; use Drupal\user\UserInterface; @@ -111,13 +110,6 @@ public function getCreatedTime() { /** * {@inheritdoc} */ - public function getChangedTime() { - return $this->get('changed')->value; - } - - /** - * {@inheritdoc} - */ public function getOwner() { return $this->get('uid')->entity; } diff --git a/core/modules/menu_link_content/src/Entity/MenuLinkContent.php b/core/modules/menu_link_content/src/Entity/MenuLinkContent.php index 5570a2a..501b580 100644 --- a/core/modules/menu_link_content/src/Entity/MenuLinkContent.php +++ b/core/modules/menu_link_content/src/Entity/MenuLinkContent.php @@ -132,13 +132,6 @@ public function getWeight() { /** * {@inheritdoc} */ - public function getChangedTime() { - return $this->get('changed')->value; - } - - /** - * {@inheritdoc} - */ public function getPluginDefinition() { $definition = array(); $definition['class'] = 'Drupal\menu_link_content\Plugin\Menu\MenuLinkContent'; diff --git a/core/modules/node/node.module b/core/modules/node/node.module index f3ca7d0..91bc6a0 100644 --- a/core/modules/node/node.module +++ b/core/modules/node/node.module @@ -751,8 +751,9 @@ function node_page_title(NodeInterface $node) { * for validation, which will be done by EntityChangedConstraintValidator. */ function node_last_changed($nid, $langcode = NULL) { - $changed = \Drupal::entityManager()->getStorage('node')->loadUnchanged($nid)->getChangedTime(); - return $changed ? $changed : FALSE; + $node = \Drupal::entityManager()->getStorage('node')->loadUnchanged($nid); + $changed = $langcode ? $node->getTranslation($langcode)->getChangedTime() : $node->getChangedTimeAcrossTranslations(); + return $changed ?: FALSE; } /** diff --git a/core/modules/node/src/Entity/Node.php b/core/modules/node/src/Entity/Node.php index 2621d82..d39d7d1 100644 --- a/core/modules/node/src/Entity/Node.php +++ b/core/modules/node/src/Entity/Node.php @@ -217,13 +217,6 @@ public function setCreatedTime($timestamp) { /** * {@inheritdoc} */ - public function getChangedTime() { - return $this->get('changed')->value; - } - - /** - * {@inheritdoc} - */ public function isPromoted() { return (bool) $this->get('promote')->value; } diff --git a/core/modules/node/src/NodeForm.php b/core/modules/node/src/NodeForm.php index 44fccbf..d88393d 100644 --- a/core/modules/node/src/NodeForm.php +++ b/core/modules/node/src/NodeForm.php @@ -289,7 +289,7 @@ protected function actions(array $form, FormStateInterface $form_state) { public function validate(array $form, FormStateInterface $form_state) { $node = parent::validate($form, $form_state); - if ($node->id() && (node_last_changed($node->id(), $this->getFormLangcode($form_state)) > $node->getChangedTime())) { + if ($node->id() && (node_last_changed($node->id()) > $node->getChangedTimeAcrossTranslations())) { $form_state->setErrorByName('changed', $this->t('The content on this page has either been modified by another user, or you have already submitted modifications using this form. As a result, your changes cannot be saved.')); } diff --git a/core/modules/node/src/Tests/NodeLastChangedTest.php b/core/modules/node/src/Tests/NodeLastChangedTest.php index 4444cc1..23fa4b4 100644 --- a/core/modules/node/src/Tests/NodeLastChangedTest.php +++ b/core/modules/node/src/Tests/NodeLastChangedTest.php @@ -38,7 +38,7 @@ function testNodeLastChanged() { // Test node last changed timestamp. $changed_timestamp = node_last_changed($node->id()); - $this->assertEqual($changed_timestamp, $node->getChangedTime(), 'Expected last changed timestamp returned.'); + $this->assertEqual($changed_timestamp, $node->getChangedTimeAcrossTranslations(), 'Expected last changed timestamp returned.'); $changed_timestamp = node_last_changed($node->id(), $node->language()->getId()); $this->assertEqual($changed_timestamp, $node->getChangedTime(), 'Expected last changed timestamp returned.'); diff --git a/core/modules/node/src/Tests/NodeSaveTest.php b/core/modules/node/src/Tests/NodeSaveTest.php index c9dc9c0..d1dafb5 100644 --- a/core/modules/node/src/Tests/NodeSaveTest.php +++ b/core/modules/node/src/Tests/NodeSaveTest.php @@ -130,7 +130,10 @@ function testTimestamps() { $node->save(); $node = $this->drupalGetNodeByTitle($edit['title'], TRUE); $this->assertEqual($node->getCreatedTime(), 979534800, 'Updating a node uses user-set "created" timestamp.'); - $this->assertNotEqual($node->getChangedTime(), 280299600, 'Updating a node does not use user-set "changed" timestamp.'); + // Allowing setting changed timestamps is required see + // Drupal\content_translation\ContentTranslationMetadataWrapper::setChangedTime($timestamp) + // for example + $this->assertEqual($node->getChangedTime(), 280299600, 'Updating a node uses user-set "changed" timestamp.'); } /** diff --git a/core/modules/node/src/Tests/NodeTranslationChangedTest.php b/core/modules/node/src/Tests/NodeTranslationChangedTest.php new file mode 100644 index 0000000..25b814f --- /dev/null +++ b/core/modules/node/src/Tests/NodeTranslationChangedTest.php @@ -0,0 +1,96 @@ +doTestBasicTranslation(); + $this->doTestTranslationChanged(); + } + + /** + * Tests the basic translation workflow. + */ + protected function doTestTranslationChanged() { + $entity = entity_load($this->entityTypeId, $this->entityId, TRUE); + for ($i = 1; $i <= 2; $i++) { + foreach ($entity->getTranslationLanguages() as $language) { + // Ensure different timestamps. + sleep(2); + + $langcode = $language->getId(); + $counters[$langcode] = $i; + + // Set the title and the custom translatable field and the revision log + // to predictable values containing a counter. + $edit = array( + 'title[0][value]' => 'title ' . $langcode . ' ' . $i, + $this->fieldName . '[0][value]' => $this->fieldName . ' ' . $langcode . ' ' . $i, + ); + $edit_path = $entity->urlInfo('edit-form', array('language' => $language)); + $this->drupalPostForm($edit_path, $edit, $this->getFormSubmitAction($entity, $langcode)); + + $entity = entity_load($this->entityTypeId, $this->entityId, TRUE); + $this->assertEqual( + $entity->getChangedTimeAcrossTranslations(), $entity->getTranslation($langcode)->getChangedTime(), + format_string('Changed time for language %language is the latest change over all languages.', array('%language' => $language->getName())) + ); + + if ($i > 1) { + // Because the base class of this test created the translations + // without a sleep command, the changed timestamps might not differ + // until the second run of that loop. + $timestamps = array(); + foreach ($entity->getTranslationLanguages() as $language) { + $next_timestamp = $entity->getTranslation($language->getId()) + ->getChangedTime(); + if (!in_array($next_timestamp, $timestamps)) { + $timestamps[] = $next_timestamp; + } + } + $this->assertTrue( + count($timestamps) == count($entity->getTranslationLanguages()), + 'All timestamps from all languages are different.' + ); + } + } + } + } + +} diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestConstraints.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestConstraints.php index 9f92e7a..f357cda 100644 --- a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestConstraints.php +++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestConstraints.php @@ -44,11 +44,4 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { return $fields; } - /** - * {@inheritdoc} - */ - public function getChangedTime() { - return $this->get('changed')->value; - } - } diff --git a/core/modules/taxonomy/src/Entity/Term.php b/core/modules/taxonomy/src/Entity/Term.php index 7ca9dd7..19efd19 100644 --- a/core/modules/taxonomy/src/Entity/Term.php +++ b/core/modules/taxonomy/src/Entity/Term.php @@ -186,13 +186,6 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { /** * {@inheritdoc} */ - public function getChangedTime() { - return $this->get('changed')->value; - } - - /** - * {@inheritdoc} - */ public function getDescription() { return $this->get('description')->value; } diff --git a/core/modules/user/src/Entity/User.php b/core/modules/user/src/Entity/User.php index 1a78c98..e9b7db8 100644 --- a/core/modules/user/src/Entity/User.php +++ b/core/modules/user/src/Entity/User.php @@ -413,13 +413,6 @@ public function setUsername($username) { /** * {@inheritdoc} */ - public function getChangedTime() { - return $this->get('changed')->value; - } - - /** - * {@inheritdoc} - */ public function setExistingPassword($password) { $this->get('pass')->existing = $password; }