Issue #2878556 by plach, matsbla, vijaycs85, Gábor Hojtsy, catch, gabesullice, effulgentsia, hchonov, hass, amateescu, xjm: Ensure that changes to untranslatable fields affect only one translation in pending revisions

merge-requests/1654/head
Gabor Hojtsy 2018-01-20 16:56:17 +01:00
parent 4e66ea208f
commit 27c3b40e39
21 changed files with 580 additions and 39 deletions

View File

@ -20,6 +20,10 @@ use Drupal\Core\TypedData\TypedDataInterface;
*/
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 @@ abstract class ContentEntityBase extends Entity implements \IteratorAggregate, C
* 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 @@ abstract class ContentEntityBase extends Entity implements \IteratorAggregate, C
// 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 @@ abstract class ContentEntityBase extends Entity implements \IteratorAggregate, C
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']);
}
}

View File

@ -227,13 +227,13 @@ abstract class ContentEntityStorageBase extends EntityStorageBase implements Con
$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 @@ abstract class ContentEntityStorageBase extends EntityStorageBase implements Con
// 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.

View File

@ -0,0 +1,36 @@
<?php
namespace Drupal\Core\Entity;
/**
* Provides helper methods to detect changes in an entity object.
*
* @internal This may be replaced by a proper entity comparison handler.
*/
trait EntityChangesDetectionTrait {
/**
* Returns an array of field names to skip when checking for changes.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* A content entity object.
*
* @return string[]
* 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'),
];
$fields = array_merge($fields, array_values($entity_type->getRevisionMetadataKeys()));
return $fields;
}
}

View File

@ -311,11 +311,16 @@ class EntityType extends PluginDefinition implements EntityTypeInterface {
$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)) {

View File

@ -0,0 +1,20 @@
<?php
namespace Drupal\Core\Entity\Plugin\Validation\Constraint;
use Symfony\Component\Validator\Constraint;
/**
* Validation constraint for the entity changed timestamp.
*
* @Constraint(
* id = "EntityUntranslatableFields",
* label = @Translation("Entity untranslatable fields", context = "Validation"),
* type = {"entity"}
* )
*/
class EntityUntranslatableFieldsConstraint extends Constraint {
public $message = 'Non translatable fields can only be changed when updating the current revision or the original language.';
}

View File

@ -0,0 +1,128 @@
<?php
namespace Drupal\Core\Entity\Plugin\Validation\Constraint;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityChangesDetectionTrait;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\ChangedFieldItemList;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Validates the EntityChanged constraint.
*/
class EntityUntranslatableFieldsConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
use EntityChangesDetectionTrait;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs an EntityUntranslatableFieldsConstraintValidator object.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->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;
}
}

View File

@ -71,4 +71,13 @@ interface TranslatableRevisionableInterface extends TranslatableInterface, Revis
*/
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();
}

View File

@ -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'

View File

@ -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();
}

View File

@ -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;
}
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace Drupal\content_translation;
/**
* Interface providing support for content translation bundle settings.
*/
interface BundleTranslationSettingsInterface {
/**
* Returns translation settings for the specified bundle.
*
* @param string $entity_type_id
* The entity type identifier.
* @param string $bundle
* The bundle name.
*
* @return array
* An associative array of values keyed by setting name.
*/
public function getBundleTranslationSettings($entity_type_id, $bundle);
/**
* Sets translation settings for the specified bundle.
*
* @param string $entity_type_id
* The entity type identifier.
* @param string $bundle
* The bundle name.
* @param array $settings
* An associative array of values keyed by setting name.
*/
public function setBundleTranslationSettings($entity_type_id, $bundle, array $settings);
}

View File

@ -5,6 +5,7 @@ namespace Drupal\content_translation;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\Core\Entity\EntityChangesDetectionTrait;
use Drupal\Core\Entity\EntityHandlerInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityManagerInterface;
@ -13,8 +14,10 @@ use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\user\Entity\User;
use Drupal\user\EntityOwnerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
@ -25,7 +28,10 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
* @ingroup entity_api
*/
class ContentTranslationHandler implements ContentTranslationHandlerInterface, EntityHandlerInterface {
use EntityChangesDetectionTrait;
use DependencySerializationTrait;
use StringTranslationTrait;
/**
* The type of the entity being translated.
@ -70,6 +76,13 @@ class ContentTranslationHandler implements ContentTranslationHandlerInterface, E
*/
protected $fieldStorageDefinitions;
/**
* The messenger service.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
protected $messenger;
/**
* Initializes an instance of the content translation controller.
*
@ -83,14 +96,17 @@ class ContentTranslationHandler implements ContentTranslationHandlerInterface, E
* The entity manager.
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger service.
*/
public function __construct(EntityTypeInterface $entity_type, LanguageManagerInterface $language_manager, ContentTranslationManagerInterface $manager, EntityManagerInterface $entity_manager, AccountInterface $current_user) {
public function __construct(EntityTypeInterface $entity_type, LanguageManagerInterface $language_manager, ContentTranslationManagerInterface $manager, EntityManagerInterface $entity_manager, AccountInterface $current_user, MessengerInterface $messenger) {
$this->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 @@ class ContentTranslationHandler implements ContentTranslationHandlerInterface, E
$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 @@ class ContentTranslationHandler implements ContentTranslationHandlerInterface, E
* {@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 @@ class ContentTranslationHandler implements ContentTranslationHandlerInterface, E
$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,11 +557,18 @@ class ContentTranslationHandler implements ContentTranslationHandlerInterface, E
// 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'])) {
// 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 @@ class ContentTranslationHandler implements ContentTranslationHandlerInterface, E
}
}
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. <a href=":url">Edit them on the original language form</a>.', [':url' => $url]));
}
return $element;
}

View File

@ -8,7 +8,7 @@ use Drupal\Core\Entity\EntityManagerInterface;
/**
* Provides common functionality for content translation.
*/
class ContentTranslationManager implements ContentTranslationManagerInterface {
class ContentTranslationManager implements ContentTranslationManagerInterface, BundleTranslationSettingsInterface {
/**
* The entity type manager.
@ -105,6 +105,23 @@ class ContentTranslationManager implements ContentTranslationManagerInterface {
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.
*

View File

@ -138,7 +138,7 @@ abstract class ContentTranslationTestBase extends BrowserTestBase {
* 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']);
}
/**

View File

@ -0,0 +1,119 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\Core\Url;
use Drupal\language\Entity\ConfigurableLanguage;
/**
* Tests the untranslatable fields behaviors.
*
* @group content_translation
*/
class ContentTranslationUntranslatableFieldsTest extends ContentTranslationTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['language', 'content_translation', 'entity_test'];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Configure one field as untranslatable.
$this->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);
}
}

View File

@ -88,10 +88,15 @@ class NodeRevisionRevertTranslationForm extends NodeRevisionRevertForm {
$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;

View File

@ -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;
}
}
}
}

View File

@ -58,7 +58,8 @@ class EntityTestMulRev extends EntityTestRev {
$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;
}

View File

@ -71,6 +71,7 @@ class EntityTestMulRevChanged extends EntityTestMulChanged {
$fields['name']->setRevisionable(TRUE);
$fields['user_id']->setRevisionable(TRUE);
$fields['changed']->setRevisionable(TRUE);
$fields['not_translatable']->setRevisionable(TRUE);
return $fields;
}

View File

@ -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 @@ class EntityDecoupledTranslationRevisionsTest extends EntityKernelTestBase {
$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 @@ class EntityDecoupledTranslationRevisionsTest extends EntityKernelTestBase {
['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 @@ class EntityDecoupledTranslationRevisionsTest extends EntityKernelTestBase {
* 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 @@ class EntityDecoupledTranslationRevisionsTest extends EntityKernelTestBase {
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 @@ class EntityDecoupledTranslationRevisionsTest extends EntityKernelTestBase {
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 @@ class EntityDecoupledTranslationRevisionsTest extends EntityKernelTestBase {
$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 @@ class EntityDecoupledTranslationRevisionsTest extends EntityKernelTestBase {
// 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 @@ class EntityDecoupledTranslationRevisionsTest extends EntityKernelTestBase {
// 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) {

View File

@ -24,7 +24,11 @@ class EntityTypeConstraintsTest extends EntityKernelTestBase {
// 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.