Move all files to 2017/

This commit is contained in:
Oliver Davies 2025-09-29 22:25:17 +01:00
parent ac7370f67f
commit 2875863330
15717 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,26 @@
# Schema for the Content Translation module.
field.field.*.*.*.third_party.content_translation:
type: mapping
label: 'Content translation field settings'
mapping:
translation_sync:
type: sequence
label: 'Field properties for which to synchronize translations'
sequence:
type: string
label: 'Field column for which to synchronize translations'
language.content_settings.*.*.third_party.content_translation:
type: mapping
label: 'Content translation content settings'
mapping:
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

@ -0,0 +1,9 @@
# Schema for the views plugins of the Content Translation module.
views.field.content_translation_link:
type: views_field
label: 'Content translation link'
mapping:
text:
type: label
label: 'Text to display'

View file

@ -0,0 +1,140 @@
/**
* @file
* Content Translation admin behaviors.
*/
(function($, Drupal, drupalSettings) {
/**
* Forces applicable options to be checked as translatable.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches content translation dependent options to the UI.
*/
Drupal.behaviors.contentTranslationDependentOptions = {
attach(context) {
const $context = $(context);
const options = drupalSettings.contentTranslationDependentOptions;
let $fields;
function fieldsChangeHandler($fields, dependentColumns) {
return function(e) {
Drupal.behaviors.contentTranslationDependentOptions.check(
$fields,
dependentColumns,
$(e.target),
);
};
}
// We're given a generic name to look for so we find all inputs containing
// that name and copy over the input values that require all columns to be
// translatable.
if (options && options.dependent_selectors) {
Object.keys(options.dependent_selectors).forEach(field => {
$fields = $context.find(`input[name^="${field}"]`);
const dependentColumns = options.dependent_selectors[field];
$fields.on('change', fieldsChangeHandler($fields, dependentColumns));
Drupal.behaviors.contentTranslationDependentOptions.check(
$fields,
dependentColumns,
);
});
}
},
check($fields, dependentColumns, $changed) {
let $element = $changed;
let column;
function filterFieldsList(index, field) {
return $(field).val() === column;
}
// A field that has many different translatable parts can also define one
// or more columns that require all columns to be translatable.
Object.keys(dependentColumns || {}).forEach(index => {
column = dependentColumns[index];
if (!$changed) {
$element = $fields.filter(filterFieldsList);
}
if ($element.is(`input[value="${column}"]:checked`)) {
$fields
.prop('checked', true)
.not($element)
.prop('disabled', true);
} else {
$fields.prop('disabled', false);
}
});
},
};
/**
* Makes field translatability inherit bundle translatability.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches content translation behavior.
*/
Drupal.behaviors.contentTranslation = {
attach(context) {
// Initially hide all field rows for non translatable bundles and all
// column rows for non translatable fields.
$(context)
.find('table .bundle-settings .translatable :input')
.once('translation-entity-admin-hide')
.each(function() {
const $input = $(this);
const $bundleSettings = $input.closest('.bundle-settings');
if (!$input.is(':checked')) {
$bundleSettings.nextUntil('.bundle-settings').hide();
} else {
$bundleSettings
.nextUntil('.bundle-settings', '.field-settings')
.find('.translatable :input:not(:checked)')
.closest('.field-settings')
.nextUntil(':not(.column-settings)')
.hide();
}
});
// When a bundle is made translatable all of its fields should inherit
// this setting. Instead when it is made non translatable its fields are
// hidden, since their translatability no longer matters.
$('body')
.once('translation-entity-admin-bind')
.on('click', 'table .bundle-settings .translatable :input', e => {
const $target = $(e.target);
const $bundleSettings = $target.closest('.bundle-settings');
const $settings = $bundleSettings.nextUntil('.bundle-settings');
const $fieldSettings = $settings.filter('.field-settings');
if ($target.is(':checked')) {
$bundleSettings
.find('.operations :input[name$="[language_alterable]"]')
.prop('checked', true);
$fieldSettings.find('.translatable :input').prop('checked', true);
$settings.show();
} else {
$settings.hide();
}
})
.on('click', 'table .field-settings .translatable :input', e => {
const $target = $(e.target);
const $fieldSettings = $target.closest('.field-settings');
const $columnSettings = $fieldSettings.nextUntil(
'.field-settings, .bundle-settings',
);
if ($target.is(':checked')) {
$columnSettings.show();
} else {
$columnSettings.hide();
}
});
},
};
})(jQuery, Drupal, drupalSettings);

View file

@ -0,0 +1,405 @@
<?php
/**
* @file
* The content translation administration forms.
*/
use Drupal\content_translation\BundleTranslationSettingsInterface;
use Drupal\content_translation\ContentTranslationManager;
use Drupal\Core\Config\Entity\ThirdPartySettingsInterface;
use Drupal\Core\Entity\ContentEntityTypeInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Render\Element;
/**
* Returns a form element to configure field synchronization.
*
* @param \Drupal\Core\Field\FieldDefinitionInterface $field
* A field definition object.
* @param string $element_name
* (optional) The element name, which is added to drupalSettings so that
* javascript can manipulate the form element.
*
* @return array
* A form element to configure field synchronization.
*/
function content_translation_field_sync_widget(FieldDefinitionInterface $field, $element_name = 'third_party_settings[content_translation][translation_sync]') {
// No way to store field sync information on this field.
if (!($field instanceof ThirdPartySettingsInterface)) {
return [];
}
$element = [];
$definition = \Drupal::service('plugin.manager.field.field_type')->getDefinition($field->getType());
$column_groups = $definition['column_groups'];
if (!empty($column_groups) && count($column_groups) > 1) {
$options = [];
$default = [];
$require_all_groups_for_translation = [];
foreach ($column_groups as $group => $info) {
$options[$group] = $info['label'];
$default[$group] = !empty($info['translatable']) ? $group : FALSE;
if (!empty($info['require_all_groups_for_translation'])) {
$require_all_groups_for_translation[] = $group;
}
}
$default = $field->getThirdPartySetting('content_translation', 'translation_sync', $default);
$element = [
'#type' => 'checkboxes',
'#title' => t('Translatable elements'),
'#options' => $options,
'#default_value' => $default,
];
if ($require_all_groups_for_translation) {
// The actual checkboxes are sometimes rendered separately and the parent
// element is ignored. Attach to the first option to ensure that this
// does not get lost.
$element[key($options)]['#attached']['drupalSettings']['contentTranslationDependentOptions'] = [
'dependent_selectors' => [
$element_name => $require_all_groups_for_translation,
],
];
$element[key($options)]['#attached']['library'][] = 'content_translation/drupal.content_translation.admin';
}
}
return $element;
}
/**
* (proxied) Implements hook_form_FORM_ID_alter().
*/
function _content_translation_form_language_content_settings_form_alter(array &$form, FormStateInterface $form_state) {
// Inject into the content language settings the translation settings if the
// user has the required permission.
if (!\Drupal::currentUser()->hasPermission('administer content translation')) {
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) {
$default[$entity_type_id] = $enabled || $content_translation_manager->isEnabled($entity_type_id) ? $entity_type_id : FALSE;
}
$form['entity_types']['#default_value'] = $default;
$form['#attached']['library'][] = 'content_translation/drupal.content_translation.admin';
$entity_manager = Drupal::entityManager();
$bundle_info_service = \Drupal::service('entity_type.bundle.info');
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) : [];
$entity_type_translatable = $content_translation_manager->isSupported($entity_type_id);
foreach ($bundle_info_service->getBundleInfo($entity_type_id) as $bundle => $bundle_info) {
// Here we do not want the widget to be altered and hold also the "Enable
// translation" checkbox, which would be redundant. Hence we add this key
// to be able to skip alterations. Alter the title and display the message
// about UI integration.
$form['settings'][$entity_type_id][$bundle]['settings']['language']['#content_translation_skip_alter'] = TRUE;
if (!$entity_type_translatable) {
$form['settings'][$entity_type_id]['#title'] = t('@label (Translation is not supported).', ['@label' => $entity_type->getLabel()]);
continue;
}
// Displayed the "shared fields widgets" toggle.
if ($content_translation_manager instanceof BundleTranslationSettingsInterface) {
$settings = $content_translation_manager->getBundleTranslationSettings($entity_type_id, $bundle);
$force_hidden = ContentTranslationManager::isPendingRevisionSupportEnabled($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' => $force_hidden || !empty($settings['untranslatable_fields_hide']),
'#disabled' => $force_hidden,
'#description' => $force_hidden ? t('Moderated content requires non-translatable fields to be edited in the original language form.') : '',
'#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) {
if ($definition->isComputed() || (!empty($storage_definitions[$field_name]) && _content_translation_is_field_translatability_configurable($entity_type, $storage_definitions[$field_name]))) {
$form['settings'][$entity_type_id][$bundle]['fields'][$field_name] = [
'#label' => $definition->getLabel(),
'#type' => 'checkbox',
'#default_value' => $definition->isTranslatable(),
];
// Display the column translatability configuration widget.
$column_element = content_translation_field_sync_widget($definition, "settings[{$entity_type_id}][{$bundle}][columns][{$field_name}]");
if ($column_element) {
$form['settings'][$entity_type_id][$bundle]['columns'][$field_name] = $column_element;
}
}
}
if (!empty($form['settings'][$entity_type_id][$bundle]['fields'])) {
// Only show the checkbox to enable translation if the bundles in the
// entity might have fields and if there are fields to translate.
$form['settings'][$entity_type_id][$bundle]['translatable'] = [
'#type' => 'checkbox',
'#default_value' => $content_translation_manager->isEnabled($entity_type_id, $bundle),
];
}
}
}
}
$form['#validate'][] = 'content_translation_form_language_content_settings_validate';
$form['#submit'][] = 'content_translation_form_language_content_settings_submit';
}
/**
* Checks whether translatability should be configurable for a field.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition.
* @param \Drupal\Core\Field\FieldStorageDefinitionInterface $definition
* The field storage definition.
*
* @return bool
* TRUE if field translatability can be configured, FALSE otherwise.
*
* @internal
*/
function _content_translation_is_field_translatability_configurable(EntityTypeInterface $entity_type, FieldStorageDefinitionInterface $definition) {
// Allow to configure only fields supporting multilingual storage. We skip our
// own fields as they are always translatable. Additionally we skip a set of
// well-known fields implementing entity system business logic.
return
$definition->isTranslatable() &&
$definition->getProvider() != 'content_translation' &&
!in_array($definition->getName(), [$entity_type->getKey('langcode'), $entity_type->getKey('default_langcode'), 'revision_translation_affected']);
}
/**
* (proxied) Implements hook_preprocess_HOOK();
*/
function _content_translation_preprocess_language_content_settings_table(&$variables) {
// Alter the 'build' variable injecting the translation settings if the user
// has the required permission.
if (!\Drupal::currentUser()->hasPermission('administer content translation')) {
return;
}
$element = $variables['element'];
$build = &$variables['build'];
array_unshift($build['#header'], ['data' => t('Translatable'), 'class' => ['translatable']]);
$rows = [];
foreach (Element::children($element) as $bundle) {
$field_names = !empty($element[$bundle]['fields']) ? Element::children($element[$bundle]['fields']) : [];
if (!empty($element[$bundle]['translatable'])) {
$checkbox_id = $element[$bundle]['translatable']['#id'];
}
$rows[$bundle] = $build['#rows'][$bundle];
if (!empty($element[$bundle]['translatable'])) {
$translatable = [
'data' => $element[$bundle]['translatable'],
'class' => ['translatable'],
];
array_unshift($rows[$bundle]['data'], $translatable);
$rows[$bundle]['data'][1]['data']['#prefix'] = '<label for="' . $checkbox_id . '">';
}
else {
$translatable = [
'data' => t('N/A'),
'class' => ['untranslatable'],
];
array_unshift($rows[$bundle]['data'], $translatable);
}
foreach ($field_names as $field_name) {
$field_element = &$element[$bundle]['fields'][$field_name];
$rows[] = [
'data' => [
[
'data' => \Drupal::service('renderer')->render($field_element),
'class' => ['translatable'],
],
[
'data' => [
'#prefix' => '<label for="' . $field_element['#id'] . '">',
'#suffix' => '</label>',
'bundle' => [
'#prefix' => '<span class="visually-hidden">',
'#suffix' => '</span> ',
'#plain_text' => $element[$bundle]['settings']['#label'],
],
'field' => [
'#plain_text' => $field_element['#label'],
],
],
'class' => ['field'],
],
[
'data' => '',
'class' => ['operations'],
],
],
'class' => ['field-settings'],
];
if (!empty($element[$bundle]['columns'][$field_name])) {
$column_element = &$element[$bundle]['columns'][$field_name];
foreach (Element::children($column_element) as $key) {
$column_label = $column_element[$key]['#title'];
unset($column_element[$key]['#title']);
$rows[] = [
'data' => [
[
'data' => \Drupal::service('renderer')->render($column_element[$key]),
'class' => ['translatable'],
],
[
'data' => [
'#prefix' => '<label for="' . $column_element[$key]['#id'] . '">',
'#suffix' => '</label>',
'bundle' => [
'#prefix' => '<span class="visually-hidden">',
'#suffix' => '</span> ',
'#plain_text' => $element[$bundle]['settings']['#label'],
],
'field' => [
'#prefix' => '<span class="visually-hidden">',
'#suffix' => '</span> ',
'#plain_text' => $field_element['#label'],
],
'columns' => [
'#plain_text' => $column_label,
],
],
'class' => ['column'],
],
[
'data' => '',
'class' => ['operations'],
],
],
'class' => ['column-settings'],
];
}
}
}
}
$build['#rows'] = $rows;
}
/**
* Form validation handler for content_translation_admin_settings_form().
*
* @see content_translation_admin_settings_form_submit()
*/
function content_translation_form_language_content_settings_validate(array $form, FormStateInterface $form_state) {
$settings = &$form_state->getValue('settings');
foreach ($settings as $entity_type => $entity_settings) {
foreach ($entity_settings as $bundle => $bundle_settings) {
if (!empty($bundle_settings['translatable'])) {
$name = "settings][$entity_type][$bundle][translatable";
$translatable_fields = isset($settings[$entity_type][$bundle]['fields']) ? array_filter($settings[$entity_type][$bundle]['fields']) : FALSE;
if (empty($translatable_fields)) {
$t_args = ['%bundle' => $form['settings'][$entity_type][$bundle]['settings']['#label']];
$form_state->setErrorByName($name, t('At least one field needs to be translatable to enable %bundle for translation.', $t_args));
}
$values = $bundle_settings['settings']['language'];
if (empty($values['language_alterable']) && \Drupal::languageManager()->isLanguageLocked($values['langcode'])) {
foreach (\Drupal::languageManager()->getLanguages(LanguageInterface::STATE_LOCKED) as $language) {
$locked_languages[] = $language->getName();
}
$form_state->setErrorByName($name, t('Translation is not supported if language is always one of: @locked_languages', ['@locked_languages' => implode(', ', $locked_languages)]));
}
}
}
}
}
/**
* Form submission handler for content_translation_admin_settings_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');
// If an entity type is not translatable all its bundles and fields must be
// marked as non-translatable. Similarly, if a bundle is made non-translatable
// all of its fields will be not translatable.
foreach ($settings as $entity_type_id => &$entity_settings) {
foreach ($entity_settings as $bundle => &$bundle_settings) {
$fields = \Drupal::entityManager()->getFieldDefinitions($entity_type_id, $bundle);
if (!empty($bundle_settings['translatable'])) {
$bundle_settings['translatable'] = $bundle_settings['translatable'] && $entity_types[$entity_type_id];
}
if (!empty($bundle_settings['fields'])) {
foreach ($bundle_settings['fields'] as $field_name => $translatable) {
$translatable = $translatable && $bundle_settings['translatable'];
// If we have column settings and no column is translatable, no point
// in making the field translatable.
if (isset($bundle_settings['columns'][$field_name]) && !array_filter($bundle_settings['columns'][$field_name])) {
$translatable = FALSE;
}
$field_config = $fields[$field_name]->getConfig($bundle);
if ($field_config->isTranslatable() != $translatable) {
$field_config
->setTranslatable($translatable)
->save();
}
}
}
if (isset($bundle_settings['translatable'])) {
// Store whether a bundle has translation enabled or not.
$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'])) {
foreach ($bundle_settings['columns'] as $field_name => $column_settings) {
$field_config = $fields[$field_name]->getConfig($bundle);
if ($field_config->isTranslatable()) {
$field_config->setThirdPartySetting('content_translation', 'translation_sync', $column_settings);
}
// If the field does not have translatable enabled we need to reset
// the sync settings to their defaults.
else {
$field_config->unsetThirdPartySetting('content_translation', 'translation_sync');
}
$field_config->save();
}
}
}
}
}
// Ensure entity and menu router information are correctly rebuilt.
\Drupal::entityManager()->clearCachedDefinitions();
\Drupal::service('router.builder')->setRebuildNeeded();
}

View file

@ -0,0 +1,91 @@
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Drupal, drupalSettings) {
Drupal.behaviors.contentTranslationDependentOptions = {
attach: function attach(context) {
var $context = $(context);
var options = drupalSettings.contentTranslationDependentOptions;
var $fields = void 0;
function fieldsChangeHandler($fields, dependentColumns) {
return function (e) {
Drupal.behaviors.contentTranslationDependentOptions.check($fields, dependentColumns, $(e.target));
};
}
if (options && options.dependent_selectors) {
Object.keys(options.dependent_selectors).forEach(function (field) {
$fields = $context.find('input[name^="' + field + '"]');
var dependentColumns = options.dependent_selectors[field];
$fields.on('change', fieldsChangeHandler($fields, dependentColumns));
Drupal.behaviors.contentTranslationDependentOptions.check($fields, dependentColumns);
});
}
},
check: function check($fields, dependentColumns, $changed) {
var $element = $changed;
var column = void 0;
function filterFieldsList(index, field) {
return $(field).val() === column;
}
Object.keys(dependentColumns || {}).forEach(function (index) {
column = dependentColumns[index];
if (!$changed) {
$element = $fields.filter(filterFieldsList);
}
if ($element.is('input[value="' + column + '"]:checked')) {
$fields.prop('checked', true).not($element).prop('disabled', true);
} else {
$fields.prop('disabled', false);
}
});
}
};
Drupal.behaviors.contentTranslation = {
attach: function attach(context) {
$(context).find('table .bundle-settings .translatable :input').once('translation-entity-admin-hide').each(function () {
var $input = $(this);
var $bundleSettings = $input.closest('.bundle-settings');
if (!$input.is(':checked')) {
$bundleSettings.nextUntil('.bundle-settings').hide();
} else {
$bundleSettings.nextUntil('.bundle-settings', '.field-settings').find('.translatable :input:not(:checked)').closest('.field-settings').nextUntil(':not(.column-settings)').hide();
}
});
$('body').once('translation-entity-admin-bind').on('click', 'table .bundle-settings .translatable :input', function (e) {
var $target = $(e.target);
var $bundleSettings = $target.closest('.bundle-settings');
var $settings = $bundleSettings.nextUntil('.bundle-settings');
var $fieldSettings = $settings.filter('.field-settings');
if ($target.is(':checked')) {
$bundleSettings.find('.operations :input[name$="[language_alterable]"]').prop('checked', true);
$fieldSettings.find('.translatable :input').prop('checked', true);
$settings.show();
} else {
$settings.hide();
}
}).on('click', 'table .field-settings .translatable :input', function (e) {
var $target = $(e.target);
var $fieldSettings = $target.closest('.field-settings');
var $columnSettings = $fieldSettings.nextUntil('.field-settings, .bundle-settings');
if ($target.is(':checked')) {
$columnSettings.show();
} else {
$columnSettings.hide();
}
});
}
};
})(jQuery, Drupal, drupalSettings);

View file

@ -0,0 +1,9 @@
name: 'Content Translation'
type: module
description: 'Allows users to translate content entities.'
dependencies:
- drupal:language
package: Multilingual
version: VERSION
core: 8.x
configure: language.content_settings_page

View file

@ -0,0 +1,103 @@
<?php
/**
* @file
* Installation functions for Content Translation module.
*/
use Drupal\Core\Entity\Sql\SqlEntityStorageInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Url;
/**
* Implements hook_install().
*/
function content_translation_install() {
// Assign a fairly low weight to ensure our implementation of
// hook_module_implements_alter() is run among the last ones.
module_set_weight('content_translation', 10);
// Translation works when at least two languages are added.
if (count(\Drupal::languageManager()->getLanguages()) < 2) {
$t_args = [
':language_url' => Url::fromRoute('entity.configurable_language.collection')->toString(),
];
$message = t('This site has only a single language enabled. <a href=":language_url">Add at least one more language</a> in order to translate content.', $t_args);
\Drupal::messenger()->addWarning($message);
}
// Point the user to the content translation settings.
$t_args = [
':settings_url' => Url::fromRoute('language.content_settings_page')->toString(),
];
$message = t('<a href=":settings_url">Enable translation</a> for <em>content types</em>, <em>taxonomy vocabularies</em>, <em>accounts</em>, or any other element you wish to translate.', $t_args);
\Drupal::messenger()->addWarning($message);
}
/**
* Rebuild the routes as the content translation routes have now new names.
*/
function content_translation_update_8001() {
\Drupal::service('router.builder')->rebuild();
}
/**
* Clear field type plugin caches to fix image field translatability.
*/
function content_translation_update_8002() {
\Drupal::service('plugin.manager.field.field_type')->clearCachedDefinitions();
}
/**
* Fix the initial values for content translation metadata fields.
*/
function content_translation_update_8400() {
$database = \Drupal::database();
/** @var \Drupal\content_translation\ContentTranslationManagerInterface $content_translation_manager */
$content_translation_manager = \Drupal::service('content_translation.manager');
/** @var \Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface $last_installed_schema_repository */
$last_installed_schema_repository = \Drupal::service('entity.last_installed_schema.repository');
$entity_type_manager = \Drupal::entityTypeManager();
$entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager();
$entity_type_manager->clearCachedDefinitions();
foreach ($content_translation_manager->getSupportedEntityTypes() as $entity_type_id => $entity_type_definition) {
$storage = $entity_type_manager->getStorage($entity_type_id);
if ($storage instanceof SqlEntityStorageInterface) {
$entity_type = $entity_definition_update_manager->getEntityType($entity_type_id);
$storage_definitions = $last_installed_schema_repository->getLastInstalledFieldStorageDefinitions($entity_type_id);
// Since the entity type is managed by Content Translation, we can assume
// that it is translatable, so we use the data and revision data tables.
$tables_to_update = [$entity_type->getDataTable()];
if ($entity_type->isRevisionable()) {
$tables_to_update += [$entity_type->getRevisionDataTable()];
}
foreach ($tables_to_update as $table_name) {
// Fix the values of the 'content_translation_source' field.
if (isset($storage_definitions['content_translation_source'])) {
$database->update($table_name)
->fields(['content_translation_source' => LanguageInterface::LANGCODE_NOT_SPECIFIED])
->isNull('content_translation_source')
->execute();
}
// Fix the values of the 'content_translation_outdated' field.
if (isset($storage_definitions['content_translation_outdated'])) {
$database->update($table_name)
->fields(['content_translation_outdated' => 0])
->isNull('content_translation_outdated')
->execute();
}
// Fix the values of the 'content_translation_status' field.
if (isset($storage_definitions['content_translation_status'])) {
$database->update($table_name)
->fields(['content_translation_status' => 1])
->isNull('content_translation_status')
->execute();
}
}
}
}
}

View file

@ -0,0 +1,11 @@
drupal.content_translation.admin:
version: VERSION
js:
content_translation.admin.js: {}
css:
theme:
css/content_translation.admin.css: {}
dependencies:
- core/jquery
- core/drupal
- core/jquery.once

View file

@ -0,0 +1,3 @@
content_translation.contextual_links:
deriver: 'Drupal\content_translation\Plugin\Derivative\ContentTranslationContextualLinks'
weight: 2

View file

@ -0,0 +1,3 @@
content_translation.local_tasks:
deriver: 'Drupal\content_translation\Plugin\Derivative\ContentTranslationLocalTasks'
weight: 100

View file

@ -0,0 +1,629 @@
<?php
/**
* @file
* Allows entities to be translated into different languages.
*/
use Drupal\content_translation\BundleTranslationSettingsInterface;
use Drupal\content_translation\ContentTranslationManager;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\ContentEntityFormInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Implements hook_help().
*/
function content_translation_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.content_translation':
$output = '';
$output .= '<h3>' . t('About') . '</h3>';
$output .= '<p>' . t('The Content Translation module allows you to translate content, comments, custom blocks, taxonomy terms, users and other <a href=":field_help" title="Field module help, with background on content entities">content entities</a>. Together with the modules <a href=":language">Language</a>, <a href=":config-trans">Configuration Translation</a>, and <a href=":locale">Interface Translation</a>, it allows you to build multilingual websites. For more information, see the <a href=":translation-entity">online documentation for the Content Translation module</a>.', [':locale' => (\Drupal::moduleHandler()->moduleExists('locale')) ? \Drupal::url('help.page', ['name' => 'locale']) : '#', ':config-trans' => (\Drupal::moduleHandler()->moduleExists('config_translation')) ? \Drupal::url('help.page', ['name' => 'config_translation']) : '#', ':language' => \Drupal::url('help.page', ['name' => 'language']), ':translation-entity' => 'https://www.drupal.org/documentation/modules/translation', ':field_help' => \Drupal::url('help.page', ['name' => 'field'])]) . '</p>';
$output .= '<h3>' . t('Uses') . '</h3>';
$output .= '<dl>';
$output .= '<dt>' . t('Enabling translation') . '</dt>';
$output .= '<dd>' . t('In order to translate content, the website must have at least two <a href=":url">languages</a>. When that is the case, you can enable translation for the desired content entities on the <a href=":translation-entity">Content language</a> page. When enabling translation you can choose the default language for content and decide whether to show the language selection field on the content editing forms.', [':url' => \Drupal::url('entity.configurable_language.collection'), ':translation-entity' => \Drupal::url('language.content_settings_page'), ':language-help' => \Drupal::url('help.page', ['name' => 'language'])]) . '</dd>';
$output .= '<dt>' . t('Enabling field translation') . '</dt>';
$output .= '<dd>' . t('You can define which fields of a content entity can be translated. For example, you might want to translate the title and body field while leaving the image field untranslated. If you exclude a field from being translated, it will still show up in the content editing form, but any changes made to that field will be applied to <em>all</em> translations of that content.') . '</dd>';
$output .= '<dt>' . t('Translating content') . '</dt>';
$output .= '<dd>' . t('If translation is enabled you can translate a content entity via the Translate tab (or Translate link). The Translations page of a content entity gives an overview of the translation status for the current content and lets you add, edit, and delete its translations. This process is similar for every translatable content entity on your site.') . '</dd>';
$output .= '<dt>' . t('Changing the source language for a translation') . '</dt>';
$output .= '<dd>' . t('When you add a new translation, the original text you are translating is displayed in the edit form as the <em>source</em>. If at least one translation of the original content already exists when you add a new translation, you can choose either the original content (default) or one of the other translations as the source, using the select list in the Source language section. After saving the translation, the chosen source language is then listed on the Translate tab of the content.') . '</dd>';
$output .= '<dt>' . t('Setting status of translations') . '</dt>';
$output .= '<dd>' . t('If you edit a translation in one language you may want to set the status of the other translations as <em>out-of-date</em>. You can set this status by selecting the <em>Flag other translations as outdated</em> checkbox in the Translation section of the content editing form. The status will be visible on the Translations page.') . '</dd>';
$output .= '</dl>';
return $output;
case 'language.content_settings_page':
$output = '';
if (!\Drupal::languageManager()->isMultilingual()) {
$output .= '<p>' . t('Before you can translate content, there must be at least two languages added on the <a href=":url">languages administration</a> page.', [':url' => \Drupal::url('entity.configurable_language.collection')]) . '</p>';
}
return $output;
}
}
/**
* Implements hook_module_implements_alter().
*/
function content_translation_module_implements_alter(&$implementations, $hook) {
switch ($hook) {
// Move our hook_entity_type_alter() implementation to the end of the list.
case 'entity_type_alter':
$group = $implementations['content_translation'];
unset($implementations['content_translation']);
$implementations['content_translation'] = $group;
break;
// Move our hook_entity_bundle_info_alter() implementation to the top of the
// list, so that any other hook implementation can rely on bundles being
// correctly marked as translatable.
case 'entity_bundle_info_alter':
$group = $implementations['content_translation'];
$implementations = ['content_translation' => $group] + $implementations;
break;
}
}
/**
* Implements hook_language_type_info_alter().
*/
function content_translation_language_types_info_alter(array &$language_types) {
// Make content language negotiation configurable by removing the 'locked'
// flag.
$language_types[LanguageInterface::TYPE_CONTENT]['locked'] = FALSE;
unset($language_types[LanguageInterface::TYPE_CONTENT]['fixed']);
}
/**
* Implements hook_entity_type_alter().
*
* The content translation UI relies on the entity info to provide its features.
* See the documentation of hook_entity_type_build() in the Entity API
* documentation for more details on all the entity info keys that may be
* defined.
*
* To make Content Translation automatically support an entity type some keys
* may need to be defined, but none of them is required unless the entity path
* is different from the usual /ENTITY_TYPE/{ENTITY_TYPE} pattern (for instance
* "/taxonomy/term/{taxonomy_term}"). Here are a list of those optional keys:
* - canonical: This key (in the 'links' entity info property) must be defined
* if the entity path is different from /ENTITY_TYPE/{ENTITY_TYPE}
* - translation: This key (in the 'handlers' entity annotation property)
* specifies the translation handler for the entity type. If an entity type is
* translatable and no translation handler is defined,
* \Drupal\content_translation\ContentTranslationHandler will be assumed.
* Every translation handler must implement
* \Drupal\content_translation\ContentTranslationHandlerInterface.
* - content_translation_ui_skip: By default, entity types that do not have a
* canonical link template cannot be enabled for translation. Setting this key
* to TRUE overrides that. When that key is set, the Content Translation
* module will not provide any UI for translating the entity type, and the
* entity type should implement its own UI. For instance, this is useful for
* entity types that are embedded into others for editing (which would not
* need a canonical link, but could still support translation).
* - content_translation_metadata: To implement its business logic the content
* translation UI relies on various metadata items describing the translation
* state. The default implementation is provided by
* \Drupal\content_translation\ContentTranslationMetadataWrapper, which is
* relying on one field for each metadata item (field definitions are provided
* by the translation handler). Entity types needing to customize this
* behavior can specify an alternative class through the
* 'content_translation_metadata' key in the entity type definition. Every
* content translation metadata wrapper needs to implement
* \Drupal\content_translation\ContentTranslationMetadataWrapperInterface.
*
* If the entity paths match the default pattern above and there is no need for
* an entity-specific translation handler, Content Translation will provide
* built-in support for the entity. However enabling translation for each
* translatable bundle will be required.
*
* @see \Drupal\Core\Entity\Annotation\EntityType
*/
function content_translation_entity_type_alter(array &$entity_types) {
// Provide defaults for translation info.
/** @var $entity_types \Drupal\Core\Entity\EntityTypeInterface[] */
foreach ($entity_types as $entity_type) {
if ($entity_type->isTranslatable()) {
if (!$entity_type->hasHandlerClass('translation')) {
$entity_type->setHandlerClass('translation', 'Drupal\content_translation\ContentTranslationHandler');
}
if (!$entity_type->get('content_translation_metadata')) {
$entity_type->set('content_translation_metadata', 'Drupal\content_translation\ContentTranslationMetadataWrapper');
}
if (!$entity_type->getFormClass('content_translation_deletion')) {
$entity_type->setFormClass('content_translation_deletion', '\Drupal\content_translation\Form\ContentTranslationDeleteForm');
}
$translation = $entity_type->get('translation');
if (!$translation || !isset($translation['content_translation'])) {
$translation['content_translation'] = [];
}
if ($entity_type->hasLinkTemplate('canonical')) {
// Provide default route names for the translation paths.
if (!$entity_type->hasLinkTemplate('drupal:content-translation-overview')) {
$translations_path = $entity_type->getLinkTemplate('canonical') . '/translations';
$entity_type->setLinkTemplate('drupal:content-translation-overview', $translations_path);
$entity_type->setLinkTemplate('drupal:content-translation-add', $translations_path . '/add/{source}/{target}');
$entity_type->setLinkTemplate('drupal:content-translation-edit', $translations_path . '/edit/{language}');
$entity_type->setLinkTemplate('drupal:content-translation-delete', $translations_path . '/delete/{language}');
}
// @todo Remove this as soon as menu access checks rely on the
// controller. See https://www.drupal.org/node/2155787.
$translation['content_translation'] += [
'access_callback' => 'content_translation_translate_access',
];
}
$entity_type->set('translation', $translation);
}
$entity_type->addConstraint('ContentTranslationSynchronizedFields');
}
}
/**
* Implements hook_entity_bundle_info_alter().
*/
function content_translation_entity_bundle_info_alter(&$bundles) {
/** @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'] = $content_translation_manager->isEnabled($entity_type_id, $bundle);
if ($bundle_info['translatable'] && $content_translation_manager instanceof BundleTranslationSettingsInterface) {
$settings = $content_translation_manager->getBundleTranslationSettings($entity_type_id, $bundle);
// If pending revision support is enabled for this bundle, we need to
// hide untranslatable field widgets, otherwise changes in pending
// revisions might be overridden by changes in later default revisions.
$bundle_info['untranslatable_fields.default_translation_affected'] =
!empty($settings['untranslatable_fields_hide']) || ContentTranslationManager::isPendingRevisionSupportEnabled($entity_type_id, $bundle);
}
}
}
}
/**
* Implements hook_entity_base_field_info().
*/
function content_translation_entity_base_field_info(EntityTypeInterface $entity_type) {
/** @var \Drupal\content_translation\ContentTranslationManagerInterface $manager */
$manager = \Drupal::service('content_translation.manager');
$entity_type_id = $entity_type->id();
if ($manager->isSupported($entity_type_id)) {
$definitions = $manager->getTranslationHandler($entity_type_id)->getFieldDefinitions();
$installed_storage_definitions = \Drupal::entityManager()->getLastInstalledFieldStorageDefinitions($entity_type_id);
// We return metadata storage fields whenever content translation is enabled
// or it was enabled before, so that we keep translation metadata around
// when translation is disabled.
// @todo Re-evaluate this approach and consider removing field storage
// definitions and the related field data if the entity type has no bundle
// enabled for translation.
// @see https://www.drupal.org/node/2907777
if ($manager->isEnabled($entity_type_id) || array_intersect_key($definitions, $installed_storage_definitions)) {
return $definitions;
}
}
}
/**
* Implements hook_field_info_alter().
*
* Content translation extends the @FieldType annotation with following key:
* - column_groups: contains information about the field type properties
* which columns should be synchronized across different translations and
* which are translatable. This is useful for instance to translate the
* "alt" and "title" textual elements of an image field, while keeping the
* same image on every translation. Each group has the following keys:
* - title: Title of the column group.
* - translatable: (optional) If the column group should be translatable by
* default, defaults to FALSE.
* - columns: (optional) A list of columns of this group. Defaults to the
* name of he group as the single column.
* - require_all_groups_for_translation: (optional) Set to TRUE to enforce
* that making this column group translatable requires all others to be
* translatable too.
*
* @see Drupal\image\Plugin\Field\FieldType\ImageItem
*/
function content_translation_field_info_alter(&$info) {
foreach ($info as $key => $settings) {
// Supply the column_groups key if it's not there.
if (empty($settings['column_groups'])) {
$info[$key]['column_groups'] = [];
}
}
}
/**
* Implements hook_entity_operation().
*/
function content_translation_entity_operation(EntityInterface $entity) {
$operations = [];
if ($entity->hasLinkTemplate('drupal:content-translation-overview') && content_translation_translate_access($entity)->isAllowed()) {
$operations['translate'] = [
'title' => t('Translate'),
'url' => $entity->urlInfo('drupal:content-translation-overview'),
'weight' => 50,
];
}
return $operations;
}
/**
* Implements hook_views_data_alter().
*/
function content_translation_views_data_alter(array &$data) {
// Add the content translation entity link definition to Views data for entity
// types having translation enabled.
$entity_types = \Drupal::entityManager()->getDefinitions();
/** @var \Drupal\content_translation\ContentTranslationManagerInterface $manager */
$manager = \Drupal::service('content_translation.manager');
foreach ($entity_types as $entity_type_id => $entity_type) {
$base_table = $entity_type->getBaseTable();
if (isset($data[$base_table]) && $entity_type->hasLinkTemplate('drupal:content-translation-overview') && $manager->isEnabled($entity_type_id)) {
$t_arguments = ['@entity_type_label' => $entity_type->getLabel()];
$data[$base_table]['translation_link'] = [
'field' => [
'title' => t('Link to translate @entity_type_label', $t_arguments),
'help' => t('Provide a translation link to the @entity_type_label.', $t_arguments),
'id' => 'content_translation_link',
],
];
}
}
}
/**
* Implements hook_menu_links_discovered_alter().
*/
function content_translation_menu_links_discovered_alter(array &$links) {
// Clarify where translation settings are located.
$links['language.content_settings_page']['title'] = new TranslatableMarkup('Content language and translation');
$links['language.content_settings_page']['description'] = new TranslatableMarkup('Configure language and translation support for content.');
}
/**
* Access callback for the translation overview page.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity whose translation overview should be displayed.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
function content_translation_translate_access(EntityInterface $entity) {
$account = \Drupal::currentUser();
$condition = $entity instanceof ContentEntityInterface && $entity->access('view') &&
!$entity->getUntranslated()->language()->isLocked() && \Drupal::languageManager()->isMultilingual() && $entity->isTranslatable() &&
($account->hasPermission('create content translations') || $account->hasPermission('update content translations') || $account->hasPermission('delete content translations'));
return AccessResult::allowedIf($condition)->cachePerPermissions()->addCacheableDependency($entity);
}
/**
* Implements hook_form_alter().
*/
function content_translation_form_alter(array &$form, FormStateInterface $form_state) {
$form_object = $form_state->getFormObject();
if (!($form_object instanceof ContentEntityFormInterface)) {
return;
}
$entity = $form_object->getEntity();
$op = $form_object->getOperation();
// Let the content translation handler alter the content entity form. This can
// be the 'add' or 'edit' form. It also tries a 'default' form in case neither
// of the aforementioned forms are defined.
if ($entity instanceof ContentEntityInterface && $entity->isTranslatable() && count($entity->getTranslationLanguages()) > 1 && in_array($op, ['edit', 'add', 'default'], TRUE)) {
$controller = \Drupal::entityManager()->getHandler($entity->getEntityTypeId(), 'translation');
$controller->entityFormAlter($form, $form_state, $entity);
// @todo Move the following lines to the code generating the property form
// elements once we have an official #multilingual FAPI key.
$translations = $entity->getTranslationLanguages();
$form_langcode = $form_object->getFormLangcode($form_state);
// Handle fields shared between translations when there is at least one
// translation available or a new one is being created.
if (!$entity->isNew() && (!isset($translations[$form_langcode]) || count($translations) > 1)) {
foreach ($entity->getFieldDefinitions() as $field_name => $definition) {
// Allow the widget to define if it should be treated as multilingual
// by respecting an already set #multilingual key.
if (isset($form[$field_name]) && !isset($form[$field_name]['#multilingual'])) {
$form[$field_name]['#multilingual'] = $definition->isTranslatable();
}
}
}
// The footer region, if defined, may contain multilingual widgets so we
// need to always display it.
if (isset($form['footer'])) {
$form['footer']['#multilingual'] = TRUE;
}
}
}
/**
* Implements hook_language_fallback_candidates_OPERATION_alter().
*
* Performs language fallback for inaccessible translations.
*/
function content_translation_language_fallback_candidates_entity_view_alter(&$candidates, $context) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $context['data'];
$entity_type_id = $entity->getEntityTypeId();
/** @var \Drupal\content_translation\ContentTranslationManagerInterface $manager */
$manager = \Drupal::service('content_translation.manager');
if ($manager->isEnabled($entity_type_id, $entity->bundle())) {
$entity_type = $entity->getEntityType();
$permission = $entity_type->getPermissionGranularity() == 'bundle' ? $permission = "translate {$entity->bundle()} $entity_type_id" : "translate $entity_type_id";
$current_user = \Drupal::currentuser();
if (!$current_user->hasPermission('translate any entity') && !$current_user->hasPermission($permission)) {
foreach ($entity->getTranslationLanguages() as $langcode => $language) {
$metadata = $manager->getTranslationMetadata($entity->getTranslation($langcode));
if (!$metadata->isPublished()) {
unset($candidates[$langcode]);
}
}
}
}
}
/**
* Implements hook_entity_extra_field_info().
*/
function content_translation_entity_extra_field_info() {
$extra = [];
$bundle_info_service = \Drupal::service('entity_type.bundle.info');
foreach (\Drupal::entityManager()->getDefinitions() as $entity_type => $info) {
foreach ($bundle_info_service->getBundleInfo($entity_type) as $bundle => $bundle_info) {
if (\Drupal::service('content_translation.manager')->isEnabled($entity_type, $bundle)) {
$extra[$entity_type][$bundle]['form']['translation'] = [
'label' => t('Translation'),
'description' => t('Translation settings'),
'weight' => 10,
];
}
}
}
return $extra;
}
/**
* Implements hook_form_FORM_ID_alter() for 'field_config_edit_form'.
*/
function content_translation_form_field_config_edit_form_alter(array &$form, FormStateInterface $form_state) {
$field = $form_state->getFormObject()->getEntity();
$bundle_is_translatable = \Drupal::service('content_translation.manager')->isEnabled($field->getTargetEntityTypeId(), $field->getTargetBundle());
$form['translatable'] = [
'#type' => 'checkbox',
'#title' => t('Users may translate this field'),
'#default_value' => $field->isTranslatable(),
'#weight' => -1,
'#disabled' => !$bundle_is_translatable,
'#access' => $field->getFieldStorageDefinition()->isTranslatable(),
];
// Provide helpful pointers for administrators.
if (\Drupal::currentUser()->hasPermission('administer content translation') && !$bundle_is_translatable) {
$toggle_url = \Drupal::url('language.content_settings_page', [], [
'query' => \Drupal::destination()->getAsArray(),
]);
$form['translatable']['#description'] = t('To configure translation for this field, <a href=":language-settings-url">enable language support</a> for this type.', [
':language-settings-url' => $toggle_url,
]);
}
if ($field->isTranslatable()) {
module_load_include('inc', 'content_translation', 'content_translation.admin');
$element = content_translation_field_sync_widget($field);
if ($element) {
$form['third_party_settings']['content_translation']['translation_sync'] = $element;
$form['third_party_settings']['content_translation']['translation_sync']['#weight'] = -10;
}
}
}
/**
* Implements hook_entity_presave().
*/
function content_translation_entity_presave(EntityInterface $entity) {
if ($entity instanceof ContentEntityInterface && $entity->isTranslatable() && !$entity->isNew()) {
/** @var \Drupal\content_translation\ContentTranslationManagerInterface $manager */
$manager = \Drupal::service('content_translation.manager');
if (!$manager->isEnabled($entity->getEntityTypeId(), $entity->bundle())) {
return;
}
// If we are creating a new translation we need to use the source language
// as original language, since source values are the only ones available to
// compare against.
if (!isset($entity->original)) {
$entity->original = \Drupal::entityTypeManager()
->getStorage($entity->entityType())->loadUnchanged($entity->id());
}
$langcode = $entity->language()->getId();
$source_langcode = !$entity->original->hasTranslation($langcode) ? $manager->getTranslationMetadata($entity)->getSource() : NULL;
\Drupal::service('content_translation.synchronizer')->synchronizeFields($entity, $langcode, $source_langcode);
}
}
/**
* Implements hook_element_info_alter().
*/
function content_translation_element_info_alter(&$type) {
if (isset($type['language_configuration'])) {
$type['language_configuration']['#process'][] = 'content_translation_language_configuration_element_process';
}
}
/**
* Returns a widget to enable content translation per entity bundle.
*
* Backward compatibility layer to support entities not using the language
* configuration form element.
*
* @todo Remove once all core entities have language configuration.
*
* @param string $entity_type
* The type of the entity being configured for translation.
* @param string $bundle
* The bundle of the entity being configured for translation.
* @param array $form
* The configuration form array.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
function content_translation_enable_widget($entity_type, $bundle, array &$form, FormStateInterface $form_state) {
$key = $form_state->get(['content_translation', 'key']);
$context = $form_state->get(['language', $key]) ?: [];
$context += ['entity_type' => $entity_type, 'bundle' => $bundle];
$form_state->set(['language', $key], $context);
$element = content_translation_language_configuration_element_process(['#name' => $key], $form_state, $form);
unset($element['content_translation']['#element_validate']);
return $element;
}
/**
* Process callback: Expands the language_configuration form element.
*
* @param array $element
* Form API element.
*
* @return
* Processed language configuration element.
*/
function content_translation_language_configuration_element_process(array $element, FormStateInterface $form_state, array &$form) {
if (empty($element['#content_translation_skip_alter']) && \Drupal::currentUser()->hasPermission('administer content translation')) {
$key = $element['#name'];
$form_state->set(['content_translation', 'key'], $key);
$context = $form_state->get(['language', $key]);
$element['content_translation'] = [
'#type' => 'checkbox',
'#title' => t('Enable translation'),
// For new bundle, we don't know the bundle name yet,
// default to no translatability.
'#default_value' => $context['bundle'] ? \Drupal::service('content_translation.manager')->isEnabled($context['entity_type'], $context['bundle']) : FALSE,
'#element_validate' => ['content_translation_language_configuration_element_validate'],
];
$submit_name = isset($form['actions']['save_continue']) ? 'save_continue' : 'submit';
// Only add the submit handler on the submit button if the #submit property
// is already available, otherwise this breaks the form submit function.
if (isset($form['actions'][$submit_name]['#submit'])) {
$form['actions'][$submit_name]['#submit'][] = 'content_translation_language_configuration_element_submit';
}
else {
$form['#submit'][] = 'content_translation_language_configuration_element_submit';
}
}
return $element;
}
/**
* Form validation handler for element added with content_translation_language_configuration_element_process().
*
* Checks whether translation can be enabled: if language is set to one of the
* special languages and language selector is not hidden, translation cannot be
* enabled.
*
* @see content_translation_language_configuration_element_submit()
*/
function content_translation_language_configuration_element_validate($element, FormStateInterface $form_state, array $form) {
$key = $form_state->get(['content_translation', 'key']);
$values = $form_state->getValue($key);
if (!$values['language_alterable'] && $values['content_translation'] && \Drupal::languageManager()->isLanguageLocked($values['langcode'])) {
foreach (\Drupal::languageManager()->getLanguages(LanguageInterface::STATE_LOCKED) as $language) {
$locked_languages[] = $language->getName();
}
// @todo Set the correct form element name as soon as the element parents
// are correctly set. We should be using NestedArray::getValue() but for
// now we cannot.
$form_state->setErrorByName('', t('"Show language selector" is not compatible with translating content that has default language: %choice. Either do not hide the language selector or pick a specific language.', ['%choice' => $locked_languages[$values['langcode']]]));
}
}
/**
* Form submission handler for element added with content_translation_language_configuration_element_process().
*
* Stores the content translation settings.
*
* @see content_translation_language_configuration_element_validate()
*/
function content_translation_language_configuration_element_submit(array $form, FormStateInterface $form_state) {
$key = $form_state->get(['content_translation', 'key']);
$context = $form_state->get(['language', $key]);
$enabled = $form_state->getValue([$key, 'content_translation']);
if (\Drupal::service('content_translation.manager')->isEnabled($context['entity_type'], $context['bundle']) != $enabled) {
\Drupal::service('content_translation.manager')->setEnabled($context['entity_type'], $context['bundle'], $enabled);
\Drupal::entityManager()->clearCachedDefinitions();
\Drupal::service('router.builder')->setRebuildNeeded();
}
}
/**
* Implements hook_form_FORM_ID_alter() for language_content_settings_form().
*/
function content_translation_form_language_content_settings_form_alter(array &$form, FormStateInterface $form_state) {
module_load_include('inc', 'content_translation', 'content_translation.admin');
_content_translation_form_language_content_settings_form_alter($form, $form_state);
}
/**
* Implements hook_preprocess_HOOK() for language-content-settings-table.html.twig.
*/
function content_translation_preprocess_language_content_settings_table(&$variables) {
module_load_include('inc', 'content_translation', 'content_translation.admin');
_content_translation_preprocess_language_content_settings_table($variables);
}
/**
* Implements hook_page_attachments().
*/
function content_translation_page_attachments(&$page) {
$route_match = \Drupal::routeMatch();
// If the current route has no parameters, return.
if (!($route = $route_match->getRouteObject()) || !($parameters = $route->getOption('parameters'))) {
return;
}
// Determine if the current route represents an entity.
foreach ($parameters as $name => $options) {
if (!isset($options['type']) || strpos($options['type'], 'entity:') !== 0) {
continue;
}
$entity = $route_match->getParameter($name);
if ($entity instanceof ContentEntityInterface && $entity->hasLinkTemplate('canonical')) {
// Current route represents a content entity. Build hreflang links.
foreach ($entity->getTranslationLanguages() as $language) {
$url = $entity->toUrl('canonical')
->setOption('language', $language)
->setAbsolute()
->toString();
$page['#attached']['html_head_link'][] = [
[
'rel' => 'alternate',
'hreflang' => $language->getId(),
'href' => $url,
],
TRUE,
];
}
}
// Since entity was found, no need to iterate further.
return;
}
}

View file

@ -0,0 +1,13 @@
administer content translation:
title: 'Administer translation settings'
create content translations:
title: 'Create translations'
update content translations:
title: 'Edit translations'
delete content translations:
title: 'Delete translations'
translate any entity:
title: 'Translate any entity'
permission_callbacks:
- \Drupal\content_translation\ContentTranslationPermissions::contentPermissions

View file

@ -0,0 +1,38 @@
services:
content_translation.synchronizer:
class: Drupal\content_translation\FieldTranslationSynchronizer
arguments: ['@entity.manager', '@plugin.manager.field.field_type']
content_translation.subscriber:
class: Drupal\content_translation\Routing\ContentTranslationRouteSubscriber
arguments: ['@content_translation.manager']
tags:
- { name: event_subscriber }
content_translation.delete_access:
class: Drupal\content_translation\Access\ContentTranslationDeleteAccess
arguments: ['@entity_type.manager', '@content_translation.manager']
tags:
- { name: access_check, applies_to: _access_content_translation_delete }
content_translation.overview_access:
class: Drupal\content_translation\Access\ContentTranslationOverviewAccess
arguments: ['@entity.manager']
tags:
- { name: access_check, applies_to: _access_content_translation_overview }
content_translation.manage_access:
class: Drupal\content_translation\Access\ContentTranslationManageAccessCheck
arguments: ['@entity.manager', '@language_manager']
tags:
- { name: access_check, applies_to: _access_content_translation_manage }
content_translation.manager:
class: Drupal\content_translation\ContentTranslationManager
arguments: ['@entity.manager', '@content_translation.updates_manager']
content_translation.updates_manager:
class: Drupal\content_translation\ContentTranslationUpdatesManager
arguments: ['@entity.manager', '@entity.definition_update_manager']
tags:
- { name: event_subscriber }

View file

@ -0,0 +1,33 @@
/**
* @file
* Styles for the content language administration page.
*/
.language-content-settings-form .bundle {
width: 24%;
}
.language-content-settings-form .field {
padding-left: 3em; /* LTR */
width: 24%;
}
[dir="rtl"] .language-content-settings-form .field {
padding-right: 3em;
padding-left: 1em;
}
.language-content-settings-form .column {
padding-left: 5em; /* LTR */
}
[dir="rtl"] .language-content-settings-form .column {
padding-right: 5em;
padding-left: 1em;
}
.language-content-settings-form .field label,
.language-content-settings-form .column label {
font-weight: normal;
}
.language-content-settings-form .translatable {
width: 1%;
}
.language-content-settings-form .operations {
width: 75%;
}

View file

@ -0,0 +1,70 @@
id: d6_block_translation
label: Block translations
migration_tags:
- Drupal 6
- Configuration
- Multilingual
source:
plugin: d6_block_translation
constants:
dest_label: 'settings/label'
process:
langcode: language
property: constants/dest_label
translation: title
id:
-
plugin: migration_lookup
migration: d6_block
source:
- module
- delta
-
plugin: skip_on_empty
method: row
plugin:
-
plugin: static_map
bypass: true
source:
- module
- delta
map:
book:
0: book_navigation
comment:
0: views_block:comments_recent-block_1
forum:
0: forum_active_block
1: forum_new_block
locale:
0: language_block
node:
0: node_syndicate_block
search:
0: search_form_block
statistics:
0: statistics_popular_block
system:
0: system_powered_by_block
user:
0: user_login_block
1: system_menu_block:tools
2: views_block:who_s_new-block_1
3: views_block:who_s_online-who_s_online_block
-
plugin: block_plugin_id
-
plugin: skip_on_empty
method: row
theme:
plugin: block_theme
source:
- theme
- default_theme
- admin_theme
destination:
plugin: entity:block
migration_dependencies:
required:
- d6_block

View file

@ -0,0 +1,50 @@
id: d6_custom_block_translation
label: Custom block translations
migration_tags:
- Drupal 6
- Content
- Multilingual
source:
plugin: d6_box_translation
process:
id:
plugin: migration_lookup
migration: d6_custom_block
source:
- bid
langcode: language
info:
-
plugin: callback
source:
- title_translated
- title
callable: array_filter
-
plugin: callback
callable: current
'body/value':
-
plugin: callback
source:
- body_translated
- body
callable: array_filter
-
plugin: callback
callable: current
'body/format':
plugin: migration_lookup
migration: d6_filter_format
source: format
destination:
plugin: entity:block_content
no_stub: true
translations: true
destination_module: content_translation
migration_dependencies:
required:
- d6_filter_format
- block_content_body_field
- d6_custom_block
- language

View file

@ -0,0 +1,26 @@
id: d6_entity_reference_translation
label: Entity reference translations
migration_tags:
- Drupal 6
- Multilingual
- Follow-up migration
deriver: Drupal\migrate_drupal\Plugin\migrate\EntityReferenceTranslationDeriver
provider:
- content_translation
- migrate_drupal
# Supported target types for entity reference translation migrations. The array
# keys are the supported target types and the values are arrays of migrations
# to lookup for the translated entity IDs.
target_types:
node:
- d6_node_translation
# The source plugin will be set by the deriver.
source:
plugin: empty
key: default
target: default
# The process pipeline will be set by the deriver.
process: []
# The destination plugin will be set by the deriver.
destination:
plugin: null

View file

@ -0,0 +1,55 @@
id: d6_menu_links_translation
label: Menu links
migration_tags:
- Drupal 6
- Content
- Multilingual
source:
plugin: d6_menu_link_translation
process:
id: mlid
langcode: language
title:
-
plugin: callback
source:
- title_translated
- link_title
callable: array_filter
-
plugin: callback
callable: current
description:
-
plugin: callback
source:
- description_translated
- description
callable: array_filter
-
plugin: callback
callable: current
menu_name:
-
plugin: migration_lookup
# The menu migration is in the system module.
migration: d6_menu
source: menu_name
-
plugin: skip_on_empty
method: row
-
plugin: static_map
map:
management: admin
bypass: true
destination:
plugin: entity:menu_link_content
default_bundle: menu_link_content
no_stub: true
translations: true
migration_dependencies:
required:
- language
- d6_menu
- d6_menu_links

View file

@ -0,0 +1,59 @@
id: d6_node_translation
label: Node translations
migration_tags:
- Drupal 6
- translation
- Content
- Multilingual
class: Drupal\node\Plugin\migrate\D6NodeTranslation
deriver: Drupal\node\Plugin\migrate\D6NodeDeriver
source:
plugin: d6_node
translations: true
process:
# If you are using this file to build a custom migration consider removing
# the nid field to allow incremental migrations.
nid: tnid
type: type
langcode:
plugin: default_value
source: language
default_value: "und"
title: title
uid: node_uid
status: status
created: created
changed: changed
promote: promote
sticky: sticky
'body/format':
plugin: migration_lookup
migration: d6_filter_format
source: format
'body/value': body
'body/summary': teaser
revision_uid: revision_uid
revision_log: log
revision_timestamp: timestamp
content_translation_source: source_langcode
# unmapped d6 fields.
# translate
# moderate
# comment
destination:
plugin: entity:node
translations: true
destination_module: content_translation
migration_dependencies:
required:
- d6_user
- d6_node_type
- d6_node_settings
- d6_filter_format
- language
optional:
- d6_field_instance_widget_settings
- d6_field_formatter_settings
- d6_upload_field_instance

View file

@ -0,0 +1,44 @@
id: d6_taxonomy_term_localized_translation
label: Taxonomy localized term translations
migration_tags:
- Drupal 6
- Content
- Multilingual
source:
plugin: d6_term_localized_translation
translations: true
process:
# If you are using this file to build a custom migration consider removing
# the tid field to allow incremental migrations.
tid: tid
langcode: language
vid:
plugin: migration
migration: d6_taxonomy_vocabulary
source: vid
name:
-
plugin: callback
source:
- name_translated
- name
callable: array_filter
-
plugin: callback
callable: current
description:
-
plugin: callback
source:
- description_translated
- description
callable: array_filter
-
plugin: callback
callable: current
destination:
plugin: entity:taxonomy_term
translations: true
migration_dependencies:
required:
- d6_taxonomy_term

View file

@ -0,0 +1,42 @@
id: d6_taxonomy_term_translation
label: Taxonomy terms
migration_tags:
- Drupal 6
- Content
- Multilingual
source:
plugin: d6_taxonomy_term
translations: true
process:
# If you are using this file to build a custom migration consider removing
# the tid field to allow incremental migrations.
tid: tid
langcode: language
vid:
plugin: migration
migration: d6_taxonomy_vocabulary
source: vid
name: name
description: description
weight: weight
# Only attempt to stub real (non-zero) parents.
parent_id:
-
plugin: skip_on_empty
method: process
source: parent
-
plugin: migration
migration: d6_taxonomy_term
parent:
plugin: default_value
default_value: 0
source: '@parent_id'
changed: timestamp
destination:
plugin: entity:taxonomy_term
destination_module: content_translation
migration_dependencies:
required:
- d6_taxonomy_vocabulary
- d6_taxonomy_term

View file

@ -0,0 +1,77 @@
id: d7_block_translation
label: Block translation
migration_tags:
- Drupal 7
- Configuration
- Multilingual
source:
plugin: d7_block_translation
constants:
dest_label: 'settings/label'
process:
multilingual:
plugin: skip_on_empty
source: i18n_mode
method: row
langcode: language
property: constants/dest_label
translation: translation
id:
-
plugin: migration_lookup
migration: d7_block
source:
- module
- delta
-
plugin: skip_on_empty
method: row
# The plugin process is copied from d7_block.yml
plugin:
-
plugin: static_map
bypass: true
source:
- module
- delta
map:
book:
navigation: book_navigation
comment:
recent: views_block:comments_recent-block_1
forum:
active: forum_active_block
new: forum_new_block
# locale:
# 0: language_block
node:
syndicate: node_syndicate_block
search:
form: search_form_block
statistics:
popular: statistics_popular_block
system:
main: system_main_block
'powered-by': system_powered_by_block
user:
login: user_login_block
# 1: system_menu_block:tools
new: views_block:who_s_new-block_1
online: views_block:who_s_online-who_s_online_block
-
plugin: block_plugin_id
-
plugin: skip_on_empty
method: row
# The theme process is copied from d7_block.yml
theme:
plugin: block_theme
source:
- theme
- default_theme
- admin_theme
destination:
plugin: entity:block
migration_dependencies:
optional:
- d7_block

View file

@ -0,0 +1,28 @@
id: d7_comment_entity_translation
label: Comment entity translations
migration_tags:
- Drupal 7
- translation
- Content
class: Drupal\comment\Plugin\migrate\D7Comment
source:
plugin: d7_comment_entity_translation
process:
cid: entity_id
subject: subject
langcode: language
uid: uid
status: status
created: created
changed: changed
content_translation_source: source
content_translation_outdated: translate
destination:
plugin: entity:comment
translations: true
destination_module: content_translation
migration_dependencies:
required:
- language
- d7_entity_translation_settings
- d7_comment

View file

@ -0,0 +1,50 @@
id: d7_custom_block_translation
label: Custom block translations
migration_tags:
- Drupal 7
- Content
- Multilingual
source:
plugin: d7_block_custom_translation
process:
id:
plugin: migration_lookup
migration: d7_custom_block
source:
- bid
langcode: language
info:
-
plugin: callback
source:
- title_translated
- title
callable: array_filter
-
plugin: callback
callable: current
'body/value':
-
plugin: callback
source:
- body_translated
- body
callable: array_filter
-
plugin: callback
callable: current
'body/format':
plugin: migration_lookup
migration: d7_filter_format
source: format
destination:
plugin: entity:block_content
no_stub: true
translations: true
destination_module: content_translation
migration_dependencies:
required:
- d7_filter_format
- block_content_body_field
- d7_custom_block
- language

View file

@ -0,0 +1,26 @@
id: d7_entity_reference_translation
label: Entity reference translations
migration_tags:
- Drupal 7
- Multilingual
- Follow-up migration
deriver: Drupal\migrate_drupal\Plugin\migrate\EntityReferenceTranslationDeriver
provider:
- content_translation
- migrate_drupal
# Supported target types for entity reference translation migrations. The array
# keys are the supported target types and the values are arrays of migrations
# to lookup for the translated entity IDs.
target_types:
node:
- d7_node_translation
# The source plugin will be set by the deriver.
source:
plugin: empty
key: default
target: default
# The process pipeline will be set by the deriver.
process: []
# The destination plugin will be set by the deriver.
destination:
plugin: null

View file

@ -0,0 +1,37 @@
id: d7_entity_translation_settings
label: Drupal 7 Entity Translation settings
migration_tags:
- Drupal 7
- Configuration
- Multilingual
source:
plugin: d7_entity_translation_settings
process:
id: id
target_entity_type_id: target_entity_type_id
target_bundle: target_bundle
default_langcode:
plugin: static_map
source: default_langcode
bypass: true
map:
xx-et-default: site_default
xx-et-current: current_interface
xx-et-author: authors_default
language_alterable: language_alterable
third_party_settings/content_translation/enabled:
plugin: default_value
default_value: true
third_party_settings/content_translation/bundle_settings/untranslatable_fields_hide: untranslatable_fields_hide
destination:
plugin: entity:language_content_settings
content_translation_update_definitions:
- comment
- node
- taxonomy_term
- user
migration_dependencies:
optional:
- d7_comment_type
- d7_node_type
- d7_taxonomy_vocabulary

View file

@ -0,0 +1,36 @@
id: d7_node_entity_translation
label: Node entity translations
migration_tags:
- Drupal 7
- translation
- Content
- Multilingual
deriver: Drupal\node\Plugin\migrate\D7NodeDeriver
source:
plugin: d7_node_entity_translation
process:
nid: entity_id
type: type
langcode: language
title: title
uid: uid
status: status
created: created
changed: changed
promote: promote
sticky: sticky
revision_uid: revision_uid
revision_log: log
revision_timestamp: timestamp
content_translation_source: source
# Boolean indicating whether this translation needs to be updated.
content_translation_outdated: translate
destination:
plugin: entity:node
translations: true
destination_module: content_translation
migration_dependencies:
required:
- language
- d7_entity_translation_settings
- d7_node

View file

@ -0,0 +1,45 @@
id: d7_node_translation
label: Node translations
migration_tags:
- Drupal 7
- translation
- Content
- Multilingual
class: Drupal\node\Plugin\migrate\D7NodeTranslation
deriver: Drupal\node\Plugin\migrate\D7NodeDeriver
source:
plugin: d7_node
translations: true
process:
# If you are using this file to build a custom migration consider removing
# the nid field to allow incremental migrations.
nid: tnid
type: type
langcode:
plugin: default_value
source: language
default_value: "und"
title: title
uid: node_uid
status: status
created: created
changed: changed
promote: promote
sticky: sticky
revision_uid: revision_uid
revision_log: log
revision_timestamp: timestamp
content_translation_source: source_langcode
destination:
plugin: entity:node
translations: true
content_translation_update_definitions:
- node
destination_module: content_translation
migration_dependencies:
required:
- d7_user
- d7_node_type
- language
optional:
- d7_field_instance

View file

@ -0,0 +1,32 @@
id: d7_taxonomy_term_entity_translation
label: Taxonomy term entity translations
migration_tags:
- Drupal 7
- translation
- Content
- Multilingual
deriver: Drupal\taxonomy\Plugin\migrate\D7TaxonomyTermDeriver
source:
plugin: d7_taxonomy_term_entity_translation
process:
tid: entity_id
name: name
description/value: description
description/format: format
langcode: language
status: status
content_translation_source: source
content_translation_outdated: translate
content_translation_uid: uid
content_translation_created: created
changed: changed
forum_container: is_container
destination:
plugin: entity:taxonomy_term
translations: true
destination_module: content_translation
migration_dependencies:
required:
- language
- d7_entity_translation_settings
- d7_taxonomy_term

View file

@ -0,0 +1,27 @@
id: d7_user_entity_translation
label: User accounts entity translations
migration_tags:
- Drupal 7
- translation
- Content
- Multilingual
class: Drupal\user\Plugin\migrate\User
source:
plugin: d7_user_entity_translation
process:
uid: entity_id
langcode: language
content_translation_source: source
content_translation_uid: uid
content_translation_status: status
content_translation_outdated: translate
content_translation_created: created
destination:
plugin: entity:user
translations: true
destination_module: content_translation
migration_dependencies:
required:
- language
- d7_entity_translation_settings
- d7_user

View file

@ -0,0 +1,120 @@
id: node_translation_menu_links
label: Node Translations Menu links
audit: true
migration_tags:
- Drupal 6
- Drupal 7
- Content
- Multilingual
source:
plugin: menu_link
constants:
entity_prefix: 'entity:'
node_prefix: 'node/'
process:
id: mlid
title: link_title
description: description
menu_name:
-
plugin: migration_lookup
# The menu migration is in the system module.
migration:
- d6_menu
- d7_menu
source: menu_name
-
plugin: skip_on_empty
method: row
-
plugin: static_map
map:
management: admin
bypass: true
# In this process pipeline, given a menu link path that might be for a
# translated node which has been merged with the default language node, we are
# trying to determine the new node ID, that is the ID of the default language
# node.
new_nid:
-
# If the path is of the form "node/<ID>" and is not routed, we will get
# back an URI of the form "base:node/<ID>".
plugin: link_uri
source:
- link_path
validate_route: false
-
# Isolate the node ID.
plugin: explode
delimiter: 'base:node/'
-
# Extract the node ID.
plugin: extract
default: false
index:
- 1
-
# Skip row if node ID is empty.
plugin: skip_on_empty
method: row
-
# With the old node ID in hand, lookup in the d6_node_translation or
# d7_node_translation mapping tables to find the new node ID.
plugin: migration_lookup
migration:
- d6_node_translation
- d7_node_translation
no_stub: true
-
# Skip row if the new node ID is empty.
plugin: skip_on_empty
method: row
-
# Extract the node ID. The migration lookup will return an array with two
# items, the new node ID and the translation langcode. We need the node ID
# which is at index 0.
plugin: extract
index:
- 0
# This will be used in the "link/uri" and "route" processes below.
link_path:
plugin: concat
source:
- 'constants/node_prefix'
- '@new_nid'
link/uri:
plugin: concat
source:
- 'constants/entity_prefix'
- '@link_path'
link/options: options
route:
plugin: route
source:
- '@link_path'
- options
route_name: '@route/route_name'
route_parameters: '@route/route_parameters'
url: '@route/url'
options: '@route/options'
external: external
weight: weight
expanded: expanded
enabled: enabled
parent:
plugin: menu_link_parent
source:
- plid
- '@menu_name'
- parent_link_path
changed: updated
destination:
plugin: entity:menu_link_content
default_bundle: menu_link_content
no_stub: true
migration_dependencies:
optional:
- d6_menu_links
- d6_node_translation
- d7_menu_links
- d7_node_translation

View file

@ -0,0 +1,35 @@
id: statistics_node_translation_counter
label: Node translation counter
migration_tags:
- Drupal 6
- Drupal 7
- Content
- Multilingual
source:
plugin: node_counter
process:
nid:
-
plugin: migration_lookup
migration:
- d6_node_translation
- d7_node_translation
source: nid
-
plugin: skip_on_empty
method: row
-
plugin: extract
index:
- 0
totalcount: totalcount
daycount: daycount
timestamp: timestamp
destination:
plugin: node_counter
migration_dependencies:
required:
- statistics_node_counter
optional:
- d6_node_translation
- d7_node_translation

View file

@ -0,0 +1,124 @@
<?php
namespace Drupal\content_translation\Access;
use Drupal\content_translation\ContentTranslationManager;
use Drupal\content_translation\ContentTranslationManagerInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\language\Entity\ContentLanguageSettings;
use Drupal\workflows\Entity\Workflow;
/**
* Access check for entity translation deletion.
*
* @internal This additional access checker only aims to prevent deletions in
* pending revisions until we are able to flag revision translations as
* deleted.
*
* @todo Remove this in https://www.drupal.org/node/2945956.
*/
class ContentTranslationDeleteAccess implements AccessInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The content translation manager.
*
* @var \Drupal\content_translation\ContentTranslationManagerInterface
*/
protected $contentTranslationManager;
/**
* Constructs a ContentTranslationDeleteAccess object.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $manager
* The entity type manager.
* @param \Drupal\content_translation\ContentTranslationManagerInterface $content_translation_manager
* The content translation manager.
*/
public function __construct(EntityTypeManagerInterface $manager, ContentTranslationManagerInterface $content_translation_manager) {
$this->entityTypeManager = $manager;
$this->contentTranslationManager = $content_translation_manager;
}
/**
* Checks access to translation deletion for the specified route match.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The parameterized route.
* @param \Drupal\Core\Session\AccountInterface $account
* The currently logged in account.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function access(RouteMatchInterface $route_match, AccountInterface $account) {
$requirement = $route_match->getRouteObject()->getRequirement('_access_content_translation_delete');
$entity_type_id = current(explode('.', $requirement));
$entity = $route_match->getParameter($entity_type_id);
return $this->checkAccess($entity);
}
/**
* Checks access to translation deletion for the specified entity.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity translation to be deleted.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function checkAccess(ContentEntityInterface $entity) {
$result = AccessResult::allowed();
$entity_type_id = $entity->getEntityTypeId();
$result->addCacheableDependency($entity);
// Add the cache dependencies used by
// ContentTranslationManager::isPendingRevisionSupportEnabled().
if (\Drupal::moduleHandler()->moduleExists('content_moderation')) {
foreach (Workflow::loadMultipleByType('content_moderation') as $workflow) {
$result->addCacheableDependency($workflow);
}
}
if (!ContentTranslationManager::isPendingRevisionSupportEnabled($entity_type_id, $entity->bundle())) {
return $result;
}
if ($entity->isDefaultTranslation()) {
return $result;
}
$config = ContentLanguageSettings::load($entity_type_id . '.' . $entity->bundle());
$result->addCacheableDependency($config);
if (!$this->contentTranslationManager->isEnabled($entity_type_id, $entity->bundle())) {
return $result;
}
/** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage($entity_type_id);
$revision_id = $storage->getLatestTranslationAffectedRevisionId($entity->id(), $entity->language()->getId());
if (!$revision_id) {
return $result;
}
/** @var \Drupal\Core\Entity\ContentEntityInterface $revision */
$revision = $storage->loadRevision($revision_id);
if ($revision->wasDefaultRevision()) {
return $result;
}
$result = $result->andIf(AccessResult::forbidden());
return $result;
}
}

View file

@ -0,0 +1,151 @@
<?php
namespace Drupal\content_translation\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Symfony\Component\Routing\Route;
/**
* Access check for entity translation CRUD operation.
*/
class ContentTranslationManageAccessCheck implements AccessInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityManagerInterface
*/
protected $entityManager;
/**
* The language manager.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected $languageManager;
/**
* Constructs a ContentTranslationManageAccessCheck object.
*
* @param \Drupal\Core\Entity\EntityManagerInterface $manager
* The entity type manager.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager.
*/
public function __construct(EntityManagerInterface $manager, LanguageManagerInterface $language_manager) {
$this->entityManager = $manager;
$this->languageManager = $language_manager;
}
/**
* Checks translation access for the entity and operation on the given route.
*
* @param \Symfony\Component\Routing\Route $route
* The route to check against.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The parametrized route.
* @param \Drupal\Core\Session\AccountInterface $account
* The currently logged in account.
* @param string $source
* (optional) For a create operation, the language code of the source.
* @param string $target
* (optional) For a create operation, the language code of the translation.
* @param string $language
* (optional) For an update or delete operation, the language code of the
* translation being updated or deleted.
* @param string $entity_type_id
* (optional) The entity type ID.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function access(Route $route, RouteMatchInterface $route_match, AccountInterface $account, $source = NULL, $target = NULL, $language = NULL, $entity_type_id = NULL) {
/* @var \Drupal\Core\Entity\ContentEntityInterface $entity */
if ($entity = $route_match->getParameter($entity_type_id)) {
$operation = $route->getRequirement('_access_content_translation_manage');
$language = $this->languageManager->getLanguage($language) ?: $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_CONTENT);
$entity_type = $this->entityManager->getDefinition($entity_type_id);
if (in_array($operation, ['update', 'delete'])) {
// Translation operations cannot be performed on the default
// translation.
if ($language->getId() == $entity->getUntranslated()->language()->getId()) {
return AccessResult::forbidden()->addCacheableDependency($entity);
}
// Editors have no access to the translation operations, as entity
// access already grants them an equal or greater access level.
$templates = ['update' => 'edit-form', 'delete' => 'delete-form'];
if ($entity->access($operation) && $entity_type->hasLinkTemplate($templates[$operation])) {
return AccessResult::forbidden()->cachePerPermissions();
}
}
if ($account->hasPermission('translate any entity')) {
return AccessResult::allowed()->cachePerPermissions();
}
switch ($operation) {
case 'create':
/* @var \Drupal\content_translation\ContentTranslationHandlerInterface $handler */
$handler = $this->entityManager->getHandler($entity->getEntityTypeId(), 'translation');
$translations = $entity->getTranslationLanguages();
$languages = $this->languageManager->getLanguages();
$source_language = $this->languageManager->getLanguage($source) ?: $entity->language();
$target_language = $this->languageManager->getLanguage($target) ?: $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_CONTENT);
$is_new_translation = ($source_language->getId() != $target_language->getId()
&& isset($languages[$source_language->getId()])
&& isset($languages[$target_language->getId()])
&& !isset($translations[$target_language->getId()]));
return AccessResult::allowedIf($is_new_translation)->cachePerPermissions()->addCacheableDependency($entity)
->andIf($handler->getTranslationAccess($entity, $operation));
case 'delete':
// @todo Remove this in https://www.drupal.org/node/2945956.
/** @var \Drupal\Core\Access\AccessResultInterface $delete_access */
$delete_access = \Drupal::service('content_translation.delete_access')->checkAccess($entity);
$access = $this->checkAccess($entity, $language, $operation);
return $delete_access->andIf($access);
case 'update':
return $this->checkAccess($entity, $language, $operation);
}
}
// No opinion.
return AccessResult::neutral();
}
/**
* Performs access checks for the specified operation.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity being checked.
* @param \Drupal\Core\Language\LanguageInterface $language
* For an update or delete operation, the language code of the translation
* being updated or deleted.
* @param string $operation
* The operation to be checked.
*
* @return \Drupal\Core\Access\AccessResultInterface
* An access result object.
*/
protected function checkAccess(ContentEntityInterface $entity, LanguageInterface $language, $operation) {
/* @var \Drupal\content_translation\ContentTranslationHandlerInterface $handler */
$handler = $this->entityManager->getHandler($entity->getEntityTypeId(), 'translation');
$translations = $entity->getTranslationLanguages();
$languages = $this->languageManager->getLanguages();
$has_translation = isset($languages[$language->getId()])
&& $language->getId() != $entity->getUntranslated()->language()->getId()
&& isset($translations[$language->getId()]);
return AccessResult::allowedIf($has_translation)->cachePerPermissions()->addCacheableDependency($entity)
->andIf($handler->getTranslationAccess($entity, $operation));
}
}

View file

@ -0,0 +1,79 @@
<?php
namespace Drupal\content_translation\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Access check for entity translation overview.
*/
class ContentTranslationOverviewAccess implements AccessInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityManagerInterface
*/
protected $entityManager;
/**
* Constructs a ContentTranslationOverviewAccess object.
*
* @param \Drupal\Core\Entity\EntityManagerInterface $manager
* The entity type manager.
*/
public function __construct(EntityManagerInterface $manager) {
$this->entityManager = $manager;
}
/**
* Checks access to the translation overview for the entity and bundle.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The parametrized route.
* @param \Drupal\Core\Session\AccountInterface $account
* The currently logged in account.
* @param string $entity_type_id
* The entity type ID.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function access(RouteMatchInterface $route_match, AccountInterface $account, $entity_type_id) {
/* @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $route_match->getParameter($entity_type_id);
if ($entity && $entity->isTranslatable()) {
// Get entity base info.
$bundle = $entity->bundle();
// Get entity access callback.
$definition = $this->entityManager->getDefinition($entity_type_id);
$translation = $definition->get('translation');
$access_callback = $translation['content_translation']['access_callback'];
$access = call_user_func($access_callback, $entity);
if ($access->isAllowed()) {
return $access;
}
// Check "translate any entity" permission.
if ($account->hasPermission('translate any entity')) {
return AccessResult::allowed()->cachePerPermissions()->inheritCacheability($access);
}
// Check per entity permission.
$permission = "translate {$entity_type_id}";
if ($definition->getPermissionGranularity() == 'bundle') {
$permission = "translate {$bundle} {$entity_type_id}";
}
return AccessResult::allowedIfHasPermission($account, $permission)->inheritCacheability($access);
}
// No opinion.
return AccessResult::neutral();
}
}

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

@ -0,0 +1,794 @@
<?php
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;
use Drupal\Core\Entity\EntityTypeInterface;
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;
/**
* Base class for content translation handlers.
*
* @ingroup entity_api
*/
class ContentTranslationHandler implements ContentTranslationHandlerInterface, EntityHandlerInterface {
use EntityChangesDetectionTrait;
use DependencySerializationTrait;
use StringTranslationTrait;
/**
* The type of the entity being translated.
*
* @var string
*/
protected $entityTypeId;
/**
* Information about the entity type.
*
* @var \Drupal\Core\Entity\EntityTypeInterface
*/
protected $entityType;
/**
* The language manager.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected $languageManager;
/**
* The content translation manager.
*
* @var \Drupal\content_translation\ContentTranslationManagerInterface
*/
protected $manager;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* The array of installed field storage definitions for the entity type, keyed
* by field name.
*
* @var \Drupal\Core\Field\FieldStorageDefinitionInterface[]
*/
protected $fieldStorageDefinitions;
/**
* The messenger service.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
protected $messenger;
/**
* Initializes an instance of the content translation controller.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The info array of the given entity type.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager.
* @param \Drupal\content_translation\ContentTranslationManagerInterface $manager
* The content translation manager service.
* @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
* 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, MessengerInterface $messenger) {
$this->entityTypeId = $entity_type->id();
$this->entityType = $entity_type;
$this->languageManager = $language_manager;
$this->manager = $manager;
$this->entityTypeManager = $entity_manager;
$this->currentUser = $current_user;
$this->fieldStorageDefinitions = $entity_manager->getLastInstalledFieldStorageDefinitions($this->entityTypeId);
$this->messenger = $messenger;
}
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static(
$entity_type,
$container->get('language_manager'),
$container->get('content_translation.manager'),
$container->get('entity.manager'),
$container->get('current_user'),
$container->get('messenger')
);
}
/**
* {@inheritdoc}
*/
public function getFieldDefinitions() {
$definitions = [];
$definitions['content_translation_source'] = BaseFieldDefinition::create('language')
->setLabel(t('Translation source'))
->setDescription(t('The source language from which this translation was created.'))
->setDefaultValue(LanguageInterface::LANGCODE_NOT_SPECIFIED)
->setInitialValue(LanguageInterface::LANGCODE_NOT_SPECIFIED)
->setRevisionable(TRUE)
->setTranslatable(TRUE);
$definitions['content_translation_outdated'] = BaseFieldDefinition::create('boolean')
->setLabel(t('Translation outdated'))
->setDescription(t('A boolean indicating whether this translation needs to be updated.'))
->setDefaultValue(FALSE)
->setInitialValue(FALSE)
->setRevisionable(TRUE)
->setTranslatable(TRUE);
if (!$this->hasAuthor()) {
$definitions['content_translation_uid'] = BaseFieldDefinition::create('entity_reference')
->setLabel(t('Translation author'))
->setDescription(t('The author of this translation.'))
->setSetting('target_type', 'user')
->setSetting('handler', 'default')
->setRevisionable(TRUE)
->setDefaultValueCallback(get_class($this) . '::getDefaultOwnerId')
->setTranslatable(TRUE);
}
if (!$this->hasPublishedStatus()) {
$definitions['content_translation_status'] = BaseFieldDefinition::create('boolean')
->setLabel(t('Translation status'))
->setDescription(t('A boolean indicating whether the translation is visible to non-translators.'))
->setDefaultValue(TRUE)
->setInitialValue(TRUE)
->setRevisionable(TRUE)
->setTranslatable(TRUE);
}
if (!$this->hasCreatedTime()) {
$definitions['content_translation_created'] = BaseFieldDefinition::create('created')
->setLabel(t('Translation created time'))
->setDescription(t('The Unix timestamp when the translation was created.'))
->setRevisionable(TRUE)
->setTranslatable(TRUE);
}
if (!$this->hasChangedTime()) {
$definitions['content_translation_changed'] = BaseFieldDefinition::create('changed')
->setLabel(t('Translation changed time'))
->setDescription(t('The Unix timestamp when the translation was most recently saved.'))
->setRevisionable(TRUE)
->setTranslatable(TRUE);
}
return $definitions;
}
/**
* Checks whether the entity type supports author natively.
*
* @return bool
* TRUE if metadata is natively supported, FALSE otherwise.
*/
protected function hasAuthor() {
// Check for field named uid, but only in case the entity implements the
// EntityOwnerInterface. This helps to exclude cases, where the uid is
// defined as field name, but is not meant to be an owner field; for
// instance, the User entity.
return $this->entityType->entityClassImplements(EntityOwnerInterface::class) && $this->checkFieldStorageDefinitionTranslatability('uid');
}
/**
* Checks whether the entity type supports published status natively.
*
* @return bool
* TRUE if metadata is natively supported, FALSE otherwise.
*/
protected function hasPublishedStatus() {
return $this->checkFieldStorageDefinitionTranslatability('status');
}
/**
* Checks whether the entity type supports modification time natively.
*
* @return bool
* TRUE if metadata is natively supported, FALSE otherwise.
*/
protected function hasChangedTime() {
return $this->entityType->entityClassImplements(EntityChangedInterface::class) && $this->checkFieldStorageDefinitionTranslatability('changed');
}
/**
* Checks whether the entity type supports creation time natively.
*
* @return bool
* TRUE if metadata is natively supported, FALSE otherwise.
*/
protected function hasCreatedTime() {
return $this->checkFieldStorageDefinitionTranslatability('created');
}
/**
* Checks the field storage definition for translatability support.
*
* Checks whether the given field is defined in the field storage definitions
* and if its definition specifies it as translatable.
*
* @param string $field_name
* The name of the field.
*
* @return bool
* TRUE if translatable field storage definition exists, FALSE otherwise.
*/
protected function checkFieldStorageDefinitionTranslatability($field_name) {
return array_key_exists($field_name, $this->fieldStorageDefinitions) && $this->fieldStorageDefinitions[$field_name]->isTranslatable();
}
/**
* {@inheritdoc}
*/
public function retranslate(EntityInterface $entity, $langcode = NULL) {
$updated_langcode = !empty($langcode) ? $langcode : $entity->language()->getId();
foreach ($entity->getTranslationLanguages() as $langcode => $language) {
$this->manager->getTranslationMetadata($entity->getTranslation($langcode))
->setOutdated($langcode != $updated_langcode);
}
}
/**
* {@inheritdoc}
*/
public function getTranslationAccess(EntityInterface $entity, $op) {
// @todo Move this logic into a translation access control handler checking also
// the translation language and the given account.
$entity_type = $entity->getEntityType();
$translate_permission = TRUE;
// If no permission granularity is defined this entity type does not need an
// explicit translate permission.
if (!$this->currentUser->hasPermission('translate any entity') && $permission_granularity = $entity_type->getPermissionGranularity()) {
$translate_permission = $this->currentUser->hasPermission($permission_granularity == 'bundle' ? "translate {$entity->bundle()} {$entity->getEntityTypeId()}" : "translate {$entity->getEntityTypeId()}");
}
return AccessResult::allowedIf($translate_permission && $this->currentUser->hasPermission("$op content translations"))->cachePerPermissions();
}
/**
* {@inheritdoc}
*/
public function getSourceLangcode(FormStateInterface $form_state) {
if ($source = $form_state->get(['content_translation', 'source'])) {
return $source->getId();
}
return FALSE;
}
/**
* {@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();
$source_langcode = $this->getSourceLangcode($form_state);
$new_translation = !empty($source_langcode);
$translations = $entity->getTranslationLanguages();
if ($new_translation) {
// Make sure a new translation does not appear as existing yet.
unset($translations[$form_langcode]);
}
$is_translation = !$form_object->isDefaultFormLangcode($form_state);
$has_translations = count($translations) > 1;
// Adjust page title to specify the current language being edited, if we
// have at least one translation.
$languages = $this->languageManager->getLanguages();
if (isset($languages[$form_langcode]) && ($has_translations || $new_translation)) {
$title = $this->entityFormTitle($entity);
// When editing the original values display just the entity label.
if ($is_translation) {
$t_args = ['%language' => $languages[$form_langcode]->getName(), '%title' => $entity->label(), '@title' => $title];
$title = empty($source_langcode) ? t('@title [%language translation]', $t_args) : t('Create %language translation of %title', $t_args);
}
$form['#title'] = $title;
}
// Display source language selector only if we are creating a new
// translation and there are at least two translations available.
if ($has_translations && $new_translation) {
$form['source_langcode'] = [
'#type' => 'details',
'#title' => t('Source language: @language', ['@language' => $languages[$source_langcode]->getName()]),
'#tree' => TRUE,
'#weight' => -100,
'#multilingual' => TRUE,
'source' => [
'#title' => t('Select source language'),
'#title_display' => 'invisible',
'#type' => 'select',
'#default_value' => $source_langcode,
'#options' => [],
],
'submit' => [
'#type' => 'submit',
'#value' => t('Change'),
'#submit' => [[$this, 'entityFormSourceChange']],
],
];
foreach ($this->languageManager->getLanguages() as $language) {
if (isset($translations[$language->getId()])) {
$form['source_langcode']['source']['#options'][$language->getId()] = $language->getName();
}
}
}
// Locate the language widget.
$langcode_key = $this->entityType->getKey('langcode');
if (isset($form[$langcode_key])) {
$language_widget = &$form[$langcode_key];
}
// If we are editing the source entity, limit the list of languages so that
// it is not possible to switch to a language for which a translation
// already exists. Note that this will only work if the widget is structured
// like \Drupal\Core\Field\Plugin\Field\FieldWidget\LanguageSelectWidget.
if (isset($language_widget['widget'][0]['value']) && !$is_translation && $has_translations) {
$language_select = &$language_widget['widget'][0]['value'];
if ($language_select['#type'] == 'language_select') {
$options = [];
foreach ($this->languageManager->getLanguages() as $language) {
// Show the current language, and the languages for which no
// translation already exists.
if (empty($translations[$language->getId()]) || $language->getId() == $entity_langcode) {
$options[$language->getId()] = $language->getName();
}
}
$language_select['#options'] = $options;
}
}
if ($is_translation) {
if (isset($language_widget)) {
$language_widget['widget']['#access'] = FALSE;
}
// Replace the delete button with the delete translation one.
if (!$new_translation) {
$weight = 100;
foreach (['delete', 'submit'] as $key) {
if (isset($form['actions'][$key]['weight'])) {
$weight = $form['actions'][$key]['weight'];
break;
}
}
/** @var \Drupal\Core\Access\AccessResultInterface $delete_access */
$delete_access = \Drupal::service('content_translation.delete_access')->checkAccess($entity);
$access = $delete_access->isAllowed() && (
$this->getTranslationAccess($entity, 'delete')->isAllowed() ||
($entity->access('delete') && $this->entityType->hasLinkTemplate('delete-form'))
);
$form['actions']['delete_translation'] = [
'#type' => 'submit',
'#value' => t('Delete translation'),
'#weight' => $weight,
'#submit' => [[$this, 'entityFormDeleteTranslation']],
'#access' => $access,
];
}
// Always remove the delete button on translation forms.
unset($form['actions']['delete']);
}
// We need to display the translation tab only when there is at least one
// translation available or a new one is about to be created.
if ($new_translation || $has_translations) {
$form['content_translation'] = [
'#type' => 'details',
'#title' => t('Translation'),
'#tree' => TRUE,
'#weight' => 10,
'#access' => $this->getTranslationAccess($entity, $source_langcode ? 'create' : 'update')->isAllowed(),
'#multilingual' => TRUE,
];
if (isset($form['advanced'])) {
$form['content_translation'] += [
'#group' => 'advanced',
'#weight' => 100,
'#attributes' => [
'class' => ['entity-translation-options'],
],
];
}
// A new translation is enabled by default.
$metadata = $this->manager->getTranslationMetadata($entity);
$status = $new_translation || $metadata->isPublished();
// If there is only one published translation we cannot unpublish it,
// since there would be nothing left to display.
$enabled = TRUE;
if ($status) {
$published = 0;
foreach ($entity->getTranslationLanguages() as $langcode => $language) {
$published += $this->manager->getTranslationMetadata($entity->getTranslation($langcode))
->isPublished();
}
$enabled = $published > 1;
}
$description = $enabled ?
t('An unpublished translation will not be visible without translation permissions.') :
t('Only this translation is published. You must publish at least one more translation to unpublish this one.');
$form['content_translation']['status'] = [
'#type' => 'checkbox',
'#title' => t('This translation is published'),
'#default_value' => $status,
'#description' => $description,
'#disabled' => !$enabled,
];
$translate = !$new_translation && $metadata->isOutdated();
$outdated_access = !ContentTranslationManager::isPendingRevisionSupportEnabled($entity->getEntityTypeId(), $entity->bundle());
if (!$outdated_access) {
$form['content_translation']['outdated'] = [
'#markup' => $this->t('Translations cannot be flagged as outdated when content is moderated.'),
];
}
elseif (!$translate) {
$form['content_translation']['retranslate'] = [
'#type' => 'checkbox',
'#title' => t('Flag other translations as outdated'),
'#default_value' => FALSE,
'#description' => t('If you made a significant change, which means the other translations should be updated, you can flag all translations of this content as outdated. This will not change any other property of them, like whether they are published or not.'),
'#access' => $outdated_access,
];
}
else {
$form['content_translation']['outdated'] = [
'#type' => 'checkbox',
'#title' => t('This translation needs to be updated'),
'#default_value' => $translate,
'#description' => t('When this option is checked, this translation needs to be updated. Uncheck when the translation is up to date again.'),
'#access' => $outdated_access,
];
$form['content_translation']['#open'] = TRUE;
}
// Default to the anonymous user.
$uid = 0;
if ($new_translation) {
$uid = $this->currentUser->id();
}
elseif (($account = $metadata->getAuthor()) && $account->id()) {
$uid = $account->id();
}
$form['content_translation']['uid'] = [
'#type' => 'entity_autocomplete',
'#title' => t('Authored by'),
'#target_type' => 'user',
'#default_value' => User::load($uid),
// Validation is done by static::entityFormValidate().
'#validate_reference' => FALSE,
'#maxlength' => 60,
'#description' => t('Leave blank for %anonymous.', ['%anonymous' => \Drupal::config('user.settings')->get('anonymous')]),
];
$date = $new_translation ? REQUEST_TIME : $metadata->getCreatedTime();
$form['content_translation']['created'] = [
'#type' => 'textfield',
'#title' => t('Authored on'),
'#maxlength' => 25,
'#description' => t('Format: %time. The date format is YYYY-MM-DD and %timezone is the time zone offset from UTC. Leave blank to use the time of form submission.', ['%time' => format_date(REQUEST_TIME, 'custom', 'Y-m-d H:i:s O'), '%timezone' => format_date(REQUEST_TIME, 'custom', 'O')]),
'#default_value' => $new_translation || !$date ? '' : format_date($date, 'custom', 'Y-m-d H:i:s O'),
];
$form['#process'][] = [$this, 'entityFormSharedElements'];
}
// Process the submitted values before they are stored.
$form['#entity_builders'][] = [$this, 'entityFormEntityBuild'];
// Handle entity validation.
$form['#validate'][] = [$this, 'entityFormValidate'];
// Handle entity deletion.
if (isset($form['actions']['delete'])) {
$form['actions']['delete']['#submit'][] = [$this, 'entityFormDelete'];
}
// Handle entity form submission before the entity has been saved.
foreach (Element::children($form['actions']) as $action) {
if (isset($form['actions'][$action]['#type']) && $form['actions'][$action]['#type'] == 'submit') {
array_unshift($form['actions'][$action]['#submit'], [$this, 'entityFormSubmit']);
}
}
}
/**
* Process callback: determines which elements get clue in the form.
*
* @see \Drupal\content_translation\ContentTranslationHandler::entityFormAlter()
*/
public function entityFormSharedElements($element, FormStateInterface $form_state, $form) {
static $ignored_types;
// @todo Find a more reliable way to determine if a form element concerns a
// multilingual value.
if (!isset($ignored_types)) {
$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);
}
else {
// Ignore non-widget form elements.
if (isset($ignored_types[$element[$key]['#type']])) {
continue;
}
// 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 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;
}
}
}
}
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;
}
/**
* Adds a clue about the form element translatability.
*
* If the given element does not have a #title attribute, the function is
* recursively applied to child elements.
*
* @param array $element
* A form element array.
*/
protected function addTranslatabilityClue(&$element) {
static $suffix, $fapi_title_elements;
// Elements which can have a #title attribute according to FAPI Reference.
if (!isset($suffix)) {
$suffix = ' <span class="translation-entity-all-languages">(' . t('all languages') . ')</span>';
$fapi_title_elements = array_flip(['checkbox', 'checkboxes', 'date', 'details', 'fieldset', 'file', 'item', 'password', 'password_confirm', 'radio', 'radios', 'select', 'text_format', 'textarea', 'textfield', 'weight']);
}
// Update #title attribute for all elements that are allowed to have a
// #title attribute according to the Form API Reference. The reason for this
// check is because some elements have a #title attribute even though it is
// not rendered; for instance, field containers.
if (isset($element['#type']) && isset($fapi_title_elements[$element['#type']]) && isset($element['#title'])) {
$element['#title'] .= $suffix;
}
// If the current element does not have a (valid) title, try child elements.
elseif ($children = Element::children($element)) {
foreach ($children as $delta) {
$this->addTranslatabilityClue($element[$delta], $suffix);
}
}
// If there are no children, fall back to the current #title attribute if it
// exists.
elseif (isset($element['#title'])) {
$element['#title'] .= $suffix;
}
}
/**
* Entity builder method.
*
* @param string $entity_type
* The type of the entity.
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity whose form is being built.
*
* @see \Drupal\content_translation\ContentTranslationHandler::entityFormAlter()
*/
public function entityFormEntityBuild($entity_type, EntityInterface $entity, array $form, FormStateInterface $form_state) {
$form_object = $form_state->getFormObject();
$form_langcode = $form_object->getFormLangcode($form_state);
$values = &$form_state->getValue('content_translation', []);
$metadata = $this->manager->getTranslationMetadata($entity);
$metadata->setAuthor(!empty($values['uid']) ? User::load($values['uid']) : User::load(0));
$metadata->setPublished(!empty($values['status']));
$metadata->setCreatedTime(!empty($values['created']) ? strtotime($values['created']) : REQUEST_TIME);
$source_langcode = $this->getSourceLangcode($form_state);
if ($source_langcode) {
$metadata->setSource($source_langcode);
}
$metadata->setOutdated(!empty($values['outdated']));
if (!empty($values['retranslate'])) {
$this->retranslate($entity, $form_langcode);
}
}
/**
* Form validation handler for ContentTranslationHandler::entityFormAlter().
*
* Validates the submitted content translation metadata.
*/
public function entityFormValidate($form, FormStateInterface $form_state) {
if (!$form_state->isValueEmpty('content_translation')) {
$translation = $form_state->getValue('content_translation');
// Validate the "authored by" field.
if (!empty($translation['uid']) && !($account = User::load($translation['uid']))) {
$form_state->setErrorByName('content_translation][uid', t('The translation authoring username %name does not exist.', ['%name' => $account->getUsername()]));
}
// Validate the "authored on" field.
if (!empty($translation['created']) && strtotime($translation['created']) === FALSE) {
$form_state->setErrorByName('content_translation][created', t('You have to specify a valid translation authoring date.'));
}
}
}
/**
* Form submission handler for ContentTranslationHandler::entityFormAlter().
*
* Updates metadata fields, which should be updated only after the validation
* has run and before the entity is saved.
*/
public function entityFormSubmit($form, FormStateInterface $form_state) {
/** @var \Drupal\Core\Entity\ContentEntityFormInterface $form_object */
$form_object = $form_state->getFormObject();
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $form_object->getEntity();
// ContentEntityForm::submit will update the changed timestamp on submit
// after the entity has been validated, so that it does not break the
// EntityChanged constraint validator. The content translation metadata
// field for the changed timestamp does not have such a constraint defined
// at the moment, but it is correct to update its value in a submission
// handler as well and have the same logic like in the Form API.
if ($entity->hasField('content_translation_changed')) {
$metadata = $this->manager->getTranslationMetadata($entity);
$metadata->setChangedTime(REQUEST_TIME);
}
}
/**
* Form submission handler for ContentTranslationHandler::entityFormAlter().
*
* Takes care of the source language change.
*/
public function entityFormSourceChange($form, FormStateInterface $form_state) {
$form_object = $form_state->getFormObject();
$entity = $form_object->getEntity();
$source = $form_state->getValue(['source_langcode', 'source']);
$entity_type_id = $entity->getEntityTypeId();
$form_state->setRedirect("entity.$entity_type_id.content_translation_add", [
$entity_type_id => $entity->id(),
'source' => $source,
'target' => $form_object->getFormLangcode($form_state),
]);
$languages = $this->languageManager->getLanguages();
$this->messenger->addStatus(t('Source language set to: %language', ['%language' => $languages[$source]->getName()]));
}
/**
* Form submission handler for ContentTranslationHandler::entityFormAlter().
*
* Takes care of entity deletion.
*/
public function entityFormDelete($form, FormStateInterface $form_state) {
$form_object = $form_state->getFormObject();
$entity = $form_object->getEntity();
if (count($entity->getTranslationLanguages()) > 1) {
$this->messenger->addWarning(t('This will delete all the translations of %label.', ['%label' => $entity->label()]));
}
}
/**
* Form submission handler for ContentTranslationHandler::entityFormAlter().
*
* Takes care of content translation deletion.
*/
public function entityFormDeleteTranslation($form, FormStateInterface $form_state) {
/** @var \Drupal\Core\Entity\ContentEntityFormInterface $form_object */
$form_object = $form_state->getFormObject();
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $form_object->getEntity();
$entity_type_id = $entity->getEntityTypeId();
if ($entity->access('delete') && $this->entityType->hasLinkTemplate('delete-form')) {
$form_state->setRedirectUrl($entity->urlInfo('delete-form'));
}
else {
$form_state->setRedirect("entity.$entity_type_id.content_translation_delete", [
$entity_type_id => $entity->id(),
'language' => $form_object->getFormLangcode($form_state),
]);
}
}
/**
* Returns the title to be used for the entity form page.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity whose form is being altered.
*
* @return string|null
* The label of the entity, or NULL if there is no label defined.
*/
protected function entityFormTitle(EntityInterface $entity) {
return $entity->label();
}
/**
* Default value callback for the owner base field definition.
*
* @return int
* The user ID.
*/
public static function getDefaultOwnerId() {
return \Drupal::currentUser()->id();
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace Drupal\content_translation;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
/**
* Interface for providing content translation.
*
* Defines a set of methods to allow any entity to be processed by the entity
* translation UI.
*/
interface ContentTranslationHandlerInterface {
/**
* Returns a set of field definitions to be used to store metadata items.
*
* @return \Drupal\Core\Field\FieldDefinitionInterface[]
*/
public function getFieldDefinitions();
/**
* Checks if the user can perform the given operation on translations of the
* wrapped entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity whose translation has to be accessed.
* @param $op
* The operation to be performed on the translation. Possible values are:
* - "create"
* - "update"
* - "delete"
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function getTranslationAccess(EntityInterface $entity, $op);
/**
* Retrieves the source language for the translation being created.
*
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*
* @return string
* The source language code.
*/
public function getSourceLangcode(FormStateInterface $form_state);
/**
* Marks translations as outdated.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being translated.
* @param string $langcode
* (optional) The language code of the updated language: all the other
* translations will be marked as outdated. Defaults to the entity language.
*/
public function retranslate(EntityInterface $entity, $langcode = NULL);
/**
* Performs the needed alterations to the entity form.
*
* @param array $form
* The entity form to be altered to provide the translation workflow.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being created or edited.
*/
public function entityFormAlter(array &$form, FormStateInterface $form_state, EntityInterface $entity);
}

View file

@ -0,0 +1,192 @@
<?php
namespace Drupal\content_translation;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\workflows\Entity\Workflow;
/**
* Provides common functionality for content translation.
*/
class ContentTranslationManager implements ContentTranslationManagerInterface, BundleTranslationSettingsInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityManagerInterface
*/
protected $entityManager;
/**
* The updates manager.
*
* @var \Drupal\content_translation\ContentTranslationUpdatesManager
*/
protected $updatesManager;
/**
* Constructs a ContentTranslationManageAccessCheck object.
*
* @param \Drupal\Core\Entity\EntityManagerInterface $manager
* The entity type manager.
* @param \Drupal\content_translation\ContentTranslationUpdatesManager $updates_manager
* The updates manager.
*/
public function __construct(EntityManagerInterface $manager, ContentTranslationUpdatesManager $updates_manager) {
$this->entityManager = $manager;
$this->updatesManager = $updates_manager;
}
/**
* {@inheritdoc}
*/
public function getTranslationHandler($entity_type_id) {
return $this->entityManager->getHandler($entity_type_id, 'translation');
}
/**
* {@inheritdoc}
*/
public function getTranslationMetadata(EntityInterface $translation) {
// We need a new instance of the metadata handler wrapping each translation.
$entity_type = $translation->getEntityType();
$class = $entity_type->get('content_translation_metadata');
return new $class($translation, $this->getTranslationHandler($entity_type->id()));
}
/**
* {@inheritdoc}
*/
public function isSupported($entity_type_id) {
$entity_type = $this->entityManager->getDefinition($entity_type_id);
return $entity_type->isTranslatable() && ($entity_type->hasLinkTemplate('drupal:content-translation-overview') || $entity_type->get('content_translation_ui_skip'));
}
/**
* {@inheritdoc}
*/
public function getSupportedEntityTypes() {
$supported_types = [];
foreach ($this->entityManager->getDefinitions() as $entity_type_id => $entity_type) {
if ($this->isSupported($entity_type_id)) {
$supported_types[$entity_type_id] = $entity_type;
}
}
return $supported_types;
}
/**
* {@inheritdoc}
*/
public function setEnabled($entity_type_id, $bundle, $value) {
$config = $this->loadContentLanguageSettings($entity_type_id, $bundle);
$config->setThirdPartySetting('content_translation', 'enabled', $value)->save();
$entity_type = $this->entityManager->getDefinition($entity_type_id);
$this->updatesManager->updateDefinitions([$entity_type_id => $entity_type]);
}
/**
* {@inheritdoc}
*/
public function isEnabled($entity_type_id, $bundle = NULL) {
$enabled = FALSE;
if ($this->isSupported($entity_type_id)) {
$bundles = !empty($bundle) ? [$bundle] : array_keys($this->entityManager->getBundleInfo($entity_type_id));
foreach ($bundles as $bundle) {
$config = $this->loadContentLanguageSettings($entity_type_id, $bundle);
if ($config->getThirdPartySetting('content_translation', 'enabled', FALSE)) {
$enabled = TRUE;
break;
}
}
}
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.
*
* @param string $entity_type_id
* ID of the entity type.
* @param string $bundle
* Bundle name.
*
* @return \Drupal\language\Entity\ContentLanguageSettings
* The content language config entity if one exists. Otherwise, returns
* default values.
*/
protected function loadContentLanguageSettings($entity_type_id, $bundle) {
if ($entity_type_id == NULL || $bundle == NULL) {
return NULL;
}
$config = $this->entityManager->getStorage('language_content_settings')->load($entity_type_id . '.' . $bundle);
if ($config == NULL) {
$config = $this->entityManager->getStorage('language_content_settings')->create(['target_entity_type_id' => $entity_type_id, 'target_bundle' => $bundle]);
}
return $config;
}
/**
* Checks whether support for pending revisions should be enabled.
*
* @param string $entity_type_id
* The ID of the entity type to be checked.
* @param string $bundle_id
* (optional) The ID of the bundle to be checked. Defaults to none.
*
* @return bool
* TRUE if pending revisions should be enabled, FALSE otherwise.
*
* @internal
* There is ongoing discussion about how pending revisions should behave.
* The logic enabling pending revision support is likely to change once a
* decision is made.
*
* @see https://www.drupal.org/node/2940575
*/
public static function isPendingRevisionSupportEnabled($entity_type_id, $bundle_id = NULL) {
if (!\Drupal::moduleHandler()->moduleExists('content_moderation')) {
return FALSE;
}
foreach (Workflow::loadMultipleByType('content_moderation') as $workflow) {
/** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $plugin */
$plugin = $workflow->getTypePlugin();
$entity_type_ids = array_flip($plugin->getEntityTypes());
if (isset($entity_type_ids[$entity_type_id])) {
if (!isset($bundle_id)) {
return TRUE;
}
else {
$bundle_ids = array_flip($plugin->getBundlesForEntityType($entity_type_id));
if (isset($bundle_ids[$bundle_id])) {
return TRUE;
}
}
}
}
return FALSE;
}
}

View file

@ -0,0 +1,80 @@
<?php
namespace Drupal\content_translation;
use Drupal\Core\Entity\EntityInterface;
/**
* Provides an interface for common functionality for content translation.
*/
interface ContentTranslationManagerInterface {
/**
* Gets the entity types that support content translation.
*
* @return \Drupal\Core\Entity\EntityTypeInterface[]
* An array of entity types that support content translation.
*/
public function getSupportedEntityTypes();
/**
* Checks whether an entity type supports translation.
*
* @param string $entity_type_id
* The entity type.
*
* @return bool
* TRUE if an entity type is supported, FALSE otherwise.
*/
public function isSupported($entity_type_id);
/**
* Returns an instance of the Content translation handler.
*
* @param string $entity_type_id
* The type of the entity being translated.
*
* @return \Drupal\content_translation\ContentTranslationHandlerInterface
* An instance of the content translation handler.
*/
public function getTranslationHandler($entity_type_id);
/**
* Returns an instance of the Content translation metadata.
*
* @param \Drupal\Core\Entity\EntityInterface $translation
* The entity translation whose metadata needs to be retrieved.
*
* @return \Drupal\content_translation\ContentTranslationMetadataWrapperInterface
* An instance of the content translation metadata.
*/
public function getTranslationMetadata(EntityInterface $translation);
/**
* Sets the value for translatability of the given entity type bundle.
*
* @param string $entity_type_id
* The entity type.
* @param string $bundle
* The bundle of the entity.
* @param bool $value
* The boolean value we need to save.
*/
public function setEnabled($entity_type_id, $bundle, $value);
/**
* Determines whether the given entity type is translatable.
*
* @param string $entity_type_id
* The type of the entity.
* @param string $bundle
* (optional) The bundle of the entity. If no bundle is provided, all the
* available bundles are checked.
*
* @returns bool
* TRUE if the specified bundle is translatable. If no bundle is provided
* returns TRUE if at least one of the entity bundles is translatable.
*/
public function isEnabled($entity_type_id, $bundle = NULL);
}

View file

@ -0,0 +1,150 @@
<?php
namespace Drupal\content_translation;
use Drupal\Core\Entity\EntityInterface;
use Drupal\user\UserInterface;
/**
* Base class for content translation metadata wrappers.
*/
class ContentTranslationMetadataWrapper implements ContentTranslationMetadataWrapperInterface {
/**
* The wrapped entity translation.
*
* @var \Drupal\Core\Entity\EntityInterface|\Drupal\Core\Entity\FieldableEntityInterface|\Drupal\Core\TypedData\TranslatableInterface
*/
protected $translation;
/**
* The content translation handler.
*
* @var \Drupal\content_translation\ContentTranslationHandlerInterface
*/
protected $handler;
/**
* Initializes an instance of the content translation metadata handler.
*
* @param \Drupal\Core\Entity\EntityInterface $translation
* The entity translation to be wrapped.
* @param ContentTranslationHandlerInterface $handler
* The content translation handler.
*/
public function __construct(EntityInterface $translation, ContentTranslationHandlerInterface $handler) {
$this->translation = $translation;
$this->handler = $handler;
}
/**
* {@inheritdoc}
*/
public function getSource() {
return $this->translation->get('content_translation_source')->value;
}
/**
* {@inheritdoc}
*/
public function setSource($source) {
$this->translation->set('content_translation_source', $source);
return $this;
}
/**
* {@inheritdoc}
*/
public function isOutdated() {
return (bool) $this->translation->get('content_translation_outdated')->value;
}
/**
* {@inheritdoc}
*/
public function setOutdated($outdated) {
$this->translation->set('content_translation_outdated', $outdated);
return $this;
}
/**
* {@inheritdoc}
*/
public function getAuthor() {
return $this->translation->hasField('content_translation_uid') ? $this->translation->get('content_translation_uid')->entity : $this->translation->getOwner();
}
/**
* {@inheritdoc}
*/
public function setAuthor(UserInterface $account) {
$field_name = $this->translation->hasField('content_translation_uid') ? 'content_translation_uid' : 'uid';
$this->setFieldOnlyIfTranslatable($field_name, $account->id());
return $this;
}
/**
* {@inheritdoc}
*/
public function isPublished() {
$field_name = $this->translation->hasField('content_translation_status') ? 'content_translation_status' : 'status';
return (bool) $this->translation->get($field_name)->value;
}
/**
* {@inheritdoc}
*/
public function setPublished($published) {
$field_name = $this->translation->hasField('content_translation_status') ? 'content_translation_status' : 'status';
$this->setFieldOnlyIfTranslatable($field_name, $published);
return $this;
}
/**
* {@inheritdoc}
*/
public function getCreatedTime() {
$field_name = $this->translation->hasField('content_translation_created') ? 'content_translation_created' : 'created';
return $this->translation->get($field_name)->value;
}
/**
* {@inheritdoc}
*/
public function setCreatedTime($timestamp) {
$field_name = $this->translation->hasField('content_translation_created') ? 'content_translation_created' : 'created';
$this->setFieldOnlyIfTranslatable($field_name, $timestamp);
return $this;
}
/**
* {@inheritdoc}
*/
public function getChangedTime() {
return $this->translation->hasField('content_translation_changed') ? $this->translation->get('content_translation_changed')->value : $this->translation->getChangedTime();
}
/**
* {@inheritdoc}
*/
public function setChangedTime($timestamp) {
$field_name = $this->translation->hasField('content_translation_changed') ? 'content_translation_changed' : 'changed';
$this->setFieldOnlyIfTranslatable($field_name, $timestamp);
return $this;
}
/**
* Updates a field value, only if the field is translatable.
*
* @param string $field_name
* The name of the field.
* @param mixed $value
* The field value to be set.
*/
protected function setFieldOnlyIfTranslatable($field_name, $value) {
if ($this->translation->getFieldDefinition($field_name)->isTranslatable()) {
$this->translation->set($field_name, $value);
}
}
}

View file

@ -0,0 +1,131 @@
<?php
namespace Drupal\content_translation;
use Drupal\user\UserInterface;
/**
* Common interface for content translation metadata wrappers.
*
* This acts as a wrapper for an entity translation object, encapsulating the
* logic needed to retrieve translation metadata.
*/
interface ContentTranslationMetadataWrapperInterface {
/**
* Retrieves the source language for this translation.
*
* @return string
* The source language code.
*/
public function getSource();
/**
* Sets the source language for this translation.
*
* @param string $source
* The source language code.
*
* @return $this
*/
public function setSource($source);
/**
* Returns the translation outdated status.
*
* @return bool
* TRUE if the translation is outdated, FALSE otherwise.
*/
public function isOutdated();
/**
* Sets the translation outdated status.
*
* @param bool $outdated
* TRUE if the translation is outdated, FALSE otherwise.
*
* @return $this
*/
public function setOutdated($outdated);
/**
* Returns the translation author.
*
* @return \Drupal\user\UserInterface
* The user entity for the translation author.
*/
public function getAuthor();
/**
* Sets the translation author.
*
* The metadata field will be updated, only if it's translatable.
*
* @param \Drupal\user\UserInterface $account
* The translation author user entity.
*
* @return $this
*/
public function setAuthor(UserInterface $account);
/**
* Returns the translation published status.
*
* @return bool
* TRUE if the translation is published, FALSE otherwise.
*/
public function isPublished();
/**
* Sets the translation published status.
*
* The metadata field will be updated, only if it's translatable.
*
* @param bool $published
* TRUE if the translation is published, FALSE otherwise.
*
* @return $this
*/
public function setPublished($published);
/**
* Returns the translation creation timestamp.
*
* @return int
* The UNIX timestamp of when the translation was created.
*/
public function getCreatedTime();
/**
* Sets the translation creation timestamp.
*
* The metadata field will be updated, only if it's translatable.
*
* @param int $timestamp
* The UNIX timestamp of when the translation was created.
*
* @return $this
*/
public function setCreatedTime($timestamp);
/**
* Returns the timestamp of the last entity change from current translation.
*
* @return int
* The timestamp of the last entity save operation.
*/
public function getChangedTime();
/**
* Sets the translation modification timestamp.
*
* The metadata field will be updated, only if it's translatable.
*
* @param int $timestamp
* The UNIX timestamp of when the translation was last modified.
*
* @return $this
*/
public function setChangedTime($timestamp);
}

View file

@ -0,0 +1,93 @@
<?php
namespace Drupal\content_translation;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides dynamic permissions for the content_translation module.
*/
class ContentTranslationPermissions implements ContainerInjectionInterface {
use StringTranslationTrait;
/**
* The entity manager.
*
* @var \Drupal\Core\Entity\EntityManagerInterface
*/
protected $entityManager;
/**
* The content translation manager.
*
* @var \Drupal\content_translation\ContentTranslationManagerInterface
*/
protected $contentTranslationManager;
/**
* Constructs a ContentTranslationPermissions instance.
*
* @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
* The entity manager.
* @param \Drupal\content_translation\ContentTranslationManagerInterface $content_translation_manager
* The content translation manager.
*/
public function __construct(EntityManagerInterface $entity_manager, ContentTranslationManagerInterface $content_translation_manager) {
$this->entityManager = $entity_manager;
$this->contentTranslationManager = $content_translation_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity.manager'),
$container->get('content_translation.manager')
);
}
/**
* Returns an array of content translation permissions.
*
* @return array
*/
public function contentPermissions() {
$permission = [];
// Create a translate permission for each enabled entity type and (optionally)
// bundle.
foreach ($this->entityManager->getDefinitions() as $entity_type_id => $entity_type) {
if ($permission_granularity = $entity_type->getPermissionGranularity()) {
$t_args = ['@entity_label' => $entity_type->getLowercaseLabel()];
switch ($permission_granularity) {
case 'bundle':
foreach ($this->entityManager->getBundleInfo($entity_type_id) as $bundle => $bundle_info) {
if ($this->contentTranslationManager->isEnabled($entity_type_id, $bundle)) {
$t_args['%bundle_label'] = isset($bundle_info['label']) ? $bundle_info['label'] : $bundle;
$permission["translate $bundle $entity_type_id"] = [
'title' => $this->t('Translate %bundle_label @entity_label', $t_args),
];
}
}
break;
case 'entity_type':
if ($this->contentTranslationManager->isEnabled($entity_type_id)) {
$permission["translate $entity_type_id"] = [
'title' => $this->t('Translate @entity_label', $t_args),
];
}
break;
}
}
}
return $permission;
}
}

View file

@ -0,0 +1,104 @@
<?php
namespace Drupal\content_translation;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Config\ConfigEvents;
use Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\migrate\Event\MigrateEvents;
use Drupal\migrate\Event\MigrateImportEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Provides the logic needed to update field storage definitions when needed.
*/
class ContentTranslationUpdatesManager implements EventSubscriberInterface {
/**
* The entity manager.
*
* @var \Drupal\Core\Entity\EntityManagerInterface
*/
protected $entityManager;
/**
* The entity definition update manager.
*
* @var \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface
*/
protected $updateManager;
/**
* Constructs an updates manager instance.
*
* @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
* The entity manager.
* @param \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface $update_manager
* The entity definition update manager.
*/
public function __construct(EntityManagerInterface $entity_manager, EntityDefinitionUpdateManagerInterface $update_manager) {
$this->entityManager = $entity_manager;
$this->updateManager = $update_manager;
}
/**
* Executes field storage definition updates if needed.
*
* @param array $entity_types
* A list of entity type definitions to be processed.
*/
public function updateDefinitions(array $entity_types) {
// Handle field storage definition creation, if needed.
// @todo Generalize this code in https://www.drupal.org/node/2346013.
// @todo Handle initial values in https://www.drupal.org/node/2346019.
if ($this->updateManager->needsUpdates()) {
foreach ($entity_types as $entity_type_id => $entity_type) {
$storage_definitions = $this->entityManager->getFieldStorageDefinitions($entity_type_id);
$installed_storage_definitions = $this->entityManager->getLastInstalledFieldStorageDefinitions($entity_type_id);
foreach (array_diff_key($storage_definitions, $installed_storage_definitions) as $storage_definition) {
/** @var $storage_definition \Drupal\Core\Field\FieldStorageDefinitionInterface */
if ($storage_definition->getProvider() == 'content_translation') {
$this->updateManager->installFieldStorageDefinition($storage_definition->getName(), $entity_type_id, 'content_translation', $storage_definition);
}
}
}
}
}
/**
* Listener for the ConfigImporter import event.
*/
public function onConfigImporterImport() {
$entity_types = array_filter($this->entityManager->getDefinitions(), function (EntityTypeInterface $entity_type) {
return $entity_type->isTranslatable();
});
$this->updateDefinitions($entity_types);
}
/**
* Listener for migration imports.
*/
public function onMigrateImport(MigrateImportEvent $event) {
$migration = $event->getMigration();
$configuration = $migration->getDestinationConfiguration();
$entity_types = NestedArray::getValue($configuration, ['content_translation_update_definitions']);
if ($entity_types) {
$entity_types = array_intersect_key($this->entityManager->getDefinitions(), array_flip($entity_types));
$this->updateDefinitions($entity_types);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
$events[ConfigEvents::IMPORT][] = ['onConfigImporterImport', 60];
if (class_exists('\Drupal\migrate\Event\MigrateEvents')) {
$events[MigrateEvents::POST_IMPORT][] = ['onMigrateImport'];
}
return $events;
}
}

View file

@ -0,0 +1,410 @@
<?php
namespace Drupal\content_translation\Controller;
use Drupal\content_translation\ContentTranslationManager;
use Drupal\content_translation\ContentTranslationManagerInterface;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Base class for entity translation controllers.
*/
class ContentTranslationController extends ControllerBase {
/**
* The content translation manager.
*
* @var \Drupal\content_translation\ContentTranslationManagerInterface
*/
protected $manager;
/**
* Initializes a content translation controller.
*
* @param \Drupal\content_translation\ContentTranslationManagerInterface $manager
* A content translation manager instance.
*/
public function __construct(ContentTranslationManagerInterface $manager) {
$this->manager = $manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static($container->get('content_translation.manager'));
}
/**
* Populates target values with the source values.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity being translated.
* @param \Drupal\Core\Language\LanguageInterface $source
* The language to be used as source.
* @param \Drupal\Core\Language\LanguageInterface $target
* The language to be used as target.
*/
public function prepareTranslation(ContentEntityInterface $entity, LanguageInterface $source, LanguageInterface $target) {
/* @var \Drupal\Core\Entity\ContentEntityInterface $source_translation */
$source_translation = $entity->getTranslation($source->getId());
$target_translation = $entity->addTranslation($target->getId(), $source_translation->toArray());
// Make sure we do not inherit the affected status from the source values.
if ($entity->getEntityType()->isRevisionable()) {
$target_translation->setRevisionTranslationAffected(NULL);
}
/** @var \Drupal\user\UserInterface $user */
$user = $this->entityManager()->getStorage('user')->load($this->currentUser()->id());
$metadata = $this->manager->getTranslationMetadata($target_translation);
// Update the translation author to current user, as well the translation
// creation time.
$metadata->setAuthor($user);
$metadata->setCreatedTime(REQUEST_TIME);
}
/**
* Builds the translations overview page.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match.
* @param string $entity_type_id
* (optional) The entity type ID.
* @return array
* Array of page elements to render.
*/
public function overview(RouteMatchInterface $route_match, $entity_type_id = NULL) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $route_match->getParameter($entity_type_id);
$account = $this->currentUser();
$handler = $this->entityManager()->getHandler($entity_type_id, 'translation');
$manager = $this->manager;
$entity_type = $entity->getEntityType();
$use_latest_revisions = $entity_type->isRevisionable() && ContentTranslationManager::isPendingRevisionSupportEnabled($entity_type_id, $entity->bundle());
// Start collecting the cacheability metadata, starting with the entity and
// later merge in the access result cacheability metadata.
$cacheability = CacheableMetadata::createFromObject($entity);
$languages = $this->languageManager()->getLanguages();
$original = $entity->getUntranslated()->language()->getId();
$translations = $entity->getTranslationLanguages();
$field_ui = $this->moduleHandler()->moduleExists('field_ui') && $account->hasPermission('administer ' . $entity_type_id . ' fields');
$rows = [];
$show_source_column = FALSE;
/** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */
$storage = $this->entityTypeManager()->getStorage($entity_type_id);
$default_revision = $storage->load($entity->id());
if ($this->languageManager()->isMultilingual()) {
// Determine whether the current entity is translatable.
$translatable = FALSE;
foreach ($this->entityManager->getFieldDefinitions($entity_type_id, $entity->bundle()) as $instance) {
if ($instance->isTranslatable()) {
$translatable = TRUE;
break;
}
}
// Show source-language column if there are non-original source langcodes.
$additional_source_langcodes = array_filter(array_keys($translations), function ($langcode) use ($entity, $original, $manager) {
$source = $manager->getTranslationMetadata($entity->getTranslation($langcode))->getSource();
return $source != $original && $source != LanguageInterface::LANGCODE_NOT_SPECIFIED;
});
$show_source_column = !empty($additional_source_langcodes);
foreach ($languages as $language) {
$language_name = $language->getName();
$langcode = $language->getId();
// If the entity type is revisionable, we may have pending revisions
// with translations not available yet in the default revision. Thus we
// need to load the latest translation-affecting revision for each
// language to be sure we are listing all available translations.
if ($use_latest_revisions) {
$entity = $default_revision;
$latest_revision_id = $storage->getLatestTranslationAffectedRevisionId($entity->id(), $langcode);
if ($latest_revision_id) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $latest_revision */
$latest_revision = $storage->loadRevision($latest_revision_id);
// Make sure we do not list removed translations, i.e. translations
// that have been part of a default revision but no longer are.
if (!$latest_revision->wasDefaultRevision() || $default_revision->hasTranslation($langcode)) {
$entity = $latest_revision;
}
}
$translations = $entity->getTranslationLanguages();
}
$options = ['language' => $language];
$add_url = $entity->toUrl('drupal:content-translation-add', $options)
->setRouteParameter('source', $original)
->setRouteParameter('target', $language->getId());
$edit_url = $entity->toUrl('drupal:content-translation-edit', $options)
->setRouteParameter('language', $language->getId());
$delete_url = $entity->toUrl('drupal:content-translation-delete', $options)
->setRouteParameter('language', $language->getId());
$operations = [
'data' => [
'#type' => 'operations',
'#links' => [],
],
];
$links = &$operations['data']['#links'];
if (array_key_exists($langcode, $translations)) {
// Existing translation in the translation set: display status.
$translation = $entity->getTranslation($langcode);
$metadata = $manager->getTranslationMetadata($translation);
$source = $metadata->getSource() ?: LanguageInterface::LANGCODE_NOT_SPECIFIED;
$is_original = $langcode == $original;
$label = $entity->getTranslation($langcode)->label();
$link = isset($links->links[$langcode]['url']) ? $links->links[$langcode] : ['url' => $entity->urlInfo()];
if (!empty($link['url'])) {
$link['url']->setOption('language', $language);
$row_title = $this->l($label, $link['url']);
}
if (empty($link['url'])) {
$row_title = $is_original ? $label : $this->t('n/a');
}
// If the user is allowed to edit the entity we point the edit link to
// the entity form, otherwise if we are not dealing with the original
// language we point the link to the translation form.
$update_access = $entity->access('update', NULL, TRUE);
$translation_access = $handler->getTranslationAccess($entity, 'update');
$cacheability = $cacheability
->merge(CacheableMetadata::createFromObject($update_access))
->merge(CacheableMetadata::createFromObject($translation_access));
if ($update_access->isAllowed() && $entity_type->hasLinkTemplate('edit-form')) {
$links['edit']['url'] = $entity->urlInfo('edit-form');
$links['edit']['language'] = $language;
}
elseif (!$is_original && $translation_access->isAllowed()) {
$links['edit']['url'] = $edit_url;
}
if (isset($links['edit'])) {
$links['edit']['title'] = $this->t('Edit');
}
$status = [
'data' => [
'#type' => 'inline_template',
'#template' => '<span class="status">{% if status %}{{ "Published"|t }}{% else %}{{ "Not published"|t }}{% endif %}</span>{% if outdated %} <span class="marker">{{ "outdated"|t }}</span>{% endif %}',
'#context' => [
'status' => $metadata->isPublished(),
'outdated' => $metadata->isOutdated(),
],
],
];
if ($is_original) {
$language_name = $this->t('<strong>@language_name (Original language)</strong>', ['@language_name' => $language_name]);
$source_name = $this->t('n/a');
}
else {
/** @var \Drupal\Core\Access\AccessResultInterface $delete_route_access */
$delete_route_access = \Drupal::service('content_translation.delete_access')->checkAccess($translation);
$cacheability->addCacheableDependency($delete_route_access);
if ($delete_route_access->isAllowed()) {
$source_name = isset($languages[$source]) ? $languages[$source]->getName() : $this->t('n/a');
$delete_access = $entity->access('delete', NULL, TRUE);
$translation_access = $handler->getTranslationAccess($entity, 'delete');
$cacheability
->addCacheableDependency($delete_access)
->addCacheableDependency($translation_access);
if ($delete_access->isAllowed() && $entity_type->hasLinkTemplate('delete-form')) {
$links['delete'] = [
'title' => $this->t('Delete'),
'url' => $entity->urlInfo('delete-form'),
'language' => $language,
];
}
elseif ($translation_access->isAllowed()) {
$links['delete'] = [
'title' => $this->t('Delete'),
'url' => $delete_url,
];
}
}
else {
$this->messenger()->addWarning($this->t('The "Delete translation" action is only available for published translations.'), FALSE);
}
}
}
else {
// No such translation in the set yet: help user to create it.
$row_title = $source_name = $this->t('n/a');
$source = $entity->language()->getId();
$create_translation_access = $handler->getTranslationAccess($entity, 'create');
$cacheability = $cacheability
->merge(CacheableMetadata::createFromObject($create_translation_access));
if ($source != $langcode && $create_translation_access->isAllowed()) {
if ($translatable) {
$links['add'] = [
'title' => $this->t('Add'),
'url' => $add_url,
];
}
elseif ($field_ui) {
$url = new Url('language.content_settings_page');
// Link directly to the fields tab to make it easier to find the
// setting to enable translation on fields.
$links['nofields'] = [
'title' => $this->t('No translatable fields'),
'url' => $url,
];
}
}
$status = $this->t('Not translated');
}
if ($show_source_column) {
$rows[] = [
$language_name,
$row_title,
$source_name,
$status,
$operations,
];
}
else {
$rows[] = [$language_name, $row_title, $status, $operations];
}
}
}
if ($show_source_column) {
$header = [
$this->t('Language'),
$this->t('Translation'),
$this->t('Source language'),
$this->t('Status'),
$this->t('Operations'),
];
}
else {
$header = [
$this->t('Language'),
$this->t('Translation'),
$this->t('Status'),
$this->t('Operations'),
];
}
$build['#title'] = $this->t('Translations of %label', ['%label' => $entity->label()]);
// Add metadata to the build render array to let other modules know about
// which entity this is.
$build['#entity'] = $entity;
$cacheability
->addCacheTags($entity->getCacheTags())
->applyTo($build);
$build['content_translation_overview'] = [
'#theme' => 'table',
'#header' => $header,
'#rows' => $rows,
];
return $build;
}
/**
* Builds an add translation page.
*
* @param \Drupal\Core\Language\LanguageInterface $source
* The language of the values being translated. Defaults to the entity
* language.
* @param \Drupal\Core\Language\LanguageInterface $target
* The language of the translated values. Defaults to the current content
* language.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match object from which to extract the entity type.
* @param string $entity_type_id
* (optional) The entity type ID.
*
* @return array
* A processed form array ready to be rendered.
*/
public function add(LanguageInterface $source, LanguageInterface $target, RouteMatchInterface $route_match, $entity_type_id = NULL) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $route_match->getParameter($entity_type_id);
// In case of a pending revision, make sure we load the latest
// translation-affecting revision for the source language, otherwise the
// initial form values may not be up-to-date.
if (!$entity->isDefaultRevision() && ContentTranslationManager::isPendingRevisionSupportEnabled($entity_type_id, $entity->bundle())) {
/** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */
$storage = $this->entityTypeManager()->getStorage($entity->getEntityTypeId());
$revision_id = $storage->getLatestTranslationAffectedRevisionId($entity->id(), $source->getId());
if ($revision_id != $entity->getRevisionId()) {
$entity = $storage->loadRevision($revision_id);
}
}
// @todo Exploit the upcoming hook_entity_prepare() when available.
// See https://www.drupal.org/node/1810394.
$this->prepareTranslation($entity, $source, $target);
// @todo Provide a way to figure out the default form operation. Maybe like
// $operation = isset($info['default_operation']) ? $info['default_operation'] : 'default';
// See https://www.drupal.org/node/2006348.
// Use the add form handler, if available, otherwise default.
$operation = $entity->getEntityType()->hasHandlerClass('form', 'add') ? 'add' : 'default';
$form_state_additions = [];
$form_state_additions['langcode'] = $target->getId();
$form_state_additions['content_translation']['source'] = $source;
$form_state_additions['content_translation']['target'] = $target;
$form_state_additions['content_translation']['translation_form'] = !$entity->access('update');
return $this->entityFormBuilder()->getForm($entity, $operation, $form_state_additions);
}
/**
* Builds the edit translation page.
*
* @param \Drupal\Core\Language\LanguageInterface $language
* The language of the translated values. Defaults to the current content
* language.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match object from which to extract the entity type.
* @param string $entity_type_id
* (optional) The entity type ID.
*
* @return array
* A processed form array ready to be rendered.
*/
public function edit(LanguageInterface $language, RouteMatchInterface $route_match, $entity_type_id = NULL) {
$entity = $route_match->getParameter($entity_type_id);
// @todo Provide a way to figure out the default form operation. Maybe like
// $operation = isset($info['default_operation']) ? $info['default_operation'] : 'default';
// See https://www.drupal.org/node/2006348.
// Use the edit form handler, if available, otherwise default.
$operation = $entity->getEntityType()->hasHandlerClass('form', 'edit') ? 'edit' : 'default';
$form_state_additions = [];
$form_state_additions['langcode'] = $language->getId();
$form_state_additions['content_translation']['translation_form'] = TRUE;
return $this->entityFormBuilder()->getForm($entity, $operation, $form_state_additions);
}
}

View file

@ -0,0 +1,354 @@
<?php
namespace Drupal\content_translation;
use Drupal\Core\Config\Entity\ThirdPartySettingsInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldTypePluginManagerInterface;
/**
* Provides field translation synchronization capabilities.
*/
class FieldTranslationSynchronizer implements FieldTranslationSynchronizerInterface {
/**
* The entity manager to use to load unchanged entities.
*
* @var \Drupal\Core\Entity\EntityManagerInterface
*/
protected $entityManager;
/**
* The field type plugin manager.
*
* @var \Drupal\Core\Field\FieldTypePluginManagerInterface
*/
protected $fieldTypeManager;
/**
* Constructs a FieldTranslationSynchronizer object.
*
* @param \Drupal\Core\Entity\EntityManagerInterface $entityManager
* The entity manager.
* @param \Drupal\Core\Field\FieldTypePluginManagerInterface $field_type_manager
* The field type plugin manager.
*/
public function __construct(EntityManagerInterface $entityManager, FieldTypePluginManagerInterface $field_type_manager) {
$this->entityManager = $entityManager;
$this->fieldTypeManager = $field_type_manager;
}
/**
* {@inheritdoc}
*/
public function getFieldSynchronizedProperties(FieldDefinitionInterface $field_definition) {
$properties = [];
$settings = $this->getFieldSynchronizationSettings($field_definition);
foreach ($settings as $group => $translatable) {
if (!$translatable) {
$field_type_definition = $this->fieldTypeManager->getDefinition($field_definition->getType());
if (!empty($field_type_definition['column_groups'][$group]['columns'])) {
$properties = array_merge($properties, $field_type_definition['column_groups'][$group]['columns']);
}
}
}
return $properties;
}
/**
* Returns the synchronization settings for the specified field.
*
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
* A field definition.
*
* @return string[]
* An array of synchronized field property names.
*/
protected function getFieldSynchronizationSettings(FieldDefinitionInterface $field_definition) {
if ($field_definition instanceof ThirdPartySettingsInterface && $field_definition->isTranslatable()) {
return $field_definition->getThirdPartySetting('content_translation', 'translation_sync', []);
}
return [];
}
/**
* {@inheritdoc}
*/
public function synchronizeFields(ContentEntityInterface $entity, $sync_langcode, $original_langcode = NULL) {
$translations = $entity->getTranslationLanguages();
// If we have no information about what to sync to, if we are creating a new
// entity, if we have no translations for the current entity and we are not
// creating one, then there is nothing to synchronize.
if (empty($sync_langcode) || $entity->isNew() || count($translations) < 2) {
return;
}
// If the entity language is being changed there is nothing to synchronize.
$entity_unchanged = $this->getOriginalEntity($entity);
if ($entity->getUntranslated()->language()->getId() != $entity_unchanged->getUntranslated()->language()->getId()) {
return;
}
if ($entity->isNewRevision()) {
if ($entity->isDefaultTranslationAffectedOnly()) {
// If changes to untranslatable fields are configured to affect only the
// default translation, we need to skip synchronization in pending
// revisions, otherwise multiple translations would be affected.
if (!$entity->isDefaultRevision()) {
return;
}
// When this mode is enabled, changes to synchronized properties are
// allowed only in the default translation, thus we need to make sure this
// is always used as source for the synchronization process.
else {
$sync_langcode = $entity->getUntranslated()->language()->getId();
}
}
elseif ($entity->isDefaultRevision()) {
// If a new default revision is being saved, but a newer default
// revision was created meanwhile, use any other translation as source
// for synchronization, since that will have been merged from the
// default revision. In this case the actual language does not matter as
// synchronized properties are the same for all the translations in the
// default revision.
/** @var \Drupal\Core\Entity\ContentEntityInterface $default_revision */
$default_revision = $this->entityManager
->getStorage($entity->getEntityTypeId())
->load($entity->id());
if ($default_revision->getLoadedRevisionId() !== $entity->getLoadedRevisionId()) {
$other_langcodes = array_diff_key($default_revision->getTranslationLanguages(), [$sync_langcode => FALSE]);
if ($other_langcodes) {
$sync_langcode = key($other_langcodes);
}
}
}
}
/** @var \Drupal\Core\Field\FieldItemListInterface $items */
foreach ($entity as $field_name => $items) {
$field_definition = $items->getFieldDefinition();
$field_type_definition = $this->fieldTypeManager->getDefinition($field_definition->getType());
$column_groups = $field_type_definition['column_groups'];
// Sync if the field is translatable, not empty, and the synchronization
// setting is enabled.
if (($translation_sync = $this->getFieldSynchronizationSettings($field_definition)) && !$items->isEmpty()) {
// Retrieve all the untranslatable column groups and merge them into
// single list.
$groups = array_keys(array_diff($translation_sync, array_filter($translation_sync)));
// If a group was selected has the require_all_groups_for_translation
// flag set, there are no untranslatable columns. This is done because
// the UI adds Javascript that disables the other checkboxes, so their
// values are not saved.
foreach (array_filter($translation_sync) as $group) {
if (!empty($column_groups[$group]['require_all_groups_for_translation'])) {
$groups = [];
break;
}
}
if (!empty($groups)) {
$columns = [];
foreach ($groups as $group) {
$info = $column_groups[$group];
// A missing 'columns' key indicates we have a single-column group.
$columns = array_merge($columns, isset($info['columns']) ? $info['columns'] : [$group]);
}
if (!empty($columns)) {
$values = [];
foreach ($translations as $langcode => $language) {
$values[$langcode] = $entity->getTranslation($langcode)->get($field_name)->getValue();
}
// If a translation is being created, the original values should be
// used as the unchanged items. In fact there are no unchanged items
// to check against.
$langcode = $original_langcode ?: $sync_langcode;
$unchanged_items = $entity_unchanged->getTranslation($langcode)->get($field_name)->getValue();
$this->synchronizeItems($values, $unchanged_items, $sync_langcode, array_keys($translations), $columns);
foreach ($translations as $langcode => $language) {
$entity->getTranslation($langcode)->get($field_name)->setValue($values[$langcode]);
}
}
}
}
}
}
/**
* Returns the original unchanged entity to be used to detect changes.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity being changed.
*
* @return \Drupal\Core\Entity\ContentEntityInterface
* The unchanged entity.
*/
protected function getOriginalEntity(ContentEntityInterface $entity) {
if (!isset($entity->original)) {
$storage = $this->entityManager->getStorage($entity->getEntityTypeId());
$original = $entity->isDefaultRevision() ? $storage->loadUnchanged($entity->id()) : $storage->loadRevision($entity->getLoadedRevisionId());
}
else {
$original = $entity->original;
}
return $original;
}
/**
* {@inheritdoc}
*/
public function synchronizeItems(array &$values, array $unchanged_items, $sync_langcode, array $translations, array $properties) {
$source_items = $values[$sync_langcode];
// Make sure we can detect any change in the source items.
$change_map = [];
// By picking the maximum size between updated and unchanged items, we make
// sure to process also removed items.
$total = max([count($source_items), count($unchanged_items)]);
// As a first step we build a map of the deltas corresponding to the column
// values to be synchronized. Recording both the old values and the new
// values will allow us to detect any change in the order of the new items
// for each column.
for ($delta = 0; $delta < $total; $delta++) {
foreach (['old' => $unchanged_items, 'new' => $source_items] as $key => $items) {
if ($item_id = $this->itemHash($items, $delta, $properties)) {
$change_map[$item_id][$key][] = $delta;
}
}
}
// Backup field values and the change map.
$original_field_values = $values;
$original_change_map = $change_map;
// Reset field values so that no spurious one is stored. Source values must
// be preserved in any case.
$values = [$sync_langcode => $source_items];
// Update field translations.
foreach ($translations as $langcode) {
// We need to synchronize only values different from the source ones.
if ($langcode != $sync_langcode) {
// Reinitialize the change map as it is emptied while processing each
// language.
$change_map = $original_change_map;
// By using the maximum cardinality we ensure to process removed items.
for ($delta = 0; $delta < $total; $delta++) {
// By inspecting the map we built before we can tell whether a value
// has been created or removed. A changed value will be interpreted as
// a new value, in fact it did not exist before.
$created = TRUE;
$removed = TRUE;
$old_delta = NULL;
$new_delta = NULL;
if ($item_id = $this->itemHash($source_items, $delta, $properties)) {
if (!empty($change_map[$item_id]['old'])) {
$old_delta = array_shift($change_map[$item_id]['old']);
}
if (!empty($change_map[$item_id]['new'])) {
$new_delta = array_shift($change_map[$item_id]['new']);
}
$created = $created && !isset($old_delta);
$removed = $removed && !isset($new_delta);
}
// If an item has been removed we do not store its translations.
if ($removed) {
continue;
}
// If a synchronized column has changed or has been created from
// scratch we need to replace the values for this language as a
// combination of the values that need to be synced from the source
// items and the other columns from the existing values. This only
// works if the delta exists in the language.
elseif ($created && !empty($original_field_values[$langcode][$delta])) {
$values[$langcode][$delta] = $this->createMergedItem($source_items[$delta], $original_field_values[$langcode][$delta], $properties);
}
// If the delta doesn't exist, copy from the source language.
elseif ($created) {
$values[$langcode][$delta] = $source_items[$delta];
}
// Otherwise the current item might have been reordered.
elseif (isset($old_delta) && isset($new_delta)) {
// If for any reason the old value is not defined for the current
// language we fall back to the new source value, this way we ensure
// the new values are at least propagated to all the translations.
// If the value has only been reordered we just move the old one in
// the new position.
$item = isset($original_field_values[$langcode][$old_delta]) ? $original_field_values[$langcode][$old_delta] : $source_items[$new_delta];
// When saving a default revision starting from a pending revision,
// we may have desynchronized field values, so we make sure that
// untranslatable properties are synchronized, even if in any other
// situation this would not be necessary.
$values[$langcode][$new_delta] = $this->createMergedItem($source_items[$new_delta], $item, $properties);
}
}
}
}
}
/**
* Creates a merged item.
*
* @param array $source_item
* An item containing the untranslatable properties to be synchronized.
* @param array $target_item
* An item containing the translatable properties to be kept.
* @param string[] $properties
* An array of properties to be synchronized.
*
* @return array
* A merged item array.
*/
protected function createMergedItem(array $source_item, array $target_item, array $properties) {
$property_keys = array_flip($properties);
$item_properties_to_sync = array_intersect_key($source_item, $property_keys);
$item_properties_to_keep = array_diff_key($target_item, $property_keys);
return $item_properties_to_sync + $item_properties_to_keep;
}
/**
* Computes a hash code for the specified item.
*
* @param array $items
* An array of field items.
* @param int $delta
* The delta identifying the item to be processed.
* @param array $properties
* An array of column names to be synchronized.
*
* @returns string
* A hash code that can be used to identify the item.
*/
protected function itemHash(array $items, $delta, array $properties) {
$values = [];
if (isset($items[$delta])) {
foreach ($properties as $property) {
if (!empty($items[$delta][$property])) {
$value = $items[$delta][$property];
// String and integer values are by far the most common item values,
// thus we special-case them to improve performance.
$values[] = is_string($value) || is_int($value) ? $value : hash('sha256', serialize($value));
}
else {
// Explicitly track also empty values.
$values[] = '';
}
}
}
return implode('.', $values);
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace Drupal\content_translation;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
/**
* Provides field translation synchronization capabilities.
*/
interface FieldTranslationSynchronizerInterface {
/**
* Performs field column synchronization on the given entity.
*
* Field column synchronization takes care of propagating any change in the
* field items order and in the column values themselves to all the available
* translations. This functionality is provided by defining a
* 'translation_sync' key for the 'content_translation' module's portion of
* the field definition's 'third_party_settings', holding an array of
* column names to be synchronized. The synchronized column values are shared
* across translations, while the rest varies per-language. This is useful for
* instance to translate the "alt" and "title" textual elements of an image
* field, while keeping the same image on every translation.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity whose values should be synchronized.
* @param string $sync_langcode
* The language of the translation whose values should be used as source for
* synchronization.
* @param string $original_langcode
* (optional) If a new translation is being created, this should be the
* language code of the original values. Defaults to NULL.
*/
public function synchronizeFields(ContentEntityInterface $entity, $sync_langcode, $original_langcode = NULL);
/**
* Synchronize the items of a single field.
*
* All the column values of the "active" language are compared to the
* unchanged values to detect any addition, removal or change in the items
* order. Subsequently the detected changes are performed on the field items
* in other available languages.
*
* @param array $field_values
* The field values to be synchronized.
* @param array $unchanged_items
* The unchanged items to be used to detect changes.
* @param string $sync_langcode
* The language code of the items to use as source values.
* @param array $translations
* An array of all the available language codes for the given field.
* @param array $properties
* An array of property names to be synchronized.
*/
public function synchronizeItems(array &$field_values, array $unchanged_items, $sync_langcode, array $translations, array $properties);
/**
* Returns the synchronized properties for the specified field definition.
*
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
* A field definition.
*
* @return string[]
* An array of synchronized field property names.
*/
public function getFieldSynchronizedProperties(FieldDefinitionInterface $field_definition);
}

View file

@ -0,0 +1,33 @@
<?php
namespace Drupal\content_translation\Form;
use Drupal\Core\Entity\ContentEntityDeleteForm;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageInterface;
/**
* Delete translation form for content_translation module.
*
* @internal
*/
class ContentTranslationDeleteForm extends ContentEntityDeleteForm {
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'content_translation_delete_confirm';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, LanguageInterface $language = NULL) {
if ($language) {
$form_state->set('langcode', $language->getId());
}
return parent::buildForm($form, $form_state);
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace Drupal\content_translation\Plugin\Derivative;
use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Drupal\content_translation\ContentTranslationManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides dynamic contextual links for content translation.
*
* @see \Drupal\content_translation\Plugin\Menu\ContextualLink\ContentTranslationContextualLinks
*/
class ContentTranslationContextualLinks extends DeriverBase implements ContainerDeriverInterface {
/**
* The content translation manager.
*
* @var \Drupal\content_translation\ContentTranslationManagerInterface
*/
protected $contentTranslationManager;
/**
* Constructs a new ContentTranslationContextualLinks.
*
* @param \Drupal\content_translation\ContentTranslationManagerInterface $content_translation_manager
* The content translation manager.
*/
public function __construct(ContentTranslationManagerInterface $content_translation_manager) {
$this->contentTranslationManager = $content_translation_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, $base_plugin_id) {
return new static(
$container->get('content_translation.manager')
);
}
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition) {
// Create contextual links for translatable entity types.
foreach ($this->contentTranslationManager->getSupportedEntityTypes() as $entity_type_id => $entity_type) {
$this->derivatives[$entity_type_id]['title'] = t('Translate');
$this->derivatives[$entity_type_id]['route_name'] = "entity.$entity_type_id.content_translation_overview";
$this->derivatives[$entity_type_id]['group'] = $entity_type_id;
}
return parent::getDerivativeDefinitions($base_plugin_definition);
}
}

View file

@ -0,0 +1,79 @@
<?php
namespace Drupal\content_translation\Plugin\Derivative;
use Drupal\content_translation\ContentTranslationManagerInterface;
use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
/**
* Provides dynamic local tasks for content translation.
*/
class ContentTranslationLocalTasks extends DeriverBase implements ContainerDeriverInterface {
use StringTranslationTrait;
/**
* The base plugin ID
*
* @var string
*/
protected $basePluginId;
/**
* The content translation manager.
*
* @var \Drupal\content_translation\ContentTranslationManagerInterface
*/
protected $contentTranslationManager;
/**
* Constructs a new ContentTranslationLocalTasks.
*
* @param string $base_plugin_id
* The base plugin ID.
* @param \Drupal\content_translation\ContentTranslationManagerInterface $content_translation_manager
* The content translation manager.
* @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
* The translation manager.
*/
public function __construct($base_plugin_id, ContentTranslationManagerInterface $content_translation_manager, TranslationInterface $string_translation) {
$this->basePluginId = $base_plugin_id;
$this->contentTranslationManager = $content_translation_manager;
$this->stringTranslation = $string_translation;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, $base_plugin_id) {
return new static(
$base_plugin_id,
$container->get('content_translation.manager'),
$container->get('string_translation')
);
}
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition) {
// Create tabs for all possible entity types.
foreach ($this->contentTranslationManager->getSupportedEntityTypes() as $entity_type_id => $entity_type) {
// Find the route name for the translation overview.
$translation_route_name = "entity.$entity_type_id.content_translation_overview";
$base_route_name = "entity.$entity_type_id.canonical";
$this->derivatives[$translation_route_name] = [
'entity_type' => $entity_type_id,
'title' => $this->t('Translate'),
'route_name' => $translation_route_name,
'base_route' => $base_route_name,
] + $base_plugin_definition;
}
return parent::getDerivativeDefinitions($base_plugin_definition);
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace Drupal\content_translation\Plugin\Validation\Constraint;
use Symfony\Component\Validator\Constraint;
/**
* Validation constraint for the entity changed timestamp.
*
* @internal
*
* @Constraint(
* id = "ContentTranslationSynchronizedFields",
* label = @Translation("Content translation synchronized fields", context = "Validation"),
* type = {"entity"}
* )
*/
class ContentTranslationSynchronizedFieldsConstraint extends Constraint {
// In this case "elements" refers to "field properties", in fact it is what we
// are using in the UI elsewhere.
public $defaultRevisionMessage = 'Non-translatable field elements can only be changed when updating the current revision.';
public $defaultTranslationMessage = 'Non-translatable field elements can only be changed when updating the original language.';
}

View file

@ -0,0 +1,226 @@
<?php
namespace Drupal\content_translation\Plugin\Validation\Constraint;
use Drupal\content_translation\ContentTranslationManagerInterface;
use Drupal\content_translation\FieldTranslationSynchronizerInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Checks that synchronized fields are handled correctly in pending revisions.
*
* As for untranslatable fields, two modes are supported:
* - When changes to untranslatable fields are configured to affect all revision
* translations, synchronized field properties can be changed only in default
* revisions.
* - When changes to untranslatable fields affect are configured to affect only
* the revision's default translation, synchronized field properties can be
* changed only when editing the default translation. This may lead to
* temporarily desynchronized values, when saving a pending revision for the
* default translation that changes a synchronized property. These are
* actually synchronized when saving changes to the default translation as a
* new default revision.
*
* @see \Drupal\content_translation\Plugin\Validation\Constraint\ContentTranslationSynchronizedFieldsConstraint
* @see \Drupal\Core\Entity\Plugin\Validation\Constraint\EntityUntranslatableFieldsConstraintValidator
*
* @internal
*/
class ContentTranslationSynchronizedFieldsConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The content translation manager.
*
* @var \Drupal\content_translation\ContentTranslationManagerInterface
*/
protected $contentTranslationManager;
/**
* The field translation synchronizer.
*
* @var \Drupal\content_translation\FieldTranslationSynchronizerInterface
*/
protected $synchronizer;
/**
* ContentTranslationSynchronizedFieldsConstraintValidator constructor.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\content_translation\ContentTranslationManagerInterface $content_translation_manager
* The content translation manager.
* @param \Drupal\content_translation\FieldTranslationSynchronizerInterface $synchronizer
* The field translation synchronizer.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, ContentTranslationManagerInterface $content_translation_manager, FieldTranslationSynchronizerInterface $synchronizer) {
$this->entityTypeManager = $entity_type_manager;
$this->contentTranslationManager = $content_translation_manager;
$this->synchronizer = $synchronizer;
}
/**
* [@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('content_translation.manager'),
$container->get('content_translation.synchronizer')
);
}
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
/** @var \Drupal\content_translation\Plugin\Validation\Constraint\ContentTranslationSynchronizedFieldsConstraint $constraint */
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $value;
if ($entity->isNew() || !$entity->getEntityType()->isRevisionable()) {
return;
}
// When changes to untranslatable fields are configured to affect all
// revision translations, we always allow changes in default revisions.
if ($entity->isDefaultRevision() && !$entity->isDefaultTranslationAffectedOnly()) {
return;
}
$entity_type_id = $entity->getEntityTypeId();
if (!$this->contentTranslationManager->isEnabled($entity_type_id, $entity->bundle())) {
return;
}
$synchronized_properties = $this->getSynchronizedPropertiesByField($entity->getFieldDefinitions());
if (!$synchronized_properties) {
return;
}
/** @var \Drupal\Core\Entity\ContentEntityInterface $original */
$original = $this->getOriginalEntity($entity);
$original_translation = $this->getOriginalTranslation($entity, $original);
if ($this->hasSynchronizedPropertyChanges($entity, $original_translation, $synchronized_properties)) {
if ($entity->isDefaultTranslationAffectedOnly()) {
foreach ($entity->getTranslationLanguages(FALSE) as $langcode => $language) {
if ($entity->getTranslation($langcode)->hasTranslationChanges()) {
$this->context->addViolation($constraint->defaultTranslationMessage);
break;
}
}
}
else {
$this->context->addViolation($constraint->defaultRevisionMessage);
}
}
}
/**
* Checks whether any synchronized property has changes.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity being validated.
* @param \Drupal\Core\Entity\ContentEntityInterface $original
* The original unchanged entity.
* @param string[][] $synchronized_properties
* An associative array of arrays of synchronized field properties keyed by
* field name.
*
* @return bool
* TRUE if changes in synchronized properties were detected, FALSE
* otherwise.
*/
protected function hasSynchronizedPropertyChanges(ContentEntityInterface $entity, ContentEntityInterface $original, array $synchronized_properties) {
foreach ($synchronized_properties as $field_name => $properties) {
foreach ($properties as $property) {
$items = $entity->get($field_name)->getValue();
$original_items = $original->get($field_name)->getValue();
if (count($items) !== count($original_items)) {
return TRUE;
}
foreach ($items as $delta => $item) {
// @todo This loose comparison is not fully reliable. Revisit this
// after https://www.drupal.org/project/drupal/issues/2941092.
if ($items[$delta][$property] != $original_items[$delta][$property]) {
return TRUE;
}
}
}
}
return FALSE;
}
/**
* Returns the original unchanged entity to be used to detect changes.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity being changed.
*
* @return \Drupal\Core\Entity\ContentEntityInterface
* The unchanged entity.
*/
protected function getOriginalEntity(ContentEntityInterface $entity) {
if (!isset($entity->original)) {
$storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
$original = $entity->isDefaultRevision() ? $storage->loadUnchanged($entity->id()) : $storage->loadRevision($entity->getLoadedRevisionId());
}
else {
$original = $entity->original;
}
return $original;
}
/**
* Returns the original translation.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity being validated.
* @param \Drupal\Core\Entity\ContentEntityInterface $original
* The original entity.
*
* @return \Drupal\Core\Entity\ContentEntityInterface
* The original entity translation object.
*/
protected function getOriginalTranslation(ContentEntityInterface $entity, ContentEntityInterface $original) {
$langcode = $entity->language()->getId();
if ($original->hasTranslation($langcode)) {
$original_langcode = $langcode;
}
else {
$metadata = $this->contentTranslationManager->getTranslationMetadata($entity);
$original_langcode = $metadata->getSource();
}
return $original->getTranslation($original_langcode);
}
/**
* Returns the synchronized properties for every specified field.
*
* @param \Drupal\Core\Field\FieldDefinitionInterface[] $field_definitions
* An array of field definitions.
*
* @return string[][]
* An associative array of arrays of field property names keyed by field
* name.
*/
public function getSynchronizedPropertiesByField(array $field_definitions) {
$synchronizer = $this->synchronizer;
$synchronized_properties = array_filter(array_map(
function (FieldDefinitionInterface $field_definition) use ($synchronizer) {
return $synchronizer->getFieldSynchronizedProperties($field_definition);
},
$field_definitions
));
return $synchronized_properties;
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace Drupal\content_translation\Plugin\migrate\source;
use Drupal\migrate\Plugin\MigrateIdMapInterface;
use Drupal\migrate\MigrateException;
use Drupal\migrate\Row;
/**
* Gets an i18n translation from the source database.
*/
trait I18nQueryTrait {
/**
* The i18n string table name.
*
* @var string
*/
protected $i18nStringTable;
/**
* Gets the translation for the property not already in the row.
*
* For some i18n migrations there are two translation values, such as a
* translated title and a translated description, that need to be retrieved.
* Since these values are stored in separate rows of the i18nStringTable
* table we get them individually, one in the source plugin query() and the
* other in prepareRow(). The names of the properties varies, for example,
* in BoxTranslation they are 'body' and 'title' whereas in
* MenuLinkTranslation they are 'title' and 'description'. This will save both
* translations to the row.
*
* @param \Drupal\migrate\Row $row
* The current migration row which must include both a 'language' property
* and an 'objectid' property. The 'objectid' is the value for the
* 'objectid' field in the i18n_string table.
* @param string $property_not_in_row
* The name of the property to get the translation for.
* @param string $object_id_name
* The value of the objectid in the i18n table.
* @param \Drupal\migrate\Plugin\MigrateIdMapInterface $id_map
* The ID map.
*
* @return bool
* FALSE if the property has already been migrated.
*
* @throws \Drupal\migrate\MigrateException
*/
protected function getPropertyNotInRowTranslation(Row $row, $property_not_in_row, $object_id_name, MigrateIdMapInterface $id_map) {
$language = $row->getSourceProperty('language');
if (!$language) {
throw new MigrateException('No language found.');
}
$object_id = $row->getSourceProperty($object_id_name);
if (!$object_id) {
throw new MigrateException('No objectid found.');
}
// If this row has been migrated it is a duplicate so skip it.
if ($id_map->lookupDestinationIds([$object_id_name => $object_id, 'language' => $language])) {
return FALSE;
}
// Save the translation for the property already in the row.
$property_in_row = $row->getSourceProperty('property');
$row->setSourceProperty($property_in_row . '_translated', $row->getSourceProperty('translation'));
// Get the translation, if one exists, for the property not already in the
// row.
$query = $this->select($this->i18nStringTable, 'i18n')
->fields('i18n', ['lid'])
->condition('i18n.property', $property_not_in_row)
->condition('i18n.objectid', $object_id);
$query->leftJoin('locales_target', 'lt', 'i18n.lid = lt.lid');
$query->condition('lt.language', $language);
$query->addField('lt', 'translation');
$results = $query->execute()->fetchAssoc();
if (!$results) {
$row->setSourceProperty($property_not_in_row . '_translated', NULL);
}
else {
$row->setSourceProperty($property_not_in_row . '_translated', $results['translation']);
}
return TRUE;
}
}

View file

@ -0,0 +1,198 @@
<?php
namespace Drupal\content_translation\Plugin\migrate\source\d7;
use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
/**
* Drupal 7 Entity Translation settings from variables.
*
* @MigrateSource(
* id = "d7_entity_translation_settings",
* source_module = "entity_translation"
* )
*/
class EntityTranslationSettings extends DrupalSqlBase {
/**
* {@inheritdoc}
*/
public function query() {
// Query all meaningful variables for entity translation.
$query = $this->select('variable', 'v')
->fields('v', ['name', 'value']);
$condition = $query->orConditionGroup()
// The 'entity_translation_entity_types' variable tells us which entity
// type uses entity translation.
->condition('name', 'entity_translation_entity_types')
// The 'entity_translation_taxonomy' variable tells us which taxonomy
// vocabulary uses entity_translation.
->condition('name', 'entity_translation_taxonomy')
// The 'entity_translation_settings_%' variables give us the entity
// translation settings for each entity type and each bundle.
->condition('name', 'entity_translation_settings_%', 'LIKE')
// The 'language_content_type_%' variables tells us which node type and
// which comment type uses entity translation.
->condition('name', 'language_content_type_%', 'LIKE');
$query->condition($condition);
return $query;
}
/**
* {@inheritdoc}
*/
protected function initializeIterator() {
$results = array_map('unserialize', $this->prepareQuery()->execute()->fetchAllKeyed());
$rows = [];
// Find out which entity type uses entity translation by looking at the
// 'entity_translation_entity_types' variable.
$entity_types = array_filter($results['entity_translation_entity_types']);
// If no entity type uses entity translation, there's nothing to do.
if (empty($entity_types)) {
return new \ArrayIterator($rows);
}
// Find out which node type uses entity translation by looking at the
// 'language_content_type_%' variables.
$node_types = [];
foreach ($results as $name => $value) {
if (preg_match('/^language_content_type_(.+)$/', $name, $matches) && (int) $value === 4) {
$node_types[] = $matches[1];
}
}
// Find out which vocabulary uses entity translation by looking at the
// 'entity_translation_taxonomy' variable.
$vocabularies = [];
if (isset($results['entity_translation_taxonomy']) && is_array($results['entity_translation_taxonomy'])) {
$vocabularies = array_keys(array_filter($results['entity_translation_taxonomy']));
}
if (in_array('node', $entity_types, TRUE) && !empty($node_types)) {
// For each node type that uses entity translation, check if a
// settings variable exists for that node type, otherwise use default
// values.
foreach ($node_types as $node_type) {
$settings = isset($results['entity_translation_settings_node__' . $node_type]) ? $results['entity_translation_settings_node__' . $node_type] : [];
$rows[] = [
'id' => 'node.' . $node_type,
'target_entity_type_id' => 'node',
'target_bundle' => $node_type,
'default_langcode' => isset($settings['default_language']) ? $settings['default_language'] : 'und',
// The Drupal 7 'hide_language_selector' configuration has become
// 'language_alterable' in Drupal 8 so we need to negate the value we
// receive from the source. The Drupal 7 'hide_language_selector'
// default value for the node entity type was FALSE so in Drupal 8 it
// should be set to TRUE, unlike the other entity types for which
// it's the opposite.
'language_alterable' => isset($settings['hide_language_selector']) ? (bool) !$settings['hide_language_selector'] : TRUE,
'untranslatable_fields_hide' => isset($settings['shared_fields_original_only']) ? (bool) $settings['shared_fields_original_only'] : FALSE,
];
}
}
if (in_array('comment', $entity_types, TRUE) && !empty($node_types)) {
// A comment type uses entity translation if the associated node type
// uses it. So, for each node type that uses entity translation, check
// if a settings variable exists for that comment type, otherwise use
// default values.
foreach ($node_types as $node_type) {
$settings = isset($results['entity_translation_settings_comment__comment_node_' . $node_type]) ? $results['entity_translation_settings_comment__comment_node_' . $node_type] : [];
// Forum uses a hardcoded comment type name, so make sure we use it
// when we're dealing with forum comment type.
$bundle = $node_type == 'forum' ? 'comment_forum' : 'comment_node_' . $node_type;
$rows[] = [
'id' => 'comment.' . $bundle,
'target_entity_type_id' => 'comment',
'target_bundle' => $bundle,
'default_langcode' => isset($settings['default_language']) ? $settings['default_language'] : 'xx-et-current',
// The Drupal 7 'hide_language_selector' configuration has become
// 'language_alterable' in Drupal 8 so we need to negate the value we
// receive from the source. The Drupal 7 'hide_language_selector'
// default value for the comment entity type was TRUE so in Drupal 8
// it should be set to FALSE.
'language_alterable' => isset($settings['hide_language_selector']) ? (bool) !$settings['hide_language_selector'] : FALSE,
'untranslatable_fields_hide' => isset($settings['shared_fields_original_only']) ? (bool) $settings['shared_fields_original_only'] : FALSE,
];
}
}
if (in_array('taxonomy_term', $entity_types, TRUE) && !empty($vocabularies)) {
// For each vocabulary that uses entity translation, check if a
// settings variable exists for that vocabulary, otherwise use default
// values.
foreach ($vocabularies as $vocabulary) {
$settings = isset($results['entity_translation_settings_taxonomy_term__' . $vocabulary]) ? $results['entity_translation_settings_taxonomy_term__' . $vocabulary] : [];
$rows[] = [
'id' => 'taxonomy_term.' . $vocabulary,
'target_entity_type_id' => 'taxonomy_term',
'target_bundle' => $vocabulary,
'default_langcode' => isset($settings['default_language']) ? $settings['default_language'] : 'xx-et-default',
// The Drupal 7 'hide_language_selector' configuration has become
// 'language_alterable' in Drupal 8 so we need to negate the value we
// receive from the source. The Drupal 7 'hide_language_selector'
// default value for the taxonomy_term entity type was TRUE so in
// Drupal 8 it should be set to FALSE.
'language_alterable' => isset($settings['hide_language_selector']) ? (bool) !$settings['hide_language_selector'] : FALSE,
'untranslatable_fields_hide' => isset($settings['shared_fields_original_only']) ? (bool) $settings['shared_fields_original_only'] : FALSE,
];
}
}
if (in_array('user', $entity_types, TRUE)) {
// User entity type is not bundleable. Check if a settings variable
// exists, otherwise use default values.
$settings = isset($results['entity_translation_settings_user__user']) ? $results['entity_translation_settings_user__user'] : [];
$rows[] = [
'id' => 'user.user',
'target_entity_type_id' => 'user',
'target_bundle' => 'user',
'default_langcode' => isset($settings['default_language']) ? $settings['default_language'] : 'xx-et-default',
// The Drupal 7 'hide_language_selector' configuration has become
// 'language_alterable' in Drupal 8 so we need to negate the value we
// receive from the source. The Drupal 7 'hide_language_selector'
// default value for the user entity type was TRUE so in Drupal 8 it
// should be set to FALSE.
'language_alterable' => isset($settings['hide_language_selector']) ? (bool) !$settings['hide_language_selector'] : FALSE,
'untranslatable_fields_hide' => isset($settings['shared_fields_original_only']) ? (bool) $settings['shared_fields_original_only'] : FALSE,
];
}
return new \ArrayIterator($rows);
}
/**
* {@inheritdoc}
*/
public function fields() {
return [
'id' => $this->t('The configuration ID'),
'target_entity_type_id' => $this->t('The target entity type ID'),
'target_bundle' => $this->t('The target bundle'),
'default_langcode' => $this->t('The default language'),
'language_alterable' => $this->t('Whether to show language selector on create and edit pages'),
'untranslatable_fields_hide' => $this->t('Whether to hide non translatable fields on translation forms'),
];
}
/**
* {@inheritdoc}
*/
public function getIds() {
$ids['id']['type'] = 'string';
return $ids;
}
/**
* {@inheritdoc}
*/
public function count($refresh = FALSE) {
// Since the number of variables we fetch with query() does not match the
// actual number of rows generated by initializeIterator(), we need to
// override count() to return the correct count.
return (int) $this->initializeIterator()->count();
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace Drupal\content_translation\Plugin\views\field;
use Drupal\views\Plugin\views\field\EntityLink;
/**
* Provides a translation link for an entity.
*
* @ingroup views_field_handlers
*
* @ViewsField("content_translation_link")
*/
class TranslationLink extends EntityLink {
/**
* {@inheritdoc}
*/
protected function getEntityLinkTemplate() {
return 'drupal:content-translation-overview';
}
/**
* {@inheritdoc}
*/
protected function getDefaultLabel() {
return $this->t('Translate');
}
}

View file

@ -0,0 +1,184 @@
<?php
namespace Drupal\content_translation\Routing;
use Drupal\content_translation\ContentTranslationManager;
use Drupal\content_translation\ContentTranslationManagerInterface;
use Drupal\Core\Routing\RouteSubscriberBase;
use Drupal\Core\Routing\RoutingEvents;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Subscriber for entity translation routes.
*/
class ContentTranslationRouteSubscriber extends RouteSubscriberBase {
/**
* The content translation manager.
*
* @var \Drupal\content_translation\ContentTranslationManagerInterface
*/
protected $contentTranslationManager;
/**
* Constructs a ContentTranslationRouteSubscriber object.
*
* @param \Drupal\content_translation\ContentTranslationManagerInterface $content_translation_manager
* The content translation manager.
*/
public function __construct(ContentTranslationManagerInterface $content_translation_manager) {
$this->contentTranslationManager = $content_translation_manager;
}
/**
* {@inheritdoc}
*/
protected function alterRoutes(RouteCollection $collection) {
foreach ($this->contentTranslationManager->getSupportedEntityTypes() as $entity_type_id => $entity_type) {
// Inherit admin route status from edit route, if exists.
$is_admin = FALSE;
$route_name = "entity.$entity_type_id.edit_form";
if ($edit_route = $collection->get($route_name)) {
$is_admin = (bool) $edit_route->getOption('_admin_route');
}
$load_latest_revision = ContentTranslationManager::isPendingRevisionSupportEnabled($entity_type_id);
if ($entity_type->hasLinkTemplate('drupal:content-translation-overview')) {
$route = new Route(
$entity_type->getLinkTemplate('drupal:content-translation-overview'),
[
'_controller' => '\Drupal\content_translation\Controller\ContentTranslationController::overview',
'entity_type_id' => $entity_type_id,
],
[
'_entity_access' => $entity_type_id . '.view',
'_access_content_translation_overview' => $entity_type_id,
],
[
'parameters' => [
$entity_type_id => [
'type' => 'entity:' . $entity_type_id,
'load_latest_revision' => $load_latest_revision,
],
],
'_admin_route' => $is_admin,
]
);
$route_name = "entity.$entity_type_id.content_translation_overview";
$collection->add($route_name, $route);
}
if ($entity_type->hasLinkTemplate('drupal:content-translation-add')) {
$route = new Route(
$entity_type->getLinkTemplate('drupal:content-translation-add'),
[
'_controller' => '\Drupal\content_translation\Controller\ContentTranslationController::add',
'source' => NULL,
'target' => NULL,
'_title' => 'Add',
'entity_type_id' => $entity_type_id,
],
[
'_entity_access' => $entity_type_id . '.view',
'_access_content_translation_manage' => 'create',
],
[
'parameters' => [
'source' => [
'type' => 'language',
],
'target' => [
'type' => 'language',
],
$entity_type_id => [
'type' => 'entity:' . $entity_type_id,
'load_latest_revision' => $load_latest_revision,
],
],
'_admin_route' => $is_admin,
]
);
$collection->add("entity.$entity_type_id.content_translation_add", $route);
}
if ($entity_type->hasLinkTemplate('drupal:content-translation-edit')) {
$route = new Route(
$entity_type->getLinkTemplate('drupal:content-translation-edit'),
[
'_controller' => '\Drupal\content_translation\Controller\ContentTranslationController::edit',
'language' => NULL,
'_title' => 'Edit',
'entity_type_id' => $entity_type_id,
],
[
'_access_content_translation_manage' => 'update',
],
[
'parameters' => [
'language' => [
'type' => 'language',
],
$entity_type_id => [
'type' => 'entity:' . $entity_type_id,
'load_latest_revision' => $load_latest_revision,
],
],
'_admin_route' => $is_admin,
]
);
$collection->add("entity.$entity_type_id.content_translation_edit", $route);
}
if ($entity_type->hasLinkTemplate('drupal:content-translation-delete')) {
$route = new Route(
$entity_type->getLinkTemplate('drupal:content-translation-delete'),
[
'_entity_form' => $entity_type_id . '.content_translation_deletion',
'language' => NULL,
'_title' => 'Delete',
'entity_type_id' => $entity_type_id,
],
[
'_access_content_translation_manage' => 'delete',
],
[
'parameters' => [
'language' => [
'type' => 'language',
],
$entity_type_id => [
'type' => 'entity:' . $entity_type_id,
'load_latest_revision' => $load_latest_revision,
],
],
'_admin_route' => $is_admin,
]
);
$collection->add("entity.$entity_type_id.content_translation_delete", $route);
}
// Add our custom translation deletion access checker.
if ($load_latest_revision) {
$entity_delete_route = $collection->get("entity.$entity_type_id.delete_form");
if ($entity_delete_route) {
$entity_delete_route->addRequirements(['_access_content_translation_delete' => "$entity_type_id.delete"]);
}
}
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
$events = parent::getSubscribedEvents();
// Should run after AdminRouteSubscriber so the routes can inherit admin
// status of the edit routes on entities. Therefore priority -210.
$events[RoutingEvents::ALTER] = ['onAlterRoutes', -210];
return $events;
}
}

View file

@ -0,0 +1,242 @@
<?php
namespace Drupal\content_translation\Tests;
use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
use Drupal\field\Entity\FieldConfig;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\simpletest\WebTestBase;
use Drupal\field\Entity\FieldStorageConfig;
/**
* Base class for content translation tests.
*
* @deprecated Scheduled for removal in Drupal 9.0.0.
* Use \Drupal\Tests\content_translation\Functional\ContentTranslationTestBase instead.
*/
abstract class ContentTranslationTestBase extends WebTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['text'];
/**
* The entity type being tested.
*
* @var string
*/
protected $entityTypeId = 'entity_test_mul';
/**
* The bundle being tested.
*
* @var string
*/
protected $bundle;
/**
* The added languages.
*
* @var array
*/
protected $langcodes;
/**
* The account to be used to test translation operations.
*
* @var \Drupal\user\UserInterface
*/
protected $translator;
/**
* The account to be used to test multilingual entity editing.
*
* @var \Drupal\user\UserInterface
*/
protected $editor;
/**
* The account to be used to test access to both workflows.
*
* @var \Drupal\user\UserInterface
*/
protected $administrator;
/**
* The name of the field used to test translation.
*
* @var string
*/
protected $fieldName;
/**
* The translation controller for the current entity type.
*
* @var \Drupal\content_translation\ContentTranslationHandlerInterface
*/
protected $controller;
/**
* @var \Drupal\content_translation\ContentTranslationManagerInterface
*/
protected $manager;
protected function setUp() {
parent::setUp();
$this->setupLanguages();
$this->setupBundle();
$this->enableTranslation();
$this->setupUsers();
$this->setupTestFields();
$this->manager = $this->container->get('content_translation.manager');
$this->controller = $this->manager->getTranslationHandler($this->entityTypeId);
// Rebuild the container so that the new languages are picked up by services
// that hold a list of languages.
$this->rebuildContainer();
}
/**
* Adds additional languages.
*/
protected function setupLanguages() {
$this->langcodes = ['it', 'fr'];
foreach ($this->langcodes as $langcode) {
ConfigurableLanguage::createFromLangcode($langcode)->save();
}
array_unshift($this->langcodes, \Drupal::languageManager()->getDefaultLanguage()->getId());
}
/**
* Returns an array of permissions needed for the translator.
*/
protected function getTranslatorPermissions() {
return array_filter([$this->getTranslatePermission(), 'create content translations', 'update content translations', 'delete content translations']);
}
/**
* Returns the translate permissions for the current entity and bundle.
*/
protected function getTranslatePermission() {
$entity_type = \Drupal::entityManager()->getDefinition($this->entityTypeId);
if ($permission_granularity = $entity_type->getPermissionGranularity()) {
return $permission_granularity == 'bundle' ? "translate {$this->bundle} {$this->entityTypeId}" : "translate {$this->entityTypeId}";
}
}
/**
* Returns an array of permissions needed for the editor.
*/
protected function getEditorPermissions() {
// Every entity-type-specific test needs to define these.
return [];
}
/**
* Returns an array of permissions needed for the administrator.
*/
protected function getAdministratorPermissions() {
return array_merge($this->getEditorPermissions(), $this->getTranslatorPermissions(), ['administer content translation']);
}
/**
* Creates and activates translator, editor and admin users.
*/
protected function setupUsers() {
$this->translator = $this->drupalCreateUser($this->getTranslatorPermissions(), 'translator');
$this->editor = $this->drupalCreateUser($this->getEditorPermissions(), 'editor');
$this->administrator = $this->drupalCreateUser($this->getAdministratorPermissions(), 'administrator');
$this->drupalLogin($this->translator);
}
/**
* Creates or initializes the bundle date if needed.
*/
protected function setupBundle() {
if (empty($this->bundle)) {
$this->bundle = $this->entityTypeId;
}
}
/**
* Enables translation for the current entity type and bundle.
*/
protected function enableTranslation() {
// Enable translation for the current entity type and ensure the change is
// picked up.
\Drupal::service('content_translation.manager')->setEnabled($this->entityTypeId, $this->bundle, TRUE);
drupal_static_reset();
\Drupal::entityManager()->clearCachedDefinitions();
\Drupal::service('router.builder')->rebuild();
\Drupal::service('entity.definition_update_manager')->applyUpdates();
}
/**
* Creates the test fields.
*/
protected function setupTestFields() {
if (empty($this->fieldName)) {
$this->fieldName = 'field_test_et_ui_test';
}
FieldStorageConfig::create([
'field_name' => $this->fieldName,
'type' => 'string',
'entity_type' => $this->entityTypeId,
'cardinality' => 1,
])->save();
FieldConfig::create([
'entity_type' => $this->entityTypeId,
'field_name' => $this->fieldName,
'bundle' => $this->bundle,
'label' => 'Test translatable text-field',
])->save();
entity_get_form_display($this->entityTypeId, $this->bundle, 'default')
->setComponent($this->fieldName, [
'type' => 'string_textfield',
'weight' => 0,
])
->save();
}
/**
* Creates the entity to be translated.
*
* @param array $values
* An array of initial values for the entity.
* @param string $langcode
* The initial language code of the entity.
* @param string $bundle_name
* (optional) The entity bundle, if the entity uses bundles. Defaults to
* NULL. If left NULL, $this->bundle will be used.
*
* @return string
* The entity id.
*/
protected function createEntity($values, $langcode, $bundle_name = NULL) {
$entity_values = $values;
$entity_values['langcode'] = $langcode;
$entity_type = \Drupal::entityManager()->getDefinition($this->entityTypeId);
if ($bundle_key = $entity_type->getKey('bundle')) {
$entity_values[$bundle_key] = $bundle_name ?: $this->bundle;
}
$controller = $this->container->get('entity.manager')->getStorage($this->entityTypeId);
if (!($controller instanceof SqlContentEntityStorage)) {
foreach ($values as $property => $value) {
if (is_array($value)) {
$entity_values[$property] = [$langcode => $value];
}
}
}
$entity = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId)
->create($entity_values);
$entity->save();
return $entity->id();
}
}

View file

@ -0,0 +1,628 @@
<?php
namespace Drupal\content_translation\Tests;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Language\Language;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Url;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\system\Tests\Cache\AssertPageCacheContextsAndTagsTrait;
/**
* Tests the Content Translation UI.
*
* @deprecated Scheduled for removal in Drupal 9.0.0.
* Use \Drupal\Tests\content_translation\Functional\ContentTranslationUITestBase instead.
*/
abstract class ContentTranslationUITestBase extends ContentTranslationTestBase {
use AssertPageCacheContextsAndTagsTrait;
/**
* The id of the entity being translated.
*
* @var mixed
*/
protected $entityId;
/**
* Whether the behavior of the language selector should be tested.
*
* @var bool
*/
protected $testLanguageSelector = TRUE;
/**
* Flag to determine if "all languages" rendering is tested.
*
* @var bool
*/
protected $testHTMLEscapeForAllLanguages = FALSE;
/**
* Default cache contexts expected on a non-translated entity.
*
* Cache contexts will not be checked if this list is empty.
*
* @var string[]
*/
protected $defaultCacheContexts = ['languages:language_interface', 'theme', 'url.query_args:_wrapper_format', 'user.permissions'];
/**
* Tests the basic translation UI.
*/
public function testTranslationUI() {
$this->doTestBasicTranslation();
$this->doTestTranslationOverview();
$this->doTestOutdatedStatus();
$this->doTestPublishedStatus();
$this->doTestAuthoringInfo();
$this->doTestTranslationEdit();
$this->doTestTranslationChanged();
$this->doTestChangedTimeAfterSaveWithoutChanges();
$this->doTestTranslationDeletion();
}
/**
* Tests the basic translation workflow.
*/
protected function doTestBasicTranslation() {
// Create a new test entity with original values in the default language.
$default_langcode = $this->langcodes[0];
$values[$default_langcode] = $this->getNewEntityValues($default_langcode);
// Create the entity with the editor as owner, so that afterwards a new
// translation is created by the translator and the translation author is
// tested.
$this->drupalLogin($this->editor);
$this->entityId = $this->createEntity($values[$default_langcode], $default_langcode);
$this->drupalLogin($this->translator);
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->assertTrue($entity, 'Entity found in the database.');
$this->drupalGet($entity->urlInfo());
$this->assertResponse(200, 'Entity URL is valid.');
// Ensure that the content language cache context is not yet added to the
// page.
$this->assertCacheContexts($this->defaultCacheContexts);
$this->drupalGet($entity->urlInfo('drupal:content-translation-overview'));
$this->assertNoText('Source language', 'Source language column correctly hidden.');
$translation = $this->getTranslation($entity, $default_langcode);
foreach ($values[$default_langcode] as $property => $value) {
$stored_value = $this->getValue($translation, $property, $default_langcode);
$value = is_array($value) ? $value[0]['value'] : $value;
$message = format_string('@property correctly stored in the default language.', ['@property' => $property]);
$this->assertEqual($stored_value, $value, $message);
}
// Add a content translation.
$langcode = 'it';
$language = ConfigurableLanguage::load($langcode);
$values[$langcode] = $this->getNewEntityValues($langcode);
$entity_type_id = $entity->getEntityTypeId();
$add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [
$entity->getEntityTypeId() => $entity->id(),
'source' => $default_langcode,
'target' => $langcode,
], ['language' => $language]);
$this->drupalPostForm($add_url, $this->getEditValues($values, $langcode), $this->getFormSubmitActionForNewTranslation($entity, $langcode));
// Assert that HTML is not escaped unexpectedly.
if ($this->testHTMLEscapeForAllLanguages) {
$this->assertNoRaw('&lt;span class=&quot;translation-entity-all-languages&quot;&gt;(all languages)&lt;/span&gt;');
$this->assertRaw('<span class="translation-entity-all-languages">(all languages)</span>');
}
// Ensure that the content language cache context is not yet added to the
// page.
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->drupalGet($entity->urlInfo());
$this->assertCacheContexts(Cache::mergeContexts(['languages:language_content'], $this->defaultCacheContexts));
// Reset the cache of the entity, so that the new translation gets the
// updated values.
$metadata_source_translation = $this->manager->getTranslationMetadata($entity->getTranslation($default_langcode));
$metadata_target_translation = $this->manager->getTranslationMetadata($entity->getTranslation($langcode));
$author_field_name = $entity->hasField('content_translation_uid') ? 'content_translation_uid' : 'uid';
if ($entity->getFieldDefinition($author_field_name)->isTranslatable()) {
$this->assertEqual($metadata_target_translation->getAuthor()->id(), $this->translator->id(),
new FormattableMarkup('Author of the target translation @langcode correctly stored for translatable owner field.', ['@langcode' => $langcode]));
$this->assertNotEqual($metadata_target_translation->getAuthor()->id(), $metadata_source_translation->getAuthor()->id(),
new FormattableMarkup('Author of the target translation @target different from the author of the source translation @source for translatable owner field.',
['@target' => $langcode, '@source' => $default_langcode]));
}
else {
$this->assertEqual($metadata_target_translation->getAuthor()->id(), $this->editor->id(), 'Author of the entity remained untouched after translation for non translatable owner field.');
}
$created_field_name = $entity->hasField('content_translation_created') ? 'content_translation_created' : 'created';
if ($entity->getFieldDefinition($created_field_name)->isTranslatable()) {
$this->assertTrue($metadata_target_translation->getCreatedTime() > $metadata_source_translation->getCreatedTime(),
new FormattableMarkup('Translation creation timestamp of the target translation @target is newer than the creation timestamp of the source translation @source for translatable created field.',
['@target' => $langcode, '@source' => $default_langcode]));
}
else {
$this->assertEqual($metadata_target_translation->getCreatedTime(), $metadata_source_translation->getCreatedTime(), 'Creation timestamp of the entity remained untouched after translation for non translatable created field.');
}
if ($this->testLanguageSelector) {
$this->assertNoFieldByXPath('//select[@id="edit-langcode-0-value"]', NULL, 'Language selector correctly disabled on translations.');
}
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->drupalGet($entity->urlInfo('drupal:content-translation-overview'));
$this->assertNoText('Source language', 'Source language column correctly hidden.');
// Switch the source language.
$langcode = 'fr';
$language = ConfigurableLanguage::load($langcode);
$source_langcode = 'it';
$edit = ['source_langcode[source]' => $source_langcode];
$entity_type_id = $entity->getEntityTypeId();
$add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [
$entity->getEntityTypeId() => $entity->id(),
'source' => $default_langcode,
'target' => $langcode,
], ['language' => $language]);
// This does not save anything, it merely reloads the form and fills in the
// fields with the values from the different source language.
$this->drupalPostForm($add_url, $edit, t('Change'));
$this->assertFieldByXPath("//input[@name=\"{$this->fieldName}[0][value]\"]", $values[$source_langcode][$this->fieldName][0]['value'], 'Source language correctly switched.');
// Add another translation and mark the other ones as outdated.
$values[$langcode] = $this->getNewEntityValues($langcode);
$edit = $this->getEditValues($values, $langcode) + ['content_translation[retranslate]' => TRUE];
$entity_type_id = $entity->getEntityTypeId();
$add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [
$entity->getEntityTypeId() => $entity->id(),
'source' => $source_langcode,
'target' => $langcode,
], ['language' => $language]);
$this->drupalPostForm($add_url, $edit, $this->getFormSubmitActionForNewTranslation($entity, $langcode));
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->drupalGet($entity->urlInfo('drupal:content-translation-overview'));
$this->assertText('Source language', 'Source language column correctly shown.');
// Check that the entered values have been correctly stored.
foreach ($values as $langcode => $property_values) {
$translation = $this->getTranslation($entity, $langcode);
foreach ($property_values as $property => $value) {
$stored_value = $this->getValue($translation, $property, $langcode);
$value = is_array($value) ? $value[0]['value'] : $value;
$message = format_string('%property correctly stored with language %language.', ['%property' => $property, '%language' => $langcode]);
$this->assertEqual($stored_value, $value, $message);
}
}
}
/**
* Tests that the translation overview shows the correct values.
*/
protected function doTestTranslationOverview() {
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$translate_url = $entity->urlInfo('drupal:content-translation-overview');
$this->drupalGet($translate_url);
$translate_url->setAbsolute(FALSE);
foreach ($this->langcodes as $langcode) {
if ($entity->hasTranslation($langcode)) {
$language = new Language(['id' => $langcode]);
$view_url = $entity->url('canonical', ['language' => $language]);
$elements = $this->xpath('//table//a[@href=:href]', [':href' => $view_url]);
$this->assertEqual((string) $elements[0], $entity->getTranslation($langcode)->label(), format_string('Label correctly shown for %language translation.', ['%language' => $langcode]));
$edit_path = $entity->url('edit-form', ['language' => $language]);
$elements = $this->xpath('//table//ul[@class="dropbutton"]/li/a[@href=:href]', [':href' => $edit_path]);
$this->assertEqual((string) $elements[0], t('Edit'), format_string('Edit link correct for %language translation.', ['%language' => $langcode]));
}
}
}
/**
* Tests up-to-date status tracking.
*/
protected function doTestOutdatedStatus() {
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$langcode = 'fr';
$languages = \Drupal::languageManager()->getLanguages();
// Mark translations as outdated.
$edit = ['content_translation[retranslate]' => TRUE];
$edit_path = $entity->urlInfo('edit-form', ['language' => $languages[$langcode]]);
$this->drupalPostForm($edit_path, $edit, $this->getFormSubmitAction($entity, $langcode));
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
// Check that every translation has the correct "outdated" status, and that
// the Translation fieldset is open if the translation is "outdated".
foreach ($this->langcodes as $added_langcode) {
$url = $entity->urlInfo('edit-form', ['language' => ConfigurableLanguage::load($added_langcode)]);
$this->drupalGet($url);
if ($added_langcode == $langcode) {
$this->assertFieldByXPath('//input[@name="content_translation[retranslate]"]', FALSE, 'The retranslate flag is not checked by default.');
$this->assertFalse($this->xpath('//details[@id="edit-content-translation" and @open="open"]'), 'The translation tab should be collapsed by default.');
}
else {
$this->assertFieldByXPath('//input[@name="content_translation[outdated]"]', TRUE, 'The translate flag is checked by default.');
$this->assertTrue($this->xpath('//details[@id="edit-content-translation" and @open="open"]'), 'The translation tab is correctly expanded when the translation is outdated.');
$edit = ['content_translation[outdated]' => FALSE];
$this->drupalPostForm($url, $edit, $this->getFormSubmitAction($entity, $added_langcode));
$this->drupalGet($url);
$this->assertFieldByXPath('//input[@name="content_translation[retranslate]"]', FALSE, 'The retranslate flag is now shown.');
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->assertFalse($this->manager->getTranslationMetadata($entity->getTranslation($added_langcode))->isOutdated(), 'The "outdated" status has been correctly stored.');
}
}
}
/**
* Tests the translation publishing status.
*/
protected function doTestPublishedStatus() {
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
// Unpublish translations.
foreach ($this->langcodes as $index => $langcode) {
if ($index > 0) {
$url = $entity->urlInfo('edit-form', ['language' => ConfigurableLanguage::load($langcode)]);
$edit = ['content_translation[status]' => FALSE];
$this->drupalPostForm($url, $edit, $this->getFormSubmitAction($entity, $langcode));
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->assertFalse($this->manager->getTranslationMetadata($entity->getTranslation($langcode))->isPublished(), 'The translation has been correctly unpublished.');
}
}
// Check that the last published translation cannot be unpublished.
$this->drupalGet($entity->urlInfo('edit-form'));
$this->assertFieldByXPath('//input[@name="content_translation[status]" and @disabled="disabled"]', TRUE, 'The last translation is published and cannot be unpublished.');
}
/**
* Tests the translation authoring information.
*/
protected function doTestAuthoringInfo() {
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$values = [];
// Post different authoring information for each translation.
foreach ($this->langcodes as $index => $langcode) {
$user = $this->drupalCreateUser();
$values[$langcode] = [
'uid' => $user->id(),
'created' => REQUEST_TIME - mt_rand(0, 1000),
];
$edit = [
'content_translation[uid]' => $user->getUsername(),
'content_translation[created]' => format_date($values[$langcode]['created'], 'custom', 'Y-m-d H:i:s O'),
];
$url = $entity->urlInfo('edit-form', ['language' => ConfigurableLanguage::load($langcode)]);
$this->drupalPostForm($url, $edit, $this->getFormSubmitAction($entity, $langcode));
}
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
foreach ($this->langcodes as $langcode) {
$metadata = $this->manager->getTranslationMetadata($entity->getTranslation($langcode));
$this->assertEqual($metadata->getAuthor()->id(), $values[$langcode]['uid'], 'Translation author correctly stored.');
$this->assertEqual($metadata->getCreatedTime(), $values[$langcode]['created'], 'Translation date correctly stored.');
}
// Try to post non valid values and check that they are rejected.
$langcode = 'en';
$edit = [
// User names have by default length 8.
'content_translation[uid]' => $this->randomMachineName(12),
'content_translation[created]' => '19/11/1978',
];
$this->drupalPostForm($entity->urlInfo('edit-form'), $edit, $this->getFormSubmitAction($entity, $langcode));
$this->assertTrue($this->xpath('//div[contains(@class, "error")]//ul'), 'Invalid values generate a list of form errors.');
$metadata = $this->manager->getTranslationMetadata($entity->getTranslation($langcode));
$this->assertEqual($metadata->getAuthor()->id(), $values[$langcode]['uid'], 'Translation author correctly kept.');
$this->assertEqual($metadata->getCreatedTime(), $values[$langcode]['created'], 'Translation date correctly kept.');
}
/**
* Tests translation deletion.
*/
protected function doTestTranslationDeletion() {
// Confirm and delete a translation.
$this->drupalLogin($this->translator);
$langcode = 'fr';
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$language = ConfigurableLanguage::load($langcode);
$url = $entity->urlInfo('edit-form', ['language' => $language]);
$this->drupalPostForm($url, [], t('Delete translation'));
$this->drupalPostForm(NULL, [], t('Delete @language translation', ['@language' => $language->getName()]));
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId, TRUE);
if ($this->assertTrue(is_object($entity), 'Entity found')) {
$translations = $entity->getTranslationLanguages();
$this->assertTrue(count($translations) == 2 && empty($translations[$langcode]), 'Translation successfully deleted.');
}
// Check that the translator cannot delete the original translation.
$args = [$this->entityTypeId => $entity->id(), 'language' => 'en'];
$this->drupalGet(Url::fromRoute("entity.$this->entityTypeId.content_translation_delete", $args));
$this->assertResponse(403);
}
/**
* Returns an array of entity field values to be tested.
*/
protected function getNewEntityValues($langcode) {
return [$this->fieldName => [['value' => $this->randomMachineName(16)]]];
}
/**
* Returns an edit array containing the values to be posted.
*/
protected function getEditValues($values, $langcode, $new = FALSE) {
$edit = $values[$langcode];
$langcode = $new ? LanguageInterface::LANGCODE_NOT_SPECIFIED : $langcode;
foreach ($values[$langcode] as $property => $value) {
if (is_array($value)) {
$edit["{$property}[0][value]"] = $value[0]['value'];
unset($edit[$property]);
}
}
return $edit;
}
/**
* Returns the form action value when submitting a new translation.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being tested.
* @param string $langcode
* Language code for the form.
*
* @return string
* Name of the button to hit.
*/
protected function getFormSubmitActionForNewTranslation(EntityInterface $entity, $langcode) {
$entity->addTranslation($langcode, $entity->toArray());
return $this->getFormSubmitAction($entity, $langcode);
}
/**
* Returns the form action value to be used to submit the entity form.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being tested.
* @param string $langcode
* Language code for the form.
*
* @return string
* Name of the button to hit.
*/
protected function getFormSubmitAction(EntityInterface $entity, $langcode) {
return t('Save') . $this->getFormSubmitSuffix($entity, $langcode);
}
/**
* Returns appropriate submit button suffix based on translatability.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being tested.
* @param string $langcode
* Language code for the form.
*
* @return string
* Submit button suffix based on translatability.
*/
protected function getFormSubmitSuffix(EntityInterface $entity, $langcode) {
return '';
}
/**
* Returns the translation object to use to retrieve the translated values.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being tested.
* @param string $langcode
* The language code identifying the translation to be retrieved.
*
* @return \Drupal\Core\TypedData\TranslatableInterface
* The translation object to act on.
*/
protected function getTranslation(EntityInterface $entity, $langcode) {
return $entity->getTranslation($langcode);
}
/**
* Returns the value for the specified property in the given language.
*
* @param \Drupal\Core\Entity\EntityInterface $translation
* The translation object the property value should be retrieved from.
* @param string $property
* The property name.
* @param string $langcode
* The property value.
*
* @return
* The property value.
*/
protected function getValue(EntityInterface $translation, $property, $langcode) {
$key = $property == 'user_id' ? 'target_id' : 'value';
return $translation->get($property)->{$key};
}
/**
* Returns the name of the field that implements the changed timestamp.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being tested.
*
* @return string
* The field name.
*/
protected function getChangedFieldName($entity) {
return $entity->hasField('content_translation_changed') ? 'content_translation_changed' : 'changed';
}
/**
* Tests edit content translation.
*/
protected function doTestTranslationEdit() {
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$languages = $this->container->get('language_manager')->getLanguages();
foreach ($this->langcodes as $langcode) {
// We only want to test the title for non-english translations.
if ($langcode != 'en') {
$options = ['language' => $languages[$langcode]];
$url = $entity->urlInfo('edit-form', $options);
$this->drupalGet($url);
$this->assertRaw($entity->getTranslation($langcode)->label());
}
}
}
/**
* Tests the basic translation workflow.
*/
protected function doTestTranslationChanged() {
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$changed_field_name = $this->getChangedFieldName($entity);
$definition = $entity->getFieldDefinition($changed_field_name);
$config = $definition->getConfig($entity->bundle());
foreach ([FALSE, TRUE] as $translatable_changed_field) {
if ($definition->isTranslatable()) {
// For entities defining a translatable changed field we want to test
// the correct behavior of that field even if the translatability is
// revoked. In that case the changed timestamp should be synchronized
// across all translations.
$config->setTranslatable($translatable_changed_field);
$config->save();
}
elseif ($translatable_changed_field) {
// For entities defining a non-translatable changed field we cannot
// declare the field as translatable on the fly by modifying its config
// because the schema doesn't support this.
break;
}
foreach ($entity->getTranslationLanguages() as $language) {
// Ensure different timestamps.
sleep(1);
$langcode = $language->getId();
$edit = [
$this->fieldName . '[0][value]' => $this->randomString(),
];
$edit_path = $entity->urlInfo('edit-form', ['language' => $language]);
$this->drupalPostForm($edit_path, $edit, $this->getFormSubmitAction($entity, $langcode));
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->assertEqual(
$entity->getChangedTimeAcrossTranslations(), $entity->getTranslation($langcode)->getChangedTime(),
format_string('Changed time for language %language is the latest change over all languages.', ['%language' => $language->getName()])
);
}
$timestamps = [];
foreach ($entity->getTranslationLanguages() as $language) {
$next_timestamp = $entity->getTranslation($language->getId())->getChangedTime();
if (!in_array($next_timestamp, $timestamps)) {
$timestamps[] = $next_timestamp;
}
}
if ($translatable_changed_field) {
$this->assertEqual(
count($timestamps), count($entity->getTranslationLanguages()),
'All timestamps from all languages are different.'
);
}
else {
$this->assertEqual(
count($timestamps), 1,
'All timestamps from all languages are identical.'
);
}
}
}
/**
* Test the changed time after API and FORM save without changes.
*/
public function doTestChangedTimeAfterSaveWithoutChanges() {
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
// Test only entities, which implement the EntityChangedInterface.
if ($entity instanceof EntityChangedInterface) {
$changed_timestamp = $entity->getChangedTime();
$entity->save();
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->assertEqual($changed_timestamp, $entity->getChangedTime(), 'The entity\'s changed time wasn\'t updated after API save without changes.');
// Ensure different save timestamps.
sleep(1);
// Save the entity on the regular edit form.
$language = $entity->language();
$edit_path = $entity->urlInfo('edit-form', ['language' => $language]);
$this->drupalPostForm($edit_path, [], $this->getFormSubmitAction($entity, $language->getId()));
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->assertNotEqual($changed_timestamp, $entity->getChangedTime(), 'The entity\'s changed time was updated after form save without changes.');
}
}
}

View file

@ -0,0 +1,10 @@
name: 'Content translation tests'
type: module
description: 'Provides content translation tests.'
package: Testing
version: VERSION
core: 8.x
dependencies:
- drupal:content_translation
- drupal:language
- drupal:entity_test

View file

@ -0,0 +1,70 @@
<?php
/**
* @file
* Helper module for the Content Translation tests.
*/
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Implements hook_entity_bundle_info_alter().
*/
function content_translation_test_entity_bundle_info_alter(&$bundles) {
// Store the initial status of the "translatable" property for the
// "entity_test_mul" bundle.
$translatable = !empty($bundles['entity_test_mul']['entity_test_mul']['translatable']);
\Drupal::state()->set('content_translation_test.translatable', $translatable);
// Make it translatable if Content Translation did not. This will make the
// entity object translatable even if it is disabled in Content Translation
// settings.
if (!$translatable) {
$bundles['entity_test_mul']['entity_test_mul']['translatable'] = TRUE;
}
}
/**
* Implements hook_entity_access().
*/
function content_translation_test_entity_access(EntityInterface $entity, $operation, AccountInterface $account) {
$access = \Drupal::state()->get('content_translation.entity_access.' . $entity->getEntityTypeId());
if (!empty($access[$operation])) {
return AccessResult::allowed();
}
else {
return AccessResult::neutral();
}
}
/**
* Implements hook_form_BASE_FORM_ID_alter().
*
* Adds a textfield to node forms based on a request parameter.
*/
function content_translation_test_form_node_form_alter(&$form, FormStateInterface $form_state, $form_id) {
$langcode = $form_state->getFormObject()->getFormLangcode($form_state);
if (in_array($langcode, ['en', 'fr']) && \Drupal::request()->get('test_field_only_en_fr')) {
$form['test_field_only_en_fr'] = [
'#type' => 'textfield',
'#title' => 'Field only available on the english and french form',
];
foreach (array_keys($form['actions']) as $action) {
if ($action != 'preview' && isset($form['actions'][$action]['#type']) && $form['actions'][$action]['#type'] === 'submit') {
$form['actions'][$action]['#submit'][] = 'content_translation_test_form_node_form_submit';
}
}
}
}
/**
* Form submission handler for custom field added based on a request parameter.
*
* @see content_translation_test_form_node_article_form_alter()
*/
function content_translation_test_form_node_form_submit($form, FormStateInterface $form_state) {
\Drupal::state()->set('test_field_only_en_fr', $form_state->getValue('test_field_only_en_fr'));
}

View file

@ -0,0 +1,39 @@
<?php
namespace Drupal\content_translation_test\Entity;
use Drupal\entity_test\Entity\EntityTest;
/**
* Defines the test entity class.
*
* @ContentEntityType(
* id = "entity_test_translatable_no_skip",
* label = @Translation("Test entity - Translatable check UI"),
* handlers = {
* "form" = {
* "default" = "Drupal\entity_test\EntityTestForm",
* },
* "route_provider" = {
* "html" = "Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider",
* },
* },
* base_table = "entity_test_mul",
* data_table = "entity_test_mul_property_data",
* entity_keys = {
* "id" = "id",
* "uuid" = "uuid",
* "bundle" = "type",
* "label" = "name",
* "langcode" = "langcode",
* },
* translatable = TRUE,
* admin_permission = "administer entity_test content",
* links = {
* "edit-form" = "/entity_test_translatable_no_skip/{entity_test_translatable_no_skip}/edit",
* },
* )
*/
class EntityTestTranslatableNoUISkip extends EntityTest {
}

View file

@ -0,0 +1,28 @@
<?php
namespace Drupal\content_translation_test\Entity;
use Drupal\entity_test\Entity\EntityTest;
/**
* Defines the test entity class.
*
* @ContentEntityType(
* id = "entity_test_translatable_UI_skip",
* label = @Translation("Test entity - Translatable skip UI check"),
* base_table = "entity_test_mul",
* data_table = "entity_test_mul_property_data",
* entity_keys = {
* "id" = "id",
* "uuid" = "uuid",
* "bundle" = "type",
* "label" = "name",
* "langcode" = "langcode",
* },
* translatable = TRUE,
* content_translation_ui_skip = TRUE,
* )
*/
class EntityTestTranslatableUISkip extends EntityTest {
}

View file

@ -0,0 +1,9 @@
name: 'Content translation test views'
type: module
description: 'Provides default views for views content translation tests.'
package: Testing
version: VERSION
core: 8.x
dependencies:
- drupal:content_translation
- drupal:views

View file

@ -0,0 +1,112 @@
langcode: en
status: true
dependencies:
module:
- content_translation
- user
id: test_entity_translations_link
label: People
module: views
description: ''
tag: ''
base_table: users_field_data
base_field: uid
core: 8.x
display:
default:
display_plugin: default
id: default
display_title: Master
position: null
display_options:
access:
type: none
cache:
type: tag
query:
type: views_query
exposed_form:
type: basic
options:
submit_button: Filter
reset_button: true
reset_button_label: Reset
pager:
type: full
options:
items_per_page: 50
style:
type: table
options:
columns:
name: name
translation_link: translation_link
default: created
row:
type: fields
fields:
name:
id: name
table: users_field_data
field: name
label: Username
plugin_id: field
type: user_name
entity_type: user
entity_field: name
translation_link:
id: translation_link
table: users
field: translation_link
label: 'Translation link'
exclude: false
alter:
alter_text: false
element_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
text: Translate
plugin_id: content_translation_link
entity_type: user
filters:
uid_raw:
id: uid_raw
table: users_field_data
field: uid_raw
operator: '!='
value:
value: '0'
group: 1
exposed: false
plugin_id: numeric
entity_type: user
sorts:
created:
id: created
table: users_field_data
field: created
order: DESC
plugin_id: date
entity_type: user
entity_field: created
title: People
empty:
area:
id: area
table: views
field: area
empty: true
content:
value: 'No people available.'
format: plain_text
plugin_id: text
page_1:
display_plugin: page
id: page_1
display_title: Page
position: null
display_options:
path: test-entity-translations-link

View file

@ -0,0 +1,40 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
/**
* Tests the test content translation UI with the test entity.
*
* @group content_translation
*/
class ContentTestTranslationUITest extends ContentTranslationUITestBase {
/**
* {@inheritdoc}
*/
protected $testHTMLEscapeForAllLanguages = TRUE;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['language', 'content_translation', 'entity_test'];
/**
* {@inheritdoc}
*/
protected function setUp() {
// Use the entity_test_mul as this has multilingual property support.
$this->entityTypeId = 'entity_test_mul_changed';
parent::setUp();
}
/**
* {@inheritdoc}
*/
protected function getTranslatorPermissions() {
return array_merge(parent::getTranslatorPermissions(), ['administer entity_test content', 'view test entity']);
}
}

View file

@ -0,0 +1,127 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\field\Entity\FieldConfig;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\Tests\BrowserTestBase;
/**
* Tests that contextual links are available for content translation.
*
* @group content_translation
*/
class ContentTranslationContextualLinksTest extends BrowserTestBase {
/**
* The bundle being tested.
*
* @var string
*/
protected $bundle;
/**
* The content type being tested.
*
* @var \Drupal\node\Entity\NodeType
*/
protected $contentType;
/**
* The 'translator' user to use during testing.
*
* @var \Drupal\user\UserInterface
*/
protected $translator;
/**
* The enabled languages.
*
* @var array
*/
protected $langcodes;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['content_translation', 'contextual', 'node'];
/**
* The profile to install as a basis for testing.
*
* @var string
*/
protected $profile = 'testing';
protected function setUp() {
parent::setUp();
// Set up an additional language.
$this->langcodes = [\Drupal::languageManager()->getDefaultLanguage()->getId(), 'es'];
ConfigurableLanguage::createFromLangcode('es')->save();
// Create a content type.
$this->bundle = $this->randomMachineName();
$this->contentType = $this->drupalCreateContentType(['type' => $this->bundle]);
// Add a field to the content type. The field is not yet translatable.
FieldStorageConfig::create([
'field_name' => 'field_test_text',
'entity_type' => 'node',
'type' => 'text',
'cardinality' => 1,
])->save();
FieldConfig::create([
'entity_type' => 'node',
'field_name' => 'field_test_text',
'bundle' => $this->bundle,
'label' => 'Test text-field',
])->save();
entity_get_form_display('node', $this->bundle, 'default')
->setComponent('field_test_text', [
'type' => 'text_textfield',
'weight' => 0,
])
->save();
// Create a translator user.
$permissions = [
'access contextual links',
'administer nodes',
"edit any $this->bundle content",
'translate any entity',
];
$this->translator = $this->drupalCreateUser($permissions);
}
/**
* Tests that a contextual link is available for translating a node.
*/
public function testContentTranslationContextualLinks() {
// Create a node.
$title = $this->randomString();
$this->drupalCreateNode(['type' => $this->bundle, 'title' => $title, 'langcode' => 'en']);
$node = $this->drupalGetNodeByTitle($title);
// Use a UI form submission to make the node type and field translatable.
// This tests that caches are properly invalidated.
$this->drupalLogin($this->rootUser);
$edit = [
'entity_types[node]' => TRUE,
'settings[node][' . $this->bundle . '][settings][language][language_alterable]' => TRUE,
'settings[node][' . $this->bundle . '][translatable]' => TRUE,
'settings[node][' . $this->bundle . '][fields][field_test_text]' => TRUE,
];
$this->drupalPostForm('admin/config/regional/content-language', $edit, t('Save configuration'));
$this->drupalLogout();
// Check that the link leads to the translate page.
$this->drupalLogin($this->translator);
$translate_link = 'node/' . $node->id() . '/translations';
$this->drupalGet($translate_link);
$this->assertRaw(t('Translations of %label', ['%label' => $node->label()]), 'The contextual link leads to the translate page.');
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Test disabling content translation module.
*
* @group content_translation
*/
class ContentTranslationDisableSettingTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'content_translation',
'menu_link_content',
'language',
];
/**
* Tests that entity schemas are up-to-date after enabling translation.
*/
public function testDisableSetting() {
// Define selectors.
$group_checkbox = 'entity_types[menu_link_content]';
$translatable_checkbox = 'settings[menu_link_content][menu_link_content][translatable]';
$language_alterable = 'settings[menu_link_content][menu_link_content][settings][language][language_alterable]';
$user = $this->drupalCreateUser([
'administer site configuration',
'administer content translation',
'create content translations',
'administer languages',
]);
$this->drupalLogin($user);
$assert = $this->assertSession();
$this->drupalGet('admin/config/regional/content-language');
$assert->checkboxNotChecked('entity_types[menu_link_content]');
$edit = [
$group_checkbox => TRUE,
$translatable_checkbox => TRUE,
$language_alterable => TRUE,
];
$this->submitForm($edit, t('Save configuration'));
$assert->pageTextContains(t('Settings successfully updated.'));
$assert->checkboxChecked($group_checkbox);
$edit = [
$group_checkbox => FALSE,
$translatable_checkbox => TRUE,
$language_alterable => TRUE,
];
$this->submitForm($edit, t('Save configuration'));
$assert->pageTextContains(t('Settings successfully updated.'));
$assert->checkboxNotChecked($group_checkbox);
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Test enabling content translation module.
*
* @group content_translation
*/
class ContentTranslationEnableTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['entity_test', 'menu_link_content', 'node'];
/**
* Tests that entity schemas are up-to-date after enabling translation.
*/
public function testEnable() {
$this->drupalLogin($this->rootUser);
// Enable modules and make sure the related config entity type definitions
// are installed.
$edit = [
'modules[content_translation][enable]' => TRUE,
'modules[language][enable]' => TRUE,
];
$this->drupalPostForm('admin/modules', $edit, t('Install'));
// Status messages are shown.
$this->assertText(t('This site has only a single language enabled. Add at least one more language in order to translate content.'));
$this->assertText(t('Enable translation for content types, taxonomy vocabularies, accounts, or any other element you wish to translate.'));
// No pending updates should be available.
$this->drupalGet('admin/reports/status');
$requirement_value = $this->cssSelect("details.system-status-report__entry summary:contains('Entity/field definitions') + div");
$this->assertEqual(t('Up to date'), trim($requirement_value[0]->getText()));
$this->drupalGet('admin/config/regional/content-language');
// The node entity type should not be an option because it has no bundles.
$this->assertNoRaw('entity_types[node]');
// Enable content translation on entity types that have will have a
// content_translation_uid.
$edit = [
'entity_types[menu_link_content]' => TRUE,
'settings[menu_link_content][menu_link_content][translatable]' => TRUE,
'entity_types[entity_test_mul]' => TRUE,
'settings[entity_test_mul][entity_test_mul][translatable]' => TRUE,
];
$this->drupalPostForm(NULL, $edit, t('Save configuration'));
// No pending updates should be available.
$this->drupalGet('admin/reports/status');
$requirement_value = $this->cssSelect("details.system-status-report__entry summary:contains('Entity/field definitions') + div");
$this->assertEqual(t('Up to date'), trim($requirement_value[0]->getText()));
// Create a node type and check the content translation settings are now
// available for nodes.
$edit = [
'name' => 'foo',
'title_label' => 'title for foo',
'type' => 'foo',
];
$this->drupalPostForm('admin/structure/types/add', $edit, t('Save content type'));
$this->drupalGet('admin/config/regional/content-language');
$this->assertRaw('entity_types[node]');
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Tests the content translation behaviours on entity bundle UI.
*
* @group content_translation
*/
class ContentTranslationEntityBundleUITest extends BrowserTestBase {
public static $modules = ['language', 'content_translation', 'node', 'comment', 'field_ui'];
protected function setUp() {
parent::setUp();
$user = $this->drupalCreateUser(['access administration pages', 'administer languages', 'administer content translation', 'administer content types']);
$this->drupalLogin($user);
}
/**
* Tests content types default translation behaviour.
*/
public function testContentTypeUI() {
// Create first content type.
$this->drupalCreateContentType(['type' => 'article']);
// Enable content translation.
$edit = ['language_configuration[content_translation]' => TRUE];
$this->drupalPostForm('admin/structure/types/manage/article', $edit, 'Save content type');
// Make sure add page does not inherit translation configuration from first
// content type.
$this->drupalGet('admin/structure/types/add');
$this->assertNoFieldChecked('edit-language-configuration-content-translation');
// Create second content type and set content translation.
$edit = [
'name' => 'Page',
'type' => 'page',
'language_configuration[content_translation]' => TRUE,
];
$this->drupalPostForm('admin/structure/types/add', $edit, 'Save and manage fields');
// Make sure the settings are saved when creating the content type.
$this->drupalGet('admin/structure/types/manage/page');
$this->assertFieldChecked('edit-language-configuration-content-translation');
}
}

View file

@ -0,0 +1,154 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\node\Functional\NodeTestBase;
use Drupal\Tests\TestFileCreationTrait;
/**
* Tests the content translation language that is set.
*
* @group content_translation
*/
class ContentTranslationLanguageChangeTest extends NodeTestBase {
use TestFileCreationTrait {
getTestFiles as drupalGetTestFiles;
}
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['language', 'content_translation', 'content_translation_test', 'node', 'block', 'field_ui', 'image'];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$langcodes = ['de', 'fr'];
foreach ($langcodes as $langcode) {
ConfigurableLanguage::createFromLangcode($langcode)->save();
}
$this->drupalPlaceBlock('local_tasks_block');
$user = $this->drupalCreateUser([
'administer site configuration',
'administer nodes',
'create article content',
'edit any article content',
'delete any article content',
'administer content translation',
'translate any entity',
'create content translations',
'administer languages',
'administer content types',
'administer node fields',
]);
$this->drupalLogin($user);
// Enable translation for article.
$edit = [
'entity_types[node]' => TRUE,
'settings[node][article][translatable]' => TRUE,
'settings[node][article][settings][language][language_alterable]' => TRUE,
];
$this->drupalPostForm('admin/config/regional/content-language', $edit, t('Save configuration'));
// Add an image field.
$this->drupalGet('admin/structure/types/manage/article/fields/add-field');
$edit = [
'new_storage_type' => 'image',
'field_name' => 'image_field',
'label' => 'image_field',
];
$this->drupalPostForm(NULL, $edit, t('Save and continue'));
$this->drupalPostForm(NULL, [], t('Save field settings'));
$this->drupalPostForm(NULL, [], t('Save settings'));
}
/**
* Test that the source language is properly set when changing.
*/
public function testLanguageChange() {
// Create a node in English.
$this->drupalGet('node/add/article');
$edit = [
'title[0][value]' => 'english_title',
];
$this->drupalPostForm(NULL, $edit, t('Save'));
// Create a translation in French.
$this->clickLink('Translate');
$this->clickLink('Add');
$this->drupalPostForm(NULL, [], t('Save (this translation)'));
$this->clickLink('Translate');
// Edit English translation.
$this->clickLink('Edit');
// Upload and image after changing the node language.
$images = $this->drupalGetTestFiles('image')[1];
$edit = [
'langcode[0][value]' => 'de',
'files[field_image_field_0]' => $images->uri,
];
$this->drupalPostForm(NULL, $edit, t('Upload'));
$this->drupalPostForm(NULL, ['field_image_field[0][alt]' => 'alternative_text'], t('Save (this translation)'));
// Check that the translation languages are correct.
$node = $this->getNodeByTitle('english_title');
$translation_languages = array_keys($node->getTranslationLanguages());
$this->assertTrue(in_array('fr', $translation_languages));
$this->assertTrue(in_array('de', $translation_languages));
}
/**
* Test that title does not change on ajax call with new language value.
*/
public function testTitleDoesNotChangesOnChangingLanguageWidgetAndTriggeringAjaxCall() {
// Create a node in English.
$this->drupalGet('node/add/article', ['query' => ['test_field_only_en_fr' => 1]]);
$edit = [
'title[0][value]' => 'english_title',
'test_field_only_en_fr' => 'node created',
];
$this->drupalPostForm(NULL, $edit, t('Save'));
$this->assertEqual('node created', \Drupal::state()->get('test_field_only_en_fr'));
// Create a translation in French.
$this->clickLink('Translate');
$this->clickLink('Add');
$this->drupalPostForm(NULL, [], t('Save (this translation)'));
$this->clickLink('Translate');
// Edit English translation.
$node = $this->getNodeByTitle('english_title');
$this->drupalGet('node/' . $node->id() . '/edit');
// Test the expected title when loading the form.
$this->assertRaw('<title>Edit Article english_title | Drupal</title>');
// Upload and image after changing the node language.
$images = $this->drupalGetTestFiles('image')[1];
$edit = [
'langcode[0][value]' => 'de',
'files[field_image_field_0]' => $images->uri,
];
$this->drupalPostForm(NULL, $edit, t('Upload'));
// Test the expected title after triggering an ajax call with a new
// language selected.
$this->assertRaw('<title>Edit Article english_title | Drupal</title>');
$edit = [
'langcode[0][value]' => 'en',
'field_image_field[0][alt]' => 'alternative_text',
];
$this->drupalPostForm(NULL, $edit, t('Save (this translation)'));
// Check that the translation languages are correct.
$node = $this->getNodeByTitle('english_title');
$translation_languages = array_keys($node->getTranslationLanguages());
$this->assertTrue(in_array('fr', $translation_languages));
$this->assertTrue(!in_array('de', $translation_languages));
}
}

View file

@ -0,0 +1,137 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\Tests\BrowserTestBase;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\entity_test\Entity\EntityTestMul;
use Drupal\content_translation_test\Entity\EntityTestTranslatableNoUISkip;
/**
* Tests whether canonical link tags are present for content entities.
*
* @group content_translation
*/
class ContentTranslationLinkTagTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['entity_test', 'content_translation', 'content_translation_test', 'language'];
/**
* The added languages.
*
* @var string[]
*/
protected $langcodes;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Set up user.
$user = $this->drupalCreateUser([
'view test entity',
'view test entity translations',
'administer entity_test content',
]);
$this->drupalLogin($user);
// Add additional languages.
$this->langcodes = ['it', 'fr'];
foreach ($this->langcodes as $langcode) {
ConfigurableLanguage::createFromLangcode($langcode)->save();
}
// Rebuild the container so that the new languages are picked up by services
// that hold a list of languages.
$this->rebuildContainer();
}
/**
* Create a test entity with translations.
*
* @return \Drupal\Core\Entity\EntityInterface
* An entity with translations.
*/
protected function createTranslatableEntity() {
$entity = EntityTestMul::create(['label' => $this->randomString()]);
// Create translations for non default languages.
foreach ($this->langcodes as $langcode) {
$entity->addTranslation($langcode, ['label' => $this->randomString()]);
}
$entity->save();
return $entity;
}
/**
* Tests alternate link tag found for entity types with canonical links.
*/
public function testCanonicalAlternateTags() {
/** @var \Drupal\Core\Language\LanguageManagerInterface $languageManager */
$languageManager = $this->container->get('language_manager');
/** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager */
$entityTypeManager = $this->container->get('entity_type.manager');
$definition = $entityTypeManager->getDefinition('entity_test_mul');
$this->assertTrue($definition->hasLinkTemplate('canonical'), 'Canonical link template found for entity_test.');
$entity = $this->createTranslatableEntity();
$url_base = $entity->toUrl('canonical')
->setAbsolute();
$langcodes_all = $this->langcodes;
$langcodes_all[] = $languageManager
->getDefaultLanguage()
->getId();
/** @var \Drupal\Core\Url[] $urls */
$urls = array_map(
function ($langcode) use ($url_base, $languageManager) {
$url = clone $url_base;
return $url
->setOption('language', $languageManager->getLanguage($langcode));
},
array_combine($langcodes_all, $langcodes_all)
);
// Ensure link tags for all languages are found on each language variation
// page of an entity.
foreach ($urls as $langcode => $url) {
$this->drupalGet($url);
foreach ($urls as $langcode_alternate => $url_alternate) {
$args = [':href' => $url_alternate->toString(), ':hreflang' => $langcode_alternate];
$links = $this->xpath('head/link[@rel = "alternate" and @href = :href and @hreflang = :hreflang]', $args);
$message = sprintf('The "%s" translation has the correct alternate hreflang link for "%s": %s.', $langcode, $langcode_alternate, $url->toString());
$this->assertTrue(isset($links[0]), $message);
}
}
}
/**
* Tests alternate link tag missing for entity types without canonical links.
*/
public function testCanonicalAlternateTagsMissing() {
/** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager */
$entityTypeManager = $this->container->get('entity_type.manager');
$definition = $entityTypeManager->getDefinition('entity_test_translatable_no_skip');
// Ensure 'canonical' link template does not exist, in case it is added in
// the future.
$this->assertFalse($definition->hasLinkTemplate('canonical'), 'Canonical link template does not exist for entity_test_translatable_no_skip entity.');
$entity = EntityTestTranslatableNoUISkip::create();
$entity->save();
$this->drupalGet($entity->toUrl('edit-form'));
$this->assertSession()->statusCodeEquals(200);
$result = $this->xpath('//link[@rel="alternate" and @hreflang]');
$this->assertFalse($result, 'No alternate link tag found.');
}
}

View file

@ -0,0 +1,144 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
/**
* Tests the Content Translation metadata fields handling.
*
* @group content_translation
*/
class ContentTranslationMetadataFieldsTest extends ContentTranslationTestBase {
/**
* The entity type being tested.
*
* @var string
*/
protected $entityTypeId = 'node';
/**
* The bundle being tested.
*
* @var string
*/
protected $bundle = 'article';
/**
* Modules to install.
*
* @var array
*/
public static $modules = ['language', 'content_translation', 'node'];
/**
* The profile to install as a basis for testing.
*
* @var string
*/
protected $profile = 'standard';
/**
* Tests skipping setting non translatable metadata fields.
*/
public function testSkipUntranslatable() {
$this->drupalLogin($this->translator);
$entity_manager = \Drupal::entityManager();
$fields = $entity_manager->getFieldDefinitions($this->entityTypeId, $this->bundle);
// Turn off translatability for the metadata fields on the current bundle.
$metadata_fields = ['created', 'changed', 'uid', 'status'];
foreach ($metadata_fields as $field_name) {
$fields[$field_name]
->getConfig($this->bundle)
->setTranslatable(FALSE)
->save();
}
// Create a new test entity with original values in the default language.
$default_langcode = $this->langcodes[0];
$entity_id = $this->createEntity(['title' => $this->randomString()], $default_langcode);
$storage = $entity_manager->getStorage($this->entityTypeId);
$storage->resetCache();
$entity = $storage->load($entity_id);
// Add a content translation.
$langcode = 'it';
$values = $entity->toArray();
// Apply a default value for the metadata fields.
foreach ($metadata_fields as $field_name) {
unset($values[$field_name]);
}
$entity->addTranslation($langcode, $values);
$metadata_source_translation = $this->manager->getTranslationMetadata($entity->getTranslation($default_langcode));
$metadata_target_translation = $this->manager->getTranslationMetadata($entity->getTranslation($langcode));
$created_time = $metadata_source_translation->getCreatedTime();
$changed_time = $metadata_source_translation->getChangedTime();
$published = $metadata_source_translation->isPublished();
$author = $metadata_source_translation->getAuthor();
$this->assertEqual($created_time, $metadata_target_translation->getCreatedTime(), 'Metadata created field has the same value for both translations.');
$this->assertEqual($changed_time, $metadata_target_translation->getChangedTime(), 'Metadata changed field has the same value for both translations.');
$this->assertEqual($published, $metadata_target_translation->isPublished(), 'Metadata published field has the same value for both translations.');
$this->assertEqual($author->id(), $metadata_target_translation->getAuthor()->id(), 'Metadata author field has the same value for both translations.');
$metadata_target_translation->setCreatedTime(time() + 50);
$metadata_target_translation->setChangedTime(time() + 50);
$metadata_target_translation->setPublished(TRUE);
$metadata_target_translation->setAuthor($this->editor);
$this->assertEqual($created_time, $metadata_target_translation->getCreatedTime(), 'Metadata created field correctly not updated');
$this->assertEqual($changed_time, $metadata_target_translation->getChangedTime(), 'Metadata changed field correctly not updated');
$this->assertEqual($published, $metadata_target_translation->isPublished(), 'Metadata published field correctly not updated');
$this->assertEqual($author->id(), $metadata_target_translation->getAuthor()->id(), 'Metadata author field correctly not updated');
}
/**
* Tests setting translatable metadata fields.
*/
public function testSetTranslatable() {
$this->drupalLogin($this->translator);
$entity_manager = \Drupal::entityManager();
$fields = $entity_manager->getFieldDefinitions($this->entityTypeId, $this->bundle);
// Turn off translatability for the metadata fields on the current bundle.
$metadata_fields = ['created', 'changed', 'uid', 'status'];
foreach ($metadata_fields as $field_name) {
$fields[$field_name]
->getConfig($this->bundle)
->setTranslatable(TRUE)
->save();
}
// Create a new test entity with original values in the default language.
$default_langcode = $this->langcodes[0];
$entity_id = $this->createEntity(['title' => $this->randomString(), 'status' => FALSE], $default_langcode);
$storage = $entity_manager->getStorage($this->entityTypeId);
$storage->resetCache();
$entity = $storage->load($entity_id);
// Add a content translation.
$langcode = 'it';
$values = $entity->toArray();
// Apply a default value for the metadata fields.
foreach ($metadata_fields as $field_name) {
unset($values[$field_name]);
}
$entity->addTranslation($langcode, $values);
$metadata_source_translation = $this->manager->getTranslationMetadata($entity->getTranslation($default_langcode));
$metadata_target_translation = $this->manager->getTranslationMetadata($entity->getTranslation($langcode));
$metadata_target_translation->setCreatedTime(time() + 50);
$metadata_target_translation->setChangedTime(time() + 50);
$metadata_target_translation->setPublished(TRUE);
$metadata_target_translation->setAuthor($this->editor);
$this->assertNotEqual($metadata_source_translation->getCreatedTime(), $metadata_target_translation->getCreatedTime(), 'Metadata created field correctly different on both translations.');
$this->assertNotEqual($metadata_source_translation->getChangedTime(), $metadata_target_translation->getChangedTime(), 'Metadata changed field correctly different on both translations.');
$this->assertNotEqual($metadata_source_translation->isPublished(), $metadata_target_translation->isPublished(), 'Metadata published field correctly different on both translations.');
$this->assertNotEqual($metadata_source_translation->getAuthor()->id(), $metadata_target_translation->getAuthor()->id(), 'Metadata author field correctly different on both translations.');
}
}

View file

@ -0,0 +1,157 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\node\Functional\NodeTestBase;
use Drupal\user\Entity\Role;
/**
* Tests the content translation operations available in the content listing.
*
* @group content_translation
*/
class ContentTranslationOperationsTest extends NodeTestBase {
/**
* A base user.
*
* @var \Drupal\user\Entity\User|false
*/
protected $baseUser1;
/**
* A base user.
*
* @var \Drupal\user\Entity\User|false
*/
protected $baseUser2;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['language', 'content_translation', 'node', 'views', 'block'];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Enable additional languages.
$langcodes = ['es', 'ast'];
foreach ($langcodes as $langcode) {
ConfigurableLanguage::createFromLangcode($langcode)->save();
}
// Enable translation for the current entity type and ensure the change is
// picked up.
\Drupal::service('content_translation.manager')->setEnabled('node', 'article', TRUE);
drupal_static_reset();
\Drupal::entityManager()->clearCachedDefinitions();
\Drupal::service('router.builder')->rebuild();
\Drupal::service('entity.definition_update_manager')->applyUpdates();
$this->baseUser1 = $this->drupalCreateUser(['access content overview']);
$this->baseUser2 = $this->drupalCreateUser(['access content overview', 'create content translations', 'update content translations', 'delete content translations']);
}
/**
* Test that the operation "Translate" is displayed in the content listing.
*/
public function testOperationTranslateLink() {
$node = $this->drupalCreateNode(['type' => 'article', 'langcode' => 'es']);
// Verify no translation operation links are displayed for users without
// permission.
$this->drupalLogin($this->baseUser1);
$this->drupalGet('admin/content');
$this->assertNoLinkByHref('node/' . $node->id() . '/translations');
$this->drupalLogout();
// Verify there's a translation operation link for users with enough
// permissions.
$this->drupalLogin($this->baseUser2);
$this->drupalGet('admin/content');
$this->assertLinkByHref('node/' . $node->id() . '/translations');
// Ensure that an unintended misconfiguration of permissions does not open
// access to the translation form, see https://www.drupal.org/node/2558905.
$this->drupalLogout();
user_role_change_permissions(
Role::AUTHENTICATED_ID,
[
'create content translations' => TRUE,
'access content' => FALSE,
]
);
$this->drupalLogin($this->baseUser1);
$this->drupalGet($node->urlInfo('drupal:content-translation-overview'));
$this->assertResponse(403);
// Ensure that the translation overview is also not accessible when the user
// has 'access content', but the node is not published.
user_role_change_permissions(
Role::AUTHENTICATED_ID,
[
'create content translations' => TRUE,
'access content' => TRUE,
]
);
$node->setUnpublished()->save();
$this->drupalGet($node->urlInfo('drupal:content-translation-overview'));
$this->assertResponse(403);
$this->drupalLogout();
// Ensure the 'Translate' local task does not show up anymore when disabling
// translations for a content type.
$node->setPublished()->save();
user_role_change_permissions(
Role::AUTHENTICATED_ID,
[
'administer content translation' => TRUE,
'administer languages' => TRUE,
]
);
$this->drupalPlaceBlock('local_tasks_block');
$this->drupalLogin($this->baseUser2);
$this->drupalGet('node/' . $node->id());
$this->assertLinkByHref('node/' . $node->id() . '/translations');
$this->drupalPostForm('admin/config/regional/content-language', ['settings[node][article][translatable]' => FALSE], t('Save configuration'));
$this->drupalGet('node/' . $node->id());
$this->assertNoLinkByHref('node/' . $node->id() . '/translations');
}
/**
* Tests the access to the overview page for translations.
*
* @see content_translation_translate_access()
*/
public function testContentTranslationOverviewAccess() {
$access_control_handler = \Drupal::entityManager()->getAccessControlHandler('node');
$user = $this->createUser(['create content translations', 'access content']);
$this->drupalLogin($user);
$node = $this->drupalCreateNode(['status' => FALSE, 'type' => 'article']);
$this->assertFalse(content_translation_translate_access($node)->isAllowed());
$access_control_handler->resetCache();
$node->setPublished();
$node->save();
$this->assertTrue(content_translation_translate_access($node)->isAllowed());
$access_control_handler->resetCache();
user_role_change_permissions(
Role::AUTHENTICATED_ID,
[
'access content' => FALSE,
]
);
$user = $this->createUser(['create content translations']);
$this->drupalLogin($user);
$this->assertFalse(content_translation_translate_access($node)->isAllowed());
$access_control_handler->resetCache();
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\Core\Url;
use Drupal\language\Entity\ConfigurableLanguage;
/**
* Tests the "Flag as outdated" functionality with revision translations.
*
* @group content_translation
*/
class ContentTranslationOutdatedRevisionTranslationTest extends ContentTranslationPendingRevisionTestBase {
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->enableContentModeration();
}
/**
* Tests that outdated revision translations work correctly.
*/
public function testFlagAsOutdatedHidden() {
// Create a test node.
$values = [
'title' => 'Test 1.1 EN',
'moderation_state' => 'published',
];
$id = $this->createEntity($values, 'en');
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $this->storage->load($id);
// Add a published Italian translation.
$add_translation_url = Url::fromRoute("entity.{$this->entityTypeId}.content_translation_add", [
$entity->getEntityTypeId() => $id,
'source' => 'en',
'target' => 'it',
],
[
'language' => ConfigurableLanguage::load('it'),
'absolute' => FALSE,
]
);
$this->drupalGet($add_translation_url);
$this->assertFlagWidget();
$edit = [
'title[0][value]' => 'Test 1.2 IT',
'moderation_state[0][state]' => 'published',
];
$this->drupalPostForm(NULL, $edit, t('Save (this translation)'));
// Add a published French translation.
$add_translation_url = Url::fromRoute("entity.{$this->entityTypeId}.content_translation_add", [
$entity->getEntityTypeId() => $id,
'source' => 'en',
'target' => 'fr',
],
[
'language' => ConfigurableLanguage::load('fr'),
'absolute' => FALSE,
]
);
$this->drupalGet($add_translation_url);
$this->assertFlagWidget();
$edit = [
'title[0][value]' => 'Test 1.3 FR',
'moderation_state[0][state]' => 'published',
];
$this->drupalPostForm(NULL, $edit, t('Save (this translation)'));
// Create an English draft.
$entity = $this->storage->loadUnchanged($id);
$en_edit_url = $this->getEditUrl($entity);
$this->drupalGet($en_edit_url);
$this->assertFlagWidget();
}
/**
* Checks whether the flag widget is displayed.
*/
protected function assertFlagWidget() {
$this->assertSession()->pageTextNotContains('Flag other translations as outdated');
$this->assertSession()->pageTextContains('Translations cannot be flagged as outdated when content is moderated.');
}
}

View file

@ -0,0 +1,179 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
/**
* Base class for pending revision translation tests.
*/
abstract class ContentTranslationPendingRevisionTestBase extends ContentTranslationTestBase {
use ContentTypeCreationTrait;
use ContentModerationTestTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['language', 'content_translation', 'content_moderation', 'node'];
/**
* The entity storage.
*
* @var \Drupal\Core\Entity\ContentEntityStorageInterface
*/
protected $storage;
/**
* Permissions common to all test accounts.
*
* @var string[]
*/
protected $commonPermissions;
/**
* The current test account.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentAccount;
/**
* {@inheritdoc}
*/
protected function setUp() {
$this->entityTypeId = 'node';
$this->bundle = 'article';
$this->commonPermissions = [
'view any unpublished content',
"translate {$this->bundle} {$this->entityTypeId}",
"create content translations",
'use editorial transition create_new_draft',
'use editorial transition publish',
'use editorial transition archive',
'use editorial transition archived_draft',
'use editorial transition archived_published',
];
parent::setUp();
/** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */
$entity_type_manager = $this->container->get('entity_type.manager');
$this->storage = $entity_type_manager->getStorage($this->entityTypeId);
// @todo Remove this line once https://www.drupal.org/node/2945928 is fixed.
$this->config('node.settings')->set('use_admin_theme', '1')->save();
}
/**
* Enables content moderation for the test entity type and bundle.
*/
protected function enableContentModeration() {
$this->drupalLogin($this->rootUser);
$workflow_id = 'editorial';
$this->drupalGet('/admin/config/workflow/workflows');
$edit['bundles[' . $this->bundle . ']'] = TRUE;
$this->drupalPostForm('admin/config/workflow/workflows/manage/' . $workflow_id . '/type/' . $this->entityTypeId, $edit, t('Save'));
// Ensure the parent environment is up-to-date.
// @see content_moderation_workflow_insert()
\Drupal::service('entity_type.bundle.info')->clearCachedBundles();
\Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
/** @var \Drupal\Core\Routing\RouteBuilderInterface $router_builder */
$router_builder = $this->container->get('router.builder');
$router_builder->rebuildIfNeeded();
}
/**
* {@inheritdoc}
*/
protected function getEditorPermissions() {
$editor_permissions = [
"edit any {$this->bundle} content",
"delete any {$this->bundle} content",
"view {$this->bundle} revisions",
"delete {$this->bundle} revisions",
];
return array_merge($editor_permissions, $this->commonPermissions);
}
/**
* {@inheritdoc}
*/
protected function getTranslatorPermissions() {
return array_merge(parent::getTranslatorPermissions(), $this->commonPermissions);
}
/**
* {@inheritdoc}
*/
protected function setupBundle() {
parent::setupBundle();
$this->createContentType(['type' => $this->bundle]);
$this->createEditorialWorkflow();
}
/**
* Loads the active revision translation for the specified entity.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity being edited.
* @param string $langcode
* The translation language code.
*
* @return \Drupal\Core\Entity\ContentEntityInterface|null
* The active revision translation or NULL if none could be identified.
*/
protected function loadRevisionTranslation(ContentEntityInterface $entity, $langcode) {
// Explicitly invalidate the cache for that node, as the call below is
// statically cached.
$this->storage->resetCache([$entity->id()]);
$revision_id = $this->storage->getLatestTranslationAffectedRevisionId($entity->id(), $langcode);
/** @var \Drupal\Core\Entity\ContentEntityInterface $revision */
$revision = $revision_id ? $this->storage->loadRevision($revision_id) : NULL;
return $revision && $revision->hasTranslation($langcode) ? $revision->getTranslation($langcode) : NULL;
}
/**
* Returns the edit URL for the specified entity.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity being edited.
*
* @return \Drupal\Core\Url
* The edit URL.
*/
protected function getEditUrl(ContentEntityInterface $entity) {
if ($entity->access('update', $this->loggedInUser)) {
$url = $entity->toUrl('edit-form');
}
else {
$url = $entity->toUrl('drupal:content-translation-edit');
$url->setRouteParameter('language', $entity->language()->getId());
}
return $url;
}
/**
* Returns the delete translation URL for the specified entity.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity being edited.
*
* @return \Drupal\Core\Url
* The delete translation URL.
*/
protected function getDeleteUrl(ContentEntityInterface $entity) {
if ($entity->access('delete', $this->loggedInUser)) {
$url = $entity->toUrl('delete-form');
}
else {
$url = $entity->toUrl('drupal:content-translation-delete');
$url->setRouteParameter('language', $entity->language()->getId());
}
return $url;
}
}

View file

@ -0,0 +1,214 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\Core\Url;
use Drupal\language\Entity\ConfigurableLanguage;
/**
* Tests that revision translation deletion is handled correctly.
*
* @group content_translation
*/
class ContentTranslationRevisionTranslationDeletionTest extends ContentTranslationPendingRevisionTestBase {
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->enableContentModeration();
}
/**
* Tests that translation overview handles pending revisions correctly.
*/
public function testOverview() {
$index = 1;
$accounts = [
$this->rootUser,
$this->editor,
$this->translator,
];
foreach ($accounts as $account) {
$this->currentAccount = $account;
$this->doTestOverview($index++);
}
}
/**
* Performs a test run.
*
* @param int $index
* The test run index.
*/
public function doTestOverview($index) {
$this->drupalLogin($this->currentAccount);
// Create a test node.
$values = [
'title' => "Test $index.1 EN",
'moderation_state' => 'published',
];
$id = $this->createEntity($values, 'en');
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $this->storage->load($id);
// Add a draft translation and check that it is available only in the latest
// revision.
$add_translation_url = Url::fromRoute("entity.{$this->entityTypeId}.content_translation_add", [
$entity->getEntityTypeId() => $id,
'source' => 'en',
'target' => 'it',
],
[
'language' => ConfigurableLanguage::load('it'),
'absolute' => FALSE,
]
);
$add_translation_href = $add_translation_url->toString();
$this->drupalGet($add_translation_url);
$edit = [
'title[0][value]' => "Test $index.2 IT",
'moderation_state[0][state]' => 'draft',
];
$this->drupalPostForm(NULL, $edit, t('Save (this translation)'));
$entity = $this->storage->loadUnchanged($id);
$this->assertFalse($entity->hasTranslation('it'));
$it_revision = $this->loadRevisionTranslation($entity, 'it');
$this->assertTrue($it_revision->hasTranslation('it'));
// Check that translations cannot be deleted in drafts.
$overview_url = $entity->toUrl('drupal:content-translation-overview');
$this->drupalGet($overview_url);
$it_delete_url = $this->getDeleteUrl($it_revision);
$it_delete_href = $it_delete_url->toString();
$this->assertSession()->linkByHrefNotExists($it_delete_href);
$warning = 'The "Delete translation" action is only available for published translations.';
$this->assertSession()->pageTextContains($warning);
$this->drupalGet($this->getEditUrl($it_revision));
$this->assertSession()->buttonNotExists('Delete translation');
// Publish the translation and verify it can be deleted.
$edit = [
'title[0][value]' => "Test $index.3 IT",
'moderation_state[0][state]' => 'published',
];
$this->drupalPostForm(NULL, $edit, t('Save (this translation)'));
$entity = $this->storage->loadUnchanged($id);
$this->assertTrue($entity->hasTranslation('it'));
$it_revision = $this->loadRevisionTranslation($entity, 'it');
$this->assertTrue($it_revision->hasTranslation('it'));
$this->drupalGet($overview_url);
$this->assertSession()->linkByHrefExists($it_delete_href);
$this->assertSession()->pageTextNotContains($warning);
$this->drupalGet($this->getEditUrl($it_revision));
$this->assertSession()->buttonExists('Delete translation');
// Create an English draft and verify the published translation was
// preserved.
$this->drupalLogin($this->editor);
$en_revision = $this->loadRevisionTranslation($entity, 'en');
$this->drupalGet($this->getEditUrl($en_revision));
$edit = [
'title[0][value]' => "Test $index.4 EN",
'moderation_state[0][state]' => 'draft',
];
$this->drupalPostForm(NULL, $edit, t('Save (this translation)'));
$entity = $this->storage->loadUnchanged($id);
$this->assertTrue($entity->hasTranslation('it'));
$en_revision = $this->loadRevisionTranslation($entity, 'en');
$this->assertTrue($en_revision->hasTranslation('it'));
$this->drupalLogin($this->currentAccount);
// Delete the translation and verify that it is actually gone and that it is
// possible to create it again.
$this->drupalGet($it_delete_url);
$this->drupalPostForm(NULL, [], 'Delete Italian translation');
$entity = $this->storage->loadUnchanged($id);
$this->assertFalse($entity->hasTranslation('it'));
$it_revision = $this->loadRevisionTranslation($entity, 'it');
$this->assertTrue($it_revision->wasDefaultRevision());
$this->assertTrue($it_revision->hasTranslation('it'));
$this->assertTrue($it_revision->getRevisionId() < $entity->getRevisionId());
$this->drupalGet($overview_url);
$this->assertSession()->linkByHrefNotExists($this->getEditUrl($it_revision)->toString());
$this->assertSession()->linkByHrefExists($add_translation_href);
// Publish the English draft and verify the translation is not accidentally
// restored.
$this->drupalLogin($this->editor);
$en_revision = $this->loadRevisionTranslation($entity, 'en');
$this->drupalGet($this->getEditUrl($en_revision));
$edit = [
'title[0][value]' => "Test $index.6 EN",
'moderation_state[0][state]' => 'published',
];
$this->drupalPostForm(NULL, $edit, t('Save'));
$entity = $this->storage->loadUnchanged($id);
$this->assertFalse($entity->hasTranslation('it'));
$this->drupalLogin($this->currentAccount);
// Create a published translation again and verify it could be deleted.
$this->drupalGet($add_translation_url);
$edit = [
'title[0][value]' => "Test $index.7 IT",
'moderation_state[0][state]' => 'published',
];
$this->drupalPostForm(NULL, $edit, t('Save (this translation)'));
$entity = $this->storage->loadUnchanged($id);
$this->assertTrue($entity->hasTranslation('it'));
$it_revision = $this->loadRevisionTranslation($entity, 'it');
$this->assertTrue($it_revision->hasTranslation('it'));
$this->drupalGet($overview_url);
$this->assertSession()->linkByHrefExists($it_delete_href);
// Create a translation draft again and verify it cannot be deleted.
$this->drupalGet($this->getEditUrl($it_revision));
$edit = [
'title[0][value]' => "Test $index.8 IT",
'moderation_state[0][state]' => 'draft',
];
$this->drupalPostForm(NULL, $edit, t('Save (this translation)'));
$entity = $this->storage->loadUnchanged($id);
$this->assertTrue($entity->hasTranslation('it'));
$it_revision = $this->loadRevisionTranslation($entity, 'it');
$this->assertTrue($it_revision->hasTranslation('it'));
$this->drupalGet($overview_url);
$this->assertSession()->linkByHrefNotExists($it_delete_href);
// Delete the translation draft and verify the translation can be deleted
// again, since the active revision is now a default revision.
$this->drupalLogin($this->editor);
$this->drupalGet($it_revision->toUrl('version-history'));
$revision_deletion_url = Url::fromRoute('node.revision_delete_confirm', [
'node' => $id,
'node_revision' => $it_revision->getRevisionId(),
],
[
'language' => ConfigurableLanguage::load('it'),
'absolute' => FALSE,
]
);
$revision_deletion_href = $revision_deletion_url->toString();
$this->getSession()->getDriver()->click("//a[@href='$revision_deletion_href']");
$this->drupalPostForm(NULL, [], 'Delete');
$this->drupalLogin($this->currentAccount);
$this->drupalGet($overview_url);
$this->assertSession()->linkByHrefExists($it_delete_href);
// Verify that now the translation can be deleted.
$this->drupalGet($it_delete_url);
$this->drupalPostForm(NULL, [], 'Delete Italian translation');
$entity = $this->storage->loadUnchanged($id);
$this->assertFalse($entity->hasTranslation('it'));
$it_revision = $this->loadRevisionTranslation($entity, 'it');
$this->assertTrue($it_revision->wasDefaultRevision());
$this->assertTrue($it_revision->hasTranslation('it'));
$this->assertTrue($it_revision->getRevisionId() < $entity->getRevisionId());
$this->drupalGet($overview_url);
$this->assertSession()->linkByHrefNotExists($this->getEditUrl($it_revision)->toString());
$this->assertSession()->linkByHrefExists($add_translation_href);
}
}

View file

@ -0,0 +1,298 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\comment\Plugin\Field\FieldType\CommentItemInterface;
use Drupal\comment\Tests\CommentTestTrait;
use Drupal\Core\Field\Entity\BaseFieldOverride;
use Drupal\Core\Language\Language;
use Drupal\field\Entity\FieldConfig;
use Drupal\language\Entity\ContentLanguageSettings;
use Drupal\Tests\BrowserTestBase;
/**
* Tests the content translation settings UI.
*
* @group content_translation
*/
class ContentTranslationSettingsTest extends BrowserTestBase {
use CommentTestTrait;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['language', 'content_translation', 'node', 'comment', 'field_ui', 'entity_test'];
protected function setUp() {
parent::setUp();
// Set up two content types to test fields shared between different
// bundles.
$this->drupalCreateContentType(['type' => 'article']);
$this->drupalCreateContentType(['type' => 'page']);
$this->addDefaultCommentField('node', 'article', 'comment_article', CommentItemInterface::OPEN, 'comment_article');
$this->addDefaultCommentField('node', 'page', 'comment_page');
$admin_user = $this->drupalCreateUser(['access administration pages', 'administer languages', 'administer content translation', 'administer content types', 'administer node fields', 'administer comment fields', 'administer comments', 'administer comment types', 'administer account settings']);
$this->drupalLogin($admin_user);
}
/**
* Tests that the settings UI works as expected.
*/
public function testSettingsUI() {
// Check for the content_translation_menu_links_discovered_alter() changes.
$this->drupalGet('admin/config');
$this->assertLink('Content language and translation');
$this->assertText('Configure language and translation support for content.');
// Test that the translation settings are ignored if the bundle is marked
// translatable but the entity type is not.
$edit = ['settings[comment][comment_article][translatable]' => TRUE];
$this->assertSettings('comment', NULL, FALSE, $edit);
// Test that the translation settings are ignored if only a field is marked
// as translatable and not the related entity type and bundle.
$edit = ['settings[comment][comment_article][fields][comment_body]' => TRUE];
$this->assertSettings('comment', NULL, FALSE, $edit);
// Test that the translation settings are not stored if an entity type and
// bundle are marked as translatable but no field is.
$edit = [
'entity_types[comment]' => TRUE,
'settings[comment][comment_article][translatable]' => TRUE,
// Base fields are translatable by default.
'settings[comment][comment_article][fields][changed]' => FALSE,
'settings[comment][comment_article][fields][created]' => FALSE,
'settings[comment][comment_article][fields][homepage]' => FALSE,
'settings[comment][comment_article][fields][hostname]' => FALSE,
'settings[comment][comment_article][fields][mail]' => FALSE,
'settings[comment][comment_article][fields][name]' => FALSE,
'settings[comment][comment_article][fields][status]' => FALSE,
'settings[comment][comment_article][fields][subject]' => FALSE,
'settings[comment][comment_article][fields][uid]' => FALSE,
];
$this->assertSettings('comment', 'comment_article', FALSE, $edit);
$xpath_err = '//div[contains(@class, "error")]';
$this->assertTrue($this->xpath($xpath_err), 'Enabling translation only for entity bundles generates a form error.');
// Test that the translation settings are not stored if a non-configurable
// language is set as default and the language selector is hidden.
$edit = [
'entity_types[comment]' => TRUE,
'settings[comment][comment_article][settings][language][langcode]' => Language::LANGCODE_NOT_SPECIFIED,
'settings[comment][comment_article][settings][language][language_alterable]' => FALSE,
'settings[comment][comment_article][translatable]' => TRUE,
'settings[comment][comment_article][fields][comment_body]' => TRUE,
];
$this->assertSettings('comment', 'comment_article', FALSE, $edit);
$this->assertTrue($this->xpath($xpath_err), 'Enabling translation with a fixed non-configurable language generates a form error.');
// Test that a field shared among different bundles can be enabled without
// needing to make all the related bundles translatable.
$edit = [
'entity_types[comment]' => TRUE,
'settings[comment][comment_article][settings][language][langcode]' => 'current_interface',
'settings[comment][comment_article][settings][language][language_alterable]' => TRUE,
'settings[comment][comment_article][translatable]' => TRUE,
'settings[comment][comment_article][fields][comment_body]' => TRUE,
// Override both comment subject fields to untranslatable.
'settings[comment][comment_article][fields][subject]' => FALSE,
'settings[comment][comment][fields][subject]' => FALSE,
];
$this->assertSettings('comment', 'comment_article', TRUE, $edit);
$definition = $this->entityManager()->getFieldDefinitions('comment', 'comment_article')['comment_body'];
$this->assertTrue($definition->isTranslatable(), 'Article comment body is translatable.');
$definition = $this->entityManager()->getFieldDefinitions('comment', 'comment_article')['subject'];
$this->assertFalse($definition->isTranslatable(), 'Article comment subject is not translatable.');
$definition = $this->entityManager()->getFieldDefinitions('comment', 'comment')['comment_body'];
$this->assertFalse($definition->isTranslatable(), 'Page comment body is not translatable.');
$definition = $this->entityManager()->getFieldDefinitions('comment', 'comment')['subject'];
$this->assertFalse($definition->isTranslatable(), 'Page comment subject is not translatable.');
// Test that translation can be enabled for base fields.
$edit = [
'entity_types[entity_test_mul]' => TRUE,
'settings[entity_test_mul][entity_test_mul][translatable]' => TRUE,
'settings[entity_test_mul][entity_test_mul][fields][name]' => TRUE,
'settings[entity_test_mul][entity_test_mul][fields][user_id]' => FALSE,
];
$this->assertSettings('entity_test_mul', 'entity_test_mul', TRUE, $edit);
$field_override = BaseFieldOverride::loadByName('entity_test_mul', 'entity_test_mul', 'name');
$this->assertTrue($field_override->isTranslatable(), 'Base fields can be overridden with a base field bundle override entity.');
$definitions = $this->entityManager()->getFieldDefinitions('entity_test_mul', 'entity_test_mul');
$this->assertTrue($definitions['name']->isTranslatable() && !$definitions['user_id']->isTranslatable(), 'Base field bundle overrides were correctly altered.');
// Test that language settings are correctly stored.
$language_configuration = ContentLanguageSettings::loadByEntityTypeBundle('comment', 'comment_article');
$this->assertEqual($language_configuration->getDefaultLangcode(), 'current_interface', 'The default language for article comments is set to the interface text language selected for page.');
$this->assertTrue($language_configuration->isLanguageAlterable(), 'The language selector for article comments is shown.');
// Verify language widget appears on comment type form.
$this->drupalGet('admin/structure/comment/manage/comment_article');
$this->assertField('language_configuration[content_translation]');
$this->assertFieldChecked('edit-language-configuration-content-translation');
// Verify that translation may be enabled for the article content type.
$edit = [
'language_configuration[content_translation]' => TRUE,
];
// Make sure the checkbox is available and not checked by default.
$this->drupalGet('admin/structure/types/manage/article');
$this->assertField('language_configuration[content_translation]');
$this->assertNoFieldChecked('edit-language-configuration-content-translation');
$this->drupalPostForm('admin/structure/types/manage/article', $edit, t('Save content type'));
$this->drupalGet('admin/structure/types/manage/article');
$this->assertFieldChecked('edit-language-configuration-content-translation');
// Test that the title field of nodes is available in the settings form.
$edit = [
'entity_types[node]' => TRUE,
'settings[node][article][settings][language][langcode]' => 'current_interface',
'settings[node][article][settings][language][language_alterable]' => TRUE,
'settings[node][article][translatable]' => TRUE,
'settings[node][article][fields][title]' => TRUE,
];
$this->assertSettings('node', NULL, TRUE, $edit);
foreach ([TRUE, FALSE] as $translatable) {
// Test that configurable field translatability is correctly switched.
$edit = ['settings[node][article][fields][body]' => $translatable];
$this->assertSettings('node', 'article', TRUE, $edit);
$field = FieldConfig::loadByName('node', 'article', 'body');
$definitions = \Drupal::entityManager()->getFieldDefinitions('node', 'article');
$this->assertEqual($definitions['body']->isTranslatable(), $translatable, 'Field translatability correctly switched.');
$this->assertEqual($field->isTranslatable(), $definitions['body']->isTranslatable(), 'Configurable field translatability correctly switched.');
// Test that also the Field UI form behaves correctly.
$translatable = !$translatable;
$edit = ['translatable' => $translatable];
$this->drupalPostForm('admin/structure/types/manage/article/fields/node.article.body', $edit, t('Save settings'));
\Drupal::entityManager()->clearCachedFieldDefinitions();
$field = FieldConfig::loadByName('node', 'article', 'body');
$definitions = \Drupal::entityManager()->getFieldDefinitions('node', 'article');
$this->assertEqual($definitions['body']->isTranslatable(), $translatable, 'Field translatability correctly switched.');
$this->assertEqual($field->isTranslatable(), $definitions['body']->isTranslatable(), 'Configurable field translatability correctly switched.');
}
// Test that the order of the language list is similar to other language
// lists, such as in Views UI.
$this->drupalGet('admin/config/regional/content-language');
$expected_elements = [
'site_default',
'current_interface',
'authors_default',
'en',
'und',
'zxx',
];
$elements = $this->xpath('//select[@id="edit-settings-node-article-settings-language-langcode"]/option');
// Compare values inside the option elements with expected values.
for ($i = 0; $i < count($elements); $i++) {
$this->assertEqual($elements[$i]->getValue(), $expected_elements[$i]);
}
}
/**
* Tests the language settings checkbox on account settings page.
*/
public function testAccountLanguageSettingsUI() {
// Make sure the checkbox is available and not checked by default.
$this->drupalGet('admin/config/people/accounts');
$this->assertField('language[content_translation]');
$this->assertNoFieldChecked('edit-language-content-translation');
$edit = [
'language[content_translation]' => TRUE,
];
$this->drupalPostForm('admin/config/people/accounts', $edit, t('Save configuration'));
$this->drupalGet('admin/config/people/accounts');
$this->assertFieldChecked('edit-language-content-translation');
// Make sure account settings can be saved.
$this->drupalPostForm('admin/config/people/accounts', ['anonymous' => 'Save me please!'], 'Save configuration');
$this->assertFieldByName('anonymous', 'Save me please!', 'Anonymous name has been changed.');
$this->assertText('The configuration options have been saved.');
}
/**
* Asserts that translatability has the expected value for the given bundle.
*
* @param string $entity_type
* The entity type for which to check translatability.
* @param string $bundle
* The bundle for which to check translatability.
* @param bool $enabled
* TRUE if translatability should be enabled, FALSE otherwise.
* @param array $edit
* An array of values to submit to the content translation settings page.
*
* @return bool
* TRUE if the assertion succeeded, FALSE otherwise.
*/
protected function assertSettings($entity_type, $bundle, $enabled, $edit) {
$this->drupalPostForm('admin/config/regional/content-language', $edit, t('Save configuration'));
$args = ['@entity_type' => $entity_type, '@bundle' => $bundle, '@enabled' => $enabled ? 'enabled' : 'disabled'];
$message = format_string('Translation for entity @entity_type (@bundle) is @enabled.', $args);
\Drupal::entityManager()->clearCachedDefinitions();
return $this->assertEqual(\Drupal::service('content_translation.manager')->isEnabled($entity_type, $bundle), $enabled, $message);
}
/**
* Tests that field setting depends on bundle translatability.
*/
public function testFieldTranslatableSettingsUI() {
// At least one field needs to be translatable to enable article for
// translation. Create an extra field to be used for this purpose. We use
// the UI to test our form alterations.
$edit = [
'new_storage_type' => 'text',
'label' => 'Test',
'field_name' => 'article_text',
];
$this->drupalPostForm('admin/structure/types/manage/article/fields/add-field', $edit, 'Save and continue');
// Tests that field doesn't have translatable setting if bundle is not
// translatable.
$path = 'admin/structure/types/manage/article/fields/node.article.field_article_text';
$this->drupalGet($path);
$this->assertFieldByXPath('//input[@id="edit-translatable" and @disabled="disabled"]');
$this->assertText('To configure translation for this field, enable language support for this type.', 'No translatable setting for field.');
// Tests that field has translatable setting if bundle is translatable.
// Note: this field is not translatable when enable bundle translatability.
$edit = [
'entity_types[node]' => TRUE,
'settings[node][article][settings][language][language_alterable]' => TRUE,
'settings[node][article][translatable]' => TRUE,
'settings[node][article][fields][field_article_text]' => TRUE,
];
$this->assertSettings('node', 'article', TRUE, $edit);
$this->drupalGet($path);
$this->assertFieldByXPath('//input[@id="edit-translatable" and not(@disabled) and @checked="checked"]');
$this->assertNoText('To enable translation of this field, enable language support for this type.', 'Translatable setting for field available.');
}
/**
* Tests the translatable settings checkbox for untranslatable entities.
*/
public function testNonTranslatableTranslationSettingsUI() {
$this->drupalGet('admin/config/regional/content-language');
$this->assertNoField('settings[entity_test][entity_test][translatable]');
}
/**
* Returns the entity manager.
*
* @return \Drupal\Core\Entity\EntityManagerInterface
* The entity manager;
*/
protected function entityManager() {
return $this->container->get('entity.manager');
}
}

View file

@ -0,0 +1,84 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Tests the Content translation settings using the standard profile.
*
* @group content_translation
*/
class ContentTranslationStandardFieldsTest extends BrowserTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = [
'language',
'content_translation',
'node',
'comment',
'field_ui',
'entity_test',
];
/**
* {@inheritdoc}
*/
protected $profile = 'standard';
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$admin_user = $this->drupalCreateUser([
'access administration pages',
'administer languages',
'administer content translation',
'administer content types',
'administer node fields',
'administer comment fields',
'administer comments',
'administer comment types',
]);
$this->drupalLogin($admin_user);
}
/**
* Tests that translatable fields are being rendered.
*/
public function testFieldTranslatableArticle() {
$path = 'admin/config/regional/content-language';
$this->drupalGet($path);
// Check content block fields.
$this->assertFieldByXPath("//input[@id='edit-settings-block-content-basic-fields-body' and @checked='checked']");
// Check comment fields.
$this->assertFieldByXPath("//input[@id='edit-settings-comment-comment-fields-comment-body' and @checked='checked']");
// Check node fields.
$this->assertFieldByXPath("//input[@id='edit-settings-node-article-fields-comment' and @checked='checked']");
$this->assertFieldByXPath("//input[@id='edit-settings-node-article-fields-field-image' and @checked='checked']");
$this->assertFieldByXPath("//input[@id='edit-settings-node-article-fields-field-tags' and @checked='checked']");
// Check user fields.
$this->assertFieldByXPath("//input[@id='edit-settings-user-user-fields-user-picture' and @checked='checked']");
}
/**
* Test that revision_log is not translatable.
*/
public function testRevisionLogNotTranslatable() {
$path = 'admin/config/regional/content-language';
$this->drupalGet($path);
$this->assertNoFieldByXPath("//input[@id='edit-settings-node-article-fields-revision-log']");
}
}

View file

@ -0,0 +1,253 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\Core\Entity\EntityInterface;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\file\Entity\File;
use Drupal\Tests\TestFileCreationTrait;
/**
* Tests the field synchronization behavior for the image field.
*
* @group content_translation
*/
class ContentTranslationSyncImageTest extends ContentTranslationTestBase {
use TestFileCreationTrait {
getTestFiles as drupalGetTestFiles;
}
/**
* The cardinality of the image field.
*
* @var int
*/
protected $cardinality;
/**
* The test image files.
*
* @var array
*/
protected $files;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['language', 'content_translation', 'entity_test', 'image', 'field_ui'];
protected function setUp() {
parent::setUp();
$this->files = $this->drupalGetTestFiles('image');
}
/**
* Creates the test image field.
*/
protected function setupTestFields() {
$this->fieldName = 'field_test_et_ui_image';
$this->cardinality = 3;
FieldStorageConfig::create([
'field_name' => $this->fieldName,
'entity_type' => $this->entityTypeId,
'type' => 'image',
'cardinality' => $this->cardinality,
])->save();
FieldConfig::create([
'entity_type' => $this->entityTypeId,
'field_name' => $this->fieldName,
'bundle' => $this->entityTypeId,
'label' => 'Test translatable image field',
'third_party_settings' => [
'content_translation' => [
'translation_sync' => [
'file' => FALSE,
'alt' => 'alt',
'title' => 'title',
],
],
],
])->save();
}
/**
* {@inheritdoc}
*/
protected function getEditorPermissions() {
// Every entity-type-specific test needs to define these.
return ['administer entity_test_mul fields', 'administer languages', 'administer content translation'];
}
/**
* Tests image field field synchronization.
*/
public function testImageFieldSync() {
// Check that the alt and title fields are enabled for the image field.
$this->drupalLogin($this->editor);
$this->drupalGet('entity_test_mul/structure/' . $this->entityTypeId . '/fields/' . $this->entityTypeId . '.' . $this->entityTypeId . '.' . $this->fieldName);
$this->assertFieldChecked('edit-third-party-settings-content-translation-translation-sync-alt');
$this->assertFieldChecked('edit-third-party-settings-content-translation-translation-sync-title');
$edit = [
'third_party_settings[content_translation][translation_sync][alt]' => FALSE,
'third_party_settings[content_translation][translation_sync][title]' => FALSE,
];
$this->drupalPostForm(NULL, $edit, t('Save settings'));
// Check that the content translation settings page reflects the changes
// performed in the field edit page.
$this->drupalGet('admin/config/regional/content-language');
$this->assertNoFieldChecked('edit-settings-entity-test-mul-entity-test-mul-columns-field-test-et-ui-image-alt');
$this->assertNoFieldChecked('edit-settings-entity-test-mul-entity-test-mul-columns-field-test-et-ui-image-title');
$edit = [
'settings[entity_test_mul][entity_test_mul][fields][field_test_et_ui_image]' => TRUE,
'settings[entity_test_mul][entity_test_mul][columns][field_test_et_ui_image][alt]' => TRUE,
'settings[entity_test_mul][entity_test_mul][columns][field_test_et_ui_image][title]' => TRUE,
];
$this->drupalPostForm('admin/config/regional/content-language', $edit, t('Save configuration'));
$errors = $this->xpath('//div[contains(@class, "messages--error")]');
$this->assertFalse($errors, 'Settings correctly stored.');
$this->assertFieldChecked('edit-settings-entity-test-mul-entity-test-mul-columns-field-test-et-ui-image-alt');
$this->assertFieldChecked('edit-settings-entity-test-mul-entity-test-mul-columns-field-test-et-ui-image-title');
$this->drupalLogin($this->translator);
$default_langcode = $this->langcodes[0];
$langcode = $this->langcodes[1];
// Populate the test entity with some random initial values.
$values = [
'name' => $this->randomMachineName(),
'user_id' => mt_rand(1, 128),
'langcode' => $default_langcode,
];
$entity = entity_create($this->entityTypeId, $values);
// Create some file entities from the generated test files and store them.
$values = [];
for ($delta = 0; $delta < $this->cardinality; $delta++) {
// For the default language use the same order for files and field items.
$index = $delta;
// Create the file entity for the image being processed and record its
// identifier.
$field_values = [
'uri' => $this->files[$index]->uri,
'uid' => \Drupal::currentUser()->id(),
'status' => FILE_STATUS_PERMANENT,
];
$file = File::create($field_values);
$file->save();
$fid = $file->id();
$this->files[$index]->fid = $fid;
// Generate the item for the current image file entity and attach it to
// the entity.
$item = [
'target_id' => $fid,
'alt' => $default_langcode . '_' . $fid . '_' . $this->randomMachineName(),
'title' => $default_langcode . '_' . $fid . '_' . $this->randomMachineName(),
];
$entity->{$this->fieldName}[] = $item;
// Store the generated values keying them by fid for easier lookup.
$values[$default_langcode][$fid] = $item;
}
$entity = $this->saveEntity($entity);
// Create some field translations for the test image field. The translated
// items will be one less than the original values to check that only the
// translated ones will be preserved. In fact we want the same fids and
// items order for both languages.
$translation = $entity->addTranslation($langcode);
for ($delta = 0; $delta < $this->cardinality - 1; $delta++) {
// Simulate a field reordering: items are shifted of one position ahead.
// The modulo operator ensures we start from the beginning after reaching
// the maximum allowed delta.
$index = ($delta + 1) % $this->cardinality;
// Generate the item for the current image file entity and attach it to
// the entity.
$fid = $this->files[$index]->fid;
$item = [
'target_id' => $fid,
'alt' => $langcode . '_' . $fid . '_' . $this->randomMachineName(),
'title' => $langcode . '_' . $fid . '_' . $this->randomMachineName(),
];
$translation->{$this->fieldName}[] = $item;
// Again store the generated values keying them by fid for easier lookup.
$values[$langcode][$fid] = $item;
}
// Perform synchronization: the translation language is used as source,
// while the default language is used as target.
$this->manager->getTranslationMetadata($translation)->setSource($default_langcode);
$entity = $this->saveEntity($translation);
$translation = $entity->getTranslation($langcode);
// Check that one value has been dropped from the original values.
$assert = count($entity->{$this->fieldName}) == 2;
$this->assertTrue($assert, 'One item correctly removed from the synchronized field values.');
// Check that fids have been synchronized and translatable column values
// have been retained.
$fids = [];
foreach ($entity->{$this->fieldName} as $delta => $item) {
$value = $values[$default_langcode][$item->target_id];
$source_item = $translation->{$this->fieldName}->get($delta);
$assert = $item->target_id == $source_item->target_id && $item->alt == $value['alt'] && $item->title == $value['title'];
$this->assertTrue($assert, format_string('Field item @fid has been successfully synchronized.', ['@fid' => $item->target_id]));
$fids[$item->target_id] = TRUE;
}
// Check that the dropped value is the right one.
$removed_fid = $this->files[0]->fid;
$this->assertTrue(!isset($fids[$removed_fid]), format_string('Field item @fid has been correctly removed.', ['@fid' => $removed_fid]));
// Add back an item for the dropped value and perform synchronization again.
$values[$langcode][$removed_fid] = [
'target_id' => $removed_fid,
'alt' => $langcode . '_' . $removed_fid . '_' . $this->randomMachineName(),
'title' => $langcode . '_' . $removed_fid . '_' . $this->randomMachineName(),
];
$translation->{$this->fieldName}->setValue(array_values($values[$langcode]));
$entity = $this->saveEntity($translation);
$translation = $entity->getTranslation($langcode);
// Check that the value has been added to the default language.
$assert = count($entity->{$this->fieldName}->getValue()) == 3;
$this->assertTrue($assert, 'One item correctly added to the synchronized field values.');
foreach ($entity->{$this->fieldName} as $delta => $item) {
// When adding an item its value is copied over all the target languages,
// thus in this case the source language needs to be used to check the
// values instead of the target one.
$fid_langcode = $item->target_id != $removed_fid ? $default_langcode : $langcode;
$value = $values[$fid_langcode][$item->target_id];
$source_item = $translation->{$this->fieldName}->get($delta);
$assert = $item->target_id == $source_item->target_id && $item->alt == $value['alt'] && $item->title == $value['title'];
$this->assertTrue($assert, format_string('Field item @fid has been successfully synchronized.', ['@fid' => $item->target_id]));
}
}
/**
* Saves the passed entity and reloads it, enabling compatibility mode.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to be saved.
*
* @return \Drupal\Core\Entity\EntityInterface
* The saved entity.
*/
protected function saveEntity(EntityInterface $entity) {
$entity->save();
$entity = entity_test_mul_load($entity->id(), TRUE);
return $entity;
}
}

View file

@ -0,0 +1,260 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
use Drupal\field\Entity\FieldConfig;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\BrowserTestBase;
use Drupal\field\Entity\FieldStorageConfig;
/**
* Base class for content translation tests.
*/
abstract class ContentTranslationTestBase extends BrowserTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['text'];
/**
* The entity type being tested.
*
* @var string
*/
protected $entityTypeId = 'entity_test_mul';
/**
* The bundle being tested.
*
* @var string
*/
protected $bundle;
/**
* The added languages.
*
* @var array
*/
protected $langcodes;
/**
* The account to be used to test translation operations.
*
* @var \Drupal\user\UserInterface
*/
protected $translator;
/**
* The account to be used to test multilingual entity editing.
*
* @var \Drupal\user\UserInterface
*/
protected $editor;
/**
* The account to be used to test access to both workflows.
*
* @var \Drupal\user\UserInterface
*/
protected $administrator;
/**
* The name of the field used to test translation.
*
* @var string
*/
protected $fieldName;
/**
* The translation controller for the current entity type.
*
* @var \Drupal\content_translation\ContentTranslationHandlerInterface
*/
protected $controller;
/**
* @var \Drupal\content_translation\ContentTranslationManagerInterface
*/
protected $manager;
protected function setUp() {
parent::setUp();
$this->setupLanguages();
$this->setupBundle();
$this->enableTranslation();
$this->setupUsers();
$this->setupTestFields();
$this->manager = $this->container->get('content_translation.manager');
$this->controller = $this->manager->getTranslationHandler($this->entityTypeId);
// Rebuild the container so that the new languages are picked up by services
// that hold a list of languages.
$this->rebuildContainer();
}
/**
* Adds additional languages.
*/
protected function setupLanguages() {
$this->langcodes = ['it', 'fr'];
foreach ($this->langcodes as $langcode) {
ConfigurableLanguage::createFromLangcode($langcode)->save();
}
array_unshift($this->langcodes, \Drupal::languageManager()->getDefaultLanguage()->getId());
}
/**
* Returns an array of permissions needed for the translator.
*/
protected function getTranslatorPermissions() {
return array_filter([$this->getTranslatePermission(), 'create content translations', 'update content translations', 'delete content translations']);
}
/**
* Returns the translate permissions for the current entity and bundle.
*/
protected function getTranslatePermission() {
$entity_type = \Drupal::entityManager()->getDefinition($this->entityTypeId);
if ($permission_granularity = $entity_type->getPermissionGranularity()) {
return $permission_granularity == 'bundle' ? "translate {$this->bundle} {$this->entityTypeId}" : "translate {$this->entityTypeId}";
}
}
/**
* Returns an array of permissions needed for the editor.
*/
protected function getEditorPermissions() {
// Every entity-type-specific test needs to define these.
return [];
}
/**
* Returns an array of permissions needed for the administrator.
*/
protected function getAdministratorPermissions() {
return array_merge($this->getEditorPermissions(), $this->getTranslatorPermissions(), ['administer languages', 'administer content translation']);
}
/**
* Creates and activates translator, editor and admin users.
*/
protected function setupUsers() {
$this->translator = $this->drupalCreateUser($this->getTranslatorPermissions(), 'translator');
$this->editor = $this->drupalCreateUser($this->getEditorPermissions(), 'editor');
$this->administrator = $this->drupalCreateUser($this->getAdministratorPermissions(), 'administrator');
$this->drupalLogin($this->translator);
}
/**
* Creates or initializes the bundle date if needed.
*/
protected function setupBundle() {
if (empty($this->bundle)) {
$this->bundle = $this->entityTypeId;
}
}
/**
* Enables translation for the current entity type and bundle.
*/
protected function enableTranslation() {
// Enable translation for the current entity type and ensure the change is
// picked up.
\Drupal::service('content_translation.manager')->setEnabled($this->entityTypeId, $this->bundle, TRUE);
drupal_static_reset();
\Drupal::entityManager()->clearCachedDefinitions();
\Drupal::service('router.builder')->rebuild();
\Drupal::service('entity.definition_update_manager')->applyUpdates();
}
/**
* Creates the test fields.
*/
protected function setupTestFields() {
if (empty($this->fieldName)) {
$this->fieldName = 'field_test_et_ui_test';
}
FieldStorageConfig::create([
'field_name' => $this->fieldName,
'type' => 'string',
'entity_type' => $this->entityTypeId,
'cardinality' => 1,
])->save();
FieldConfig::create([
'entity_type' => $this->entityTypeId,
'field_name' => $this->fieldName,
'bundle' => $this->bundle,
'label' => 'Test translatable text-field',
])->save();
entity_get_form_display($this->entityTypeId, $this->bundle, 'default')
->setComponent($this->fieldName, [
'type' => 'string_textfield',
'weight' => 0,
])
->save();
}
/**
* Creates the entity to be translated.
*
* @param array $values
* An array of initial values for the entity.
* @param string $langcode
* The initial language code of the entity.
* @param string $bundle_name
* (optional) The entity bundle, if the entity uses bundles. Defaults to
* NULL. If left NULL, $this->bundle will be used.
*
* @return string
* The entity id.
*/
protected function createEntity($values, $langcode, $bundle_name = NULL) {
$entity_values = $values;
$entity_values['langcode'] = $langcode;
$entity_type = \Drupal::entityManager()->getDefinition($this->entityTypeId);
if ($bundle_key = $entity_type->getKey('bundle')) {
$entity_values[$bundle_key] = $bundle_name ?: $this->bundle;
}
$controller = $this->container->get('entity.manager')->getStorage($this->entityTypeId);
if (!($controller instanceof SqlContentEntityStorage)) {
foreach ($values as $property => $value) {
if (is_array($value)) {
$entity_values[$property] = [$langcode => $value];
}
}
}
$entity = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId)
->create($entity_values);
$entity->save();
return $entity->id();
}
/**
* Returns the edit URL for the specified entity.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity being edited.
*
* @return \Drupal\Core\Url
* The edit URL.
*/
protected function getEditUrl(ContentEntityInterface $entity) {
if ($entity->access('update', $this->loggedInUser)) {
$url = $entity->toUrl('edit-form');
}
else {
$url = $entity->toUrl('drupal:content-translation-edit');
$url->setRouteParameter('language', $entity->language()->getId());
}
return $url;
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Tests the content translation UI check skip.
*
* @group content_translation
*/
class ContentTranslationUISkipTest extends BrowserTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['content_translation_test', 'user', 'node'];
/**
* Tests the content_translation_ui_skip key functionality.
*/
public function testUICheckSkip() {
$admin_user = $this->drupalCreateUser([
'translate any entity',
'administer content translation',
'administer languages',
]);
$this->drupalLogin($admin_user);
// Visit the content translation.
$this->drupalGet('admin/config/regional/content-language');
// Check the message regarding UI integration.
$this->assertText('Test entity - Translatable skip UI check');
$this->assertText('Test entity - Translatable check UI (Translation is not supported)');
}
}

View file

@ -0,0 +1,625 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Language\Language;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Url;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
/**
* Tests the Content Translation UI.
*/
abstract class ContentTranslationUITestBase extends ContentTranslationTestBase {
use AssertPageCacheContextsAndTagsTrait;
/**
* The id of the entity being translated.
*
* @var mixed
*/
protected $entityId;
/**
* Whether the behavior of the language selector should be tested.
*
* @var bool
*/
protected $testLanguageSelector = TRUE;
/**
* Flag to determine if "all languages" rendering is tested.
*
* @var bool
*/
protected $testHTMLEscapeForAllLanguages = FALSE;
/**
* Default cache contexts expected on a non-translated entity.
*
* Cache contexts will not be checked if this list is empty.
*
* @var string[]
*/
protected $defaultCacheContexts = ['languages:language_interface', 'theme', 'url.query_args:_wrapper_format', 'user.permissions'];
/**
* Tests the basic translation UI.
*/
public function testTranslationUI() {
$this->doTestBasicTranslation();
$this->doTestTranslationOverview();
$this->doTestOutdatedStatus();
$this->doTestPublishedStatus();
$this->doTestAuthoringInfo();
$this->doTestTranslationEdit();
$this->doTestTranslationChanged();
$this->doTestChangedTimeAfterSaveWithoutChanges();
$this->doTestTranslationDeletion();
}
/**
* Tests the basic translation workflow.
*/
protected function doTestBasicTranslation() {
// Create a new test entity with original values in the default language.
$default_langcode = $this->langcodes[0];
$values[$default_langcode] = $this->getNewEntityValues($default_langcode);
// Create the entity with the editor as owner, so that afterwards a new
// translation is created by the translator and the translation author is
// tested.
$this->drupalLogin($this->editor);
$this->entityId = $this->createEntity($values[$default_langcode], $default_langcode);
$this->drupalLogin($this->translator);
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->assertTrue($entity, 'Entity found in the database.');
$this->drupalGet($entity->urlInfo());
$this->assertResponse(200, 'Entity URL is valid.');
// Ensure that the content language cache context is not yet added to the
// page.
$this->assertCacheContexts($this->defaultCacheContexts);
$this->drupalGet($entity->urlInfo('drupal:content-translation-overview'));
$this->assertNoText('Source language', 'Source language column correctly hidden.');
$translation = $this->getTranslation($entity, $default_langcode);
foreach ($values[$default_langcode] as $property => $value) {
$stored_value = $this->getValue($translation, $property, $default_langcode);
$value = is_array($value) ? $value[0]['value'] : $value;
$message = format_string('@property correctly stored in the default language.', ['@property' => $property]);
$this->assertEqual($stored_value, $value, $message);
}
// Add a content translation.
$langcode = 'it';
$language = ConfigurableLanguage::load($langcode);
$values[$langcode] = $this->getNewEntityValues($langcode);
$entity_type_id = $entity->getEntityTypeId();
$add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [
$entity->getEntityTypeId() => $entity->id(),
'source' => $default_langcode,
'target' => $langcode,
], ['language' => $language]);
$this->drupalPostForm($add_url, $this->getEditValues($values, $langcode), $this->getFormSubmitActionForNewTranslation($entity, $langcode));
// Assert that HTML is not escaped unexpectedly.
if ($this->testHTMLEscapeForAllLanguages) {
$this->assertNoRaw('&lt;span class=&quot;translation-entity-all-languages&quot;&gt;(all languages)&lt;/span&gt;');
$this->assertRaw('<span class="translation-entity-all-languages">(all languages)</span>');
}
// Ensure that the content language cache context is not yet added to the
// page.
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->drupalGet($entity->urlInfo());
$this->assertCacheContexts(Cache::mergeContexts(['languages:language_content'], $this->defaultCacheContexts));
// Reset the cache of the entity, so that the new translation gets the
// updated values.
$metadata_source_translation = $this->manager->getTranslationMetadata($entity->getTranslation($default_langcode));
$metadata_target_translation = $this->manager->getTranslationMetadata($entity->getTranslation($langcode));
$author_field_name = $entity->hasField('content_translation_uid') ? 'content_translation_uid' : 'uid';
if ($entity->getFieldDefinition($author_field_name)->isTranslatable()) {
$this->assertEqual($metadata_target_translation->getAuthor()->id(), $this->translator->id(),
new FormattableMarkup('Author of the target translation @langcode correctly stored for translatable owner field.', ['@langcode' => $langcode]));
$this->assertNotEqual($metadata_target_translation->getAuthor()->id(), $metadata_source_translation->getAuthor()->id(),
new FormattableMarkup('Author of the target translation @target different from the author of the source translation @source for translatable owner field.',
['@target' => $langcode, '@source' => $default_langcode]));
}
else {
$this->assertEqual($metadata_target_translation->getAuthor()->id(), $this->editor->id(), 'Author of the entity remained untouched after translation for non translatable owner field.');
}
$created_field_name = $entity->hasField('content_translation_created') ? 'content_translation_created' : 'created';
if ($entity->getFieldDefinition($created_field_name)->isTranslatable()) {
$this->assertTrue($metadata_target_translation->getCreatedTime() > $metadata_source_translation->getCreatedTime(),
new FormattableMarkup('Translation creation timestamp of the target translation @target is newer than the creation timestamp of the source translation @source for translatable created field.',
['@target' => $langcode, '@source' => $default_langcode]));
}
else {
$this->assertEqual($metadata_target_translation->getCreatedTime(), $metadata_source_translation->getCreatedTime(), 'Creation timestamp of the entity remained untouched after translation for non translatable created field.');
}
if ($this->testLanguageSelector) {
$this->assertNoFieldByXPath('//select[@id="edit-langcode-0-value"]', NULL, 'Language selector correctly disabled on translations.');
}
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->drupalGet($entity->urlInfo('drupal:content-translation-overview'));
$this->assertNoText('Source language', 'Source language column correctly hidden.');
// Switch the source language.
$langcode = 'fr';
$language = ConfigurableLanguage::load($langcode);
$source_langcode = 'it';
$edit = ['source_langcode[source]' => $source_langcode];
$entity_type_id = $entity->getEntityTypeId();
$add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [
$entity->getEntityTypeId() => $entity->id(),
'source' => $default_langcode,
'target' => $langcode,
], ['language' => $language]);
// This does not save anything, it merely reloads the form and fills in the
// fields with the values from the different source language.
$this->drupalPostForm($add_url, $edit, t('Change'));
$this->assertFieldByXPath("//input[@name=\"{$this->fieldName}[0][value]\"]", $values[$source_langcode][$this->fieldName][0]['value'], 'Source language correctly switched.');
// Add another translation and mark the other ones as outdated.
$values[$langcode] = $this->getNewEntityValues($langcode);
$edit = $this->getEditValues($values, $langcode) + ['content_translation[retranslate]' => TRUE];
$entity_type_id = $entity->getEntityTypeId();
$add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [
$entity->getEntityTypeId() => $entity->id(),
'source' => $source_langcode,
'target' => $langcode,
], ['language' => $language]);
$this->drupalPostForm($add_url, $edit, $this->getFormSubmitActionForNewTranslation($entity, $langcode));
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->drupalGet($entity->urlInfo('drupal:content-translation-overview'));
$this->assertText('Source language', 'Source language column correctly shown.');
// Check that the entered values have been correctly stored.
foreach ($values as $langcode => $property_values) {
$translation = $this->getTranslation($entity, $langcode);
foreach ($property_values as $property => $value) {
$stored_value = $this->getValue($translation, $property, $langcode);
$value = is_array($value) ? $value[0]['value'] : $value;
$message = format_string('%property correctly stored with language %language.', ['%property' => $property, '%language' => $langcode]);
$this->assertEqual($stored_value, $value, $message);
}
}
}
/**
* Tests that the translation overview shows the correct values.
*/
protected function doTestTranslationOverview() {
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$translate_url = $entity->urlInfo('drupal:content-translation-overview');
$this->drupalGet($translate_url);
$translate_url->setAbsolute(FALSE);
foreach ($this->langcodes as $langcode) {
if ($entity->hasTranslation($langcode)) {
$language = new Language(['id' => $langcode]);
$view_url = $entity->url('canonical', ['language' => $language]);
$elements = $this->xpath('//table//a[@href=:href]', [':href' => $view_url]);
$this->assertEqual($elements[0]->getText(), $entity->getTranslation($langcode)->label(), new FormattableMarkup('Label correctly shown for %language translation.', ['%language' => $langcode]));
$edit_path = $entity->url('edit-form', ['language' => $language]);
$elements = $this->xpath('//table//ul[@class="dropbutton"]/li/a[@href=:href]', [':href' => $edit_path]);
$this->assertEqual($elements[0]->getText(), t('Edit'), new FormattableMarkup('Edit link correct for %language translation.', ['%language' => $langcode]));
}
}
}
/**
* Tests up-to-date status tracking.
*/
protected function doTestOutdatedStatus() {
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$langcode = 'fr';
$languages = \Drupal::languageManager()->getLanguages();
// Mark translations as outdated.
$edit = ['content_translation[retranslate]' => TRUE];
$edit_path = $entity->urlInfo('edit-form', ['language' => $languages[$langcode]]);
$this->drupalPostForm($edit_path, $edit, $this->getFormSubmitAction($entity, $langcode));
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
// Check that every translation has the correct "outdated" status, and that
// the Translation fieldset is open if the translation is "outdated".
foreach ($this->langcodes as $added_langcode) {
$url = $entity->urlInfo('edit-form', ['language' => ConfigurableLanguage::load($added_langcode)]);
$this->drupalGet($url);
if ($added_langcode == $langcode) {
$this->assertFieldByXPath('//input[@name="content_translation[retranslate]"]', FALSE, 'The retranslate flag is not checked by default.');
$this->assertFalse($this->xpath('//details[@id="edit-content-translation" and @open="open"]'), 'The translation tab should be collapsed by default.');
}
else {
$this->assertFieldByXPath('//input[@name="content_translation[outdated]"]', TRUE, 'The translate flag is checked by default.');
$this->assertTrue($this->xpath('//details[@id="edit-content-translation" and @open="open"]'), 'The translation tab is correctly expanded when the translation is outdated.');
$edit = ['content_translation[outdated]' => FALSE];
$this->drupalPostForm($url, $edit, $this->getFormSubmitAction($entity, $added_langcode));
$this->drupalGet($url);
$this->assertFieldByXPath('//input[@name="content_translation[retranslate]"]', FALSE, 'The retranslate flag is now shown.');
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->assertFalse($this->manager->getTranslationMetadata($entity->getTranslation($added_langcode))->isOutdated(), 'The "outdated" status has been correctly stored.');
}
}
}
/**
* Tests the translation publishing status.
*/
protected function doTestPublishedStatus() {
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
// Unpublish translations.
foreach ($this->langcodes as $index => $langcode) {
if ($index > 0) {
$url = $entity->urlInfo('edit-form', ['language' => ConfigurableLanguage::load($langcode)]);
$edit = ['content_translation[status]' => FALSE];
$this->drupalPostForm($url, $edit, $this->getFormSubmitAction($entity, $langcode));
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->assertFalse($this->manager->getTranslationMetadata($entity->getTranslation($langcode))->isPublished(), 'The translation has been correctly unpublished.');
}
}
// Check that the last published translation cannot be unpublished.
$this->drupalGet($entity->urlInfo('edit-form'));
$this->assertFieldByXPath('//input[@name="content_translation[status]" and @disabled="disabled"]', TRUE, 'The last translation is published and cannot be unpublished.');
}
/**
* Tests the translation authoring information.
*/
protected function doTestAuthoringInfo() {
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$values = [];
// Post different authoring information for each translation.
foreach ($this->langcodes as $index => $langcode) {
$user = $this->drupalCreateUser();
$values[$langcode] = [
'uid' => $user->id(),
'created' => REQUEST_TIME - mt_rand(0, 1000),
];
$edit = [
'content_translation[uid]' => $user->getUsername(),
'content_translation[created]' => format_date($values[$langcode]['created'], 'custom', 'Y-m-d H:i:s O'),
];
$url = $entity->urlInfo('edit-form', ['language' => ConfigurableLanguage::load($langcode)]);
$this->drupalPostForm($url, $edit, $this->getFormSubmitAction($entity, $langcode));
}
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
foreach ($this->langcodes as $langcode) {
$metadata = $this->manager->getTranslationMetadata($entity->getTranslation($langcode));
$this->assertEqual($metadata->getAuthor()->id(), $values[$langcode]['uid'], 'Translation author correctly stored.');
$this->assertEqual($metadata->getCreatedTime(), $values[$langcode]['created'], 'Translation date correctly stored.');
}
// Try to post non valid values and check that they are rejected.
$langcode = 'en';
$edit = [
// User names have by default length 8.
'content_translation[uid]' => $this->randomMachineName(12),
'content_translation[created]' => '19/11/1978',
];
$this->drupalPostForm($entity->urlInfo('edit-form'), $edit, $this->getFormSubmitAction($entity, $langcode));
$this->assertTrue($this->xpath('//div[contains(@class, "error")]//ul'), 'Invalid values generate a list of form errors.');
$metadata = $this->manager->getTranslationMetadata($entity->getTranslation($langcode));
$this->assertEqual($metadata->getAuthor()->id(), $values[$langcode]['uid'], 'Translation author correctly kept.');
$this->assertEqual($metadata->getCreatedTime(), $values[$langcode]['created'], 'Translation date correctly kept.');
}
/**
* Tests translation deletion.
*/
protected function doTestTranslationDeletion() {
// Confirm and delete a translation.
$this->drupalLogin($this->translator);
$langcode = 'fr';
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$language = ConfigurableLanguage::load($langcode);
$url = $entity->urlInfo('edit-form', ['language' => $language]);
$this->drupalPostForm($url, [], t('Delete translation'));
$this->drupalPostForm(NULL, [], t('Delete @language translation', ['@language' => $language->getName()]));
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId, TRUE);
if ($this->assertTrue(is_object($entity), 'Entity found')) {
$translations = $entity->getTranslationLanguages();
$this->assertTrue(count($translations) == 2 && empty($translations[$langcode]), 'Translation successfully deleted.');
}
// Check that the translator cannot delete the original translation.
$args = [$this->entityTypeId => $entity->id(), 'language' => 'en'];
$this->drupalGet(Url::fromRoute("entity.$this->entityTypeId.content_translation_delete", $args));
$this->assertResponse(403);
}
/**
* Returns an array of entity field values to be tested.
*/
protected function getNewEntityValues($langcode) {
return [$this->fieldName => [['value' => $this->randomMachineName(16)]]];
}
/**
* Returns an edit array containing the values to be posted.
*/
protected function getEditValues($values, $langcode, $new = FALSE) {
$edit = $values[$langcode];
$langcode = $new ? LanguageInterface::LANGCODE_NOT_SPECIFIED : $langcode;
foreach ($values[$langcode] as $property => $value) {
if (is_array($value)) {
$edit["{$property}[0][value]"] = $value[0]['value'];
unset($edit[$property]);
}
}
return $edit;
}
/**
* Returns the form action value when submitting a new translation.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being tested.
* @param string $langcode
* Language code for the form.
*
* @return string
* Name of the button to hit.
*/
protected function getFormSubmitActionForNewTranslation(EntityInterface $entity, $langcode) {
$entity->addTranslation($langcode, $entity->toArray());
return $this->getFormSubmitAction($entity, $langcode);
}
/**
* Returns the form action value to be used to submit the entity form.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being tested.
* @param string $langcode
* Language code for the form.
*
* @return string
* Name of the button to hit.
*/
protected function getFormSubmitAction(EntityInterface $entity, $langcode) {
return t('Save') . $this->getFormSubmitSuffix($entity, $langcode);
}
/**
* Returns appropriate submit button suffix based on translatability.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being tested.
* @param string $langcode
* Language code for the form.
*
* @return string
* Submit button suffix based on translatability.
*/
protected function getFormSubmitSuffix(EntityInterface $entity, $langcode) {
return '';
}
/**
* Returns the translation object to use to retrieve the translated values.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being tested.
* @param string $langcode
* The language code identifying the translation to be retrieved.
*
* @return \Drupal\Core\TypedData\TranslatableInterface
* The translation object to act on.
*/
protected function getTranslation(EntityInterface $entity, $langcode) {
return $entity->getTranslation($langcode);
}
/**
* Returns the value for the specified property in the given language.
*
* @param \Drupal\Core\Entity\EntityInterface $translation
* The translation object the property value should be retrieved from.
* @param string $property
* The property name.
* @param string $langcode
* The property value.
*
* @return
* The property value.
*/
protected function getValue(EntityInterface $translation, $property, $langcode) {
$key = $property == 'user_id' ? 'target_id' : 'value';
return $translation->get($property)->{$key};
}
/**
* Returns the name of the field that implements the changed timestamp.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being tested.
*
* @return string
* The field name.
*/
protected function getChangedFieldName($entity) {
return $entity->hasField('content_translation_changed') ? 'content_translation_changed' : 'changed';
}
/**
* Tests edit content translation.
*/
protected function doTestTranslationEdit() {
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$languages = $this->container->get('language_manager')->getLanguages();
foreach ($this->langcodes as $langcode) {
// We only want to test the title for non-english translations.
if ($langcode != 'en') {
$options = ['language' => $languages[$langcode]];
$url = $entity->urlInfo('edit-form', $options);
$this->drupalGet($url);
$this->assertRaw($entity->getTranslation($langcode)->label());
}
}
}
/**
* Tests the basic translation workflow.
*/
protected function doTestTranslationChanged() {
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$changed_field_name = $this->getChangedFieldName($entity);
$definition = $entity->getFieldDefinition($changed_field_name);
$config = $definition->getConfig($entity->bundle());
foreach ([FALSE, TRUE] as $translatable_changed_field) {
if ($definition->isTranslatable()) {
// For entities defining a translatable changed field we want to test
// the correct behavior of that field even if the translatability is
// revoked. In that case the changed timestamp should be synchronized
// across all translations.
$config->setTranslatable($translatable_changed_field);
$config->save();
}
elseif ($translatable_changed_field) {
// For entities defining a non-translatable changed field we cannot
// declare the field as translatable on the fly by modifying its config
// because the schema doesn't support this.
break;
}
foreach ($entity->getTranslationLanguages() as $language) {
// Ensure different timestamps.
sleep(1);
$langcode = $language->getId();
$edit = [
$this->fieldName . '[0][value]' => $this->randomString(),
];
$edit_path = $entity->urlInfo('edit-form', ['language' => $language]);
$this->drupalPostForm($edit_path, $edit, $this->getFormSubmitAction($entity, $langcode));
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->assertEqual(
$entity->getChangedTimeAcrossTranslations(), $entity->getTranslation($langcode)->getChangedTime(),
format_string('Changed time for language %language is the latest change over all languages.', ['%language' => $language->getName()])
);
}
$timestamps = [];
foreach ($entity->getTranslationLanguages() as $language) {
$next_timestamp = $entity->getTranslation($language->getId())->getChangedTime();
if (!in_array($next_timestamp, $timestamps)) {
$timestamps[] = $next_timestamp;
}
}
if ($translatable_changed_field) {
$this->assertEqual(
count($timestamps), count($entity->getTranslationLanguages()),
'All timestamps from all languages are different.'
);
}
else {
$this->assertEqual(
count($timestamps), 1,
'All timestamps from all languages are identical.'
);
}
}
}
/**
* Test the changed time after API and FORM save without changes.
*/
public function doTestChangedTimeAfterSaveWithoutChanges() {
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
// Test only entities, which implement the EntityChangedInterface.
if ($entity instanceof EntityChangedInterface) {
$changed_timestamp = $entity->getChangedTime();
$entity->save();
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->assertEqual($changed_timestamp, $entity->getChangedTime(), 'The entity\'s changed time wasn\'t updated after API save without changes.');
// Ensure different save timestamps.
sleep(1);
// Save the entity on the regular edit form.
$language = $entity->language();
$edit_path = $entity->urlInfo('edit-form', ['language' => $language]);
$this->drupalPostForm($edit_path, [], $this->getFormSubmitAction($entity, $language->getId()));
$storage->resetCache([$this->entityId]);
$entity = $storage->load($this->entityId);
$this->assertNotEqual($changed_timestamp, $entity->getChangedTime(), 'The entity\'s changed time was updated after form save without changes.');
}
}
}

View file

@ -0,0 +1,177 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\Core\Url;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\language\Entity\ConfigurableLanguage;
/**
* Tests the untranslatable fields behaviors.
*
* @group content_translation
*/
class ContentTranslationUntranslatableFieldsTest extends ContentTranslationPendingRevisionTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['field_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 setupTestFields() {
parent::setupTestFields();
$field_storage = FieldStorageConfig::create([
'field_name' => 'field_multilingual',
'type' => 'test_field',
'entity_type' => $this->entityTypeId,
'cardinality' => 1,
]);
$field_storage->save();
FieldConfig::create([
'field_storage' => $field_storage,
'bundle' => $this->bundle,
'label' => 'Untranslatable-but-visible test field',
'translatable' => FALSE,
])->save();
entity_get_form_display($this->entityTypeId, $this->bundle, 'default')
->setComponent('field_multilingual', [
'type' => 'test_field_widget_multilingual',
])
->save();
}
/**
* 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(['title' => $this->randomString()], '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.
$en_edit_url = $entity->toUrl('edit-form');
$this->drupalGet($en_edit_url);
$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));
$this->assertSession()->pageTextContains('Untranslatable-but-visible test field');
// 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->assertSession()->pageTextContains('Untranslatable-but-visible test field');
$this->drupalPostForm(NULL, [], 'Save');
// Check that the widget is displayed along with its clue in the edit form
// for both languages.
$this->drupalGet($en_edit_url);
$this->assertNotEmpty($this->xpath($field_xpath));
$this->assertNotEmpty($this->xpath($clue_xpath));
$it_edit_url = $entity->toUrl('edit-form', ['language' => ConfigurableLanguage::load('it')]);
$this->drupalGet($it_edit_url);
$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.
$settings_key = 'settings[' . $this->entityTypeId . '][' . $this->bundle . '][settings][content_translation][untranslatable_fields_hide]';
$settings_url = 'admin/config/regional/content-language';
$this->drupalPostForm($settings_url, [$settings_key => 1], 'Save configuration');
// Verify that the widget is displayed in the default language edit form,
// but no clue is displayed.
$this->drupalGet($en_edit_url);
$field_xpath = '//input[@name="' . $this->fieldName . '[0][value]"]';
$this->assertNotEmpty($this->xpath($field_xpath));
$this->assertEmpty($this->xpath($clue_xpath));
$this->assertSession()->pageTextContains('Untranslatable-but-visible test field');
// Verify no widget is displayed on the non-default language edit form.
$this->drupalGet($it_edit_url);
$this->assertEmpty($this->xpath($field_xpath));
$this->assertEmpty($this->xpath($clue_xpath));
$this->assertSession()->pageTextContains('Untranslatable-but-visible test field');
// 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);
// Configure untranslatable field widgets to be displayed on non-default
// language edit forms.
$this->drupalPostForm($settings_url, [$settings_key => 0], 'Save configuration');
// Check that the widget is displayed along with its clue in the edit form
// for both languages.
$this->drupalGet($en_edit_url);
$this->assertNotEmpty($this->xpath($field_xpath));
$this->assertNotEmpty($this->xpath($clue_xpath));
$this->drupalGet($it_edit_url);
$this->assertNotEmpty($this->xpath($field_xpath));
$this->assertNotEmpty($this->xpath($clue_xpath));
// Enable content moderation and verify that widgets are hidden despite them
// being configured to be displayed.
$this->enableContentModeration();
$this->drupalGet($it_edit_url);
$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.');
$elements = $this->xpath($link_xpath, [':edit_path' => $edit_path]);
$this->assertNotEmpty($elements);
// Verify that checkboxes on the language content settings page are checked
// and disabled for moderated bundles.
$this->drupalGet($settings_url);
$input_xpath = '//input[@name="settings[' . $this->entityTypeId . '][' . $this->bundle . '][settings][content_translation][untranslatable_fields_hide]" and @value=1 and @disabled="disabled"]';
$elements = $this->xpath($input_xpath);
$this->assertNotEmpty($elements);
$this->drupalPostForm(NULL, [$settings_key => 0], 'Save configuration');
$elements = $this->xpath($input_xpath);
$this->assertNotEmpty($elements);
}
}

View file

@ -0,0 +1,259 @@
<?php
namespace Drupal\Tests\content_translation\Functional;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Url;
use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
use Drupal\user\UserInterface;
/**
* Tests the content translation workflows for the test entity.
*
* @group content_translation
*/
class ContentTranslationWorkflowsTest extends ContentTranslationTestBase {
use AssertPageCacheContextsAndTagsTrait;
/**
* The entity used for testing.
*
* @var \Drupal\Core\Entity\EntityInterface
*/
protected $entity;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['language', 'content_translation', 'entity_test'];
protected function setUp() {
parent::setUp();
$this->setupEntity();
}
/**
* {@inheritdoc}
*/
protected function getTranslatorPermissions() {
$permissions = parent::getTranslatorPermissions();
$permissions[] = 'view test entity';
return $permissions;
}
/**
* {@inheritdoc}
*/
protected function getEditorPermissions() {
return ['administer entity_test content'];
}
/**
* Creates a test entity and translate it.
*/
protected function setupEntity() {
$default_langcode = $this->langcodes[0];
// Create a test entity.
$user = $this->drupalCreateUser();
$values = [
'name' => $this->randomMachineName(),
'user_id' => $user->id(),
$this->fieldName => [['value' => $this->randomMachineName(16)]],
];
$id = $this->createEntity($values, $default_langcode);
$storage = $this->container->get('entity_type.manager')
->getStorage($this->entityTypeId);
$storage->resetCache([$id]);
$this->entity = $storage->load($id);
// Create a translation.
$this->drupalLogin($this->translator);
$add_translation_url = Url::fromRoute("entity.$this->entityTypeId.content_translation_add", [$this->entityTypeId => $this->entity->id(), 'source' => $default_langcode, 'target' => $this->langcodes[2]]);
$this->drupalPostForm($add_translation_url, [], t('Save'));
$this->rebuildContainer();
}
/**
* Test simple and editorial translation workflows.
*/
public function testWorkflows() {
// Test workflows for the editor.
$expected_status = [
'edit' => 200,
'delete' => 200,
'overview' => 403,
'add_translation' => 403,
'edit_translation' => 403,
'delete_translation' => 403,
];
$this->doTestWorkflows($this->editor, $expected_status);
// Test workflows for the translator.
$expected_status = [
'edit' => 403,
'delete' => 403,
'overview' => 200,
'add_translation' => 200,
'edit_translation' => 200,
'delete_translation' => 200,
];
$this->doTestWorkflows($this->translator, $expected_status);
// Test workflows for the admin.
$expected_status = [
'edit' => 200,
'delete' => 200,
'overview' => 200,
'add_translation' => 200,
'edit_translation' => 403,
'delete_translation' => 403,
];
$this->doTestWorkflows($this->administrator, $expected_status);
// Check that translation permissions allow the associated operations.
$ops = ['create' => t('Add'), 'update' => t('Edit'), 'delete' => t('Delete')];
$translations_url = $this->entity->urlInfo('drupal:content-translation-overview');
foreach ($ops as $current_op => $item) {
$user = $this->drupalCreateUser([$this->getTranslatePermission(), "$current_op content translations", 'view test entity']);
$this->drupalLogin($user);
$this->drupalGet($translations_url);
// Make sure that the user.permissions cache context and the cache tags
// for the entity are present.
$this->assertCacheContext('user.permissions');
foreach ($this->entity->getCacheTags() as $cache_tag) {
$this->assertCacheTag($cache_tag);
}
foreach ($ops as $op => $label) {
if ($op != $current_op) {
$this->assertNoLink($label, format_string('No %op link found.', ['%op' => $label]));
}
else {
$this->assertLink($label, 0, format_string('%op link found.', ['%op' => $label]));
}
}
}
}
/**
* Checks that workflows have the expected behaviors for the given user.
*
* @param \Drupal\user\UserInterface $user
* The user to test the workflow behavior against.
* @param array $expected_status
* The an associative array with the operation name as key and the expected
* status as value.
*/
protected function doTestWorkflows(UserInterface $user, $expected_status) {
$default_langcode = $this->langcodes[0];
$languages = $this->container->get('language_manager')->getLanguages();
$args = ['@user_label' => $user->getUsername()];
$options = ['language' => $languages[$default_langcode], 'absolute' => TRUE];
$this->drupalLogin($user);
// Check whether the user is allowed to access the entity form in edit mode.
$edit_url = $this->entity->urlInfo('edit-form', $options);
$this->drupalGet($edit_url, $options);
$this->assertResponse($expected_status['edit'], new FormattableMarkup('The @user_label has the expected edit access.', $args));
// Check whether the user is allowed to access the entity delete form.
$delete_url = $this->entity->urlInfo('delete-form', $options);
$this->drupalGet($delete_url, $options);
$this->assertResponse($expected_status['delete'], new FormattableMarkup('The @user_label has the expected delete access.', $args));
// Check whether the user is allowed to access the translation overview.
$langcode = $this->langcodes[1];
$options['language'] = $languages[$langcode];
$translations_url = $this->entity->url('drupal:content-translation-overview', $options);
$this->drupalGet($translations_url);
$this->assertResponse($expected_status['overview'], new FormattableMarkup('The @user_label has the expected translation overview access.', $args));
// Check whether the user is allowed to create a translation.
$add_translation_url = Url::fromRoute("entity.$this->entityTypeId.content_translation_add", [$this->entityTypeId => $this->entity->id(), 'source' => $default_langcode, 'target' => $langcode], $options);
if ($expected_status['add_translation'] == 200) {
$this->clickLink('Add');
$this->assertUrl($add_translation_url->toString(), [], 'The translation overview points to the translation form when creating translations.');
// Check that the translation form does not contain shared elements for
// translators.
if ($expected_status['edit'] == 403) {
$this->assertNoSharedElements();
}
}
else {
$this->drupalGet($add_translation_url);
}
$this->assertResponse($expected_status['add_translation'], new FormattableMarkup('The @user_label has the expected translation creation access.', $args));
// Check whether the user is allowed to edit a translation.
$langcode = $this->langcodes[2];
$options['language'] = $languages[$langcode];
$edit_translation_url = Url::fromRoute("entity.$this->entityTypeId.content_translation_edit", [$this->entityTypeId => $this->entity->id(), 'language' => $langcode], $options);
if ($expected_status['edit_translation'] == 200) {
$this->drupalGet($translations_url);
$editor = $expected_status['edit'] == 200;
if ($editor) {
$this->clickLink('Edit', 2);
// An editor should be pointed to the entity form in multilingual mode.
// We need a new expected edit path with a new language.
$expected_edit_path = $this->entity->url('edit-form', $options);
$this->assertUrl($expected_edit_path, [], 'The translation overview points to the edit form for editors when editing translations.');
}
else {
$this->clickLink('Edit');
// While a translator should be pointed to the translation form.
$this->assertUrl($edit_translation_url->toString(), [], 'The translation overview points to the translation form for translators when editing translations.');
// Check that the translation form does not contain shared elements.
$this->assertNoSharedElements();
}
}
else {
$this->drupalGet($edit_translation_url);
}
$this->assertResponse($expected_status['edit_translation'], new FormattableMarkup('The @user_label has the expected translation edit access.', $args));
// Check whether the user is allowed to delete a translation.
$langcode = $this->langcodes[2];
$options['language'] = $languages[$langcode];
$delete_translation_url = Url::fromRoute("entity.$this->entityTypeId.content_translation_delete", [$this->entityTypeId => $this->entity->id(), 'language' => $langcode], $options);
if ($expected_status['delete_translation'] == 200) {
$this->drupalGet($translations_url);
$editor = $expected_status['delete'] == 200;
if ($editor) {
$this->clickLink('Delete', 2);
// An editor should be pointed to the entity deletion form in
// multilingual mode. We need a new expected delete path with a new
// language.
$expected_delete_path = $this->entity->url('delete-form', $options);
$this->assertUrl($expected_delete_path, [], 'The translation overview points to the delete form for editors when deleting translations.');
}
else {
$this->clickLink('Delete');
// While a translator should be pointed to the translation deletion
// form.
$this->assertUrl($delete_translation_url->toString(), [], 'The translation overview points to the translation deletion form for translators when deleting translations.');
}
}
else {
$this->drupalGet($delete_translation_url);
}
$this->assertResponse($expected_status['delete_translation'], new FormattableMarkup('The @user_label has the expected translation deletion access.', $args));
}
/**
* Assert that the current page does not contain shared form elements.
*/
protected function assertNoSharedElements() {
$language_none = LanguageInterface::LANGCODE_NOT_SPECIFIED;
return $this->assertNoFieldByXPath("//input[@name='field_test_text[$language_none][0][value]']", NULL, 'Shared elements are not available on the translation form.');
}
}

View file

@ -0,0 +1,101 @@
<?php
namespace Drupal\Tests\content_translation\Functional\Update;
use Drupal\Core\Language\LanguageInterface;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
use Drupal\Tests\system\Functional\Entity\Traits\EntityDefinitionTestTrait;
/**
* Tests the upgrade path for the Content Translation module.
*
* @group Update
* @group legacy
*/
class ContentTranslationUpdateTest extends UpdatePathTestBase {
use EntityDefinitionTestTrait;
/**
* The database connection used.
*
* @var \Drupal\Core\Database\Connection
*/
protected $database;
/**
* The entity definition update manager.
*
* @var \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface
*/
protected $entityDefinitionUpdateManager;
/**
* The entity manager service.
*
* @var \Drupal\Core\Entity\EntityManagerInterface
*/
protected $entityManager;
/**
* The state service.
*
* @var \Drupal\Core\State\StateInterface
*/
protected $state;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->database = \Drupal::database();
$this->entityDefinitionUpdateManager = \Drupal::entityDefinitionUpdateManager();
$this->entityManager = \Drupal::entityManager();
$this->state = \Drupal::state();
}
/**
* {@inheritdoc}
*/
public function setDatabaseDumpFiles() {
$this->databaseDumpFiles = [
__DIR__ . '/../../../../../system/tests/fixtures/update/drupal-8.0.0-rc1-filled.standard.entity_test_update_mul.php.gz',
];
}
/**
* Tests that initial values for metadata fields are populated correctly.
*/
public function testContentTranslationUpdate8400() {
$this->updateEntityTypeToTranslatable();
// The test database dump contains NULL values for
// 'content_translation_source', 'content_translation_outdated' and
// 'content_translation_status' for the first 50 test entities.
// @see _entity_test_update_create_test_entities()
$first_entity_record = $this->database->select('entity_test_update_data', 'etud')
->fields('etud')
->condition('etud.id', 1)
->execute()
->fetchAllAssoc('id');
$this->assertNull($first_entity_record[1]->content_translation_source);
$this->assertNull($first_entity_record[1]->content_translation_outdated);
$this->assertNull($first_entity_record[1]->content_translation_status);
$this->runUpdates();
// After running the updates, all those fields should be populated with
// their default values.
$first_entity_record = $this->database->select('entity_test_update_data', 'etud')
->fields('etud')
->condition('etud.id', 1)
->execute()
->fetchAllAssoc('id');
$this->assertEqual(LanguageInterface::LANGCODE_NOT_SPECIFIED, $first_entity_record[1]->content_translation_source);
$this->assertEqual(0, $first_entity_record[1]->content_translation_outdated);
$this->assertEqual(1, $first_entity_record[1]->content_translation_status);
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace Drupal\Tests\content_translation\Functional\Views;
use Drupal\Tests\views_ui\Functional\UITestBase;
/**
* Tests the views UI when content_translation is enabled.
*
* @group content_translation
*/
class ContentTranslationViewsUITest extends UITestBase {
/**
* Views used by this test.
*
* @var array
*/
public static $testViews = ['test_view'];
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['content_translation'];
/**
* Tests the views UI.
*/
public function testViewsUI() {
$this->drupalGet('admin/structure/views/view/test_view/edit');
$this->assertTitle(t('@label (@table) | @site-name', ['@label' => 'Test view', '@table' => 'Views test data', '@site-name' => $this->config('system.site')->get('name')]));
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace Drupal\Tests\content_translation\Functional\Views;
use Drupal\Tests\content_translation\Functional\ContentTranslationTestBase;
use Drupal\views\Tests\ViewTestData;
use Drupal\Core\Language\Language;
use Drupal\user\Entity\User;
/**
* Tests the content translation overview link field handler.
*
* @group content_translation
* @see \Drupal\content_translation\Plugin\views\field\TranslationLink
*/
class TranslationLinkTest extends ContentTranslationTestBase {
/**
* Views used by this test.
*
* @var array
*/
public static $testViews = ['test_entity_translations_link'];
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['content_translation_test_views'];
protected function setUp() {
// @todo Use entity_type once it is has multilingual Views integration.
$this->entityTypeId = 'user';
parent::setUp();
// Assign user 1 a language code so that the entity can be translated.
$user = User::load(1);
$user->langcode = 'en';
$user->save();
// Assign user 2 LANGCODE_NOT_SPECIFIED code so entity can't be translated.
$user = User::load(2);
$user->langcode = Language::LANGCODE_NOT_SPECIFIED;
$user->save();
ViewTestData::createTestViews(get_class($this), ['content_translation_test_views']);
}
/**
* {@inheritdoc}
*/
protected function getTranslatorPermissions() {
$permissions = parent::getTranslatorPermissions();
$permissions[] = 'access user profiles';
return $permissions;
}
/**
* Tests the content translation overview link field handler.
*/
public function testTranslationLink() {
$this->drupalGet('test-entity-translations-link');
$this->assertLinkByHref('user/1/translations');
$this->assertNoLinkByHref('user/2/translations', 'The translations link is not present when content_translation_translate_access() is FALSE.');
}
}

View file

@ -0,0 +1,72 @@
<?php
namespace Drupal\Tests\content_translation\FunctionalJavascript;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\language\Entity\ConfigurableLanguage;
/**
* Tests that contextual links are available for content translation.
*
* @group content_translation
*/
class ContentTranslationContextualLinksTest extends WebDriverTestBase {
/**
* The 'translator' user to use during testing.
*
* @var \Drupal\user\UserInterface
*/
protected $translator;
/**
* {@inheritdoc}
*/
public static $modules = ['content_translation', 'contextual', 'node'];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Set up an additional language.
ConfigurableLanguage::createFromLangcode('es')->save();
// Create a content type.
$this->drupalCreateContentType(['type' => 'page']);
// Enable content translation.
$this->drupalLogin($this->rootUser);
$this->drupalGet('admin/config/regional/content-language');
$edit = [
'entity_types[node]' => TRUE,
'settings[node][page][translatable]' => TRUE,
];
$this->drupalPostForm(NULL, $edit, t('Save configuration'));
$this->drupalLogout();
// Create a translator user.
$permissions = [
'access contextual links',
'administer nodes',
'edit any page content',
'translate any entity',
];
$this->translator = $this->drupalCreateUser($permissions);
}
/**
* Tests that a contextual link is available for translating a node.
*/
public function testContentTranslationContextualLinks() {
$node = $this->drupalCreateNode(['type' => 'page', 'title' => 'Test']);
// Check that the translate link appears on the node page.
$this->drupalLogin($this->translator);
$this->drupalGet('node/' . $node->id());
$link = $this->assertSession()->waitForElement('css', '[data-contextual-id^="node:node=1"] .contextual-links a:contains("Translate")');
$this->assertContains('node/1/translations', $link->getAttribute('href'));
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace Drupal\Tests\content_translation\Kernel;
use Drupal\Core\Config\ConfigImporter;
use Drupal\Core\Config\StorageComparer;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests content translation updates performed during config import.
*
* @group content_translation
*/
class ContentTranslationConfigImportTest extends KernelTestBase {
/**
* Config Importer object used for testing.
*
* @var \Drupal\Core\Config\ConfigImporter
*/
protected $configImporter;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['system', 'user', 'entity_test', 'language', 'content_translation'];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installConfig(['system']);
$this->installEntitySchema('entity_test_mul');
$this->copyConfig($this->container->get('config.storage'), $this->container->get('config.storage.sync'));
// Set up the ConfigImporter object for testing.
$storage_comparer = new StorageComparer(
$this->container->get('config.storage.sync'),
$this->container->get('config.storage'),
$this->container->get('config.manager')
);
$this->configImporter = new ConfigImporter(
$storage_comparer->createChangelist(),
$this->container->get('event_dispatcher'),
$this->container->get('config.manager'),
$this->container->get('lock'),
$this->container->get('config.typed'),
$this->container->get('module_handler'),
$this->container->get('module_installer'),
$this->container->get('theme_handler'),
$this->container->get('string_translation')
);
}
/**
* Tests config import updates.
*/
public function testConfigImportUpdates() {
$entity_type_id = 'entity_test_mul';
$config_id = $entity_type_id . '.' . $entity_type_id;
$config_name = 'language.content_settings.' . $config_id;
$storage = $this->container->get('config.storage');
$sync = $this->container->get('config.storage.sync');
// Verify the configuration to create does not exist yet.
$this->assertIdentical($storage->exists($config_name), FALSE, $config_name . ' not found.');
// Create new config entity.
$data = [
'uuid' => 'a019d89b-c4d9-4ed4-b859-894e4e2e93cf',
'langcode' => 'en',
'status' => TRUE,
'dependencies' => [
'module' => ['content_translation'],
],
'id' => $config_id,
'target_entity_type_id' => 'entity_test_mul',
'target_bundle' => 'entity_test_mul',
'default_langcode' => 'site_default',
'language_alterable' => FALSE,
'third_party_settings' => [
'content_translation' => ['enabled' => TRUE],
],
];
$sync->write($config_name, $data);
$this->assertIdentical($sync->exists($config_name), TRUE, $config_name . ' found.');
// Import.
$this->configImporter->reset()->import();
// Verify the values appeared.
$config = $this->config($config_name);
$this->assertIdentical($config->get('id'), $config_id);
// Verify that updates were performed.
$entity_type = $this->container->get('entity.manager')->getDefinition($entity_type_id);
$table = $entity_type->getDataTable();
$db_schema = $this->container->get('database')->schema();
$result = $db_schema->fieldExists($table, 'content_translation_source') && $db_schema->fieldExists($table, 'content_translation_outdated');
$this->assertTrue($result, 'Content translation updates were successfully performed during config import.');
}
}

View file

@ -0,0 +1,90 @@
<?php
namespace Drupal\Tests\content_translation\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\entity_test\Entity\EntityTestMul;
use Drupal\language\Entity\ConfigurableLanguage;
/**
* Tests the Content Translation bundle info logic.
*
* @group content_translation
*/
class ContentTranslationEntityBundleInfoTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['user', 'language', 'content_translation_test', 'content_translation', 'entity_test'];
/**
* The content translation manager.
*
* @var \Drupal\content_translation\ContentTranslationManagerInterface
*/
protected $contentTranslationManager;
/**
* The bundle info service.
*
* @var \Drupal\Core\Entity\EntityTypeBundleInfo
*/
protected $bundleInfo;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->contentTranslationManager = $this->container->get('content_translation.manager');
$this->bundleInfo = $this->container->get('entity_type.bundle.info');
$this->installEntitySchema('entity_test_mul');
ConfigurableLanguage::createFromLangcode('it')->save();
}
/**
* Tests that modules can know whether bundles are translatable.
*/
public function testHookInvocationOrder() {
$this->contentTranslationManager->setEnabled('entity_test_mul', 'entity_test_mul', TRUE);
$this->bundleInfo->clearCachedBundles();
$this->bundleInfo->getAllBundleInfo();
// Verify that the test module comes first in the module list, which would
// normally make its hook implementation to be invoked first.
/** @var \Drupal\Core\Extension\ModuleHandlerInterface $module_handler */
$module_handler = $this->container->get('module_handler');
$module_list = $module_handler->getModuleList();
$expected_modules = [
'content_translation_test',
'content_translation',
];
$actual_modules = array_keys(array_intersect_key($module_list, array_flip($expected_modules)));
$this->assertEquals($expected_modules, $actual_modules);
// Check that the "content_translation_test" hook implementation has access
// to the "translatable" bundle info property.
/** @var \Drupal\Core\State\StateInterface $state */
$state = $this->container->get('state');
$this->assertTrue($state->get('content_translation_test.translatable'));
}
/**
* Tests that field synchronization is skipped for disabled bundles.
*/
public function testFieldSynchronizationWithDisabledBundle() {
$entity = EntityTestMul::create();
$entity->save();
/** @var \Drupal\Core\Entity\ContentEntityInterface $translation */
$translation = $entity->addTranslation('it');
$translation->save();
$this->assertTrue($entity->isTranslatable());
}
}

View file

@ -0,0 +1,482 @@
<?php
namespace Drupal\Tests\content_translation\Kernel;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityConstraintViolationListInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\entity_test\Entity\EntityTestMulRev;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\file\Entity\File;
use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\user\Entity\User;
/**
* Tests the field synchronization logic when revisions are involved.
*
* @group content_translation
*/
class ContentTranslationFieldSyncRevisionTest extends EntityKernelTestBase {
use TestFileCreationTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['file', 'image', 'language', 'content_translation', 'simpletest', 'content_translation_test'];
/**
* The synchronized field name.
*
* @var string
*/
protected $fieldName = 'sync_field';
/**
* The content translation manager.
*
* @var \Drupal\content_translation\ContentTranslationManagerInterface|\Drupal\content_translation\BundleTranslationSettingsInterface
*/
protected $contentTranslationManager;
/**
* The test entity storage.
*
* @var \Drupal\Core\Entity\ContentEntityStorageInterface
*/
protected $storage;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$entity_type_id = 'entity_test_mulrev';
$this->installEntitySchema($entity_type_id);
$this->installEntitySchema('file');
$this->installSchema('file', ['file_usage']);
ConfigurableLanguage::createFromLangcode('it')->save();
ConfigurableLanguage::createFromLangcode('fr')->save();
/** @var \Drupal\field\Entity\FieldStorageConfig $field_storage */
$field_storage_config = FieldStorageConfig::create([
'field_name' => $this->fieldName,
'type' => 'image',
'entity_type' => $entity_type_id,
'cardinality' => 1,
'translatable' => 1,
]);
$field_storage_config->save();
$field_config = FieldConfig::create([
'entity_type' => $entity_type_id,
'field_name' => $this->fieldName,
'bundle' => $entity_type_id,
'label' => 'Synchronized field',
'translatable' => 1,
]);
$field_config->save();
$property_settings = [
'alt' => 'alt',
'title' => 'title',
'file' => 0,
];
$field_config->setThirdPartySetting('content_translation', 'translation_sync', $property_settings);
$field_config->save();
$this->entityManager->clearCachedDefinitions();
$this->contentTranslationManager = $this->container->get('content_translation.manager');
$this->contentTranslationManager->setEnabled($entity_type_id, $entity_type_id, TRUE);
$this->storage = $this->entityManager->getStorage($entity_type_id);
foreach ($this->getTestFiles('image') as $file) {
$entity = File::create((array) $file + ['status' => 1]);
$entity->save();
}
$this->state->set('content_translation.entity_access.file', ['view' => TRUE]);
$account = User::create([
'name' => $this->randomMachineName(),
'status' => 1,
]);
$account->save();
}
/**
* Checks that field synchronization works as expected with revisions.
*
* @covers \Drupal\content_translation\Plugin\Validation\Constraint\ContentTranslationSynchronizedFieldsConstraintValidator::create
* @covers \Drupal\content_translation\Plugin\Validation\Constraint\ContentTranslationSynchronizedFieldsConstraintValidator::validate
* @covers \Drupal\content_translation\Plugin\Validation\Constraint\ContentTranslationSynchronizedFieldsConstraintValidator::hasSynchronizedPropertyChanges
* @covers \Drupal\content_translation\FieldTranslationSynchronizer::getFieldSynchronizedProperties
* @covers \Drupal\content_translation\FieldTranslationSynchronizer::synchronizeFields
* @covers \Drupal\content_translation\FieldTranslationSynchronizer::synchronizeItems
*/
public function testFieldSynchronizationAndValidation() {
// Test that when untranslatable field widgets are displayed, synchronized
// field properties can be changed only in default revisions.
$this->setUntranslatableFieldWidgetsDisplay(TRUE);
$entity = $this->saveNewEntity();
$entity_id = $entity->id();
$this->assertLatestRevisionFieldValues($entity_id, [1, 1, 1, 'Alt 1 EN']);
/** @var \Drupal\Core\Entity\ContentEntityInterface $en_revision */
$en_revision = $this->createRevision($entity, FALSE);
$en_revision->get($this->fieldName)->target_id = 2;
$violations = $en_revision->validate();
$this->assertViolations($violations);
$it_translation = $entity->addTranslation('it', $entity->toArray());
/** @var \Drupal\Core\Entity\ContentEntityInterface $it_revision */
$it_revision = $this->createRevision($it_translation, FALSE);
$metadata = $this->contentTranslationManager->getTranslationMetadata($it_revision);
$metadata->setSource('en');
$it_revision->get($this->fieldName)->target_id = 2;
$it_revision->get($this->fieldName)->alt = 'Alt 2 IT';
$violations = $it_revision->validate();
$this->assertViolations($violations);
$it_revision->isDefaultRevision(TRUE);
$violations = $it_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($it_revision);
$this->assertLatestRevisionFieldValues($entity_id, [2, 2, 2, 'Alt 1 EN', 'Alt 2 IT']);
$en_revision = $this->createRevision($en_revision, FALSE);
$en_revision->get($this->fieldName)->alt = 'Alt 3 EN';
$violations = $en_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($en_revision);
$this->assertLatestRevisionFieldValues($entity_id, [3, 2, 2, 'Alt 3 EN', 'Alt 2 IT']);
$it_revision = $this->createRevision($it_revision, FALSE);
$it_revision->get($this->fieldName)->alt = 'Alt 4 IT';
$violations = $it_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($it_revision);
$this->assertLatestRevisionFieldValues($entity_id, [4, 2, 2, 'Alt 1 EN', 'Alt 4 IT']);
$en_revision = $this->createRevision($en_revision);
$en_revision->get($this->fieldName)->alt = 'Alt 5 EN';
$violations = $en_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($en_revision);
$this->assertLatestRevisionFieldValues($entity_id, [5, 2, 2, 'Alt 5 EN', 'Alt 2 IT']);
$en_revision = $this->createRevision($en_revision);
$en_revision->get($this->fieldName)->target_id = 6;
$en_revision->get($this->fieldName)->alt = 'Alt 6 EN';
$violations = $en_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($en_revision);
$this->assertLatestRevisionFieldValues($entity_id, [6, 6, 6, 'Alt 6 EN', 'Alt 2 IT']);
$it_revision = $this->createRevision($it_revision);
$it_revision->get($this->fieldName)->alt = 'Alt 7 IT';
$violations = $it_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($it_revision);
$this->assertLatestRevisionFieldValues($entity_id, [7, 6, 6, 'Alt 6 EN', 'Alt 7 IT']);
// Test that when untranslatable field widgets are hidden, synchronized
// field properties can be changed only when editing the default
// translation. This may lead to temporarily desynchronized values, when
// saving a pending revision for the default translation that changes a
// synchronized property (see revision 11).
$this->setUntranslatableFieldWidgetsDisplay(FALSE);
$entity = $this->saveNewEntity();
$entity_id = $entity->id();
$this->assertLatestRevisionFieldValues($entity_id, [8, 1, 1, 'Alt 1 EN']);
/** @var \Drupal\Core\Entity\ContentEntityInterface $en_revision */
$en_revision = $this->createRevision($entity, FALSE);
$en_revision->get($this->fieldName)->target_id = 2;
$en_revision->get($this->fieldName)->alt = 'Alt 2 EN';
$violations = $en_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($en_revision);
$this->assertLatestRevisionFieldValues($entity_id, [9, 2, 2, 'Alt 2 EN']);
$it_translation = $entity->addTranslation('it', $entity->toArray());
/** @var \Drupal\Core\Entity\ContentEntityInterface $it_revision */
$it_revision = $this->createRevision($it_translation, FALSE);
$metadata = $this->contentTranslationManager->getTranslationMetadata($it_revision);
$metadata->setSource('en');
$it_revision->get($this->fieldName)->target_id = 3;
$violations = $it_revision->validate();
$this->assertViolations($violations);
$it_revision->isDefaultRevision(TRUE);
$violations = $it_revision->validate();
$this->assertViolations($violations);
$it_revision = $this->createRevision($it_translation);
$metadata = $this->contentTranslationManager->getTranslationMetadata($it_revision);
$metadata->setSource('en');
$it_revision->get($this->fieldName)->alt = 'Alt 3 IT';
$violations = $it_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($it_revision);
$this->assertLatestRevisionFieldValues($entity_id, [10, 1, 1, 'Alt 1 EN', 'Alt 3 IT']);
$en_revision = $this->createRevision($en_revision, FALSE);
$en_revision->get($this->fieldName)->alt = 'Alt 4 EN';
$violations = $en_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($en_revision);
$this->assertLatestRevisionFieldValues($entity_id, [11, 2, 1, 'Alt 4 EN', 'Alt 3 IT']);
$it_revision = $this->createRevision($it_revision, FALSE);
$it_revision->get($this->fieldName)->alt = 'Alt 5 IT';
$violations = $it_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($it_revision);
$this->assertLatestRevisionFieldValues($entity_id, [12, 1, 1, 'Alt 1 EN', 'Alt 5 IT']);
$en_revision = $this->createRevision($en_revision);
$en_revision->get($this->fieldName)->target_id = 6;
$en_revision->get($this->fieldName)->alt = 'Alt 6 EN';
$violations = $en_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($en_revision);
$this->assertLatestRevisionFieldValues($entity_id, [13, 6, 6, 'Alt 6 EN', 'Alt 3 IT']);
$it_revision = $this->createRevision($it_revision);
$it_revision->get($this->fieldName)->target_id = 7;
$violations = $it_revision->validate();
$this->assertViolations($violations);
$it_revision = $this->createRevision($it_revision);
$it_revision->get($this->fieldName)->alt = 'Alt 7 IT';
$violations = $it_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($it_revision);
$this->assertLatestRevisionFieldValues($entity_id, [14, 6, 6, 'Alt 6 EN', 'Alt 7 IT']);
// Test that creating a default revision starting from a pending revision
// having changes to synchronized properties, without introducing new
// changes works properly.
$this->setUntranslatableFieldWidgetsDisplay(FALSE);
$entity = $this->saveNewEntity();
$entity_id = $entity->id();
$this->assertLatestRevisionFieldValues($entity_id, [15, 1, 1, 'Alt 1 EN']);
$it_translation = $entity->addTranslation('it', $entity->toArray());
/** @var \Drupal\Core\Entity\ContentEntityInterface $it_revision */
$it_revision = $this->createRevision($it_translation);
$metadata = $this->contentTranslationManager->getTranslationMetadata($it_revision);
$metadata->setSource('en');
$it_revision->get($this->fieldName)->alt = 'Alt 2 IT';
$violations = $it_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($it_revision);
$this->assertLatestRevisionFieldValues($entity_id, [16, 1, 1, 'Alt 1 EN', 'Alt 2 IT']);
/** @var \Drupal\Core\Entity\ContentEntityInterface $en_revision */
$en_revision = $this->createRevision($entity);
$en_revision->get($this->fieldName)->target_id = 3;
$en_revision->get($this->fieldName)->alt = 'Alt 3 EN';
$violations = $en_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($en_revision);
$this->assertLatestRevisionFieldValues($entity_id, [17, 3, 3, 'Alt 3 EN', 'Alt 2 IT']);
$en_revision = $this->createRevision($entity, FALSE);
$en_revision->get($this->fieldName)->target_id = 4;
$en_revision->get($this->fieldName)->alt = 'Alt 4 EN';
$violations = $en_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($en_revision);
$this->assertLatestRevisionFieldValues($entity_id, [18, 4, 3, 'Alt 4 EN', 'Alt 2 IT']);
$en_revision = $this->createRevision($entity);
$violations = $en_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($en_revision);
$this->assertLatestRevisionFieldValues($entity_id, [19, 4, 4, 'Alt 4 EN', 'Alt 2 IT']);
$it_revision = $this->createRevision($it_revision);
$it_revision->get($this->fieldName)->alt = 'Alt 6 IT';
$violations = $it_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($it_revision);
$this->assertLatestRevisionFieldValues($entity_id, [20, 4, 4, 'Alt 4 EN', 'Alt 6 IT']);
// Check that we are not allowed to perform changes to multiple translations
// in pending revisions when synchronized properties are involved.
$this->setUntranslatableFieldWidgetsDisplay(FALSE);
$entity = $this->saveNewEntity();
$entity_id = $entity->id();
$this->assertLatestRevisionFieldValues($entity_id, [21, 1, 1, 'Alt 1 EN']);
$it_translation = $entity->addTranslation('it', $entity->toArray());
/** @var \Drupal\Core\Entity\ContentEntityInterface $it_revision */
$it_revision = $this->createRevision($it_translation);
$metadata = $this->contentTranslationManager->getTranslationMetadata($it_revision);
$metadata->setSource('en');
$it_revision->get($this->fieldName)->alt = 'Alt 2 IT';
$violations = $it_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($it_revision);
$this->assertLatestRevisionFieldValues($entity_id, [22, 1, 1, 'Alt 1 EN', 'Alt 2 IT']);
$en_revision = $this->createRevision($entity, FALSE);
$en_revision->get($this->fieldName)->target_id = 2;
$en_revision->getTranslation('it')->get($this->fieldName)->alt = 'Alt 3 IT';
$violations = $en_revision->validate();
$this->assertViolations($violations);
// Test that when saving a new default revision starting from a pending
// revision, outdated synchronized properties do not override more recent
// ones.
$this->setUntranslatableFieldWidgetsDisplay(TRUE);
$entity = $this->saveNewEntity();
$entity_id = $entity->id();
$this->assertLatestRevisionFieldValues($entity_id, [23, 1, 1, 'Alt 1 EN']);
$it_translation = $entity->addTranslation('it', $entity->toArray());
/** @var \Drupal\Core\Entity\ContentEntityInterface $it_revision */
$it_revision = $this->createRevision($it_translation, FALSE);
$metadata = $this->contentTranslationManager->getTranslationMetadata($it_revision);
$metadata->setSource('en');
$it_revision->get($this->fieldName)->alt = 'Alt 2 IT';
$violations = $it_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($it_revision);
$this->assertLatestRevisionFieldValues($entity_id, [24, 1, 1, 'Alt 1 EN', 'Alt 2 IT']);
/** @var \Drupal\Core\Entity\ContentEntityInterface $en_revision */
$en_revision = $this->createRevision($entity);
$en_revision->get($this->fieldName)->target_id = 3;
$en_revision->get($this->fieldName)->alt = 'Alt 3 EN';
$violations = $en_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($en_revision);
$this->assertLatestRevisionFieldValues($entity_id, [25, 3, 3, 'Alt 3 EN', 'Alt 2 IT']);
$it_revision = $this->createRevision($it_revision);
$it_revision->get($this->fieldName)->alt = 'Alt 4 IT';
$violations = $it_revision->validate();
$this->assertEmpty($violations);
$this->storage->save($it_revision);
$this->assertLatestRevisionFieldValues($entity_id, [26, 3, 3, 'Alt 3 EN', 'Alt 4 IT']);
}
/**
* Sets untranslatable field widgets' display status.
*
* @param bool $display
* Whether untranslatable field widgets should be displayed.
*/
protected function setUntranslatableFieldWidgetsDisplay($display) {
$entity_type_id = $this->storage->getEntityTypeId();
$settings = ['untranslatable_fields_hide' => !$display];
$this->contentTranslationManager->setBundleTranslationSettings($entity_type_id, $entity_type_id, $settings);
/** @var \Drupal\Core\Entity\EntityTypeBundleInfo $bundle_info */
$bundle_info = $this->container->get('entity_type.bundle.info');
$bundle_info->clearCachedBundles();
}
/**
* @return \Drupal\Core\Entity\ContentEntityInterface
*/
protected function saveNewEntity() {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = EntityTestMulRev::create([
'uid' => 1,
'langcode' => 'en',
$this->fieldName => [
'target_id' => 1,
'alt' => 'Alt 1 EN',
],
]);
$metadata = $this->contentTranslationManager->getTranslationMetadata($entity);
$metadata->setSource(LanguageInterface::LANGCODE_NOT_SPECIFIED);
$violations = $entity->validate();
$this->assertEmpty($violations);
$this->storage->save($entity);
return $entity;
}
/**
* Creates a new revision starting from the latest translation-affecting one.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $translation
* The translation to be revisioned.
* @param bool $default
* (optional) Whether the new revision should be marked as default. Defaults
* to TRUE.
*
* @return \Drupal\Core\Entity\ContentEntityInterface
* An entity revision object.
*/
protected function createRevision(ContentEntityInterface $translation, $default = TRUE) {
if (!$translation->isNewTranslation()) {
$langcode = $translation->language()->getId();
$revision_id = $this->storage->getLatestTranslationAffectedRevisionId($translation->id(), $langcode);
/** @var \Drupal\Core\Entity\ContentEntityInterface $revision */
$revision = $this->storage->loadRevision($revision_id);
$translation = $revision->getTranslation($langcode);
}
/** @var \Drupal\Core\Entity\ContentEntityInterface $revision */
$revision = $this->storage->createRevision($translation, $default);
return $revision;
}
/**
* Asserts that the expected violations were found.
*
* @param \Drupal\Core\Entity\EntityConstraintViolationListInterface $violations
* A list of violations.
*/
protected function assertViolations(EntityConstraintViolationListInterface $violations) {
$entity_type_id = $this->storage->getEntityTypeId();
$settings = $this->contentTranslationManager->getBundleTranslationSettings($entity_type_id, $entity_type_id);
$message = !empty($settings['untranslatable_fields_hide']) ?
'Non-translatable field elements can only be changed when updating the original language.' :
'Non-translatable field elements can only be changed when updating the current revision.';
$list = [];
foreach ($violations as $violation) {
if ((string) $violation->getMessage() === $message) {
$list[] = $violation;
}
}
$this->assertCount(1, $list);
}
/**
* Asserts that the latest revision has the expected field values.
*
* @param $entity_id
* The entity ID.
* @param array $expected_values
* An array of expected values in the following order:
* - revision ID
* - target ID (en)
* - target ID (it)
* - alt (en)
* - alt (it)
*/
protected function assertLatestRevisionFieldValues($entity_id, array $expected_values) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $this->storage->loadRevision($this->storage->getLatestRevisionId($entity_id));
@list($revision_id, $target_id_en, $target_id_it, $alt_en, $alt_it) = $expected_values;
$this->assertEquals($revision_id, $entity->getRevisionId());
$this->assertEquals($target_id_en, $entity->get($this->fieldName)->target_id);
$this->assertEquals($alt_en, $entity->get($this->fieldName)->alt);
if ($entity->hasTranslation('it')) {
$it_translation = $entity->getTranslation('it');
$this->assertEquals($target_id_it, $it_translation->get($this->fieldName)->target_id);
$this->assertEquals($alt_it, $it_translation->get($this->fieldName)->alt);
}
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace Drupal\Tests\content_translation\Kernel;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests the content translation settings API.
*
* @group content_translation
*/
class ContentTranslationSettingsApiTest extends KernelTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['language', 'content_translation', 'user', 'entity_test'];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installEntitySchema('entity_test_mul');
}
/**
* Tests that enabling translation via the API triggers schema updates.
*/
public function testSettingsApi() {
$this->container->get('content_translation.manager')->setEnabled('entity_test_mul', 'entity_test_mul', TRUE);
$result =
db_field_exists('entity_test_mul_property_data', 'content_translation_source') &&
db_field_exists('entity_test_mul_property_data', 'content_translation_outdated');
$this->assertTrue($result, 'Schema updates correctly performed.');
}
}

View file

@ -0,0 +1,260 @@
<?php
namespace Drupal\Tests\content_translation\Kernel;
use Drupal\content_translation\FieldTranslationSynchronizer;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests the field synchronization logic.
*
* @group content_translation
*/
class ContentTranslationSyncUnitTest extends KernelTestBase {
/**
* The synchronizer class to be tested.
*
* @var \Drupal\content_translation\FieldTranslationSynchronizer
*/
protected $synchronizer;
/**
* The columns to be synchronized.
*
* @var array
*/
protected $synchronized;
/**
* All the field columns.
*
* @var array
*/
protected $columns;
/**
* The available language codes.
*
* @var array
*/
protected $langcodes;
/**
* The field cardinality.
*
* @var int
*/
protected $cardinality;
/**
* The unchanged field values.
*
* @var array
*/
protected $unchangedFieldValues;
public static $modules = ['language', 'content_translation'];
protected function setUp() {
parent::setUp();
$this->synchronizer = new FieldTranslationSynchronizer($this->container->get('entity.manager'), $this->container->get('plugin.manager.field.field_type'));
$this->synchronized = ['sync1', 'sync2'];
$this->columns = array_merge($this->synchronized, ['var1', 'var2']);
$this->langcodes = ['en', 'it', 'fr', 'de', 'es'];
$this->cardinality = 4;
$this->unchangedFieldValues = [];
// Set up an initial set of values in the correct state, that is with
// "synchronized" values being equal.
foreach ($this->langcodes as $langcode) {
for ($delta = 0; $delta < $this->cardinality; $delta++) {
foreach ($this->columns as $column) {
$sync = in_array($column, $this->synchronized) && $langcode != $this->langcodes[0];
$value = $sync ? $this->unchangedFieldValues[$this->langcodes[0]][$delta][$column] : $langcode . '-' . $delta . '-' . $column;
$this->unchangedFieldValues[$langcode][$delta][$column] = $value;
}
}
}
}
/**
* Tests the field synchronization algorithm.
*/
public function testFieldSync() {
// Add a new item to the source items and check that its added to all the
// translations.
$sync_langcode = $this->langcodes[2];
$unchanged_items = $this->unchangedFieldValues[$sync_langcode];
$field_values = $this->unchangedFieldValues;
$item = [];
foreach ($this->columns as $column) {
$item[$column] = $this->randomMachineName();
}
$field_values[$sync_langcode][] = $item;
$this->synchronizer->synchronizeItems($field_values, $unchanged_items, $sync_langcode, $this->langcodes, $this->synchronized);
$result = TRUE;
foreach ($this->unchangedFieldValues as $langcode => $items) {
// Check that the old values are still in place.
for ($delta = 0; $delta < $this->cardinality; $delta++) {
foreach ($this->columns as $column) {
$result = $result && ($this->unchangedFieldValues[$langcode][$delta][$column] == $field_values[$langcode][$delta][$column]);
}
}
// Check that the new item is available in all languages.
foreach ($this->columns as $column) {
$result = $result && ($field_values[$langcode][$delta][$column] == $field_values[$sync_langcode][$delta][$column]);
}
}
$this->assertTrue($result, 'A new item has been correctly synchronized.');
// Remove an item from the source items and check that its removed from all
// the translations.
$sync_langcode = $this->langcodes[1];
$unchanged_items = $this->unchangedFieldValues[$sync_langcode];
$field_values = $this->unchangedFieldValues;
$sync_delta = mt_rand(0, count($field_values[$sync_langcode]) - 1);
unset($field_values[$sync_langcode][$sync_delta]);
// Renumber deltas to start from 0.
$field_values[$sync_langcode] = array_values($field_values[$sync_langcode]);
$this->synchronizer->synchronizeItems($field_values, $unchanged_items, $sync_langcode, $this->langcodes, $this->synchronized);
$result = TRUE;
foreach ($this->unchangedFieldValues as $langcode => $items) {
$new_delta = 0;
// Check that the old values are still in place.
for ($delta = 0; $delta < $this->cardinality; $delta++) {
// Skip the removed item.
if ($delta != $sync_delta) {
foreach ($this->columns as $column) {
$result = $result && ($this->unchangedFieldValues[$langcode][$delta][$column] == $field_values[$langcode][$new_delta][$column]);
}
$new_delta++;
}
}
}
$this->assertTrue($result, 'A removed item has been correctly synchronized.');
// Move the items around in the source items and check that they are moved
// in all the translations.
$sync_langcode = $this->langcodes[3];
$unchanged_items = $this->unchangedFieldValues[$sync_langcode];
$field_values = $this->unchangedFieldValues;
$field_values[$sync_langcode] = [];
// Scramble the items.
foreach ($unchanged_items as $delta => $item) {
$new_delta = ($delta + 1) % $this->cardinality;
$field_values[$sync_langcode][$new_delta] = $item;
}
// Renumber deltas to start from 0.
ksort($field_values[$sync_langcode]);
$this->synchronizer->synchronizeItems($field_values, $unchanged_items, $sync_langcode, $this->langcodes, $this->synchronized);
$result = TRUE;
foreach ($field_values as $langcode => $items) {
for ($delta = 0; $delta < $this->cardinality; $delta++) {
foreach ($this->columns as $column) {
$value = $field_values[$langcode][$delta][$column];
if (in_array($column, $this->synchronized)) {
// If we are dealing with a synchronize column the current value is
// supposed to be the same of the source items.
$result = $result && $field_values[$sync_langcode][$delta][$column] == $value;
}
else {
// Otherwise the values should be unchanged.
$old_delta = ($delta > 0 ? $delta : $this->cardinality) - 1;
$result = $result && $this->unchangedFieldValues[$langcode][$old_delta][$column] == $value;
}
}
}
}
$this->assertTrue($result, 'Scrambled items have been correctly synchronized.');
}
/**
* Tests that items holding the same values are correctly synchronized.
*/
public function testMultipleSyncedValues() {
$sync_langcode = $this->langcodes[1];
$unchanged_items = $this->unchangedFieldValues[$sync_langcode];
// Determine whether the unchanged values should be altered depending on
// their delta.
$delta_callbacks = [
// Continuous field values: all values are equal.
function ($delta) {
return TRUE;
},
// Alternated field values: only the even ones are equal.
function ($delta) {
return $delta % 2 !== 0;
},
// Sparse field values: only the "middle" ones are equal.
function ($delta) {
return $delta === 1 || $delta === 2;
},
// Sparse field values: only the "extreme" ones are equal.
function ($delta) {
return $delta === 0 || $delta === 3;
},
];
foreach ($delta_callbacks as $delta_callback) {
$field_values = $this->unchangedFieldValues;
for ($delta = 0; $delta < $this->cardinality; $delta++) {
if ($delta_callback($delta)) {
foreach ($this->columns as $column) {
if (in_array($column, $this->synchronized)) {
$field_values[$sync_langcode][$delta][$column] = $field_values[$sync_langcode][0][$column];
}
}
}
}
$changed_items = $field_values[$sync_langcode];
$this->synchronizer->synchronizeItems($field_values, $unchanged_items, $sync_langcode, $this->langcodes, $this->synchronized);
foreach ($this->unchangedFieldValues as $langcode => $unchanged_items) {
for ($delta = 0; $delta < $this->cardinality; $delta++) {
foreach ($this->columns as $column) {
// The first item is always unchanged hence it is retained by the
// synchronization process. The other ones are retained or synced
// depending on the logic implemented by the delta callback and
// whether it is a sync column or not.
$value = $delta > 0 && $delta_callback($delta) && in_array($column, $this->synchronized) ? $changed_items[0][$column] : $unchanged_items[$delta][$column];
$this->assertEqual($field_values[$langcode][$delta][$column], $value, "Item $delta column $column for langcode $langcode synced correctly");
}
}
}
}
}
/**
* Tests that one change in a synchronized column triggers a change in all columns.
*/
public function testDifferingSyncedColumns() {
$sync_langcode = $this->langcodes[2];
$unchanged_items = $this->unchangedFieldValues[$sync_langcode];
$field_values = $this->unchangedFieldValues;
for ($delta = 0; $delta < $this->cardinality; $delta++) {
$index = ($delta % 2) + 1;
$field_values[$sync_langcode][$delta]['sync' . $index] .= '-updated';
}
$changed_items = $field_values[$sync_langcode];
$this->synchronizer->synchronizeItems($field_values, $unchanged_items, $sync_langcode, $this->langcodes, $this->synchronized);
foreach ($this->unchangedFieldValues as $langcode => $unchanged_items) {
for ($delta = 0; $delta < $this->cardinality; $delta++) {
foreach ($this->columns as $column) {
// If the column is synchronized, the value should have been synced,
// for unsynchronized columns, the value must not change.
$expected_value = in_array($column, $this->synchronized) ? $changed_items[$delta][$column] : $this->unchangedFieldValues[$langcode][$delta][$column];
$this->assertEqual($field_values[$langcode][$delta][$column], $expected_value, "Differing Item $delta column $column for langcode $langcode synced correctly");
}
}
}
}
}

View file

@ -0,0 +1,132 @@
<?php
namespace Drupal\Tests\content_translation\Kernel\Migrate\d6;
use Drupal\taxonomy\Entity\Term;
use Drupal\Tests\migrate_drupal\Kernel\d6\MigrateDrupal6TestBase;
use Drupal\taxonomy\TermInterface;
/**
* Test migration of translated taxonomy terms.
*
* @group migrate_drupal_6
*/
class MigrateTaxonomyTermTranslationTest extends MigrateDrupal6TestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'content_translation',
'language',
'menu_ui',
// Required for translation migrations.
'migrate_drupal_multilingual',
'node',
'taxonomy',
];
/**
* The cached taxonomy tree items, keyed by vid and tid.
*
* @var array
*/
protected $treeData = [];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installEntitySchema('taxonomy_term');
$this->installConfig(static::$modules);
$this->executeMigrations([
'd6_node_type',
'd6_field',
'd6_taxonomy_vocabulary',
'd6_field_instance',
'd6_taxonomy_term',
'd6_taxonomy_term_translation',
]);
}
/**
* Validate a migrated term contains the expected values.
*
* @param int $id
* Entity ID to load and check.
* @param string $expected_language
* The language code for this term.
* @param string $expected_label
* The label the migrated entity should have.
* @param string $expected_vid
* The parent vocabulary the migrated entity should have.
* @param string $expected_description
* The description the migrated entity should have.
* @param string $expected_format
* The format the migrated entity should have.
* @param int $expected_weight
* The weight the migrated entity should have.
* @param array $expected_parents
* The parent terms the migrated entity should have.
* @param int $expected_field_integer_value
* The value the migrated entity field should have.
* @param int $expected_term_reference_tid
* The term reference ID the migrated entity field should have.
*/
protected function assertEntity($id, $expected_language, $expected_label, $expected_vid, $expected_description = '', $expected_format = NULL, $expected_weight = 0, $expected_parents = [], $expected_field_integer_value = NULL, $expected_term_reference_tid = NULL) {
/** @var \Drupal\taxonomy\TermInterface $entity */
$entity = Term::load($id);
$this->assertInstanceOf(TermInterface::class, $entity);
$this->assertSame($expected_language, $entity->language()->getId());
$this->assertSame($expected_label, $entity->label());
$this->assertSame($expected_vid, $entity->bundle());
$this->assertSame($expected_description, $entity->getDescription());
$this->assertSame($expected_format, $entity->getFormat());
$this->assertSame($expected_weight, $entity->getWeight());
$this->assertHierarchy($expected_vid, $id, $expected_parents);
}
/**
* Assert that a term is present in the tree storage, with the right parents.
*
* @param string $vid
* Vocabulary ID.
* @param int $tid
* ID of the term to check.
* @param array $parent_ids
* The expected parent term IDs.
*/
protected function assertHierarchy($vid, $tid, array $parent_ids) {
if (!isset($this->treeData[$vid])) {
$tree = \Drupal::entityTypeManager()->getStorage('taxonomy_term')->loadTree($vid);
$this->treeData[$vid] = [];
foreach ($tree as $item) {
$this->treeData[$vid][$item->tid] = $item;
}
}
$this->assertArrayHasKey($tid, $this->treeData[$vid], "Term $tid exists in taxonomy tree");
$term = $this->treeData[$vid][$tid];
// PostgreSQL, MySQL and SQLite may not return the parent terms in the same
// order so sort before testing.
sort($parent_ids);
$actual_terms = array_filter($term->parents);
sort($actual_terms);
$this->assertEquals($parent_ids, $actual_terms, "Term $tid has correct parents in taxonomy tree");
}
/**
* Tests the Drupal 6 i18n taxonomy term to Drupal 8 migration.
*/
public function testTranslatedTaxonomyTerms() {
$this->assertEntity(1, 'zu', 'zu - term 1 of vocabulary 1', 'vocabulary_1_i_0_', 'zu - description of term 1 of vocabulary 1', NULL, '0', []);
$this->assertEntity(2, 'fr', 'fr - term 2 of vocabulary 2', 'vocabulary_2_i_1_', 'fr - description of term 2 of vocabulary 2', NULL, '3', []);
$this->assertEntity(3, 'fr', 'fr - term 3 of vocabulary 2', 'vocabulary_2_i_1_', 'fr - description of term 3 of vocabulary 2', NULL, '4', ['2']);
$this->assertEntity(4, 'en', 'term 4 of vocabulary 3', 'vocabulary_3_i_2_', 'description of term 4 of vocabulary 3', NULL, '6', []);
$this->assertEntity(5, 'en', 'term 5 of vocabulary 3', 'vocabulary_3_i_2_', 'description of term 5 of vocabulary 3', NULL, '7', ['4']);
$this->assertEntity(6, 'en', 'term 6 of vocabulary 3', 'vocabulary_3_i_2_', 'description of term 6 of vocabulary 3', NULL, '8', ['4', '5']);
$this->assertEntity(7, 'fr', 'fr - term 2 of vocabulary 1', 'vocabulary_1_i_0_', 'fr - desc of term 2 vocab 1', NULL, '0', []);
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace Drupal\Tests\content_translation\Kernel\Migrate\d7;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Tests\migrate_drupal\Kernel\d7\MigrateDrupal7TestBase;
/**
* Tests the migration of entity translation settings.
*
* @group migrate_drupal_7
*/
class MigrateEntityTranslationSettingsTest extends MigrateDrupal7TestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'comment',
'content_translation',
'language',
'menu_ui',
// Required for translation migrations.
'migrate_drupal_multilingual',
'node',
'taxonomy',
'text',
'user',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installConfig([
'comment',
'content_translation',
'node',
'taxonomy',
'user',
]);
$this->installEntitySchema('comment');
$this->installEntitySchema('node');
$this->installEntitySchema('taxonomy_term');
$this->installEntitySchema('user');
$this->executeMigrations([
'd7_comment_type',
'd7_node_type',
'd7_taxonomy_vocabulary',
'd7_entity_translation_settings',
]);
}
/**
* Tests entity translation settings migration.
*/
public function testEntityTranslationSettingsMigration() {
// Tests 'comment_node_test_content_type' entity translation settings.
$config = $this->config('language.content_settings.comment.comment_node_test_content_type');
$this->assertSame($config->get('target_entity_type_id'), 'comment');
$this->assertSame($config->get('target_bundle'), 'comment_node_test_content_type');
$this->assertSame($config->get('default_langcode'), 'current_interface');
$this->assertFalse((bool) $config->get('language_alterable'));
$this->assertTrue((bool) $config->get('third_party_settings.content_translation.enabled'));
$this->assertFalse((bool) $config->get('third_party_settings.content_translation.bundle_settings.untranslatable_fields_hide'));
// Tests 'test_content_type' entity translation settings.
$config = $this->config('language.content_settings.node.test_content_type');
$this->assertSame($config->get('target_entity_type_id'), 'node');
$this->assertSame($config->get('target_bundle'), 'test_content_type');
$this->assertSame($config->get('default_langcode'), LanguageInterface::LANGCODE_NOT_SPECIFIED);
$this->assertTrue((bool) $config->get('language_alterable'));
$this->assertTrue((bool) $config->get('third_party_settings.content_translation.enabled'));
$this->assertFalse((bool) $config->get('third_party_settings.content_translation.bundle_settings.untranslatable_fields_hide'));
// Tests 'test_vocabulary' entity translation settings.
$config = $this->config('language.content_settings.taxonomy_term.test_vocabulary');
$this->assertSame($config->get('target_entity_type_id'), 'taxonomy_term');
$this->assertSame($config->get('target_bundle'), 'test_vocabulary');
$this->assertSame($config->get('default_langcode'), LanguageInterface::LANGCODE_SITE_DEFAULT);
$this->assertFalse((bool) $config->get('language_alterable'));
$this->assertTrue((bool) $config->get('third_party_settings.content_translation.enabled'));
$this->assertFalse((bool) $config->get('third_party_settings.content_translation.bundle_settings.untranslatable_fields_hide'));
// Tests 'user' entity translation settings.
$config = $this->config('language.content_settings.user.user');
$this->assertSame($config->get('target_entity_type_id'), 'user');
$this->assertSame($config->get('target_bundle'), 'user');
$this->assertSame($config->get('default_langcode'), LanguageInterface::LANGCODE_SITE_DEFAULT);
$this->assertFalse((bool) $config->get('language_alterable'));
$this->assertTrue((bool) $config->get('third_party_settings.content_translation.enabled'));
$this->assertFalse((bool) $config->get('third_party_settings.content_translation.bundle_settings.untranslatable_fields_hide'));
}
}

View file

@ -0,0 +1,254 @@
<?php
namespace Drupal\Tests\content_translation\Kernel\Plugin\migrate\source\d7;
use Drupal\Tests\migrate\Kernel\MigrateSqlSourceTestBase;
/**
* Tests entity translation settings source plugin.
*
* @covers \Drupal\content_translation\Plugin\migrate\source\d7\EntityTranslationSettings
*
* @group content_translation
*/
class EntityTranslationSettingsTest extends MigrateSqlSourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'content_translation',
'language',
'migrate_drupal',
];
/**
* {@inheritdoc}
*/
public function providerSource() {
$tests = [];
// Source data when there's no entity type that uses entity translation.
$tests[0]['source_data']['variable'] = [
[
'name' => 'entity_translation_entity_types',
'value' => 'a:4:{s:7:"comment";i:0;s:4:"node";i:0;s:13:"taxonomy_term";i:0;s:4:"user";i:0;}',
],
];
// Source data when there's no bundle settings variables.
$tests[1]['source_data']['variable'] = [
[
'name' => 'entity_translation_entity_types',
'value' => 'a:4:{s:7:"comment";s:7:"comment";s:4:"node";s:4:"node";s:13:"taxonomy_term";s:13:"taxonomy_term";s:4:"user";s:4:"user";}',
],
[
'name' => 'entity_translation_taxonomy',
'value' => 'a:3:{s:6:"forums";b:1;s:4:"tags";b:1;s:4:"test";b:0;}',
],
[
'name' => 'language_content_type_article',
'value' => 's:1:"2";',
],
[
'name' => 'language_content_type_forum',
'value' => 's:1:"4";',
],
[
'name' => 'language_content_type_page',
'value' => 's:1:"4";',
],
];
// Source data when there's bundle settings variables.
$tests[2]['source_data']['variable'] = [
[
'name' => 'entity_translation_entity_types',
'value' => 'a:4:{s:7:"comment";s:7:"comment";s:4:"node";s:4:"node";s:13:"taxonomy_term";s:13:"taxonomy_term";s:4:"user";s:4:"user";}',
],
[
'name' => 'entity_translation_settings_comment__comment_node_forum',
'value' => 'a:5:{s:16:"default_language";s:12:"xx-et-author";s:22:"hide_language_selector";i:1;s:21:"exclude_language_none";i:0;s:13:"lock_language";i:0;s:27:"shared_fields_original_only";i:0;}',
],
[
'name' => 'entity_translation_settings_comment__comment_node_page',
'value' => 'a:5:{s:16:"default_language";s:12:"xx-et-author";s:22:"hide_language_selector";i:0;s:21:"exclude_language_none";i:0;s:13:"lock_language";i:0;s:27:"shared_fields_original_only";i:1;}',
],
[
'name' => 'entity_translation_settings_node__forum',
'value' => 'a:5:{s:16:"default_language";s:12:"xx-et-author";s:22:"hide_language_selector";i:0;s:21:"exclude_language_none";i:0;s:13:"lock_language";i:0;s:27:"shared_fields_original_only";i:0;}',
],
[
'name' => 'entity_translation_settings_node__page',
'value' => 'a:5:{s:16:"default_language";s:13:"xx-et-default";s:22:"hide_language_selector";i:1;s:21:"exclude_language_none";i:0;s:13:"lock_language";i:0;s:27:"shared_fields_original_only";i:1;}',
],
[
'name' => 'entity_translation_settings_taxonomy_term__forums',
'value' => 'a:5:{s:16:"default_language";s:13:"xx-et-current";s:22:"hide_language_selector";i:0;s:21:"exclude_language_none";i:0;s:13:"lock_language";i:0;s:27:"shared_fields_original_only";i:1;}',
],
[
'name' => 'entity_translation_settings_taxonomy_term__tags',
'value' => 'a:5:{s:16:"default_language";s:13:"xx-et-current";s:22:"hide_language_selector";i:1;s:21:"exclude_language_none";i:0;s:13:"lock_language";i:0;s:27:"shared_fields_original_only";i:0;}',
],
[
'name' => 'entity_translation_settings_user__user',
'value' => 'a:5:{s:16:"default_language";s:12:"xx-et-author";s:22:"hide_language_selector";i:1;s:21:"exclude_language_none";i:0;s:13:"lock_language";i:0;s:27:"shared_fields_original_only";i:1;}',
],
[
'name' => 'entity_translation_taxonomy',
'value' => 'a:3:{s:6:"forums";b:1;s:4:"tags";b:1;s:4:"test";b:0;}',
],
[
'name' => 'language_content_type_article',
'value' => 's:1:"2";',
],
[
'name' => 'language_content_type_forum',
'value' => 's:1:"4";',
],
[
'name' => 'language_content_type_page',
'value' => 's:1:"4";',
],
];
// Source data when taxonomy terms are translatable but the
// 'entity_translation_taxonomy' variable is not set.
$tests[3]['source_data']['variable'] = [
[
'name' => 'entity_translation_entity_types',
'value' => 'a:4:{s:7:"comment";i:0;s:4:"node";i:0;s:13:"taxonomy_term";i:1;s:4:"user";i:0;}',
],
];
// Expected data when there's no entity type that uses entity translation.
$tests[0]['expected_data'] = [];
// Expected data when there's no bundle settings variables.
$tests[1]['expected_data'] = [
[
'id' => 'node.forum',
'target_entity_type_id' => 'node',
'target_bundle' => 'forum',
'default_langcode' => 'und',
'language_alterable' => TRUE,
'untranslatable_fields_hide' => FALSE,
],
[
'id' => 'node.page',
'target_entity_type_id' => 'node',
'target_bundle' => 'page',
'default_langcode' => 'und',
'language_alterable' => TRUE,
'untranslatable_fields_hide' => FALSE,
],
[
'id' => 'comment.comment_forum',
'target_entity_type_id' => 'comment',
'target_bundle' => 'comment_forum',
'default_langcode' => 'xx-et-current',
'language_alterable' => FALSE,
'untranslatable_fields_hide' => FALSE,
],
[
'id' => 'comment.comment_node_page',
'target_entity_type_id' => 'comment',
'target_bundle' => 'comment_node_page',
'default_langcode' => 'xx-et-current',
'language_alterable' => FALSE,
'untranslatable_fields_hide' => FALSE,
],
[
'id' => 'taxonomy_term.forums',
'target_entity_type_id' => 'taxonomy_term',
'target_bundle' => 'forums',
'default_langcode' => 'xx-et-default',
'language_alterable' => FALSE,
'untranslatable_fields_hide' => FALSE,
],
[
'id' => 'taxonomy_term.tags',
'target_entity_type_id' => 'taxonomy_term',
'target_bundle' => 'tags',
'default_langcode' => 'xx-et-default',
'language_alterable' => FALSE,
'untranslatable_fields_hide' => FALSE,
],
[
'id' => 'user.user',
'target_entity_type_id' => 'user',
'target_bundle' => 'user',
'default_langcode' => 'xx-et-default',
'language_alterable' => FALSE,
'untranslatable_fields_hide' => FALSE,
],
];
// Expected data when there's bundle settings variables.
$tests[2]['expected_data'] = [
[
'id' => 'node.forum',
'target_entity_type_id' => 'node',
'target_bundle' => 'forum',
'default_langcode' => 'xx-et-author',
'language_alterable' => TRUE,
'untranslatable_fields_hide' => FALSE,
],
[
'id' => 'node.page',
'target_entity_type_id' => 'node',
'target_bundle' => 'page',
'default_langcode' => 'xx-et-default',
'language_alterable' => FALSE,
'untranslatable_fields_hide' => TRUE,
],
[
'id' => 'comment.comment_forum',
'target_entity_type_id' => 'comment',
'target_bundle' => 'comment_forum',
'default_langcode' => 'xx-et-author',
'language_alterable' => FALSE,
'untranslatable_fields_hide' => FALSE,
],
[
'id' => 'comment.comment_node_page',
'target_entity_type_id' => 'comment',
'target_bundle' => 'comment_node_page',
'default_langcode' => 'xx-et-author',
'language_alterable' => TRUE,
'untranslatable_fields_hide' => TRUE,
],
[
'id' => 'taxonomy_term.forums',
'target_entity_type_id' => 'taxonomy_term',
'target_bundle' => 'forums',
'default_langcode' => 'xx-et-current',
'language_alterable' => TRUE,
'untranslatable_fields_hide' => TRUE,
],
[
'id' => 'taxonomy_term.tags',
'target_entity_type_id' => 'taxonomy_term',
'target_bundle' => 'tags',
'default_langcode' => 'xx-et-current',
'language_alterable' => FALSE,
'untranslatable_fields_hide' => FALSE,
],
[
'id' => 'user.user',
'target_entity_type_id' => 'user',
'target_bundle' => 'user',
'default_langcode' => 'xx-et-author',
'language_alterable' => FALSE,
'untranslatable_fields_hide' => TRUE,
],
];
// Expected data when taxonomy terms are translatable but the
// 'entity_translation_taxonomy' variable is not set.
$tests[3]['expected_data'] = [];
return $tests;
}
}

View file

@ -0,0 +1,133 @@
<?php
namespace Drupal\Tests\content_translation\Unit\Access;
use Drupal\content_translation\Access\ContentTranslationManageAccessCheck;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Language\Language;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\Routing\Route;
/**
* Tests for content translation manage check.
*
* @coversDefaultClass \Drupal\content_translation\Access\ContentTranslationManageAccessCheck
* @group Access
* @group content_translation
*/
class ContentTranslationManageAccessCheckTest extends UnitTestCase {
/**
* The cache contexts manager.
*
* @var \Drupal\Core\Cache\Context\CacheContextsManager|\PHPUnit_Framework_MockObject_MockObject
*/
protected $cacheContextsManager;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->cacheContextsManager = $this->getMockBuilder('Drupal\Core\Cache\Context\CacheContextsManager')
->disableOriginalConstructor()
->getMock();
$this->cacheContextsManager->method('assertValidTokens')->willReturn(TRUE);
$container = new ContainerBuilder();
$container->set('cache_contexts_manager', $this->cacheContextsManager);
\Drupal::setContainer($container);
}
/**
* Tests the create access method.
*
* @covers ::access
*/
public function testCreateAccess() {
// Set the mock translation handler.
$translation_handler = $this->getMock('\Drupal\content_translation\ContentTranslationHandlerInterface');
$translation_handler->expects($this->once())
->method('getTranslationAccess')
->will($this->returnValue(AccessResult::allowed()));
$entity_manager = $this->getMock('Drupal\Core\Entity\EntityManagerInterface');
$entity_manager->expects($this->once())
->method('getHandler')
->withAnyParameters()
->will($this->returnValue($translation_handler));
// Set our source and target languages.
$source = 'en';
$target = 'it';
// Set the mock language manager.
$language_manager = $this->getMock('Drupal\Core\Language\LanguageManagerInterface');
$language_manager->expects($this->at(0))
->method('getLanguage')
->with($this->equalTo($source))
->will($this->returnValue(new Language(['id' => 'en'])));
$language_manager->expects($this->at(1))
->method('getLanguages')
->will($this->returnValue(['en' => [], 'it' => []]));
$language_manager->expects($this->at(2))
->method('getLanguage')
->with($this->equalTo($source))
->will($this->returnValue(new Language(['id' => 'en'])));
$language_manager->expects($this->at(3))
->method('getLanguage')
->with($this->equalTo($target))
->will($this->returnValue(new Language(['id' => 'it'])));
// Set the mock entity. We need to use ContentEntityBase for mocking due to
// issues with phpunit and multiple interfaces.
$entity = $this->getMockBuilder('Drupal\Core\Entity\ContentEntityBase')
->disableOriginalConstructor()
->getMock();
$entity->expects($this->once())
->method('getEntityTypeId');
$entity->expects($this->once())
->method('getTranslationLanguages')
->with()
->will($this->returnValue([]));
$entity->expects($this->once())
->method('getCacheContexts')
->willReturn([]);
$entity->expects($this->once())
->method('getCacheMaxAge')
->willReturn(Cache::PERMANENT);
$entity->expects($this->once())
->method('getCacheTags')
->will($this->returnValue(['node:1337']));
$entity->expects($this->once())
->method('getCacheContexts')
->willReturn([]);
// Set the route requirements.
$route = new Route('test_route');
$route->setRequirement('_access_content_translation_manage', 'create');
// Set up the route match.
$route_match = $this->getMock('Drupal\Core\Routing\RouteMatchInterface');
$route_match->expects($this->once())
->method('getParameter')
->with('node')
->will($this->returnValue($entity));
// Set the mock account.
$account = $this->getMock('Drupal\Core\Session\AccountInterface');
// The access check under test.
$check = new ContentTranslationManageAccessCheck($entity_manager, $language_manager);
// The request params.
$language = 'en';
$entity_type_id = 'node';
$this->assertTrue($check->access($route, $route_match, $account, $source, $target, $language, $entity_type_id)->isAllowed(), "The access check matches");
}
}

View file

@ -0,0 +1,79 @@
<?php
namespace Drupal\Tests\content_translation\Unit\Menu;
use Drupal\Tests\Core\Menu\LocalTaskIntegrationTestBase;
/**
* Tests content translation local tasks.
*
* @group content_translation
*/
class ContentTranslationLocalTasksTest extends LocalTaskIntegrationTestBase {
protected function setUp() {
$this->directoryList = [
'content_translation' => 'core/modules/content_translation',
'node' => 'core/modules/node',
];
parent::setUp();
$entity_type = $this->getMock('Drupal\Core\Entity\EntityTypeInterface');
$entity_type->expects($this->any())
->method('getLinkTemplate')
->will($this->returnValueMap([
['canonical', 'entity.node.canonical'],
['drupal:content-translation-overview', 'entity.node.content_translation_overview'],
]));
$content_translation_manager = $this->getMock('Drupal\content_translation\ContentTranslationManagerInterface');
$content_translation_manager->expects($this->any())
->method('getSupportedEntityTypes')
->will($this->returnValue([
'node' => $entity_type,
]));
\Drupal::getContainer()->set('content_translation.manager', $content_translation_manager);
\Drupal::getContainer()->set('string_translation', $this->getStringTranslationStub());
}
/**
* Tests the block admin display local tasks.
*
* @dataProvider providerTestBlockAdminDisplay
*/
public function testBlockAdminDisplay($route, $expected) {
$this->assertLocalTasks($route, $expected);
}
/**
* Provides a list of routes to test.
*/
public function providerTestBlockAdminDisplay() {
return [
[
'entity.node.canonical',
[
[
'content_translation.local_tasks:entity.node.content_translation_overview',
'entity.node.canonical',
'entity.node.edit_form',
'entity.node.delete_form',
'entity.node.version_history',
],
],
],
[
'entity.node.content_translation_overview',
[
[
'content_translation.local_tasks:entity.node.content_translation_overview',
'entity.node.canonical',
'entity.node.edit_form',
'entity.node.delete_form',
'entity.node.version_history',
],
],
],
];
}
}