diff --git a/core/lib/Drupal/Core/Entity/ContentEntityBase.php b/core/lib/Drupal/Core/Entity/ContentEntityBase.php index 13c452cac5..ad6c62dbb1 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. * @@ -1373,17 +1377,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); } /** @@ -1423,10 +1417,15 @@ public function hasTranslationChanges() { // The list of fields to skip from the comparision. $skip_fields = $this->getFieldsToSkipFromTranslationChangesCheck(); + // We also check 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); @@ -1447,4 +1446,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/ContentEntityStorageBase.php b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php index c85c7252ac..b82af9bd31 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php @@ -227,13 +227,13 @@ public function createRevision(RevisionableInterface $entity, $default = TRUE, $ $active_langcode = $entity->language()->getId(); $skipped_field_names = array_flip($this->getRevisionTranslationMergeSkippedFieldNames()); - // Default to preserving the untranslatable field values in the default - // revision, otherwise we may expose data that was not meant to be - // accessible. + // 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)) { - // @todo Implement a more complete default logic in - // https://www.drupal.org/project/drupal/issues/2878556. - $keep_untranslatable_fields = FALSE; + $keep_untranslatable_fields = $entity->isDefaultTranslation() && $entity->isDefaultTranslationAffectedOnly(); } /** @var \Drupal\Core\Entity\ContentEntityInterface $default_revision */ @@ -262,6 +262,13 @@ public function createRevision(RevisionableInterface $entity, $default = TRUE, $ // No need to copy untranslatable field values more than once. $keep_untranslatable_fields = TRUE; } + + // The "original" property is used in various places to detect changes in + // field values with respect to the stored ones. If the property is not + // defined, the stored version is loaded explicitly. Since the merged + // revision generated here is not stored anywhere, we need to populate the + // "original" property manually, so that changes can be properly detected. + $new_revision->original = clone $new_revision; } // Eventually mark the new revision as such. 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/Plugin/Validation/Constraint/EntityUntranslatableFieldsConstraint.php b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityUntranslatableFieldsConstraint.php new file mode 100644 index 0000000000..0044b6b4fb --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityUntranslatableFieldsConstraint.php @@ -0,0 +1,20 @@ +entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function validate($entity, Constraint $constraint) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + + // Untranslatable field restrictions apply only to pending revisions of + // multilingual entities. + if ($entity->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 field 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 */ + if (isset($entity->original)) { + $original = $entity->original; + } + else { + $original = $this->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/TranslatableRevisionableInterface.php b/core/lib/Drupal/Core/Entity/TranslatableRevisionableInterface.php index e1bc1e375c..7460eeb16b 100644 --- a/core/lib/Drupal/Core/Entity/TranslatableRevisionableInterface.php +++ b/core/lib/Drupal/Core/Entity/TranslatableRevisionableInterface.php @@ -71,4 +71,13 @@ public function isRevisionTranslationAffectedEnforced(); */ public function setRevisionTranslationAffectedEnforced($enforced); + /** + * 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/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..f157749a90 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\BundleTranslationSettingsInterface; 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 BundleTranslationSettingsInterface) { + $settings = $content_translation_manager->getBundleTranslationSettings($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 $content_translation_manager */ + $content_translation_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']); + $content_translation_manager->setEnabled($entity_type_id, $bundle, $bundle_settings['translatable']); + + // Store any other bundle settings. + if ($content_translation_manager instanceof BundleTranslationSettingsInterface) { + $content_translation_manager->setBundleTranslationSettings($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..48feccc6cd 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\BundleTranslationSettingsInterface; 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 $content_translation_manager */ + $content_translation_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'] = $content_translation_manager->isEnabled($entity_type_id, $bundle); + if ($content_translation_manager instanceof BundleTranslationSettingsInterface) { + $settings = $content_translation_manager->getBundleTranslationSettings($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/BundleTranslationSettingsInterface.php b/core/modules/content_translation/src/BundleTranslationSettingsInterface.php new file mode 100644 index 0000000000..df7b64bbaf --- /dev/null +++ b/core/modules/content_translation/src/BundleTranslationSettingsInterface.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'), + $container->get('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 should + // not be affected by this logic (the "revision_log" field, for instance). + $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..8b3831a251 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, BundleTranslationSettingsInterface { /** * The entity type manager. @@ -105,6 +105,23 @@ public function isEnabled($entity_type_id, $bundle = NULL) { return $enabled; } + /** + * {@inheritdoc} + */ + public function setBundleTranslationSettings($entity_type_id, $bundle, array $settings) { + $config = $this->loadContentLanguageSettings($entity_type_id, $bundle); + $config->setThirdPartySetting('content_translation', 'bundle_settings', $settings) + ->save(); + } + + /** + * {@inheritdoc} + */ + public function getBundleTranslationSettings($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..31431e6763 --- /dev/null +++ b/core/modules/content_translation/tests/src/Functional/ContentTranslationUntranslatableFieldsTest.php @@ -0,0 +1,119 @@ +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/system/tests/modules/entity_test/entity_test.module b/core/modules/system/tests/modules/entity_test/entity_test.module index 7d4df70bae..a934ad65ac 100644 --- a/core/modules/system/tests/modules/entity_test/entity_test.module +++ b/core/modules/system/tests/modules/entity_test/entity_test.module @@ -217,6 +217,9 @@ function entity_test_entity_bundle_info_alter(&$bundles) { 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; + } } } } 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 724385fd94..b4493e31f8 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 @@ -58,7 +58,8 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { $fields['non_mul_field'] = BaseFieldDefinition::create('string') ->setLabel(t('Non translatable')) - ->setDescription(t('A non-translatable string field')); + ->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 index 213d190cca..ab01d91529 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php @@ -3,6 +3,7 @@ namespace Drupal\KernelTests\Core\Entity; use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Language\LanguageInterface; use Drupal\entity_test\Entity\EntityTestMulRev; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\user\Entity\User; @@ -215,6 +216,9 @@ public function dataTestUntranslatableFields() { $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], @@ -222,6 +226,23 @@ public function dataTestUntranslatableFields() { ['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; @@ -234,11 +255,20 @@ public function dataTestUntranslatableFields() { * 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) { + 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('non_mul_field', 0); @@ -289,14 +319,23 @@ protected function doTestEditSequence($sequence) { 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, @@ -304,10 +343,12 @@ protected function doEditStep($active_langcode, $default_revision, $untranslatab 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 @@ -332,10 +373,21 @@ protected function doEditStep($active_langcode, $default_revision, $untranslatab $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('non_mul_field')->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('non_mul_field')->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); @@ -345,12 +397,13 @@ protected function doEditStep($active_langcode, $default_revision, $untranslatab // the new value, besides the upcoming revision ID. Useful to analyze test // failures. $prev = 0; - if (isset($value)) { - preg_match('/^\d+ -> (\d+)$/', $value, $matches); + 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('non_mul_field', $value); + $previous_untranslatable_field_value = $value; } $violations = $entity->validate(); @@ -378,7 +431,7 @@ protected function doEditStep($active_langcode, $default_revision, $untranslatab // translation was marked as affected. foreach ($entity->getTranslationLanguages() as $langcode => $language) { $translation = $entity->getTranslation($langcode); - $rta_expected = $langcode == $active_langcode || $untranslatable_update; + $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) { 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.