diff --git a/core/lib/Drupal/Core/Entity/ContentEntityBase.php b/core/lib/Drupal/Core/Entity/ContentEntityBase.php index f27ae3021a..b84190435f 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityBase.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php @@ -370,11 +370,12 @@ public function createNewRevision($default_revision = TRUE, $copy_untranslatable $new_revision = $new_revision->hasTranslation($active_langcode) ? $new_revision->getTranslation($active_langcode) : $new_revision->addTranslation($active_langcode); - // We skip untranslatable fields unless we are dealing with the default - // translation, otherwise more than one language would be affected in this - // revision. + // 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(); + $copy_untranslatable_fields = $this->isDefaultTranslation() && $this->isDefaultTranslationAffectedOnly(); } foreach ($new_revision->getFieldDefinitions() as $field_name => $definition) { if ($copy_untranslatable_fields || $definition->isTranslatable()) { @@ -1401,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); @@ -1425,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 110ce991f8..ff2a97ebd2 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityInterface.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityInterface.php @@ -89,4 +89,13 @@ public function updateLoadedRevisionId(); */ 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/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/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/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 380ecfe2f0..df41d76167 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php @@ -4,6 +4,7 @@ use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\Query\Queries; +use Drupal\Core\Language\LanguageInterface; use Drupal\entity_test\Entity\EntityTestMulRevChanged; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\user\Entity\User; @@ -208,6 +209,75 @@ public function testDecoupledPendingRevisions($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. * @@ -246,14 +316,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] = 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, @@ -261,10 +340,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 @@ -328,7 +409,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.