diff --git a/core/core.services.yml b/core/core.services.yml index 459503e44c..bf3b24c68d 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -888,6 +888,9 @@ services: entity.query.keyvalue: class: Drupal\Core\Entity\KeyValueStore\Query\QueryFactory arguments: ['@keyvalue'] + entity.common_queries: + class: Drupal\Core\Entity\Query\Queries + arguments: ['@entity_type.manager'] router.dumper: class: Drupal\Core\Routing\MatcherDumper arguments: ['@database', '@state'] diff --git a/core/lib/Drupal/Core/Entity/ContentEntityBase.php b/core/lib/Drupal/Core/Entity/ContentEntityBase.php index 2c62589fa2..b84190435f 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityBase.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php @@ -351,6 +351,71 @@ public function getRevisionId() { return $this->getEntityKey('revision'); } + /** + * {@inheritdoc} + */ + public function createNewRevision($default_revision = TRUE, $copy_untranslatable_fields = NULL) { + $new_revision = NULL; + $storage = $this->entityTypeManager()->getStorage($this->entityTypeId); + + if ($this->isTranslatable()) { + $active_langcode = $this->language()->getId(); + + // Start from the default revision and merge the values of the latest + // translation affected revision for the active language. This allows to + // create pending revisions that can always be saved as the new default + // revision without reverting changes in other languages. + /** @var \Drupal\Core\Entity\ContentEntityBase $new_revision */ + $new_revision = clone $storage->load($this->id()); + $new_revision = $new_revision->hasTranslation($active_langcode) ? + $new_revision->getTranslation($active_langcode) : $new_revision->addTranslation($active_langcode); + + // We skip untranslatable fields unless they are configured to affect only + // the default translation, so that we can ensure we always have only one + // affected translation in pending revisions. This constraint is enforced + // by EntityUntranslatableFieldsConstraintValidator. + if (!isset($copy_untranslatable_fields)) { + $copy_untranslatable_fields = $this->isDefaultTranslation() && $this->isDefaultTranslationAffectedOnly(); + } + foreach ($new_revision->getFieldDefinitions() as $field_name => $definition) { + if ($copy_untranslatable_fields || $definition->isTranslatable()) { + $new_revision->set($field_name, $this->get($field_name)->getValue()); + } + } + + // Preserve a reference to the current revision, as the new revision would + // appear as being originated from the default revision otherwise. + $new_revision->set($this->getEntityType()->getKey('revision'), $this->getLoadedRevisionId()); + $new_revision->updateLoadedRevisionId(); + + // Populate the "original" property with the current values, given that + // the new revision is not stored anywhere. + $new_revision->original = clone $new_revision; + } + else { + $new_revision = clone $this; + } + + // Eventually mark the new revision as such. + $new_revision->setNewRevision(); + $new_revision->isDefaultRevision($default_revision); + + // 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; + } + + /** + * {@inheritdoc} + */ + public function isDefaultRevisionType() { + $revision_type = $this->get('revision_type')->value; + return isset($revision_type) && intval($revision_type) === 1; + } + /** * {@inheritdoc} */ @@ -1337,10 +1402,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); @@ -1361,4 +1431,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 d045152f6a..ff2a97ebd2 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityInterface.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityInterface.php @@ -71,4 +71,31 @@ public function getLoadedRevisionId(); */ public function updateLoadedRevisionId(); + /** + * Creates a new revision starting off from the current entity object. + * + * When dealing with a translatable entity, this will merge the default + * revision with the latest affected revision for the entity active language. + * + * @param bool $default_revision + * (optional) Whether the new revision should be marked as default. Defaults + * to TRUE. + * @param null $copy_untranslatable_fields + * (optional) Whether untranslatable field values should be copied over when + * generating a merged revision. Defaults to the behavior specified by the + * bundle settings. + * + * @return static + */ + public function createNewRevision($default_revision = TRUE, $copy_untranslatable_fields = NULL); + + /** + * 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/EntityType.php b/core/lib/Drupal/Core/Entity/EntityType.php index 9584ed3e26..de16f951f6 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/Plugin/Validation/Constraint/EntityUntranslatableFieldsConstraint.php b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityUntranslatableFieldsConstraint.php new file mode 100644 index 0000000000..1b8a33d3c8 --- /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, + // until we support conflict management. The only case where 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; + } + + /** + * Returns an array of field names to skip when checking for changes. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * A content entity object. + * + * @return array + * An array of field names. + */ + protected function getFieldsToSkipFromTranslationChangesCheck(ContentEntityInterface $entity) { + /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type */ + $entity_type = $entity->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'), + 'revision_type', // FIXME + ]; + $fields = array_merge($fields, array_values($entity_type->getRevisionMetadataKeys())); + + return $fields; + } + +} diff --git a/core/lib/Drupal/Core/Entity/Query/Queries.php b/core/lib/Drupal/Core/Entity/Query/Queries.php new file mode 100644 index 0000000000..6517ec9d7e --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Query/Queries.php @@ -0,0 +1,100 @@ +entityTypeManager = $entity_type_manager; + } + + /** + * Returns the latest revision identifier for an entity. + * + * @param string $entity_type_id + * The entity type identifier. + * @param int $entity_id + * The entity identifier. + * + * @return int|null + * The latest revision identifier or NULL if no revision could be found. + */ + public function getLatestRevisionId($entity_type_id, $entity_id) { + $id_key = $this->entityTypeManager + ->getDefinition($entity_type_id) + ->getKey('id'); + + $result = $this->entityTypeManager + ->getStorage($entity_type_id) + ->getQuery() + ->latestRevision() + ->condition($id_key, $entity_id) + ->accessCheck(FALSE) + ->execute(); + + return key($result); + } + + /** + * Returns the latest affected revision identifier for an entity translation. + * + * @param string $entity_type_id + * The entity type identifier. + * @param int $entity_id + * The entity identifier. + * @param string $langcode + * The language of the translation. + * + * @return int|null + * The latest revision identifier or NULL if no revision could be found. + */ + public function getLatestAffectedRevisionId($entity_type_id, $entity_id, $langcode) { + $entity_type = $this->entityTypeManager->getDefinition($entity_type_id); + $id_key = $entity_type->getKey('id'); + $revision_key = $entity_type->getKey('revision'); + $rta_key = $entity_type->getKey('revision_translation_affected'); + + $result = $this->entityTypeManager + ->getStorage($entity_type_id) + ->getQuery() + ->allRevisions() + ->condition($id_key, $entity_id) + ->condition($rta_key, 1, '=', $langcode) + ->range(0, 1) + ->sort($revision_key, 'DESC') + ->accessCheck(FALSE) + ->execute(); + + return key($result); + } + +} 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..8cf6e2c8c4 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,17 @@ language.content_settings.*.*.third_party.content_translation: enabled: type: boolean label: 'Content translation enabled' + +content_translation.settings: + type: config_object + label: 'Content translation settings' + mapping: + untranslatable_fields_hide: + type: sequence + label: 'Entity types' + sequence: + type: sequence + label: 'Bundles' + sequence: + type: boolean + label: 'Hide shared fields on translation forms' diff --git a/core/modules/content_translation/content_translation.admin.inc b/core/modules/content_translation/content_translation.admin.inc index bf33ba0af0..2f8b834fe5 100644 --- a/core/modules/content_translation/content_translation.admin.inc +++ b/core/modules/content_translation/content_translation.admin.inc @@ -83,6 +83,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) { @@ -94,6 +95,7 @@ function _content_translation_form_language_content_settings_form_alter(array &$ $entity_manager = Drupal::entityManager(); $bundle_info_service = \Drupal::service('entity_type.bundle.info'); + $config = \Drupal::configFactory()->get('content_translation.settings'); foreach ($form['#labels'] as $entity_type_id => $label) { $entity_type = $entity_manager->getDefinition($entity_type_id); $storage_definitions = $entity_type instanceof ContentEntityTypeInterface ? $entity_manager->getFieldStorageDefinitions($entity_type_id) : []; @@ -110,6 +112,22 @@ function _content_translation_form_language_content_settings_form_alter(array &$ continue; } + // Displayed the "shared fields widgets" toggle. + $key = 'untranslatable_fields_hide.' . $entity_type_id . '.' . $bundle; + $form['settings'][$entity_type_id][$bundle]['settings']['untranslatable_fields_hide'] = [ + '#type' => 'checkbox', + '#title' => t('Hide shared fields on translation forms'), + '#description' => t('This will prevent editors from changing field values shared among translations when editing content translations.'), + '#default_value' => $config->get($key), + '#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 +335,9 @@ 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'); + $config = \Drupal::configFactory()->getEditable('content_translation.settings'); $entity_types = $form_state->getValue('entity_types'); $settings = &$form_state->getValue('settings'); @@ -347,7 +368,11 @@ 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']); + + // TODO Should we use the content translation manager for this too? + $key = 'untranslatable_fields_hide.' . $entity_type_id . '.' . $bundle; + $config->set($key, (bool) $bundle_settings['settings']['untranslatable_fields_hide']); // Save translation_sync settings. if (!empty($bundle_settings['columns'])) { @@ -367,8 +392,10 @@ function content_translation_form_language_content_settings_submit(array $form, } } } + + $config->save(); + // 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..3f53d7d598 100644 --- a/core/modules/content_translation/content_translation.module +++ b/core/modules/content_translation/content_translation.module @@ -161,9 +161,14 @@ 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'); + $config = \Drupal::configFactory()->get('content_translation.settings'); + 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); + $key = 'untranslatable_fields_hide.' . $entity_type_id . '.' . $bundle; + $bundle_info['untranslatable_fields.default_translation_affected'] = $config->get($key); } } } @@ -319,6 +324,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/ContentTranslationHandler.php b/core/modules/content_translation/src/ContentTranslationHandler.php index 7e6a7ca3ea..d76b5c3645 100644 --- a/core/modules/content_translation/src/ContentTranslationHandler.php +++ b/core/modules/content_translation/src/ContentTranslationHandler.php @@ -512,6 +512,13 @@ 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_shared_fields = $entity->isDefaultTranslationAffectedOnly() && !$entity->isDefaultTranslation(); + foreach (Element::children($element) as $key) { if (!isset($element[$key]['#type'])) { $this->entityFormSharedElements($element[$key], $form_state, $form); @@ -526,8 +533,10 @@ public function entityFormSharedElements($element, FormStateInterface $form_stat // 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]); + if (!$hide_shared_fields && !$form_state->get(['content_translation', 'translation_form'])) { + if ($display_translatability_clue) { + $this->addTranslatabilityClue($element[$key]); + } } else { $element[$key]['#access'] = FALSE; 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..fa0dd01b60 --- /dev/null +++ b/core/modules/content_translation/tests/src/Functional/ContentTranslationUntranslatableFieldsTest.php @@ -0,0 +1,111 @@ +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 untransltable 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 not 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 the + // translatability clue. + $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][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)); + } +} 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/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/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..df41d76167 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php @@ -0,0 +1,465 @@ +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 ::createNewRevision + * + * @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 - Only default revisions supported'] = [ + [ + ['en', TRUE, TRUE], + ['it', FALSE, TRUE, FALSE], + ['en', FALSE, TRUE, FALSE], + ['en', TRUE, TRUE], + ['it', TRUE, TRUE], + ['en', FALSE], + ['it', FALSE], + ], + 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 ::createNewRevision + * @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 = EntityTestMulRevChanged::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. + */ + 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] = EntityTestMulRevChanged::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 = Queries::get()->getLatestAffectedRevisionId($entity->getEntityTypeId(), $entity->id(), $entity->language()->getId()); + /** @var \Drupal\Core\Entity\ContentEntityInterface $latest_affected_revision */ + $latest_affected_revision = $this->storage->loadRevision($latest_affected_revision_id); + $translation = $latest_affected_revision->hasTranslation($active_langcode) ? + $latest_affected_revision->getTranslation($active_langcode) : $latest_affected_revision->addTranslation($active_langcode); + $entity = $translation->createNewRevision($default_revision); + $this->assertEquals($previous_label, $entity->label(), $this->formatMessage('Loaded translatable field value does not match the previous one.')); + } + + $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.')); + } + + // 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 for analyzing + // test failures. + $prev = 0; + if (isset($value)) { + preg_match('/^\d+ -> (\d+)$/', $value, $matches); + $prev = $matches[1]; + } + $value = $prev . ' -> ' . ($entity->getLoadedRevisionId() + 1); + $entity->set('not_translatable', $value); + $previous_untranslatable_field_value = $value; + } + + $violations = $entity->validate(); + $this->assertEquals($valid, !$violations->count(), $this->formatMessage('Validation does not match the expected result.')); + + 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 ? + Queries::get()->getLatestRevisionId($revision->getEntityTypeId(), $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) { + $params = $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); + } + +} 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.