diff --git a/core/lib/Drupal/Core/Entity/ContentEntityBase.php b/core/lib/Drupal/Core/Entity/ContentEntityBase.php index 6094bdfefd..a236a978cb 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityBase.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php @@ -20,6 +20,10 @@ */ abstract class ContentEntityBase extends Entity implements \IteratorAggregate, ContentEntityInterface, TranslationStatusInterface { + use EntityChangesDetectionTrait { + getFieldsToSkipFromTranslationChangesCheck as traitGetFieldsToSkipFromTranslationChangesCheck; + } + /** * The plain data values of the contained fields. * @@ -1356,17 +1360,7 @@ public static function bundleFieldDefinitions(EntityTypeInterface $entity_type, * An array of field names. */ protected function getFieldsToSkipFromTranslationChangesCheck() { - /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type */ - $entity_type = $this->getEntityType(); - // A list of known revision metadata fields which should be skipped from - // the comparision. - $fields = [ - $entity_type->getKey('revision'), - 'revision_translation_affected', - ]; - $fields = array_merge($fields, array_values($entity_type->getRevisionMetadataKeys())); - - return $fields; + return $this->traitGetFieldsToSkipFromTranslationChangesCheck($this); } /** @@ -1406,10 +1400,15 @@ public function hasTranslationChanges() { // The list of fields to skip from the comparision. $skip_fields = $this->getFieldsToSkipFromTranslationChangesCheck(); + // We check also untranslatable fields, so that a change to those will mark + // all translations as affected, unless they are configured to only affect + // the default translation. + $skip_untranslatable_fields = !$this->isDefaultTranslation() && $this->isDefaultTranslationAffectedOnly(); + foreach ($this->getFieldDefinitions() as $field_name => $definition) { // @todo Avoid special-casing the following fields. See // https://www.drupal.org/node/2329253. - if (in_array($field_name, $skip_fields, TRUE)) { + if (in_array($field_name, $skip_fields, TRUE) || ($skip_untranslatable_fields && !$definition->isTranslatable())) { continue; } $field = $this->get($field_name); @@ -1430,4 +1429,14 @@ public function hasTranslationChanges() { return FALSE; } + /** + * {@inheritdoc} + */ + public function isDefaultTranslationAffectedOnly() { + $bundle_name = $this->bundle(); + $bundle_info = \Drupal::service('entity_type.bundle.info') + ->getBundleInfo($this->getEntityTypeId()); + return !empty($bundle_info[$bundle_name]['untranslatable_fields.default_translation_affected']); + } + } diff --git a/core/lib/Drupal/Core/Entity/ContentEntityInterface.php b/core/lib/Drupal/Core/Entity/ContentEntityInterface.php index f43bc3b453..61604ba6a5 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityInterface.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityInterface.php @@ -41,4 +41,13 @@ public function getLoadedRevisionId(); */ public function updateLoadedRevisionId(); + /** + * Checks if untranslatable fields should affect only the default translation. + * + * @return bool + * TRUE if untranslatable fields should affect only the default translation, + * FALSE otherwise. + */ + public function isDefaultTranslationAffectedOnly(); + } diff --git a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php index 7f36c11a67..baf9fadd51 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php @@ -6,6 +6,7 @@ use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldStorageDefinitionInterface; +use Drupal\Core\TypedData\TranslationStatusInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -148,6 +149,41 @@ protected function initFieldValues(ContentEntityInterface $entity, array $values $this->invokeHook('field_values_init', $entity); } + /** + * {@inheritdoc} + */ + public function hasStoredTranslations(TranslatableInterface $entity) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $result = FALSE; + + if (!$entity->isNew()) { + if ($entity instanceof TranslationStatusInterface) { + foreach ($entity->getTranslationLanguages(FALSE) as $langcode => $language) { + if ($entity->getTranslationStatus($langcode)) { + $result = TRUE; + break; + } + } + } + + if (!$result) { + $query = $this->getQuery() + ->condition($this->entityType->getKey('id'), $entity->id()) + ->condition($this->entityType->getKey('default_langcode'), FALSE) + ->accessCheck(FALSE) + ->range(0, 1); + + if ($entity->getEntityType()->isRevisionable()) { + $query->allRevisions(); + } + + $result = (bool) $query->execute(); + } + } + + return $result; + } + /** * {@inheritdoc} */ @@ -166,6 +202,96 @@ public function createTranslation(ContentEntityInterface $entity, $langcode, arr return $translation; } + /** + * {@inheritdoc} + */ + public function createRevision(RevisionableInterface $entity, $default = TRUE, $keep_untranslatable_fields = NULL) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $new_revision = clone $entity; + + // For translatable entities, create a merged revision of the active + // translation and the other translations in the default revision. This + // allows to create pending revisions that can always be saved as the new + // default revision without reverting changes in other languages. + if (!$entity->isNew() && !$entity->isDefaultRevision() && $entity->isTranslatable() && $this->hasStoredTranslations($entity)) { + $active_langcode = $entity->language()->getId(); + $skipped_field_names = array_flip($this->getRevisionTranslationMergeSkippedFieldNames()); + + // By default we copy untranslatable field values from the default + // revision, unless they are configured to affect only the default + // translation. This way we can ensure we always have only one affected + // translation in pending revisions. This constraint is enforced by + // EntityUntranslatableFieldsConstraintValidator. + if (!isset($keep_untranslatable_fields)) { + $keep_untranslatable_fields = $entity->isDefaultTranslation() && $entity->isDefaultTranslationAffectedOnly(); + } + + /** @var \Drupal\Core\Entity\ContentEntityInterface $default_revision */ + $default_revision = $this->load($entity->id()); + foreach ($default_revision->getTranslationLanguages() as $langcode => $language) { + if ($langcode == $active_langcode) { + continue; + } + + $default_revision_translation = $default_revision->getTranslation($langcode); + $new_revision_translation = $new_revision->hasTranslation($langcode) ? + $new_revision->getTranslation($langcode) : $new_revision->addTranslation($langcode); + + /** @var \Drupal\Core\Field\FieldItemListInterface[] $sync_items */ + $sync_items = $keep_untranslatable_fields ? + $default_revision_translation->getTranslatableFields() : $default_revision_translation->getFields(); + $sync_items = array_diff_key($sync_items, $skipped_field_names); + + foreach ($sync_items as $field_name => $items) { + $new_revision_translation->set($field_name, $items->getValue()); + } + + // Make sure the "revision_translation_affected" flag is recalculated. + $new_revision_translation->setRevisionTranslationAffected(NULL); + + // No need to copy untranslatable field values more than once. + $keep_untranslatable_fields = TRUE; + } + + // Populate the "original" property with the current values, given that + // the new revision is not stored anywhere. This way we can detect changes + // properly. + $new_revision->original = clone $new_revision; + } + + // Eventually mark the new revision as such. + $new_revision->setNewRevision(); + $new_revision->isDefaultRevision($default); + + // Actually make sure the current translation is marked as affected, even if + // there are no explicit changes, to be sure this revision can be related + // to the correct translation. + $new_revision->setRevisionTranslationAffected(TRUE); + + return $new_revision; + } + + /** + * Returns an array of field names to skip when merging revision translations. + * + * @return array + * An array of field names. + */ + protected function getRevisionTranslationMergeSkippedFieldNames() { + /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type */ + $entity_type = $this->getEntityType(); + + // A list of known revision metadata fields which should be skipped from + // the comparision. + $field_names = [ + $entity_type->getKey('revision'), + $entity_type->getKey('revision_translation_affected'), + ]; + $field_names = array_merge($field_names, array_values($entity_type->getRevisionMetadataKeys())); + + return $field_names; + } + /** * {@inheritdoc} */ diff --git a/core/lib/Drupal/Core/Entity/EntityChangesDetectionTrait.php b/core/lib/Drupal/Core/Entity/EntityChangesDetectionTrait.php new file mode 100644 index 0000000000..e598e8c089 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/EntityChangesDetectionTrait.php @@ -0,0 +1,36 @@ +getEntityType(); + + // A list of known revision metadata fields which should be skipped from + // the comparision. + $fields = [ + $entity_type->getKey('revision'), + $entity_type->getKey('revision_translation_affected'), + ]; + $fields = array_merge($fields, array_values($entity_type->getRevisionMetadataKeys())); + + return $fields; + } + +} diff --git a/core/lib/Drupal/Core/Entity/EntityType.php b/core/lib/Drupal/Core/Entity/EntityType.php index 19a52ca0fa..f5204f8241 100644 --- a/core/lib/Drupal/Core/Entity/EntityType.php +++ b/core/lib/Drupal/Core/Entity/EntityType.php @@ -311,11 +311,16 @@ public function __construct($definition) { $this->checkStorageClass($this->handlers['storage']); } - // Automatically add the EntityChanged constraint if the entity type tracks - // the changed time. + // Automatically add the "EntityChanged" constraint if the entity type + // tracks the changed time. if ($this->entityClassImplements(EntityChangedInterface::class)) { $this->addConstraint('EntityChanged'); } + // Automatically add the "EntityUntranslatableFields" constraint if we have + // an entity type supporting translatable fields and pending revisions. + if ($this->entityClassImplements(ContentEntityInterface::class)) { + $this->addConstraint('EntityUntranslatableFields'); + } // Ensure a default list cache tag is set. if (empty($this->list_cache_tags)) { diff --git a/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php b/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php index b0725251e5..6aa18ff840 100644 --- a/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php +++ b/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php @@ -4,6 +4,8 @@ use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\ContentEntityStorageInterface; +use Drupal\Core\Entity\RevisionableInterface; +use Drupal\Core\Entity\TranslatableInterface; /** * Provides a key value backend for content entities. @@ -18,6 +20,20 @@ public function createTranslation(ContentEntityInterface $entity, $langcode, arr // https://www.drupal.org/node/2618436. } + /** + * {@inheritdoc} + */ + public function hasStoredTranslations(TranslatableInterface $entity) { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function createRevision(RevisionableInterface $entity, $default = TRUE, $keep_untranslatable_fields = NULL) { + return NULL; + } + /** * {@inheritdoc} */ diff --git a/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityUntranslatableFieldsConstraint.php b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityUntranslatableFieldsConstraint.php new file mode 100644 index 0000000000..e1aae729c0 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityUntranslatableFieldsConstraint.php @@ -0,0 +1,20 @@ +isNew() || $entity->isDefaultRevision() || !$entity->isTranslatable() || !$entity->getEntityType()->isRevisionable()) { + return; + } + + // To avoid unintentional reverts and data losses, we forbid changes to + // untranslatable fields in pending revisions for multilingual entities. The + // only case where changes in pending revisions are acceptable is when + // untranslatable fields affect only the default translation, in which case + // a pending revision contains only one affected translation. Even in this + // case, multiple translations would be affected in a single revision, if we + // allowed changes to untranslatable fields while editing non-default + // translations, so that is forbidden too. + if ($this->hasUntranslatableFieldsChanges($entity)) { + if ($entity->isDefaultTranslationAffectedOnly()) { + foreach ($entity->getTranslationLanguages(FALSE) as $langcode => $language) { + if ($entity->getTranslation($langcode)->hasTranslationChanges()) { + $this->context->addViolation($constraint->message); + break; + } + } + } + else { + $this->context->addViolation($constraint->message); + } + } + } + + /** + * Checks whether an entity has untranslatable fields changes. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * A content entity object. + * + * @return bool + * TRUE if untranslatable fields have changes, FALSE otherwise. + */ + protected function hasUntranslatableFieldsChanges(ContentEntityInterface $entity) { + $skip_fields = $this->getFieldsToSkipFromTranslationChangesCheck($entity); + /** @var \Drupal\Core\Entity\ContentEntityInterface $original */ + $original = isset($entity->original) ? + $entity->original : + \Drupal::entityTypeManager() + ->getStorage($entity->getEntityTypeId()) + ->loadRevision($entity->getLoadedRevisionId()); + + foreach ($entity->getFieldDefinitions() as $field_name => $definition) { + if (in_array($field_name, $skip_fields, TRUE) || $definition->isTranslatable() || $definition->isComputed()) { + continue; + } + + // When saving entities in the user interface, the changed timestamp is + // automatically incremented by ContentEntityForm::submitForm() even if + // nothing was actually changed. Thus, the changed time needs to be + // ignored when determining whether there are any actual changes in the + // entity. + $field = $entity->get($field_name); + if ($field instanceof ChangedFieldItemList) { + continue; + } + + $items = $field->filterEmptyItems(); + $original_items = $original->get($field_name)->filterEmptyItems(); + if (!$items->equals($original_items)) { + return TRUE; + } + } + + return FALSE; + } + +} diff --git a/core/lib/Drupal/Core/Entity/RevisionableStorageInterface.php b/core/lib/Drupal/Core/Entity/RevisionableStorageInterface.php index f92d419aa5..d23808b1b6 100644 --- a/core/lib/Drupal/Core/Entity/RevisionableStorageInterface.php +++ b/core/lib/Drupal/Core/Entity/RevisionableStorageInterface.php @@ -7,6 +7,20 @@ */ interface RevisionableStorageInterface { + /** + * Creates a new revision starting off from the specified entity object. + * + * @param \Drupal\Core\Entity\EntityInterface|\Drupal\Core\Entity\RevisionableInterface $entity + * The revisionable entity object being modified. + * @param bool $default + * (optional) Whether the new revision should be marked as default. Defaults + * to TRUE. + * + * @return \Drupal\Core\Entity\EntityInterface|\Drupal\Core\Entity\RevisionableInterface + * A new entity revision object. + */ + public function createRevision(RevisionableInterface $entity, $default = TRUE); + /** * Loads a specific entity revision. * diff --git a/core/lib/Drupal/Core/Entity/TranslatableRevisionableStorageInterface.php b/core/lib/Drupal/Core/Entity/TranslatableRevisionableStorageInterface.php index 1a6b73784e..b86317ff01 100644 --- a/core/lib/Drupal/Core/Entity/TranslatableRevisionableStorageInterface.php +++ b/core/lib/Drupal/Core/Entity/TranslatableRevisionableStorageInterface.php @@ -7,6 +7,26 @@ */ interface TranslatableRevisionableStorageInterface extends TranslatableStorageInterface, RevisionableStorageInterface { + /** + * Creates a new revision starting off from the specified entity object. + * + * When dealing with a translatable entity, this will merge the default + * revision with the active translation of the passed entity. + * + * @param \Drupal\Core\Entity\EntityInterface|\Drupal\Core\Entity\RevisionableInterface $entity + * The revisionable entity object being modified. + * @param bool $default + * (optional) Whether the new revision should be marked as default. Defaults + * to TRUE. + * @param bool|null $keep_untranslatable_fields + * (optional) Whether untranslatable field values should be kept or copied + * from the default revision when generating a merged revision. + * + * @return \Drupal\Core\Entity\EntityInterface|\Drupal\Core\Entity\RevisionableInterface + * A new translatable entity revision object. + */ + public function createRevision(RevisionableInterface $entity, $default = TRUE, $keep_untranslatable_fields = NULL); + /** * Returns the latest revision affecting the specified translation. * diff --git a/core/lib/Drupal/Core/Entity/TranslatableStorageInterface.php b/core/lib/Drupal/Core/Entity/TranslatableStorageInterface.php index c9d93fef4f..ad00f8cd6d 100644 --- a/core/lib/Drupal/Core/Entity/TranslatableStorageInterface.php +++ b/core/lib/Drupal/Core/Entity/TranslatableStorageInterface.php @@ -27,4 +27,23 @@ */ public function createTranslation(ContentEntityInterface $entity, $langcode, array $values = []); + /** + * Checks whether the specified has stored translations. + * + * A revisionable entity can have translations in a pending revision, hence + * the default revision may appear as not translated. This method allows to + * determine whether the entity has any translation in the storage and thus + * should be considered as multilingual. + * + * @param \Drupal\Core\Entity\EntityInterface|\Drupal\Core\Entity\TranslatableInterface $entity + * The entity object to be checked. + * + * @return bool + * TRUE if the entity has at least one translation in any revision, FALSE + * otherwise. + * + * @see \Drupal\Core\TypedData\TranslatableInterface::getTranslationLanguages() + */ + public function hasStoredTranslations(TranslatableInterface $entity); + } diff --git a/core/modules/content_translation/config/schema/content_translation.schema.yml b/core/modules/content_translation/config/schema/content_translation.schema.yml index 808a6e73e8..73676724bd 100644 --- a/core/modules/content_translation/config/schema/content_translation.schema.yml +++ b/core/modules/content_translation/config/schema/content_translation.schema.yml @@ -18,3 +18,9 @@ language.content_settings.*.*.third_party.content_translation: enabled: type: boolean label: 'Content translation enabled' + bundle_settings: + type: sequence + label: 'Content translation bundle settings' + sequence: + type: string + label: 'Bundle settings values' diff --git a/core/modules/content_translation/content_translation.admin.inc b/core/modules/content_translation/content_translation.admin.inc index bf33ba0af0..98a5d5cee1 100644 --- a/core/modules/content_translation/content_translation.admin.inc +++ b/core/modules/content_translation/content_translation.admin.inc @@ -5,6 +5,7 @@ * The content translation administration forms. */ +use Drupal\content_translation\BundleSettingsInterface; use Drupal\Core\Config\Entity\ThirdPartySettingsInterface; use Drupal\Core\Entity\ContentEntityTypeInterface; use Drupal\Core\Entity\EntityTypeInterface; @@ -83,6 +84,7 @@ function _content_translation_form_language_content_settings_form_alter(array &$ return; } + /** @var \Drupal\content_translation\ContentTranslationManagerInterface $content_translation_manager */ $content_translation_manager = \Drupal::service('content_translation.manager'); $default = $form['entity_types']['#default_value']; foreach ($default as $entity_type_id => $enabled) { @@ -110,6 +112,23 @@ function _content_translation_form_language_content_settings_form_alter(array &$ continue; } + // Displayed the "shared fields widgets" toggle. + if ($content_translation_manager instanceof BundleSettingsInterface) { + $settings = $content_translation_manager->getBundleSettings($entity_type_id, $bundle); + $form['settings'][$entity_type_id][$bundle]['settings']['content_translation']['untranslatable_fields_hide'] = [ + '#type' => 'checkbox', + '#title' => t('Hide non translatable fields on translation forms'), + '#default_value' => !empty($settings['untranslatable_fields_hide']), + '#states' => [ + 'visible' => [ + ':input[name="settings[' . $entity_type_id . '][' . $bundle . '][translatable]"]' => [ + 'checked' => TRUE, + ], + ], + ], + ]; + } + $fields = $entity_manager->getFieldDefinitions($entity_type_id, $bundle); if ($fields) { foreach ($fields as $field_name => $definition) { @@ -317,6 +336,8 @@ function content_translation_form_language_content_settings_validate(array $form * @see content_translation_admin_settings_form_validate() */ function content_translation_form_language_content_settings_submit(array $form, FormStateInterface $form_state) { + /** @var \Drupal\content_translation\ContentTranslationManagerInterface $manager */ + $manager = \Drupal::service('content_translation.manager'); $entity_types = $form_state->getValue('entity_types'); $settings = &$form_state->getValue('settings'); @@ -347,7 +368,12 @@ function content_translation_form_language_content_settings_submit(array $form, } if (isset($bundle_settings['translatable'])) { // Store whether a bundle has translation enabled or not. - \Drupal::service('content_translation.manager')->setEnabled($entity_type_id, $bundle, $bundle_settings['translatable']); + $manager->setEnabled($entity_type_id, $bundle, $bundle_settings['translatable']); + + // Store any other bundle settings. + if ($manager instanceof BundleSettingsInterface) { + $manager->setBundleSettings($entity_type_id, $bundle, $bundle_settings['settings']['content_translation']); + } // Save translation_sync settings. if (!empty($bundle_settings['columns'])) { @@ -367,8 +393,8 @@ function content_translation_form_language_content_settings_submit(array $form, } } } + // Ensure entity and menu router information are correctly rebuilt. \Drupal::entityManager()->clearCachedDefinitions(); \Drupal::service('router.builder')->setRebuildNeeded(); - } diff --git a/core/modules/content_translation/content_translation.module b/core/modules/content_translation/content_translation.module index 6a0c229adb..a6db070e59 100644 --- a/core/modules/content_translation/content_translation.module +++ b/core/modules/content_translation/content_translation.module @@ -5,6 +5,7 @@ * Allows entities to be translated into different languages. */ +use Drupal\content_translation\BundleSettingsInterface; use Drupal\Core\Access\AccessResult; use Drupal\Core\Entity\ContentEntityFormInterface; use Drupal\Core\Entity\ContentEntityInterface; @@ -161,9 +162,15 @@ function content_translation_entity_type_alter(array &$entity_types) { * Implements hook_entity_bundle_info_alter(). */ function content_translation_entity_bundle_info_alter(&$bundles) { - foreach ($bundles as $entity_type => &$info) { + /** @var \Drupal\content_translation\ContentTranslationManagerInterface $manager */ + $manager = \Drupal::service('content_translation.manager'); + foreach ($bundles as $entity_type_id => &$info) { foreach ($info as $bundle => &$bundle_info) { - $bundle_info['translatable'] = \Drupal::service('content_translation.manager')->isEnabled($entity_type, $bundle); + $bundle_info['translatable'] = $manager->isEnabled($entity_type_id, $bundle); + if ($manager instanceof BundleSettingsInterface) { + $settings = $manager->getBundleSettings($entity_type_id, $bundle); + $bundle_info['untranslatable_fields.default_translation_affected'] = !empty($settings['untranslatable_fields_hide']); + } } } } @@ -319,6 +326,11 @@ function content_translation_form_alter(array &$form, FormStateInterface $form_s } } + // The footer region, if defined, may contain multilingual widgets so we + // need to always display it. + if (isset($form['footer'])) { + $form['footer']['#multilingual'] = TRUE; + } } } diff --git a/core/modules/content_translation/src/BundleSettingsInterface.php b/core/modules/content_translation/src/BundleSettingsInterface.php new file mode 100644 index 0000000000..f02522927e --- /dev/null +++ b/core/modules/content_translation/src/BundleSettingsInterface.php @@ -0,0 +1,35 @@ +entityTypeId = $entity_type->id(); $this->entityType = $entity_type; $this->languageManager = $language_manager; $this->manager = $manager; $this->currentUser = $current_user; $this->fieldStorageDefinitions = $entity_manager->getLastInstalledFieldStorageDefinitions($this->entityTypeId); + $this->messenger = $messenger; } /** @@ -102,7 +118,8 @@ public static function createInstance(ContainerInterface $container, EntityTypeI $container->get('language_manager'), $container->get('content_translation.manager'), $container->get('entity.manager'), - $container->get('current_user') + $container->get('current_user'), + \Drupal::messenger() ); } @@ -269,6 +286,8 @@ public function getSourceLangcode(FormStateInterface $form_state) { * {@inheritdoc} */ public function entityFormAlter(array &$form, FormStateInterface $form_state, EntityInterface $entity) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $form_object = $form_state->getFormObject(); $form_langcode = $form_object->getFormLangcode($form_state); $entity_langcode = $entity->getUntranslated()->language()->getId(); @@ -512,6 +531,20 @@ public function entityFormSharedElements($element, FormStateInterface $form_stat $ignored_types = array_flip(['actions', 'value', 'hidden', 'vertical_tabs', 'token', 'details']); } + /** @var \Drupal\Core\Entity\ContentEntityForm $form_object */ + $form_object = $form_state->getFormObject(); + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $form_object->getEntity(); + $display_translatability_clue = !$entity->isDefaultTranslationAffectedOnly(); + $hide_untranslatable_fields = $entity->isDefaultTranslationAffectedOnly() && !$entity->isDefaultTranslation(); + $translation_form = $form_state->get(['content_translation', 'translation_form']); + $display_warning = FALSE; + + // We use field definitions to identify untranslatable field widgets to be + // hidden. Fields that are not involved in translation changes checks, for + // instance the "revision_log" field, should not be affected by this logic. + $field_definitions = array_diff_key($entity->getFieldDefinitions(), array_flip($this->getFieldsToSkipFromTranslationChangesCheck($entity))); + foreach (Element::children($element) as $key) { if (!isset($element[$key]['#type'])) { $this->entityFormSharedElements($element[$key], $form_state, $form); @@ -524,10 +557,17 @@ public function entityFormSharedElements($element, FormStateInterface $form_stat // Elements are considered to be non multilingual by default. if (empty($element[$key]['#multilingual'])) { // If we are displaying a multilingual entity form we need to provide - // translatability clues, otherwise the shared form elements should be - // hidden. - if (!$form_state->get(['content_translation', 'translation_form'])) { - $this->addTranslatabilityClue($element[$key]); + // translatability clues, otherwise the non-multilingual form elements + // should be hidden. + if (!$translation_form) { + if ($display_translatability_clue) { + $this->addTranslatabilityClue($element[$key]); + } + // Hide widgets for untranslatable fields. + if ($hide_untranslatable_fields && isset($field_definitions[$key])) { + $element[$key]['#access'] = FALSE; + $display_warning = TRUE; + } } else { $element[$key]['#access'] = FALSE; @@ -536,6 +576,11 @@ public function entityFormSharedElements($element, FormStateInterface $form_stat } } + if ($display_warning && !$form_state->isSubmitted() && !$form_state->isRebuilding()) { + $url = $entity->getUntranslated()->toUrl('edit-form')->toString(); + $this->messenger->addWarning($this->t('Fields that apply to all languages are hidden to avoid conflicting changes. Edit them on the original language form.', ['@url' => $url])); + } + return $element; } diff --git a/core/modules/content_translation/src/ContentTranslationManager.php b/core/modules/content_translation/src/ContentTranslationManager.php index da8bd1c618..28acd42815 100644 --- a/core/modules/content_translation/src/ContentTranslationManager.php +++ b/core/modules/content_translation/src/ContentTranslationManager.php @@ -8,7 +8,7 @@ /** * Provides common functionality for content translation. */ -class ContentTranslationManager implements ContentTranslationManagerInterface { +class ContentTranslationManager implements ContentTranslationManagerInterface, BundleSettingsInterface { /** * The entity type manager. @@ -105,6 +105,23 @@ public function isEnabled($entity_type_id, $bundle = NULL) { return $enabled; } + /** + * {@inheritdoc} + */ + public function setBundleSettings($entity_type_id, $bundle, array $settings) { + $config = $this->loadContentLanguageSettings($entity_type_id, $bundle); + $config->setThirdPartySetting('content_translation', 'bundle_settings', $settings) + ->save(); + } + + /** + * {@inheritdoc} + */ + public function getBundleSettings($entity_type_id, $bundle) { + $config = $this->loadContentLanguageSettings($entity_type_id, $bundle); + return $config->getThirdPartySetting('content_translation', 'bundle_settings', []); + } + /** * Loads a content language config entity based on the entity type and bundle. * diff --git a/core/modules/content_translation/tests/src/Functional/ContentTranslationTestBase.php b/core/modules/content_translation/tests/src/Functional/ContentTranslationTestBase.php index ec7f87dd1b..d3e81a6b4a 100644 --- a/core/modules/content_translation/tests/src/Functional/ContentTranslationTestBase.php +++ b/core/modules/content_translation/tests/src/Functional/ContentTranslationTestBase.php @@ -138,7 +138,7 @@ protected function getEditorPermissions() { * Returns an array of permissions needed for the administrator. */ protected function getAdministratorPermissions() { - return array_merge($this->getEditorPermissions(), $this->getTranslatorPermissions(), ['administer content translation']); + return array_merge($this->getEditorPermissions(), $this->getTranslatorPermissions(), ['administer languages', 'administer content translation']); } /** diff --git a/core/modules/content_translation/tests/src/Functional/ContentTranslationUntranslatableFieldsTest.php b/core/modules/content_translation/tests/src/Functional/ContentTranslationUntranslatableFieldsTest.php new file mode 100644 index 0000000000..75ed9255fe --- /dev/null +++ b/core/modules/content_translation/tests/src/Functional/ContentTranslationUntranslatableFieldsTest.php @@ -0,0 +1,118 @@ +drupalLogin($this->administrator); + $edit = [ + 'settings[' . $this->entityTypeId . '][' . $this->bundle . '][fields][' . $this->fieldName . ']' => 0, + ]; + $this->drupalPostForm('admin/config/regional/content-language', $edit, 'Save configuration'); + + /** @var \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager */ + $entity_field_manager = $this->container->get('entity_field.manager'); + $entity_field_manager->clearCachedFieldDefinitions(); + $definitions = $entity_field_manager->getFieldDefinitions($this->entityTypeId, $this->bundle); + $this->assertFalse($definitions[$this->fieldName]->isTranslatable()); + } + + /** + * {@inheritdoc} + */ + protected function getEditorPermissions() { + return array_merge(parent::getTranslatorPermissions(), ['administer entity_test content', 'view test entity']); + } + + /** + * Tests that hiding untranslatable field widgets works correctly. + */ + public function testHiddenWidgets() { + /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */ + $entity_type_manager = $this->container->get('entity_type.manager'); + $id = $this->createEntity([], 'en'); + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $entity_type_manager + ->getStorage($this->entityTypeId) + ->load($id); + + // Check that the untranslatable field widget is displayed on the edit form + // and no translatability clue is displayed yet. + $this->drupalGet($entity->toUrl('edit-form')); + $field_xpath = '//input[@name="' . $this->fieldName . '[0][value]"]'; + $this->assertNotEmpty($this->xpath($field_xpath)); + $clue_xpath = '//label[@for="edit-' . strtr($this->fieldName, '_', '-') . '-0-value"]/span[text()="(all languages)"]'; + $this->assertEmpty($this->xpath($clue_xpath)); + + // Add a translation and check that the untranslatable field widget is + // displayed on the translation and edit forms along with translatability + // clues. + $add_url = Url::fromRoute("entity.{$this->entityTypeId}.content_translation_add", [ + $entity->getEntityTypeId() => $entity->id(), + 'source' => 'en', + 'target' => 'it' + ]); + $this->drupalGet($add_url); + $this->assertNotEmpty($this->xpath($field_xpath)); + $this->assertNotEmpty($this->xpath($clue_xpath)); + $this->drupalPostForm(NULL, [], 'Save'); + + // Check that the widget is displayed along with its clue in the edit form + // for both languages. + $this->drupalGet($entity->toUrl('edit-form')); + $this->assertNotEmpty($this->xpath($field_xpath)); + $this->assertNotEmpty($this->xpath($clue_xpath)); + $it_language = ConfigurableLanguage::load('it'); + $this->drupalGet($entity->toUrl('edit-form', ['language' => $it_language])); + $this->assertNotEmpty($this->xpath($field_xpath)); + $this->assertNotEmpty($this->xpath($clue_xpath)); + + // Configure untranslatable field widgets to be hidden on non-default + // language edit forms. + $edit = [ + 'settings[' . $this->entityTypeId . '][' . $this->bundle . '][settings][content_translation][untranslatable_fields_hide]' => 1, + ]; + $this->drupalPostForm('admin/config/regional/content-language', $edit, 'Save configuration'); + + // Verify that the widget is displayed in the default language edit form, + // but no clue is displayed. + $this->drupalGet($entity->toUrl('edit-form')); + $field_xpath = '//input[@name="' . $this->fieldName . '[0][value]"]'; + $this->assertNotEmpty($this->xpath($field_xpath)); + $this->assertEmpty($this->xpath($clue_xpath)); + + // Verify no widget is displayed on the non-default language edit form. + $this->drupalGet($entity->toUrl('edit-form', ['language' => $it_language])); + $this->assertEmpty($this->xpath($field_xpath)); + $this->assertEmpty($this->xpath($clue_xpath)); + + // Verify a warning is displayed. + $this->assertSession()->pageTextContains('Fields that apply to all languages are hidden to avoid conflicting changes.'); + $edit_path = $entity->toUrl('edit-form')->toString(); + $link_xpath = '//a[@href=:edit_path and text()="Edit them on the original language form"]'; + $elements = $this->xpath($link_xpath, [':edit_path' => $edit_path]); + $this->assertNotEmpty($elements); + } +} diff --git a/core/modules/node/src/Form/NodeRevisionRevertTranslationForm.php b/core/modules/node/src/Form/NodeRevisionRevertTranslationForm.php index 0c68d8b254..eb07b7af44 100644 --- a/core/modules/node/src/Form/NodeRevisionRevertTranslationForm.php +++ b/core/modules/node/src/Form/NodeRevisionRevertTranslationForm.php @@ -88,10 +88,15 @@ public function buildForm(array $form, FormStateInterface $form_state, $node_rev $this->langcode = $langcode; $form = parent::buildForm($form, $form_state, $node_revision); + // Unless untranslatable fields are configured to affect only the default + // translation, we need to ask the user whether they should be included in + // the revert process. + $default_translation_affected = $this->revision->isDefaultTranslationAffectedOnly(); $form['revert_untranslated_fields'] = [ '#type' => 'checkbox', '#title' => $this->t('Revert content shared among translations'), - '#default_value' => FALSE, + '#default_value' => $default_translation_affected && $this->revision->getTranslation($this->langcode)->isDefaultTranslation(), + '#access' => !$default_translation_affected, ]; return $form; diff --git a/core/modules/serialization/tests/src/Kernel/EntitySerializationTest.php b/core/modules/serialization/tests/src/Kernel/EntitySerializationTest.php index 5df9d75db0..ad9ee9e3d2 100644 --- a/core/modules/serialization/tests/src/Kernel/EntitySerializationTest.php +++ b/core/modules/serialization/tests/src/Kernel/EntitySerializationTest.php @@ -154,6 +154,7 @@ public function testNormalize() { ['value' => TRUE], ], 'non_rev_field' => [], + 'not_translatable' => [], 'field_test_text' => [ [ 'value' => $this->values['field_test_text']['value'], @@ -226,6 +227,7 @@ public function testSerialize() { 'revision_id' => '' . $this->entity->getRevisionId() . '', 'default_langcode' => '1', 'revision_translation_affected' => '1', + 'not_translatable' => '', 'non_rev_field' => '', 'field_test_text' => '' . $this->values['field_test_text']['value'] . '' . $this->values['field_test_text']['format'] . '' . $this->values['field_test_text']['value'] . '

]]>
', ]; diff --git a/core/modules/system/tests/modules/entity_test/entity_test.module b/core/modules/system/tests/modules/entity_test/entity_test.module index 025cb5fc2f..a934ad65ac 100644 --- a/core/modules/system/tests/modules/entity_test/entity_test.module +++ b/core/modules/system/tests/modules/entity_test/entity_test.module @@ -206,6 +206,26 @@ function entity_test_entity_bundle_info() { return $bundles; } +/** + * Implements hook_entity_bundle_info_alter(). + */ +function entity_test_entity_bundle_info_alter(&$bundles) { + $entity_info = \Drupal::entityTypeManager()->getDefinitions(); + $state = \Drupal::state(); + foreach ($bundles as $entity_type_id => &$all_bundle_info) { + if ($entity_info[$entity_type_id]->getProvider() == 'entity_test') { + if ($state->get('entity_test.translation')) { + foreach ($all_bundle_info as $bundle_name => &$bundle_info) { + $bundle_info['translatable'] = TRUE; + if ($state->get('entity_test.untranslatable_fields.default_translation_affected')) { + $bundle_info['untranslatable_fields.default_translation_affected'] = TRUE; + } + } + } + } + } +} + /** * Implements hook_entity_view_mode_info_alter(). */ diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRev.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRev.php index 2bfdf41419..80f265259d 100644 --- a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRev.php +++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRev.php @@ -3,6 +3,7 @@ namespace Drupal\entity_test\Entity; use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Field\BaseFieldDefinition; /** * Defines the test entity class. @@ -53,7 +54,14 @@ class EntityTestMulRev extends EntityTestRev { * {@inheritdoc} */ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { - return parent::baseFieldDefinitions($entity_type) + \Drupal::state()->get($entity_type->id() . '.additional_base_field_definitions', []); + $fields = parent::baseFieldDefinitions($entity_type) + \Drupal::state()->get($entity_type->id() . '.additional_base_field_definitions', []); + + $fields['not_translatable'] = BaseFieldDefinition::create('string') + ->setLabel(t('Non translatable')) + ->setDescription(t('A non-translatable string field')) + ->setRevisionable(TRUE); + + return $fields; } } diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRevChanged.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRevChanged.php index 391307576e..13a169df49 100644 --- a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRevChanged.php +++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRevChanged.php @@ -71,6 +71,7 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { $fields['name']->setRevisionable(TRUE); $fields['user_id']->setRevisionable(TRUE); $fields['changed']->setRevisionable(TRUE); + $fields['not_translatable']->setRevisionable(TRUE); return $fields; } diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php new file mode 100644 index 0000000000..782bdd8712 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php @@ -0,0 +1,518 @@ +installEntitySchema($entity_type_id); + $this->storage = $this->container->get('entity_type.manager') + ->getStorage($entity_type_id); + + $this->installConfig(['language']); + $langcodes = ['it', 'fr']; + foreach ($langcodes as $langcode) { + ConfigurableLanguage::createFromLangcode($langcode)->save(); + } + + $values = [ + 'name' => $this->randomString(), + 'status' => 1, + ]; + User::create($values)->save(); + + // Make sure entity bundles are translatable. + $this->state->set('entity_test.translation', TRUE); + $this->bundleInfo = \Drupal::service('entity_type.bundle.info'); + $this->bundleInfo->clearCachedBundles(); + } + + /** + * Data provider for ::testDecoupledDefaultRevisions. + */ + public function dataTestDecoupledPendingRevisions() { + $sets = []; + + $sets['Intermixed languages - No initial default translation'] = [[ + ['en', TRUE], + ['en', FALSE], + ['it', FALSE], + ['en', FALSE], + ['it', FALSE], + ['en', TRUE], + ['it', TRUE], + ]]; + + $sets['Intermixed languages - With initial default translation'] = [[ + ['en', TRUE], + ['it', TRUE], + ['en', FALSE], + ['it', FALSE], + ['en', TRUE], + ['it', TRUE], + ]]; + + $sets['Alternate languages - No initial default translation'] = [[ + ['en', TRUE], + ['en', FALSE], + ['en', FALSE], + ['en', TRUE], + ['it', FALSE], + ['en', TRUE], + ['it', FALSE], + ['it', FALSE], + ['it', TRUE], + ]]; + + $sets['Alternate languages - With initial default translation'] = [[ + ['en', TRUE], + ['it', TRUE], + ['en', TRUE], + ['en', FALSE], + ['en', FALSE], + ['en', TRUE], + ['it', TRUE], + ['it', FALSE], + ['it', FALSE], + ['it', TRUE], + ]]; + + $sets['Multiple languages - No initial default translation'] = [[ + ['en', TRUE], + ['it', FALSE], + ['fr', FALSE], + ['en', FALSE], + ['en', TRUE], + ['it', TRUE], + ['fr', FALSE], + ['en', FALSE], + ['it', FALSE], + ['en', TRUE], + ['fr', TRUE], + ['it', TRUE], + ['fr', TRUE], + ]]; + + $sets['Multiple languages - With initial default translation'] = [[ + ['en', TRUE], + ['it', TRUE], + ['fr', TRUE], + ['en', FALSE], + ['it', FALSE], + ['en', TRUE], + ['it', TRUE], + ['fr', FALSE], + ['en', FALSE], + ['it', FALSE], + ['en', TRUE], + ['fr', TRUE], + ['it', TRUE], + ['fr', TRUE], + ]]; + + return $sets; + } + + /** + * Test decoupled default revisions. + * + * @param array[] $sequence + * An array with arrays of arguments for the ::doSaveNewRevision() method as + * values. Every child array corresponds to a method invocation. + * + * @covers ::createRevision + * + * @dataProvider dataTestDecoupledPendingRevisions + */ + public function testDecoupledPendingRevisions($sequence) { + $revision_id = $this->doTestEditSequence($sequence); + $this->assertEquals(count($sequence), $revision_id); + } + + /** + * Data provider for ::testUntranslatableFields. + */ + public function dataTestUntranslatableFields() { + $sets = []; + + $sets['Default behavior - Untranslatable fields affect all revisions'] = [ + [ + ['en', TRUE, TRUE], + ['it', FALSE, TRUE, FALSE], + ['en', FALSE, TRUE, FALSE], + ['en', TRUE, TRUE], + ['it', TRUE, TRUE], + ['en', FALSE], + ['it', FALSE], + ['en', TRUE], + ['it', TRUE], + ], + FALSE, + ]; + + $sets['Alternative behavior - Untranslatable fields affect only default translation'] = [ + [ + ['en', TRUE, TRUE], + ['it', FALSE, TRUE, FALSE], + ['en', FALSE, TRUE], + ['it', FALSE], + ['it', TRUE], + ['en', TRUE, TRUE], + ['it', FALSE], + ['en', FALSE], + ['it', TRUE], + ['en', TRUE, TRUE], + ], + TRUE, + ]; + + return $sets; + } + + /** + * Tests that untranslatable fields are handled correctly. + * + * @param array[] $sequence + * An array with arrays of arguments for the ::doSaveNewRevision() method as + * values. Every child array corresponds to a method invocation. + * + * @param bool $default_translation_affected + * Whether untranslatable field changes affect all revisions or only the + * default revision. + * + * @covers ::createRevision + * @covers \Drupal\Core\Entity\Plugin\Validation\Constraint\EntityUntranslatableFieldsConstraintValidator::validate + * + * @dataProvider dataTestUntranslatableFields + */ + public function testUntranslatableFields($sequence, $default_translation_affected) { + // Configure the untranslatable fields edit mode. + $this->state->set('entity_test.untranslatable_fields.default_translation_affected', $default_translation_affected); + $this->bundleInfo->clearCachedBundles(); + + // Test that a new entity is always valid. + $entity = EntityTestMulRev::create(); + $entity->set('not_translatable', 0); + $violations = $entity->validate(); + $this->assertEmpty($violations); + + // Test the specified sequence. + $this->doTestEditSequence($sequence); + } + + /** + * Actually tests an edit step sequence. + * + * @param array[] $sequence + * An array of sequence steps. + * + * @return int + * The latest saved revision id. + */ + protected function doTestEditSequence($sequence) { + $revision_id = NULL; + foreach ($sequence as $index => $step) { + $this->stepIndex = $index; + $revision_id = call_user_func_array([$this, 'doEditStep'], $step); + } + return $revision_id; + } + + /** + * Saves a new revision of the test entity. + * + * @param string $active_langcode + * The language of the translation for which a new revision will be saved. + * @param bool $default_revision + * Whether the revision should be flagged as the default revision. + * @param bool $untranslatable_update + * (optional) Whether an untranslatable field update should be performed. + * Defaults to FALSE. + * @param bool $valid + * (optional) Whether entity validation is expected to succeed. Defaults to + * TRUE. + * + * @return int + * The new revision identifier. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + protected function doEditStep($active_langcode, $default_revision, $untranslatable_update = FALSE, $valid = TRUE) { + $this->stepInfo = [$active_langcode, $default_revision, $untranslatable_update, $valid]; + + // If changes to untranslatable fields affect only the default translation, + // we can different values for untranslatable fields in the various + // revision translations, so we need to track their previous value per + // language. + $all_translations_affected = !$this->state->get('entity_test.untranslatable_fields.default_translation_affected'); + $previous_untranslatable_field_langcode = $all_translations_affected ? LanguageInterface::LANGCODE_DEFAULT : $active_langcode; + + // Initialize previous data tracking. + if (!isset($this->translations)) { + $this->translations[$active_langcode] = EntityTestMulRev::create(); + $this->previousRevisionId[$active_langcode] = 0; + $this->previousUntranslatableFieldValue[$previous_untranslatable_field_langcode] = NULL; + } + if (!isset($this->translations[$active_langcode])) { + $this->translations[$active_langcode] = reset($this->translations)->addTranslation($active_langcode); + $this->previousRevisionId[$active_langcode] = 0; + $this->previousUntranslatableFieldValue[$active_langcode] = NULL; + } + + // We want to update previous data only if we expect a valid result, + // otherwise we would be just polluting it with invalid values. + if ($valid) { + $entity = &$this->translations[$active_langcode]; + $previous_revision_id = &$this->previousRevisionId[$active_langcode]; + $previous_untranslatable_field_value = &$this->previousUntranslatableFieldValue[$previous_untranslatable_field_langcode]; + } + else { + $entity = clone $this->translations[$active_langcode]; + $previous_revision_id = $this->previousRevisionId[$active_langcode]; + $previous_untranslatable_field_value = $this->previousUntranslatableFieldValue[$previous_untranslatable_field_langcode]; + } + + // Check that after instantiating a new revision for the specified + // translation, we are resuming work from where we left the last time. If + // that is the case, the label generated for the previous revision should + // match the stored one. + if (!$entity->isNew()) { + $previous_label = NULL; + if (!$entity->isNewTranslation()) { + $previous_label = $this->generateNewEntityLabel($entity, $previous_revision_id); + } + $previous_revision_id = (int) $entity->getLoadedRevisionId(); + $latest_affected_revision_id = $this->storage->getLatestTranslationAffectedRevisionId($entity->id(), $entity->language()->getId()); + /** @var \Drupal\Core\Entity\ContentEntityInterface $latest_affected_revision */ + $latest_affected_revision = isset($latest_affected_revision_id) ? + $this->storage->loadRevision($latest_affected_revision_id) : $this->storage->load($entity->id()); + $translation = $latest_affected_revision->hasTranslation($active_langcode) ? + $latest_affected_revision->getTranslation($active_langcode) : $latest_affected_revision->addTranslation($active_langcode); + $entity = $this->storage->createRevision($translation, $default_revision); + $this->assertEquals($default_revision, $entity->isDefaultRevision()); + $this->assertEquals($translation->getLoadedRevisionId(), $entity->getLoadedRevisionId()); + $this->assertEquals($previous_label, $entity->label(), $this->formatMessage('Loaded translatable field value does not match the previous one.')); + } + + // Check that the previous untranslatable field value is loaded in the new + // revision as expected. When we are dealing with a non default translation + // the expected value is always the one stored in the default revision, as + // untranslatable fields can only be changed in the default translation or + // in the default revision, depending on the configured mode. + $value = $entity->get('not_translatable')->value; + if (isset($previous_untranslatable_field_value)) { + $this->assertEquals($previous_untranslatable_field_value, $value, $this->formatMessage('Loaded untranslatable field value does not match the previous one.')); + } + elseif (!$entity->isDefaultTranslation()) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $default_revision */ + $default_revision = $this->storage->loadUnchanged($entity->id()); + $expected_value = $default_revision->get('not_translatable')->value; + $this->assertEquals($expected_value, $value, $this->formatMessage('Loaded untranslatable field value does not match the previous one.')); + } + + // Perform a change and store it. + $label = $this->generateNewEntityLabel($entity, $previous_revision_id, TRUE); + $entity->set('name', $label); + if ($untranslatable_update) { + // Store the revision ID of the previous untranslatable fields update in + // the new value, besides the upcoming revision ID. Useful to analyze test + // failures. + $prev = 0; + if (isset($previous_untranslatable_field_value)) { + preg_match('/^\d+ -> (\d+)$/', $previous_untranslatable_field_value, $matches); + $prev = $matches[1]; + } + $value = $prev . ' -> ' . ($entity->getLoadedRevisionId() + 1); + $entity->set('not_translatable', $value); + $previous_untranslatable_field_value = $value; + } + + $violations = $entity->validate(); + $messages = []; + foreach ($violations as $violation) { + /** \Symfony\Component\Validator\ConstraintViolationInterface */ + $messages[] = $violation->getMessage(); + } + $this->assertEquals($valid, !$violations->count(), $this->formatMessage('Validation does not match the expected result: %s', implode(', ', $messages))); + + if ($valid) { + $entity->save(); + + // Reload the current revision translation and the default revision to + // make sure data was stored correctly. + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $this->storage->loadRevision($entity->getRevisionId()); + $entity = $entity->getTranslation($active_langcode); + /** @var \Drupal\Core\Entity\ContentEntityInterface $default_entity */ + $default_entity = $this->storage->loadUnchanged($entity->id()); + + // Verify that the values for the current revision translation match the + // expected ones, while for the other translations they match the default + // revision. We also need to verify that only the current revision + // translation was marked as affected. + foreach ($entity->getTranslationLanguages() as $langcode => $language) { + $translation = $entity->getTranslation($langcode); + $rta_expected = $langcode == $active_langcode || ($untranslatable_update && $all_translations_affected); + $this->assertEquals($rta_expected, $translation->isRevisionTranslationAffected(), $this->formatMessage("'$langcode' translation incorrectly affected")); + $label_expected = $label; + if ($langcode !== $active_langcode) { + $default_translation = $default_entity->hasTranslation($langcode) ? $default_entity->getTranslation($langcode) : $default_entity; + $label_expected = $default_translation->label(); + } + $this->assertEquals($label_expected, $translation->label(), $this->formatMessage("Incorrect '$langcode' translation label")); + } + } + + return $entity->getRevisionId(); + } + + /** + * Generates a new label for the specified revision. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $revision + * An entity object. + * @param int $previous_revision_id + * The previous revision identifier for this revision translation. + * @param bool $next + * (optional) Whether the label describes the current revision or the one + * to be created. Defaults to FALSE. + * + * @return string + * A revision label. + */ + protected function generateNewEntityLabel(ContentEntityInterface $revision, $previous_revision_id, $next = FALSE) { + $language_label = $revision->language()->getName(); + $revision_type = $revision->isDefaultRevision() ? 'Default' : 'Pending'; + $revision_id = $next ? $this->storage->getLatestRevisionId($revision->id()) + 1 : $revision->getLoadedRevisionId(); + return sprintf('%s (%s %d -> %d)', $language_label, $revision_type, $previous_revision_id, $revision_id); + } + + /** + * Formats an assertion message. + * + * @param string $message + * The human-readable message. + * + * @return string + * The formatted message. + */ + protected function formatMessage($message) { + $args = func_get_args(); + array_shift($args); + $params = array_merge($args, $this->stepInfo); + array_unshift($params, $this->stepIndex + 1); + array_unshift($params, '[Step %d] ' . $message . ' (langcode: %s, default_revision: %d, untranslatable_update: %d, valid: %d)'); + return call_user_func_array('sprintf', $params); + } + + /** + * Tests that internal properties are preserved while creating a new revision. + */ + public function testInternalProperties() { + $entity = EntityTestMulRev::create(); + $this->doTestInternalProperties($entity); + + $entity = EntityTestMulRev::create(); + $entity->save(); + $this->doTestInternalProperties($entity); + + /** @var \Drupal\entity_test\Entity\EntityTestMulRev $translation */ + $translation = EntityTestMulRev::create()->addTranslation('it'); + $translation->save(); + $this->doTestInternalProperties($translation); + } + + /** + * Checks that internal properties are preserved for the specified entity. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * An entity object. + */ + protected function doTestInternalProperties(ContentEntityInterface $entity) { + $this->assertFalse($entity->isValidationRequired()); + $entity->setValidationRequired(TRUE); + $this->assertTrue($entity->isValidationRequired()); + $new_revision = $this->storage->createRevision($entity); + $this->assertTrue($new_revision->isValidationRequired()); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityRevisionTranslationTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityRevisionTranslationTest.php index e82d77e713..46aebabb7d 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityRevisionTranslationTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityRevisionTranslationTest.php @@ -2,6 +2,7 @@ namespace Drupal\KernelTests\Core\Entity; +use Drupal\entity_test\Entity\EntityTestMul; use Drupal\entity_test\Entity\EntityTestMulRev; use Drupal\language\Entity\ConfigurableLanguage; @@ -23,9 +24,11 @@ class EntityRevisionTranslationTest extends EntityKernelTestBase { protected function setUp() { parent::setUp(); - // Enable an additional language. + // Enable some additional languages. ConfigurableLanguage::createFromLangcode('de')->save(); + ConfigurableLanguage::createFromLangcode('it')->save(); + $this->installEntitySchema('entity_test_mul'); $this->installEntitySchema('entity_test_mulrev'); } @@ -186,4 +189,72 @@ public function testSetNewRevision() { } } + /** + * Tests that revision translations are correctly detected. + * + * @covers \Drupal\Core\Entity\ContentEntityStorageBase::hasStoredTranslations + */ + public function testHasStoredTranslations() { + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = $this->entityManager->getStorage('entity_test_mul'); + + // Check that a non-revisionable new entity is handled correctly. + $entity = EntityTestMul::create(); + $this->assertEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertFalse($storage->hasStoredTranslations($entity)); + $entity->addTranslation('it'); + $this->assertNotEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertFalse($storage->hasStoredTranslations($entity)); + + // Check that a non-revisionable not translated entity is handled correctly. + $entity = EntityTestMul::create(); + $entity->save(); + $this->assertEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertFalse($storage->hasStoredTranslations($entity)); + + // Check that a non-revisionable translated entity is handled correctly. + $entity->addTranslation('it'); + $entity->save(); + $this->assertNotEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertTrue($storage->hasStoredTranslations($entity)); + + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = $this->entityManager->getStorage('entity_test_mulrev'); + + // Check that a revisionable new entity is handled correctly. + $entity = EntityTestMulRev::create(); + $this->assertEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertFalse($storage->hasStoredTranslations($entity)); + $entity->addTranslation('it'); + $this->assertNotEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertFalse($storage->hasStoredTranslations($entity)); + + // Check that a revisionable not translated entity is handled correctly. + $entity = EntityTestMulRev::create(); + $entity->save(); + $this->assertEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertFalse($storage->hasStoredTranslations($entity)); + + // Check that a revisionable translated pending revision is handled + // correctly. + $new_revision = $storage->createRevision($entity, FALSE); + $new_revision->addTranslation('it'); + $new_revision->save(); + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $storage->loadUnchanged($entity->id()); + $this->assertEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertNotEmpty($new_revision->getTranslationLanguages(FALSE)); + $this->assertTrue($storage->hasStoredTranslations($entity)); + + // Check that a revisionable translated default revision is handled + // correctly. + $new_revision->isDefaultRevision(TRUE); + $new_revision->save(); + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $storage->loadUnchanged($entity->id()); + $this->assertNotEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertNotEmpty($new_revision->getTranslationLanguages(FALSE)); + $this->assertTrue($storage->hasStoredTranslations($entity)); + } + } diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityTypeConstraintsTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityTypeConstraintsTest.php index a58b47c1ce..cdf4b96fb5 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityTypeConstraintsTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityTypeConstraintsTest.php @@ -24,7 +24,11 @@ public function testConstraintDefinition() { // Test reading the annotation. There should be two constraints, the defined // constraint and the automatically added EntityChanged constraint. $entity_type = $this->entityManager->getDefinition('entity_test_constraints'); - $default_constraints = ['NotNull' => [], 'EntityChanged' => NULL]; + $default_constraints = [ + 'NotNull' => [], + 'EntityChanged' => NULL, + 'EntityUntranslatableFields' => NULL, + ]; $this->assertEqual($default_constraints, $entity_type->getConstraints()); // Enable our test module and test extending constraints.