diff --git a/core/config/schema/core.data_types.schema.yml b/core/config/schema/core.data_types.schema.yml index 0e589e1..4ac1082 100644 --- a/core/config/schema/core.data_types.schema.yml +++ b/core/config/schema/core.data_types.schema.yml @@ -599,3 +599,34 @@ field.float.value: value: type: float label: 'Value' + +# Human readable string that is associated with a format. +text_format: + type: mapping + label: 'Text with text format' + # Even though it is not sensible to translate the text format of a formatted + # string, we conceive of the text and its date format as a single composite + # object and declare that object (or in other words the entire mapping) as + # translatable. This causes the entire mapping to be saved to the language + # overrides of the configuration. Storing only the (to be formatted) text + # could result in security problems in case the text format of the source + # text is changed. + translatable: true + mapping: + value: + type: text + label: 'Text' + # locale.module integrates the language overrides of shipped configuration + # with https://localize.drupal.org. Because it only handles strings and + # cannot deal with complex data structures, it parses the configuration + # schema until it reaches a primitive and only then checks whether the + # element is translatable. + translatable: true + format: + type: string + label: 'Text format' + # Even though the entire 'text_format' is marked as translatable for the + # sake of language configuration overrides, the ID of the text format of + # texts in shipped configuration should not be exposed to + # https://localize.drupal.org + diff --git a/core/lib/Drupal/Core/Config/ConfigEvents.php b/core/lib/Drupal/Core/Config/ConfigEvents.php index 7fa22a5..7fc0750 100644 --- a/core/lib/Drupal/Core/Config/ConfigEvents.php +++ b/core/lib/Drupal/Core/Config/ConfigEvents.php @@ -9,6 +9,8 @@ /** * Defines events for the configuration system. + * + * @see \Drupal\Core\Config\ConfigCrudEvent */ final class ConfigEvents { diff --git a/core/lib/Drupal/Core/Config/TypedConfigManagerInterface.php b/core/lib/Drupal/Core/Config/TypedConfigManagerInterface.php index d9ad30b..84d347b 100644 --- a/core/lib/Drupal/Core/Config/TypedConfigManagerInterface.php +++ b/core/lib/Drupal/Core/Config/TypedConfigManagerInterface.php @@ -24,7 +24,7 @@ * @param string $name * Configuration object name. * - * @return \Drupal\Core\Config\Schema\Element + * @return \Drupal\Core\TypedData\TraversableTypedDataInterface * Typed configuration element. */ public function get($name); diff --git a/core/modules/config_translation/config_translation.module b/core/modules/config_translation/config_translation.module index 32b3307..a96b434 100644 --- a/core/modules/config_translation/config_translation.module +++ b/core/modules/config_translation/config_translation.module @@ -173,10 +173,22 @@ function config_translation_entity_operation(EntityInterface $entity) { * Implements hook_config_schema_info_alter(). */ function config_translation_config_schema_info_alter(&$definitions) { + $map = array( + 'label' => '\Drupal\config_translation\FormElement\Textfield', + 'text' => '\Drupal\config_translation\FormElement\Textarea', + 'date_format' => '\Drupal\config_translation\FormElement\DateFormat', + 'text_format' => '\Drupal\config_translation\FormElement\TextFormat', + 'mapping' => '\Drupal\config_translation\FormElement\ListElement', + 'sequence' => '\Drupal\config_translation\FormElement\ListElement', + ); + // Enhance the text and date type definitions with classes to generate proper // form elements in ConfigTranslationFormBase. Other translatable types will // appear as a one line textfield. - $definitions['text']['form_element_class'] = '\Drupal\config_translation\FormElement\Textarea'; - $definitions['date_format']['form_element_class'] = '\Drupal\config_translation\FormElement\DateFormat'; + foreach ($definitions as $type => &$definition) { + if (!isset($definition['form_element_class']) && isset($map[$type])) { + $definition['form_element_class'] = $map[$type]; + } + } } diff --git a/core/modules/config_translation/src/Form/ConfigTranslationFormBase.php b/core/modules/config_translation/src/Form/ConfigTranslationFormBase.php index 5e05293..c6c5d4f 100644 --- a/core/modules/config_translation/src/Form/ConfigTranslationFormBase.php +++ b/core/modules/config_translation/src/Form/ConfigTranslationFormBase.php @@ -8,17 +8,12 @@ namespace Drupal\config_translation\Form; use Drupal\config_translation\ConfigMapperManagerInterface; -use Drupal\Core\Config\Config; -use Drupal\Core\Config\Schema\Element; use Drupal\Core\Config\TypedConfigManagerInterface; -use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\TypedData\TypedDataInterface; use Drupal\Core\Form\BaseFormIdInterface; use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; -use Drupal\Core\Language\LanguageInterface; -use Drupal\language\Config\LanguageConfigOverride; use Drupal\language\ConfigurableLanguageManagerInterface; -use Drupal\locale\StringStorageInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -43,13 +38,6 @@ protected $configMapperManager; /** - * The string translation storage object. - * - * @var \Drupal\locale\StringStorageInterface - */ - protected $localeStorage; - - /** * The mapper for configuration translation. * * @var \Drupal\config_translation\ConfigMapperInterface @@ -85,19 +73,18 @@ protected $baseConfigData = array(); /** - * Creates manage form object with string translation storage. + * Constructs a ConfigTranslationFormBase. * * @param \Drupal\Core\Config\TypedConfigManagerInterface $typed_config_manager * The typed configuration manager. * @param \Drupal\config_translation\ConfigMapperManagerInterface $config_mapper_manager * The configuration mapper manager. - * @param \Drupal\locale\StringStorageInterface $locale_storage - * The translation storage object. + * @param \Drupal\language\ConfigurableLanguageManagerInterface $language_manager + * The configurable language manager. */ - public function __construct(TypedConfigManagerInterface $typed_config_manager, ConfigMapperManagerInterface $config_mapper_manager, StringStorageInterface $locale_storage, ConfigurableLanguageManagerInterface $language_manager) { + public function __construct(TypedConfigManagerInterface $typed_config_manager, ConfigMapperManagerInterface $config_mapper_manager, ConfigurableLanguageManagerInterface $language_manager) { $this->typedConfigManager = $typed_config_manager; $this->configMapperManager = $config_mapper_manager; - $this->localeStorage = $locale_storage; $this->languageManager = $language_manager; } @@ -108,7 +95,6 @@ public static function create(ContainerInterface $container) { return new static( $container->get('config.typed'), $container->get('plugin.manager.config_translation.mapper'), - $container->get('locale.storage'), $container->get('language_manager') ); } @@ -179,13 +165,21 @@ public function buildForm(array $form, FormStateInterface $form_state, Request $ $form['#attached']['library'][] = 'config_translation/drupal.config_translation.admin'; - $form['config_names'] = array( - '#type' => 'container', - '#tree' => TRUE, - ); + // Even though this is a nested form, we do not set #tree to TRUE because + // the form value structure is generated by using #parents for each element. + // @see \Drupal\config_translation\FormElement\FormElementBase::getElements() + $form['config_names'] = array('#type' => 'container'); foreach ($this->mapper->getConfigNames() as $name) { $form['config_names'][$name] = array('#type' => 'container'); - $form['config_names'][$name] += $this->buildConfigForm($this->typedConfigManager->get($name), $config_factory->get($name)->get(), $this->baseConfigData[$name]); + + $schema = $this->typedConfigManager->get($name); + $source_config = $this->baseConfigData[$name]; + $translation_config = $config_factory->get($name)->get(); + + if ($form_element = $this->createFormElement($schema)) { + $parents = array('config_names', $name); + $form['config_names'][$name] += $form_element->getTranslationBuild($this->sourceLanguage, $this->language, $source_config, $translation_config, $parents); + } } $form['actions']['#type'] = 'actions'; @@ -205,7 +199,7 @@ public function buildForm(array $form, FormStateInterface $form_state, Request $ * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { - $form_values = $form_state->getValue('config_names'); + $form_values = $form_state->getValue(array('translation', 'config_names')); // For the form submission handling, use the raw data. $config_factory = $this->configFactory(); @@ -213,12 +207,14 @@ public function submitForm(array &$form, FormStateInterface $form_state) { $config_factory->setOverrideState(FALSE); foreach ($this->mapper->getConfigNames() as $name) { + $schema = $this->typedConfigManager->get($name); + // Set configuration values based on form submission and source values. $base_config = $config_factory->get($name); $config_translation = $this->languageManager->getLanguageConfigOverride($this->language->getId(), $name); - $locations = $this->localeStorage->getLocations(array('type' => 'configuration', 'name' => $name)); - $this->setConfig($this->language, $base_config, $config_translation, $form_values[$name], !empty($locations)); + $element = $this->createFormElement($schema); + $element->setConfig($base_config, $config_translation, $form_values[$name]); // If no overrides, delete language specific configuration file. $saved_config = $config_translation->get(); @@ -238,169 +234,25 @@ public function submitForm(array &$form, FormStateInterface $form_state) { } /** - * Formats configuration schema as a form tree. + * Create form element builder. * - * @param \Drupal\Core\Config\Schema\Element $schema + * @param \Drupal\Core\TypedData\TypedDataInterface $schema * Schema definition of configuration. - * @param array|string $config_data - * Configuration object of requested language, a string when done traversing - * the data building each sub-structure for the form. - * @param array|string $base_config_data - * Configuration object of base language, a string when done traversing - * the data building each sub-structure for the form. - * @param bool $open - * (optional) Whether or not the details element of the form should be open. - * Defaults to TRUE. - * @param string|null $base_key - * (optional) Base configuration key. Defaults to an empty string. * - * @return array - * An associative array containing the structure of the form. + * @return \Drupal\config_translation\FormElement\ElementInterface|null + * The element builder object if possible. */ - protected function buildConfigForm(Element $schema, $config_data, $base_config_data, $open = TRUE, $base_key = '') { - $build = array(); - foreach ($schema as $key => $element) { - // Make the specific element key, "$base_key.$key". - $element_key = implode('.', array_filter(array($base_key, $key))); - $definition = $element->getDataDefinition(); + public static function createFormElement(TypedDataInterface $schema) { + $definition = $schema->getDataDefinition(); + // Form element classes can be specified even for non-translatable elements + // such as the ListElement form element which is used for Mapping and + // Sequence schema elements. + if (isset($definition['form_element_class'])) { if (!$definition->getLabel()) { - $definition->setLabel($this->t('N/A')); - } - if ($element instanceof Element) { - // Build sub-structure and include it with a wrapper in the form - // if there are any translatable elements there. - $sub_build = $this->buildConfigForm($element, $config_data[$key], $base_config_data[$key], FALSE, $element_key); - if (!empty($sub_build)) { - // For some configuration elements the same element structure can - // repeat multiple times, (like views displays, filters, etc.). - // So try to find a more usable title for the details summary. First - // check if there is an element which is called title or label, then - // check if there is an element which contains these words. - $title = ''; - if (isset($sub_build['title']['source'])) { - $title = $sub_build['title']['source']['#markup']; - } - elseif (isset($sub_build['label']['source'])) { - $title = $sub_build['label']['source']['#markup']; - } - else { - foreach (array_keys($sub_build) as $title_key) { - if (isset($sub_build[$title_key]['source']) && (strpos($title_key, 'title') !== FALSE || strpos($title_key, 'label') !== FALSE)) { - $title = $sub_build[$title_key]['source']['#markup']; - break; - } - } - } - $build[$key] = array( - '#type' => 'details', - '#title' => (!empty($title) ? (strip_tags($title) . ' ') : '') . $this->t($definition['label']), - '#open' => $open, - ) + $sub_build; - } - } - else { - $definition = $element->getDataDefinition(); - - // Create form element only for translatable items. - if (!isset($definition['translatable']) || !isset($definition['type'])) { - continue; - } - - $value = $config_data[$key]; - $build[$element_key] = array( - '#theme' => 'config_translation_manage_form_element', - ); - $build[$element_key]['source'] = array( - '#markup' => $base_config_data[$key] ? ('' . nl2br($base_config_data[$key] . '')) : t('(Empty)'), - '#title' => $this->t( - '!label (!source_language)', - array( - '!label' => $this->t($definition['label']), - '!source_language' => $this->sourceLanguage->getName(), - ) - ), - '#type' => 'item', - ); - - if (!isset($definition['form_element_class'])) { - $definition['form_element_class'] = '\Drupal\config_translation\FormElement\Textfield'; - } - - /** @var \Drupal\config_translation\FormElement\ElementInterface $form_element */ - $form_element = new $definition['form_element_class'](); - $build[$element_key]['translation'] = $form_element->getFormElement($definition, $this->language, $value); - } - } - return $build; - } - - /** - * Sets configuration based on a nested form value array. - * - * @param \Drupal\Core\Language\LanguageInterface $language - * Set the configuration in this language. - * @param \Drupal\Core\Config\Config $base_config - * Base configuration values, in the source language. - * @param \Drupal\language\Config\LanguageConfigOverride $config_translation - * Translation configuration override data. - * @param array $config_values - * A simple one dimensional or recursive array: - * - simple: - * array(name => array('translation' => 'French site name')); - * - recursive: - * cancel_confirm => array( - * cancel_confirm.subject => array('translation' => 'Subject'), - * cancel_confirm.body => array('translation' => 'Body content'), - * ); - * Either format is used, the nested arrays are just containers and not - * needed for saving the data. - * @param bool $shipped_config - * (optional) Flag to specify whether the configuration had a shipped - * version and therefore should also be stored in the locale database. - * - * @return array - * Translation configuration override data. - */ - protected function setConfig(LanguageInterface $language, Config $base_config, LanguageConfigOverride $config_translation, array $config_values, $shipped_config = FALSE) { - foreach ($config_values as $key => $value) { - if (is_array($value) && !isset($value['translation'])) { - // Traverse into this level in the configuration. - $this->setConfig($language, $base_config, $config_translation, $value, $shipped_config); - } - else { - - // If the configuration file being translated was originally shipped, we - // should update the locale translation storage. The string should - // already be there, but we make sure to check. - if ($shipped_config && $source_string = $this->localeStorage->findString(array('source' => $base_config->get($key)))) { - - // Get the translation for this original source string from locale. - $conditions = array( - 'lid' => $source_string->lid, - 'language' => $language->getId(), - ); - $translations = $this->localeStorage->getTranslations($conditions + array('translated' => TRUE)); - // If we got a translation, take that, otherwise create a new one. - $translation = reset($translations) ?: $this->localeStorage->createTranslation($conditions); - - // If we have a new translation or different from what is stored in - // locale before, save this as an updated customize translation. - if ($translation->isNew() || $translation->getString() != $value['translation']) { - $translation->setString($value['translation']) - ->setCustomized() - ->save(); - } - } - - // Save value, if different from the source value in the base - // configuration. If same as original configuration, remove override. - if ($base_config->get($key) !== $value['translation']) { - $config_translation->set($key, $value['translation']); - } - else { - $config_translation->clear($key); - } + $definition->setLabel(t('n/a')); } + $class = $definition['form_element_class']; + return $class::create($schema); } } diff --git a/core/modules/config_translation/src/FormElement/DateFormat.php b/core/modules/config_translation/src/FormElement/DateFormat.php index c3478e6..729afee 100644 --- a/core/modules/config_translation/src/FormElement/DateFormat.php +++ b/core/modules/config_translation/src/FormElement/DateFormat.php @@ -12,34 +12,29 @@ use Drupal\Core\Ajax\ReplaceCommand; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Language\LanguageInterface; -use Drupal\Core\StringTranslation\StringTranslationTrait; -use Drupal\Core\TypedData\DataDefinitionInterface; /** * Defines the date format element for the configuration translation interface. */ -class DateFormat implements ElementInterface { - use StringTranslationTrait; +class DateFormat extends FormElementBase { /** * {@inheritdoc} */ - public function getFormElement(DataDefinitionInterface $definition, LanguageInterface $language, $value) { + public function getTranslationElement(LanguageInterface $translation_language, $source_config, $translation_config) { $description = $this->t('A user-defined date format. See the PHP manual for available options.', array('@url' => 'http://php.net/manual/function.date.php')); - $format = $this->t('Displayed as %date_format', array('%date_format' => \Drupal::service('date.formatter')->format(REQUEST_TIME, 'custom', $value))); + $format = $this->t('Displayed as %date_format', array('%date_format' => \Drupal::service('date.formatter')->format(REQUEST_TIME, 'custom', $translation_config))); + return array( '#type' => 'textfield', - '#title' => $this->t($definition->getLabel()) . ' (' . $language->getName() . ')', '#description' => $description, - '#default_value' => $value, - '#attributes' => array('lang' => $language->getId()), '#field_suffix' => '
' . $format . '
', '#ajax' => array( 'callback' => 'Drupal\config_translation\FormElement\DateFormat::ajaxSample', 'event' => 'keyup', 'progress' => array('type' => 'throbber', 'message' => NULL), ), - ); + ) + parent::getTranslationElement($translation_language, $source_config, $translation_config); } /** diff --git a/core/modules/config_translation/src/FormElement/ElementInterface.php b/core/modules/config_translation/src/FormElement/ElementInterface.php index 418860f..dd660b6 100644 --- a/core/modules/config_translation/src/FormElement/ElementInterface.php +++ b/core/modules/config_translation/src/FormElement/ElementInterface.php @@ -7,8 +7,10 @@ namespace Drupal\config_translation\FormElement; +use Drupal\Core\Config\Config; use Drupal\Core\Language\LanguageInterface; -use Drupal\Core\TypedData\DataDefinitionInterface; +use Drupal\Core\TypedData\TypedDataInterface; +use Drupal\language\Config\LanguageConfigOverride; /** * Provides an interface for configuration translation form elements. @@ -16,19 +18,56 @@ interface ElementInterface { /** - * Returns the translation form element for a given configuration definition. + * Creates a form element instance from a schema definition. * - * @param \Drupal\Core\TypedData\DataDefinitionInterface $definition - * Configuration schema type definition of the element. - * @param \Drupal\Core\Language\LanguageInterface $language - * Language object to display the translation form for. - * @param string $value - * Default value for the form element. + * @param \Drupal\Core\TypedData\TypedDataInterface $schema + * The configuration schema. + * + * @return static + */ + public static function create(TypedDataInterface $schema); + + /** + * Returns the source and translation elements for a given configuration + * definition. + * + * @param \Drupal\Core\Language\LanguageInterface $source_language + * Thee source language of the configuration object. + * @param \Drupal\Core\Language\LanguageInterface $translation_language + * The language to display the translation form for. + * @param mixed $source_config + * The configuration value of the element in the source language. + * @param mixed $translation_config + * The configuration value of the element in the language to translate to. + * @param array $parents + * Parents array for the element in the form. + * @param string $base_key + * (optional) Base key to be used for the elements in the form. NULL for + * top-level form elements. * * @return array - * Form API array to represent the form element. + * A render array for the source value. */ - public function getFormElement(DataDefinitionInterface $definition, LanguageInterface $language, $value); + public function getTranslationBuild(LanguageInterface $source_language, LanguageInterface $translation_language, $source_config, $translation_config, $parents, $base_key = NULL); + /** + * Sets configuration based on a nested form value array. + * + * @param \Drupal\Core\Config\Config $base_config + * Base configuration values, in the source language. + * @param \Drupal\language\Config\LanguageConfigOverride $config_translation + * Translation configuration override data. + * @param mixed $config_values + * The configuration value of the element taken from the form values. + * @param string|null $base_key + * (optional) The base key that the schema and the configuration values + * belong to. This should be NULL for the top-level configuration object and + * be populated consecutively when recursing into the configuration + * structure. + * + * @return array + * Translation configuration override data. + */ + public function setConfig(Config $base_config, LanguageConfigOverride $config_translation, $config_values, $base_key = NULL); } diff --git a/core/modules/config_translation/src/FormElement/FormElementBase.php b/core/modules/config_translation/src/FormElement/FormElementBase.php new file mode 100644 index 0000000..88d8f83 --- /dev/null +++ b/core/modules/config_translation/src/FormElement/FormElementBase.php @@ -0,0 +1,190 @@ +element = $element; + $this->definition = $element->getDataDefinition(); + } + + /** + * {@inheritdoc} + */ + public static function create(TypedDataInterface $schema) { + return new static($schema); + } + + /** + * {@inheritdoc} + */ + public function getTranslationBuild(LanguageInterface $source_language, LanguageInterface $translation_language, $source_config, $translation_config, $parents, $base_key = NULL) { + $build['#theme'] = 'config_translation_manage_form_element'; + + // For accessibility we make source and translation appear next to each + // other in the source for each element, which is why we utilize the + // 'source' and 'translation' sub-keys for the form. The form values, + // however, should mirror the configuration structure, so that we can + // traverse the configuration schema and still access the right + // configuration values in ConfigTranslationFormBase::setConfig(). + // Therefore we make the 'source' and 'translation' keys the top-level + // keys in $form_state['values']. + $build['source'] = $this->getSourceElement($source_language, $source_config); + $build['translation'] = $this->getTranslationElement($translation_language, $source_config, $translation_config); + + $build['source']['#parents'] = array_merge(array('source'), $parents); + $build['translation']['#parents'] = array_merge(array('translation'), $parents); + return $build; + } + + /** + * Returns the source element for a given configuration definition. + * + * This can be either a render array that actually outputs the source values + * directly or a read-only form element with the source values depending on + * what is considered to provide a more intuitive user interface for the + * translator. + * + * @param \Drupal\Core\Language\LanguageInterface $source_language + * Thee source language of the configuration object. + * @param mixed $source_config + * The configuration value of the element in the source language. + * + * @return array + * A render array for the source value. + */ + protected function getSourceElement(LanguageInterface $source_language, $source_config) { + if ($source_config) { + $value = '' . nl2br($source_config) . ''; + } + else { + $value = $this->t('(Empty)'); + } + + return array( + '#type' => 'item', + '#title' => $this->t('!label (!source_language)', array( + '!label' => $this->t($this->definition->getLabel()), + '!source_language' => $source_language->getName(), + )), + '#markup' => $value, + ); + } + + /** + * Returns the translation form element for a given configuration definition. + * + * For complex data structures (such as mappings) that are translatable + * wholesale but contain non-translatable properties, the form element is + * responsible for checking access to the source value of those properties. In + * case of formatted text, for example, access to the source text format must + * be checked. If the translator does not have access to the text format, the + * textarea must be disabled and the translator may not be able to translate + * this particular configuration element. If the translator does have access + * to the text format, the element must be locked down to that particular text + * format; in other words, the format may not be changed by the translator + * (because the text format property is not itself translatable). + * + * In addition, the form element is responsible for checking whether the + * value of such non-translatable properties in the translated configuration + * is equal to the corresponding source values. If not, that means that the + * source value has changed after the translation was added. In this case - + * again - the translation of this element must be disabled if the translator + * does not have access to the source value of the non-translatable property. + * For example, if a formatted text element, whose source format was plain + * text when it was first translated, gets changed to the full HTML format, + * simply changing the format of the translation would lead to an XSS + * vulnerability as the translated text, that was intended to be escaped, + * would now be displayed unescaped. Thus, if the translator does not have + * access to the Full HTML format, the translation for this particular element + * may not be updated at all (the textarea must be disabled). Only if access + * to the Full HTML format is granted, an explicit translation taking into + * account the updated source value(s) may be submitted. + * + * In the specific case of formatted text this logic is implemented by + * utilizing a form element of type 'text_format' and its #format and + * #allowed_formats properties. The access logic explained above is then + * handled by the 'text_format' element itself, specifically by + * filter_process_format(). In case such a rich element is not available for + * translation of complex data, similar access logic must be implemented + * manually. + * + * @param \Drupal\Core\Language\LanguageInterface $translation_language + * The language to display the translation form for. + * @param mixed $source_config + * The configuration value of the element in the source language. + * @param mixed $translation_config + * The configuration value of the element in the language to translate to. + * + * @return array + * Form API array to represent the form element. + * + * @see \Drupal\config_translation\FormElement\TextFormat + * @see filter_process_format() + */ + protected function getTranslationElement(LanguageInterface $translation_language, $source_config, $translation_config) { + // Add basic properties that apply to all form elements. + return array( + '#title' => $this->t('!label (!source_language)', array( + '!label' => $this->t($this->definition['label']), + '!source_language' => $translation_language->getName(), + )), + '#default_value' => $translation_config, + '#attributes' => array('lang' => $translation_language->getId()), + ); + } + + /** + * {@inheritdoc} + */ + public function setConfig(Config $base_config, LanguageConfigOverride $config_translation, $config_values, $base_key = NULL) { + // Save value, if different from the source value in the base + // configuration. If same as original configuration, remove override. + if ($base_config->get($base_key) !== $config_values) { + $config_translation->set($base_key, $config_values); + } + else { + $config_translation->clear($base_key); + } + } + +} diff --git a/core/modules/config_translation/src/FormElement/ListElement.php b/core/modules/config_translation/src/FormElement/ListElement.php new file mode 100644 index 0000000..3481a0f --- /dev/null +++ b/core/modules/config_translation/src/FormElement/ListElement.php @@ -0,0 +1,143 @@ +element = $element; + } + + /** + * {@inheritdoc} + */ + public static function create(TypedDataInterface $schema) { + return new static($schema); + } + + /** + * {@inheritdoc} + */ + public function getTranslationBuild(LanguageInterface $source_language, LanguageInterface $translation_language, $source_config, $translation_config, $parents, $base_key = NULL) { + $build = array(); + foreach ($this->element as $key => $element) { + $sub_build = array(); + // Make the specific element key, "$base_key.$key". + $element_key = implode('.', array_filter(array($base_key, $key))); + $definition = $element->getDataDefinition(); + + if ($form_element = ConfigTranslationFormBase::createFormElement($element)) { + $element_parents = array_merge($parents, array($key)); + $sub_build += $form_element->getTranslationBuild($source_language, $translation_language, $source_config[$key], $translation_config[$key], $element_parents, $element_key); + + if (empty($sub_build)) { + continue; + } + + // Build sub-structure and include it with a wrapper in the form + // if there are any translatable elements there. + $build[$key] = array(); + if ($element instanceof TraversableTypedDataInterface) { + $build[$key] = array( + '#type' => 'details', + '#title' => $this->getGroupTitle($definition, $sub_build), + '#open' => empty($base_key), + ); + } + $build[$key] += $sub_build; + } + } + + return $build; + } + + /** + * {@inheritdoc} + */ + public function setConfig(Config $base_config, LanguageConfigOverride $config_translation, $config_values, $base_key = NULL) { + foreach ($this->element as $key => $element) { + // Do not bother traversing schema elements for which no values have been + // submitted. + if (!isset($config_values[$key])) { + continue; + } + $value = $config_values[$key]; + + $element_key = implode('.', array_filter(array($base_key, $key))); + if ($form_element = ConfigTranslationFormBase::createFormElement($element)) { + // Traverse into the next level of the configuration. + $form_element->setConfig($base_config, $config_translation, $value, $element_key); + } + } + } + + /** + * Returns the title for the 'details' element of a group of schema elements. + * + * For some configuration elements the same element structure can be repeated + * multiple times (for example views displays, filters, etc.). Thus, we try to + * find a more usable title for the details summary. First check if there is + * an element which is called title or label and use its value, then check if + * there is an element which contains these words and use those. Fall back + * to the generic definition label if no such element is found. + * + * @param \Drupal\Core\TypedData\DataDefinitionInterface $definition + * The defintion of the schema element. + * @param $group_build + * The renderable array for the group of schema elements. + * + * @return string + * The title for the group of schema elements. + */ + protected function getGroupTitle(DataDefinitionInterface $definition, $group_build) { + $title = ''; + if (isset($group_build['title']['source'])) { + $title = $group_build['title']['source']['#markup']; + } + elseif (isset($group_build['label']['source'])) { + $title = $group_build['label']['source']['#markup']; + } + else { + foreach (array_keys($group_build) as $title_key) { + if (isset($group_build[$title_key]['source']) && (strpos($title_key, 'title') !== FALSE || strpos($title_key, 'label') !== FALSE)) { + $title = $group_build[$title_key]['source']['#markup']; + break; + } + } + } + return (!empty($title) ? (strip_tags($title) . ' ') : '') . $this->t($definition['label']); + } + +} diff --git a/core/modules/config_translation/src/FormElement/TextFormat.php b/core/modules/config_translation/src/FormElement/TextFormat.php new file mode 100644 index 0000000..c06c449 --- /dev/null +++ b/core/modules/config_translation/src/FormElement/TextFormat.php @@ -0,0 +1,45 @@ +getTranslationElement($source_language, $source_config, $source_config) + array( + '#value' => $source_config['value'], + '#disabled' => TRUE, + '#allow_focus' => TRUE, + ); + } + + /** + * {@inheritdoc} + */ + public function getTranslationElement(LanguageInterface $translation_language, $source_config, $translation_config) { + return array( + '#type' => 'text_format', + // Override the #default_value property from the parent class. + '#default_value' => $translation_config['value'], + '#format' => $translation_config['format'], + // @see \Drupal\config_translation\Element\FormElementBase::getTranslationElement() + '#allowed_formats' => array($source_config['format']), + ) + parent::getTranslationElement($translation_language, $source_config, $translation_config); + } + +} diff --git a/core/modules/config_translation/src/FormElement/Textarea.php b/core/modules/config_translation/src/FormElement/Textarea.php index 804301c..17ee1a2 100644 --- a/core/modules/config_translation/src/FormElement/Textarea.php +++ b/core/modules/config_translation/src/FormElement/Textarea.php @@ -8,31 +8,25 @@ namespace Drupal\config_translation\FormElement; use Drupal\Core\Language\LanguageInterface; -use Drupal\Core\StringTranslation\StringTranslationTrait; -use Drupal\Core\TypedData\DataDefinitionInterface; /** * Defines the textarea element for the configuration translation interface. */ -class Textarea implements ElementInterface { - use StringTranslationTrait; +class Textarea extends FormElementBase { /** * {@inheritdoc} */ - public function getFormElement(DataDefinitionInterface $definition, LanguageInterface $language, $value) { + public function getTranslationElement(LanguageInterface $translation_language, $source_config, $translation_config) { // Estimate a comfortable size of the input textarea. - $rows_words = ceil(str_word_count($value) / 5); - $rows_newlines = substr_count($value, "\n" ) + 1; + $rows_words = ceil(str_word_count($translation_config) / 5); + $rows_newlines = substr_count($translation_config, "\n" ) + 1; $rows = max($rows_words, $rows_newlines); return array( '#type' => 'textarea', - '#default_value' => $value, - '#title' => $this->t($definition->getLabel()) . ' (' . $language->getName() . ')', '#rows' => $rows, - '#attributes' => array('lang' => $language->getId()), - ); + ) + parent::getTranslationElement($translation_language, $source_config, $translation_config); } } diff --git a/core/modules/config_translation/src/FormElement/Textfield.php b/core/modules/config_translation/src/FormElement/Textfield.php index c28ca8d..87632f4 100644 --- a/core/modules/config_translation/src/FormElement/Textfield.php +++ b/core/modules/config_translation/src/FormElement/Textfield.php @@ -8,25 +8,19 @@ namespace Drupal\config_translation\FormElement; use Drupal\Core\Language\LanguageInterface; -use Drupal\Core\StringTranslation\StringTranslationTrait; -use Drupal\Core\TypedData\DataDefinitionInterface; /** * Defines the textfield element for the configuration translation interface. */ -class Textfield implements ElementInterface { - use StringTranslationTrait; +class Textfield extends FormElementBase { /** * {@inheritdoc} */ - public function getFormElement(DataDefinitionInterface $definition, LanguageInterface $language, $value) { + public function getTranslationElement(LanguageInterface $translation_language, $source_config, $translation_config) { return array( '#type' => 'textfield', - '#default_value' => $value, - '#title' => $this->t($definition->getLabel()) . ' (' . $language->getName() . ')', - '#attributes' => array('lang' => $language->getId()), - ); + ) + parent::getTranslationElement($translation_language, $source_config, $translation_config); } } diff --git a/core/modules/config_translation/src/Tests/ConfigTranslationFormTest.php b/core/modules/config_translation/src/Tests/ConfigTranslationFormTest.php index da049df..e747bd2 100644 --- a/core/modules/config_translation/src/Tests/ConfigTranslationFormTest.php +++ b/core/modules/config_translation/src/Tests/ConfigTranslationFormTest.php @@ -22,7 +22,7 @@ class ConfigTranslationFormTest extends WebTestBase { * * @var array */ - public static $modules = array('config_translation', 'config_translation_test'); + public static $modules = array('config_translation', 'config_translation_test', 'editor'); /** * The plugin ID of the mapper to test. diff --git a/core/modules/config_translation/src/Tests/ConfigTranslationUiTest.php b/core/modules/config_translation/src/Tests/ConfigTranslationUiTest.php index bb8d359..d9a024c 100644 --- a/core/modules/config_translation/src/Tests/ConfigTranslationUiTest.php +++ b/core/modules/config_translation/src/Tests/ConfigTranslationUiTest.php @@ -8,6 +8,7 @@ namespace Drupal\config_translation\Tests; use Drupal\Component\Serialization\Json; +use Drupal\Component\Utility\String; use Drupal\Component\Utility\Unicode; use Drupal\Core\Config\FileStorage; use Drupal\Core\Language\Language; @@ -27,7 +28,7 @@ class ConfigTranslationUiTest extends WebTestBase { * * @var array */ - public static $modules = array('node', 'contact', 'contact_test', 'config_translation', 'config_translation_test', 'views', 'views_ui', 'contextual'); + public static $modules = array('node', 'contact', 'contact_test', 'config_translation', 'config_translation_test', 'views', 'views_ui', 'contextual', 'filter', 'filter_test'); /** * Languages to enable. @@ -62,12 +63,24 @@ protected function setUp() { $translator_permissions = array( 'translate configuration', ); + + /** @var \Drupal\filter\FilterFormatInterface $filter_test_format */ + $filter_test_format = entity_load('filter_format', 'filter_test'); + /** @var \Drupal\filter\FilterFormatInterface $filtered_html_format */ + $filtered_html_format = entity_load('filter_format', 'filtered_html'); + /** @var \Drupal\filter\FilterFormatInterface $full_html_format */ + $full_html_format = entity_load('filter_format', 'full_html'); + $admin_permissions = array_merge( $translator_permissions, array( 'administer languages', 'administer site configuration', 'administer contact forms', + 'administer filters', + $filtered_html_format->getPermissionName(), + $full_html_format->getPermissionName(), + $filter_test_format->getPermissionName(), 'access site-wide contact form', 'access contextual links', 'administer views', @@ -122,8 +135,8 @@ public function testSiteInformationTranslationUi() { // Update site name and slogan for French. $edit = array( - 'config_names[system.site][name][translation]' => $fr_site_name, - 'config_names[system.site][slogan][translation]' => $fr_site_slogan, + 'translation[config_names][system.site][name]' => $fr_site_name, + 'translation[config_names][system.site][slogan]' => $fr_site_slogan, ); $this->drupalPostForm("$translation_base_url/fr/add", $edit, t('Save translation')); @@ -136,8 +149,8 @@ public function testSiteInformationTranslationUi() { // Check translation saved proper. $this->drupalGet("$translation_base_url/fr/edit"); - $this->assertFieldByName('config_names[system.site][name][translation]', $fr_site_name); - $this->assertFieldByName('config_names[system.site][slogan][translation]', $fr_site_slogan); + $this->assertFieldByName('translation[config_names][system.site][name]', $fr_site_name); + $this->assertFieldByName('translation[config_names][system.site][slogan]', $fr_site_slogan); // Check French translation of site name and slogan are in place. $this->drupalGet('fr'); @@ -165,8 +178,8 @@ public function testSourceValueDuplicateSave() { // Case 1: Update new value for site slogan and site name. $edit = array( - 'config_names[system.site][name][translation]' => 'FR ' . $site_name, - 'config_names[system.site][slogan][translation]' => 'FR ' . $site_slogan, + 'translation[config_names][system.site][name]' => 'FR ' . $site_name, + 'translation[config_names][system.site][slogan]' => 'FR ' . $site_slogan, ); // First time, no overrides, so just Add link. $this->drupalPostForm("$translation_base_url/fr/add", $edit, t('Save translation')); @@ -188,8 +201,8 @@ public function testSourceValueDuplicateSave() { $this->assertNoText('FR ' . $site_name); $this->assertNoText('FR ' . $site_slogan); $edit = array( - 'config_names[system.site][name][translation]' => $site_name, - 'config_names[system.site][slogan][translation]' => 'FR ' . $site_slogan, + 'translation[config_names][system.site][name]' => $site_name, + 'translation[config_names][system.site][slogan]' => 'FR ' . $site_slogan, ); $this->drupalPostForm(NULL, $edit, t('Save translation')); $this->assertRaw(t('Successfully updated @language translation.', array('@language' => 'French'))); @@ -203,8 +216,8 @@ public function testSourceValueDuplicateSave() { $this->drupalGet("$translation_base_url/fr/edit"); $this->assertNoText('FR ' . $site_slogan); $edit = array( - 'config_names[system.site][name][translation]' => $site_name, - 'config_names[system.site][slogan][translation]' => $site_slogan, + 'translation[config_names][system.site][name]' => $site_name, + 'translation[config_names][system.site][slogan]' => $site_slogan, ); $this->drupalPostForm(NULL, $edit, t('Save translation')); $override = \Drupal::languageManager()->getLanguageConfigOverride('fr', 'system.site'); @@ -268,8 +281,8 @@ public function testContactConfigEntityTranslation() { // Update translatable fields. $edit = array( - 'config_names[contact.form.feedback][label][translation]' => 'Website feedback - ' . $langcode, - 'config_names[contact.form.feedback][reply][translation]' => 'Thank you for your mail - ' . $langcode, + 'translation[config_names][contact.form.feedback][label]' => 'Website feedback - ' . $langcode, + 'translation[config_names][contact.form.feedback][reply]' => 'Thank you for your mail - ' . $langcode, ); // Save language specific version of form. @@ -307,7 +320,7 @@ public function testContactConfigEntityTranslation() { $langcode_prefixes = array_merge(array(''), $this->langcodes); foreach ($langcode_prefixes as $langcode_prefix) { $this->drupalGet(ltrim("$langcode_prefix/$translation_base_url/$langcode/edit")); - $this->assertFieldByName('config_names[contact.form.feedback][label][translation]', 'Website feedback - ' . $langcode); + $this->assertFieldByName('translation[config_names][contact.form.feedback][label]', 'Website feedback - ' . $langcode); $this->assertText($label); } } @@ -397,8 +410,8 @@ public function testDateFormatTranslation() { // Update translatable fields. $edit = array( - 'config_names[core.date_format.' . $id . '][label][translation]' => $id . ' - FR', - 'config_names[core.date_format.' . $id . '][pattern][translation]' => 'D', + 'translation[config_names][core.date_format.' . $id . '][label]' => $id . ' - FR', + 'translation[config_names][core.date_format.' . $id . '][pattern]' => 'D', ); // Save language specific version of form. @@ -434,9 +447,9 @@ public function testAccountSettingsConfigurationTranslation() { // Update account settings fields for French. $edit = array( - 'config_names[user.settings][anonymous][translation]' => 'Anonyme', - 'config_names[user.mail][status_blocked][status_blocked.subject][translation]' => 'Testing, your account is blocked.', - 'config_names[user.mail][status_blocked][status_blocked.body][translation]' => 'Testing account blocked body.', + 'translation[config_names][user.settings][anonymous]' => 'Anonyme', + 'translation[config_names][user.mail][status_blocked][subject]' => 'Testing, your account is blocked.', + 'translation[config_names][user.mail][status_blocked][body]' => 'Testing account blocked body.', ); $this->drupalPostForm('admin/config/people/accounts/translate/fr/add', $edit, t('Save translation')); @@ -445,7 +458,7 @@ public function testAccountSettingsConfigurationTranslation() { $this->drupalGet('admin/config/people/accounts/translate/fr/edit'); foreach ($edit as $key => $value) { // Check the translations appear in the right field type as well. - $xpath = '//' . (strpos($key, '.body') ? 'textarea' : 'input') . '[@name="'. $key . '"]'; + $xpath = '//' . (strpos($key, '[body]') ? 'textarea' : 'input') . '[@name="'. $key . '"]'; $this->assertFieldByXPath($xpath, $value); } // Check that labels for email settings appear. @@ -532,10 +545,10 @@ public function testViewsTranslationUI() { // Update Views Fields for French. $edit = array( - 'config_names[views.view.frontpage][description][translation]' => $description . " FR", - 'config_names[views.view.frontpage][label][translation]' => $human_readable_name . " FR", - 'config_names[views.view.frontpage][display][default][display.default.display_title][translation]' => $display_settings_master . " FR", - 'config_names[views.view.frontpage][display][default][display_options][display.default.display_options.title][translation]' => $display_options_master . " FR", + 'translation[config_names][views.view.frontpage][description]' => $description . " FR", + 'translation[config_names][views.view.frontpage][label]' => $human_readable_name . " FR", + 'translation[config_names][views.view.frontpage][display][default][display_title]' => $display_settings_master . " FR", + 'translation[config_names][views.view.frontpage][display][default][display_options][title]' => $display_options_master . " FR", ); $this->drupalPostForm("$translation_base_url/fr/add", $edit, t('Save translation')); $this->assertRaw(t('Successfully saved @language translation.', array('@language' => 'French'))); @@ -547,10 +560,10 @@ public function testViewsTranslationUI() { // Check translation saved proper. $this->drupalGet("$translation_base_url/fr/edit"); - $this->assertFieldByName('config_names[views.view.frontpage][description][translation]', $description . " FR"); - $this->assertFieldByName('config_names[views.view.frontpage][label][translation]', $human_readable_name . " FR"); - $this->assertFieldByName('config_names[views.view.frontpage][display][default][display.default.display_title][translation]', $display_settings_master . " FR"); - $this->assertFieldByName('config_names[views.view.frontpage][display][default][display_options][display.default.display_options.title][translation]', $display_options_master . " FR"); + $this->assertFieldByName('translation[config_names][views.view.frontpage][description]', $description . " FR"); + $this->assertFieldByName('translation[config_names][views.view.frontpage][label]', $human_readable_name . " FR"); + $this->assertFieldByName('translation[config_names][views.view.frontpage][display][default][display_title]', $display_settings_master . " FR"); + $this->assertFieldByName('translation[config_names][views.view.frontpage][display][default][display_options][title]', $display_options_master . " FR"); } /** @@ -581,7 +594,7 @@ public function testLocaleDBStorage() { // Add custom translation. $edit = array( - 'config_names[user.settings][anonymous][translation]' => 'Anonyme', + 'translation[config_names][user.settings][anonymous]' => 'Anonyme', ); $this->drupalPostForm('admin/config/people/accounts/translate/fr/add', $edit, t('Save translation')); @@ -592,7 +605,7 @@ public function testLocaleDBStorage() { // revert custom translations to base translation. $edit = array( - 'config_names[user.settings][anonymous][translation]' => 'Anonymous', + 'translation[config_names][user.settings][anonymous]' => 'Anonymous', ); $this->drupalPostForm('admin/config/people/accounts/translate/fr/edit', $edit, t('Save translation')); @@ -650,6 +663,104 @@ public function testAlterInfo() { } /** + * Test text_format translation. + */ + public function testTextFormatTranslation() { + $this->drupalLogin($this->admin_user); + /** @var \Drupal\Core\Config\ConfigFactoryInterface $config_factory */ + $config_factory = $this->container->get('config.factory'); + + $expected = array( + 'value' => '

Hello World

', + 'format' => 'plain_text', + ); + $actual = $config_factory + ->setOverrideState(FALSE) + ->get('config_translation_test.content') + ->get('content'); + $this->assertEqual($expected, $actual); + + $translation_base_url = 'admin/config/media/file-system/translate'; + $this->drupalGet($translation_base_url); + + // 'Add' link should be present for French translation. + $translation_page_url = "$translation_base_url/fr/add"; + $this->assertLinkByHref($translation_page_url); + + $this->drupalGet($translation_page_url); + + // Assert that changing the text format is not possible, even for an + // administrator. + $this->assertNoFieldByName('translation[config_names][config_translation_test.content][content][format]'); + + // Update translatable fields. + $edit = array( + 'translation[config_names][config_translation_test.content][content][value]' => '

Hello World - FR

', + ); + + // Save language specific version of form. + $this->drupalPostForm($translation_page_url, $edit, t('Save translation')); + + // Get translation and check we've got the right value. + $expected = array( + 'value' => '

Hello World - FR

', + 'format' => 'plain_text', + ); + $this->container->get('language.config_factory_override') + ->setLanguage(new Language(array('id' => 'fr'))); + $actual = $config_factory + ->setOverrideState(TRUE) + ->get('config_translation_test.content') + ->get('content'); + $this->assertEqual($expected, $actual); + + // Change the text format of the source configuration and verify that the + // text format of the translation does not change because that could lead to + // security vulnerabilities. + $config_factory + ->setOverrideState(FALSE) + ->get('config_translation_test.content') + ->set('content.format', 'full_html') + ->save(); + + $actual = $config_factory + ->setOverrideState(TRUE) + ->get('config_translation_test.content') + ->get('content'); + // The translation should not have changed, so re-use $expected. + $this->assertEqual($expected, $actual); + + // Because the text is now in a text format that the translator does not + // have access to, the translator should not be able to translate it. + $translation_page_url = "$translation_base_url/fr/edit"; + $this->drupalLogin($this->translator_user); + $this->drupalGet($translation_page_url); + $this->assertDisabledTextarea('edit-translation-config-names-config-translation-testcontent-content-value'); + $this->drupalPostForm(NULL, array(), t('Save translation')); + // Check that submitting the form did not update the text format of the + // translation. + $actual = $config_factory + ->get('config_translation_test.content') + ->get('content'); + $this->assertEqual($expected, $actual); + + // The administrator must explicitly change the text format. + $this->drupalLogin($this->admin_user); + $edit = array( + 'translation[config_names][config_translation_test.content][content][format]' => 'full_html', + ); + $this->drupalPostForm($translation_page_url, $edit, t('Save translation')); + $expected = array( + 'value' => '

Hello World - FR

', + 'format' => 'full_html', + ); + $actual = $config_factory + ->get('config_translation_test.content') + ->get('content'); + $this->assertEqual($expected, $actual); + } + + /** * Gets translation from locale storage. * * @param $config_name @@ -717,4 +828,33 @@ protected function renderContextualLinks($ids, $current_path) { return $this->drupalPost('contextual/render', 'application/json', $post, array('query' => array('destination' => $current_path))); } + /** + * Asserts that a textarea with a given ID has been disabled from editing. + * + * @param string $id + * The HTML ID of the textarea. + * + * @return bool + * TRUE if the assertion passed; FALSE otherwise. + */ + protected function assertDisabledTextarea($id) { + $textarea = $this->xpath('//textarea[@id=:id and contains(@disabled, "disabled")]', array( + ':id' => $id, + )); + $textarea = reset($textarea); + $passed = $this->assertTrue($textarea instanceof \SimpleXMLElement, String::format('Disabled field @id exists.', array( + '@id' => $id, + ))); + $expected = 'This field has been disabled because you do not have sufficient permissions to edit it.'; + $passed = $passed && $this->assertEqual((string) $textarea, $expected, String::format('Disabled textarea @id hides text in an inaccessible text format.', array( + '@id' => $id, + ))); + // Make sure the text format select is not shown. + $select_id = str_replace('value', 'format--2', $id); + $select = $this->xpath('//select[@id=:id]', array(':id' => $select_id)); + return $passed && $this->assertFalse($select, String::format('Field @id does not exist.', array( + '@id' => $id, + ))); + } + } diff --git a/core/modules/config_translation/tests/modules/config_translation_test/config/install/config_translation_test.content.yml b/core/modules/config_translation/tests/modules/config_translation_test/config/install/config_translation_test.content.yml new file mode 100644 index 0000000..5a4d4bb --- /dev/null +++ b/core/modules/config_translation/tests/modules/config_translation_test/config/install/config_translation_test.content.yml @@ -0,0 +1,6 @@ +id: test +label: 'Test' +langcode: en +content: + value: "

Hello World

" + format: plain_text diff --git a/core/modules/config_translation/tests/modules/config_translation_test/config/schema/config_translation_test.schema.yml b/core/modules/config_translation/tests/modules/config_translation_test/config/schema/config_translation_test.schema.yml new file mode 100644 index 0000000..757448f --- /dev/null +++ b/core/modules/config_translation/tests/modules/config_translation_test/config/schema/config_translation_test.schema.yml @@ -0,0 +1,18 @@ +# Schema for the configuration files of the Configuration translation test module. + +config_translation_test.content: + type: mapping + label: 'Content' + mapping: + id: + type: string + label: 'Category identifier' + label: + type: label + label: 'Label' + langcode: + type: string + label: 'Default language' + content: + type: text_format + label: 'Content' diff --git a/core/modules/config_translation/tests/modules/config_translation_test/config_translation_test.config_translation.yml b/core/modules/config_translation/tests/modules/config_translation_test/config_translation_test.config_translation.yml new file mode 100644 index 0000000..070245c --- /dev/null +++ b/core/modules/config_translation/tests/modules/config_translation_test/config_translation_test.config_translation.yml @@ -0,0 +1,6 @@ +# Attach to file settings for testing. The base route does not matter. +system.file_system_settings: + title: 'Test config translation' + base_route_name: system.file_system_settings + names: + - config_translation_test.content diff --git a/core/modules/config_translation/tests/modules/config_translation_test/config_translation_test.info.yml b/core/modules/config_translation/tests/modules/config_translation_test/config_translation_test.info.yml index d9308c3..8afde0f 100644 --- a/core/modules/config_translation/tests/modules/config_translation_test/config_translation_test.info.yml +++ b/core/modules/config_translation/tests/modules/config_translation_test/config_translation_test.info.yml @@ -5,4 +5,5 @@ package: Testing version: VERSION core: 8.x dependencies: + - config_translation - config_test diff --git a/core/modules/config_translation/tests/modules/config_translation_test/config_translation_test.links.task.yml b/core/modules/config_translation/tests/modules/config_translation_test/config_translation_test.links.task.yml new file mode 100644 index 0000000..92581b4 --- /dev/null +++ b/core/modules/config_translation/tests/modules/config_translation_test/config_translation_test.links.task.yml @@ -0,0 +1,7 @@ +# Add a default local task for the file system settings page, so that the local +# task added by Configuration Translation becomes visible. This facilitates +# manual testing. +system.file_system_settings: + route_name: system.file_system_settings + title: Settings + base_route: system.file_system_settings diff --git a/core/modules/config_translation/tests/themes/config_translation_test_theme/config_translation_test_theme.config_translation.yml b/core/modules/config_translation/tests/themes/config_translation_test_theme/config_translation_test_theme.config_translation.yml index 7c8cdd8..5d84070 100644 --- a/core/modules/config_translation/tests/themes/config_translation_test_theme/config_translation_test_theme.config_translation.yml +++ b/core/modules/config_translation/tests/themes/config_translation_test_theme/config_translation_test_theme.config_translation.yml @@ -1,3 +1,4 @@ +# Attach to performance settings for testing. The base route does not matter. system.performance_settings: title: 'Theme translation test' base_route_name: system.performance_settings diff --git a/core/modules/language/src/Config/LanguageConfigCollectionNameTrait.php b/core/modules/language/src/Config/LanguageConfigCollectionNameTrait.php new file mode 100644 index 0000000..9feaf77 --- /dev/null +++ b/core/modules/language/src/Config/LanguageConfigCollectionNameTrait.php @@ -0,0 +1,53 @@ + $collection))); + } + return $matches[1]; + } + +} diff --git a/core/modules/language/src/Config/LanguageConfigFactoryOverride.php b/core/modules/language/src/Config/LanguageConfigFactoryOverride.php index a0fbbfe..3d0a67b 100644 --- a/core/modules/language/src/Config/LanguageConfigFactoryOverride.php +++ b/core/modules/language/src/Config/LanguageConfigFactoryOverride.php @@ -7,7 +7,6 @@ namespace Drupal\language\Config; -use Drupal\Component\Utility\String; use Drupal\Core\Config\ConfigCollectionInfo; use Drupal\Core\Config\ConfigCrudEvent; use Drupal\Core\Config\ConfigFactoryOverrideBase; @@ -24,6 +23,8 @@ */ class LanguageConfigFactoryOverride extends ConfigFactoryOverrideBase implements LanguageConfigFactoryOverrideInterface, EventSubscriberInterface { + use LanguageConfigCollectionNameTrait; + /** * The configuration storage. * @@ -95,7 +96,14 @@ public function loadOverrides($names) { public function getOverride($langcode, $name) { $storage = $this->getStorage($langcode); $data = $storage->read($name); - $override = new LanguageConfigOverride($name, $storage, $this->typedConfigManager); + + $override = new LanguageConfigOverride( + $name, + $storage, + $this->typedConfigManager, + $this->eventDispatcher + ); + if (!empty($data)) { $override->initWithData($data); } @@ -160,42 +168,6 @@ public function createConfigObject($name, $collection = StorageInterface::DEFAUL } /** - * Creates a configuration collection name based on a langcode. - * - * @param string $langcode - * The langcode. - * - * @return string - * The configuration collection name for a langcode. - */ - protected function createConfigCollectionName($langcode) { - return 'language.' . $langcode; - } - - /** - * Converts a configuration collection name to a langcode. - * - * @param string $collection - * The configuration collection name. - * - * @return string - * The langcode of the collection. - * - * @throws \InvalidArgumentException - * Exception thrown if the provided collection name is not in the format - * "language.LANGCODE". - * - * @see self::createConfigCollectionName() - */ - protected function getLangcodeFromCollectionName($collection) { - preg_match('/^language\.(.*)$/', $collection, $matches); - if (!isset($matches[1])) { - throw new \InvalidArgumentException(String::format('!collection is not a valid language override collection', array('!collection' => $collection))); - } - return $matches[1]; - } - - /** * {@inheritdoc} */ public function addCollections(ConfigCollectionInfo $collection_info) { diff --git a/core/modules/language/src/Config/LanguageConfigOverride.php b/core/modules/language/src/Config/LanguageConfigOverride.php index 19fb549..d64dda7 100644 --- a/core/modules/language/src/Config/LanguageConfigOverride.php +++ b/core/modules/language/src/Config/LanguageConfigOverride.php @@ -10,12 +10,22 @@ use Drupal\Core\Config\StorableConfigBase; use Drupal\Core\Config\StorageInterface; use Drupal\Core\Config\TypedConfigManagerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; /** * Defines language configuration overrides. */ class LanguageConfigOverride extends StorableConfigBase { + use LanguageConfigCollectionNameTrait; + + /** + * The event dispatcher. + * + * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface + */ + protected $eventDispatcher; + /** * Constructs a language override object. * @@ -26,11 +36,14 @@ class LanguageConfigOverride extends StorableConfigBase { * configuration override. * @param \Drupal\Core\Config\TypedConfigManagerInterface $typed_config * The typed configuration manager service. + * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher + * The event dispatcher. */ - public function __construct($name, StorageInterface $storage, TypedConfigManagerInterface $typed_config) { + public function __construct($name, StorageInterface $storage, TypedConfigManagerInterface $typed_config, EventDispatcherInterface $event_dispatcher) { $this->name = $name; $this->storage = $storage; $this->typedConfigManager = $typed_config; + $this->eventDispatcher = $event_dispatcher; } /** @@ -45,6 +58,7 @@ public function save() { } $this->storage->write($this->name, $this->data); $this->isNew = FALSE; + $this->eventDispatcher->dispatch(LanguageConfigOverrideEvents::SAVE_OVERRIDE, new LanguageConfigOverrideCrudEvent($this)); $this->originalData = $this->data; return $this; } @@ -56,8 +70,19 @@ public function delete() { $this->data = array(); $this->storage->delete($this->name); $this->isNew = TRUE; + $this->eventDispatcher->dispatch(LanguageConfigOverrideEvents::DELETE_OVERRIDE, new LanguageConfigOverrideCrudEvent($this)); $this->originalData = $this->data; return $this; } + /** + * Returns the language code of this language override. + * + * @return string + * The language code. + */ + public function getLangcode() { + return $this->getLangcodeFromCollectionName($this->getStorage()->getCollectionName()); + } + } diff --git a/core/modules/language/src/Config/LanguageConfigOverrideCrudEvent.php b/core/modules/language/src/Config/LanguageConfigOverrideCrudEvent.php new file mode 100644 index 0000000..3b91d07 --- /dev/null +++ b/core/modules/language/src/Config/LanguageConfigOverrideCrudEvent.php @@ -0,0 +1,46 @@ +override = $override; + } + + /** + * Gets configuration object. + * + * @return \Drupal\language\Config\LanguageConfigOverride + * The configuration object that caused the event to fire. + */ + public function getLanguageConfigOverride() { + return $this->override; + } + +} diff --git a/core/modules/language/src/Config/LanguageConfigOverrideEvents.php b/core/modules/language/src/Config/LanguageConfigOverrideEvents.php new file mode 100644 index 0000000..7770f59 --- /dev/null +++ b/core/modules/language/src/Config/LanguageConfigOverrideEvents.php @@ -0,0 +1,33 @@ +get($name); + $wrapper = $locale_config_manager->get($name); foreach ($langcodes as $langcode) { $translation = $wrapper->getValue() ? $wrapper->getTranslation($langcode)->getValue() : NULL; if ($translation) { - \Drupal\locale\Locale::config()->saveTranslationData($name, $langcode, $translation); + $locale_config_manager->saveTranslationData($name, $langcode, $translation); $count++; } else { - \Drupal\locale\Locale::config()->deleteTranslationData($name, $langcode); + // Do not bother deleting language overrides which do not exist in the + // first place. + if (!$language_manager->getLanguageConfigOverride($langcode, $name)->isNew()) { + $locale_config_manager->deleteTranslationData($name, $langcode); + } } } } diff --git a/core/modules/locale/locale.module b/core/modules/locale/locale.module index 846ee88..b46d03f 100644 --- a/core/modules/locale/locale.module +++ b/core/modules/locale/locale.module @@ -320,10 +320,10 @@ function locale_modules_installed($modules) { } /** - * Implements hook_modules_uninstalled(). + * Implements hook_module_preuninstall(). */ -function locale_modules_uninstalled($modules) { - $components['module'] = $modules; +function locale_module_preuninstall($module) { + $components['module'] = array($module); locale_system_remove($components); } diff --git a/core/modules/locale/locale.services.yml b/core/modules/locale/locale.services.yml index e25786d..27cef2c 100644 --- a/core/modules/locale/locale.services.yml +++ b/core/modules/locale/locale.services.yml @@ -20,3 +20,8 @@ services: class: Drupal\locale\StreamWrapper\TranslationsStream tags: - { name: stream_wrapper, scheme: translations } + locale.config_subscriber: + class: Drupal\locale\LocaleConfigSubscriber + arguments: ['@locale.storage', '@config.factory', '@locale.config.typed'] + tags: + - { name: event_subscriber } diff --git a/core/modules/locale/src/LocaleConfigManager.php b/core/modules/locale/src/LocaleConfigManager.php index e2f4452..7bc846e 100644 --- a/core/modules/locale/src/LocaleConfigManager.php +++ b/core/modules/locale/src/LocaleConfigManager.php @@ -66,6 +66,13 @@ class LocaleConfigManager { protected $typedConfigManager; /** + * Whether or not configuration translations are currently being updated. + * + * @var bool + */ + protected $isUpdating = FALSE; + + /** * Creates a new typed configuration manager. * * @param \Drupal\Core\Config\StorageInterface $config_storage @@ -156,7 +163,9 @@ protected function compareConfigData(array $default, $updated) { * Configuration data to be saved, that will be only the translated values. */ public function saveTranslationData($name, $langcode, array $data) { + $this->isUpdating = TRUE; $this->languageManager->getLanguageConfigOverride($langcode, $name)->setData($data)->save(); + $this->isUpdating = FALSE; } /** @@ -168,7 +177,9 @@ public function saveTranslationData($name, $langcode, array $data) { * Language code. */ public function deleteTranslationData($name, $langcode) { + $this->isUpdating = TRUE; $this->languageManager->getLanguageConfigOverride($langcode, $name)->delete(); + $this->isUpdating = FALSE; } /** @@ -206,6 +217,7 @@ public function getComponentNames(array $components) { * Array of language codes. */ public function deleteComponentTranslations(array $components, array $langcodes) { + $this->isUpdating = TRUE; $names = $this->getComponentNames($components); if ($names && $langcodes) { foreach ($names as $name) { @@ -214,6 +226,7 @@ public function deleteComponentTranslations(array $components, array $langcodes) } } } + $this->isUpdating = FALSE; } /** @@ -241,10 +254,12 @@ public function getStringNames(array $lids) { * Language code to delete. */ public function deleteLanguageTranslations($langcode) { + $this->isUpdating = TRUE; $storage = $this->languageManager->getLanguageConfigOverrideStorage($langcode); foreach ($storage->listAll() as $name) { $this->languageManager->getLanguageConfigOverride($langcode, $name)->delete(); } + $this->isUpdating = FALSE; } /** @@ -330,4 +345,14 @@ public function hasTranslation($name, LanguageInterface $language) { return !$translation->isNew(); } + /** + * Indicates whether configuration translations are currently being updated. + * + * @return bool + * Whether or not configuration translations are currently being updated. + */ + public function isUpdatingConfigTranslations() { + return $this->isUpdating; + } + } diff --git a/core/modules/locale/src/LocaleConfigSubscriber.php b/core/modules/locale/src/LocaleConfigSubscriber.php new file mode 100644 index 0000000..568ae86 --- /dev/null +++ b/core/modules/locale/src/LocaleConfigSubscriber.php @@ -0,0 +1,270 @@ +stringStorage = $string_storage; + $this->configFactory = $config_factory; + $this->localeConfigManager = $locale_config_manager; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $events[LanguageConfigOverrideEvents::SAVE_OVERRIDE] = 'onSave'; + $events[LanguageConfigOverrideEvents::DELETE_OVERRIDE] = 'onDelete'; + return $events; + } + + + /** + * Updates the translation strings when shipped configuration is saved. + * + * @param \Drupal\language\Config\LanguageConfigOverrideCrudEvent $event + */ + public function onSave(LanguageConfigOverrideCrudEvent $event) { + // Do not mark strings as customized when community translations are being + // imported. + if ($this->localeConfigManager->isUpdatingConfigTranslations()) { + $callable = [$this, 'saveTranslation']; + } + else { + $callable = [$this, 'saveCustomizedTranslation']; + } + + $this->onUpdate($event, $callable); + } + + /** + * Updates the translation strings when shipped configuration is deleted. + * + * @param \Drupal\language\Config\LanguageConfigOverrideCrudEvent $event + */ + public function onDelete(LanguageConfigOverrideCrudEvent $event) { + if ($this->localeConfigManager->isUpdatingConfigTranslations()) { + $callable = [$this, 'deleteTranslation']; + } + else { + // Do not delete the string, but save a customized translation with the + // source value so that the deletion will not be reverted by importing + // community translations. + // @see \Drupal\locale\LocaleConfigSubscriber::saveCustomizedTranslation() + $callable = [$this, 'saveCustomizedTranslation']; + } + + $this->onUpdate($event, $callable); + } + + /** + * Updates the translation strings of shipped configuration. + * + * @param \Drupal\language\Config\LanguageConfigOverrideCrudEvent $event + */ + protected function onUpdate(LanguageConfigOverrideCrudEvent $event, $callable) { + $translation_config = $event->getLanguageConfigOverride(); + $name = $translation_config->getName(); + + // Only do anything if the configuration was shipped. + if ($this->stringStorage->getLocations(['type' => 'configuration', 'name' => $name])) { + $override_state = $this->configFactory->getOverrideState(); + $this->configFactory->setOverrideState(FALSE); + + $source_config = $this->configFactory->get($name); + $schema = $this->localeConfigManager->get($name)->getTypedConfig(); + + $this->traverseSchema($schema, $source_config, $translation_config, $callable); + + $this->configFactory->setOverrideState($override_state); + } + } + + /** + * Traverses configuration schema and applies a callback to each leaf element. + * + * It skips leaf elements that are not translatable. + * + * @param \Drupal\Core\TypedData\TraversableTypedDataInterface $schema + * The respective configuration schema. + * @param callable $callable + * The callable to apply to each leaf element. The callable will be called + * with the leaf element and the element key as arguments. + * @param string|null $base_key + * (optional) The base key that the schema belongs to. This should be NULL + * for the top-level schema and be populated consecutively when recursing + * into the schema structure. + */ + protected function traverseSchema(TraversableTypedDataInterface $schema, Config $source_config, LanguageConfigOverride $translation_config, $callable, $base_key = NULL) { + foreach ($schema as $key => $element) { + $element_key = implode('.', array_filter([$base_key, $key])); + + // We only care for strings here, so traverse the schema further in the + // case of traversable elements. + if ($element instanceof TraversableTypedDataInterface) { + $this->traverseSchema($element, $source_config, $translation_config, $callable, $element_key); + } + // Skip elements which are not translatable. + elseif (!empty($element->getDataDefinition()['translatable'])) { + $callable( + $source_config->get($element_key), + $translation_config->getLangcode(), + $translation_config->get($element_key) + ); + } + } + } + + /** + * Saves a translation string. + * + * @param string $source_value + * The source string value. + * @param string $langcode + * The language code of the translation. + * @param string|null $translation_value + * (optional) The translation string value. If omitted, no translation will + * be saved. + */ + protected function saveTranslation($source_value, $langcode, $translation_value = NULL) { + if ($translation_value && ($translation = $this->getTranslation($source_value, $langcode, TRUE))) { + if ($translation->isNew() || $translation->getString() != $translation_value) { + $translation + ->setString($translation_value) + ->save(); + } + } + } + + /** + * Saves a translation string and marks it as customized. + * + * @param string $source_value + * The source string value. + * @param string $langcode + * The language code of the translation. + * @param string|null $translation_value + * (optional) The translation string value. If omitted, a customized string + * with the source value will be saved. + * + * @see \Drupal\locale\LocaleConfigSubscriber::onDelete() + */ + protected function saveCustomizedTranslation($source_value, $langcode, $translation_value = NULL) { + if ($translation = $this->getTranslation($source_value, $langcode, TRUE)) { + if (!isset($translation_value)) { + $translation_value = $source_value; + } + if ($translation->isNew() || $translation->getString() != $translation_value) { + $translation + ->setString($translation_value) + ->setCustomized(TRUE) + ->save(); + } + } + } + + /** + * Deletes a translation string, if it exists. + * + * @param string $source_value + * The source string value. + * @param string $langcode + * The language code of the translation. + * + * @see \Drupal\locale\LocaleConfigSubscriber::onDelete() + */ + protected function deleteTranslation($source_value, $langcode) { + if ($translation = $this->getTranslation($source_value, $langcode, FALSE)) { + $translation->delete(); + } + } + + /** + * Gets a translation string. + * + * @param string $source_value + * The source string value. + * @param string $langcode + * The language code of the translation. + * @param bool $create_fallback + * (optional) By default if a source string could be found and no + * translation in the given language exists yet, a translation object is + * created. This can be circumvented by passing FALSE. + * + * @return \Drupal\locale\TranslationString|null + * The translation string if one was found or created. + */ + protected function getTranslation($source_value, $langcode, $create_fallback = TRUE) { + // There is no point in creating a translation without a source. + if ($source_string = $this->stringStorage->findString(['source' => $source_value])) { + // Get the translation for this original source string from locale. + $conditions = [ + 'lid' => $source_string->lid, + 'language' => $langcode, + ]; + $translations = $this->stringStorage->getTranslations($conditions + ['translated' => TRUE]); + if ($translations) { + return reset($translations); + } + elseif ($create_fallback) { + return $translation = $this->stringStorage->createTranslation($conditions); + } + } + } + +} diff --git a/core/modules/locale/src/Tests/LocaleConfigManagerTest.php b/core/modules/locale/src/Tests/LocaleConfigManagerTest.php index 5e44225..f87ced5 100644 --- a/core/modules/locale/src/Tests/LocaleConfigManagerTest.php +++ b/core/modules/locale/src/Tests/LocaleConfigManagerTest.php @@ -9,13 +9,14 @@ use Drupal\language\Entity\ConfigurableLanguage; use Drupal\simpletest\DrupalUnitTestBase; +use Drupal\simpletest\KernelTestBase; /** * Tests that the locale config manager operates correctly. * * @group locale */ -class LocaleConfigManagerTest extends DrupalUnitTestBase { +class LocaleConfigManagerTest extends KernelTestBase { /** * A list of modules to install for this test. @@ -28,6 +29,7 @@ class LocaleConfigManagerTest extends DrupalUnitTestBase { * Tests hasTranslation(). */ public function testHasTranslation() { + $this->installSchema('locale', array('locales_location')); $this->installConfig(array('locale_test')); $locale_config_manager = \Drupal::service('locale.config.typed'); diff --git a/core/modules/locale/src/Tests/LocaleConfigSubscriberTest.php b/core/modules/locale/src/Tests/LocaleConfigSubscriberTest.php new file mode 100644 index 0000000..ea012b3 --- /dev/null +++ b/core/modules/locale/src/Tests/LocaleConfigSubscriberTest.php @@ -0,0 +1,397 @@ +languageManager = $this->container->get('language_manager'); + $this->configFactory = $this->container->get('config.factory'); + $this->stringStorage = $this->container->get('locale.storage'); + $this->localeConfigManager = $this->container->get('locale.config.typed'); + + $this->installSchema('locale', ['locales_source', 'locales_target', 'locales_location']); + + $this->installConfig(['locale_test']); + ConfigurableLanguage::createFromLangcode($this->langcode)->save(); + } + + /** + * Tests creating translations of shipped configuration. + */ + public function testCreateTranslation() { + $config_name = 'locale_test.no_translation'; + + $this->setUpNoTranslation($config_name, 'test', 'Test'); + $this->saveLanguageOverride($config_name, 'test', 'Test (German)'); + $this->assertTranslation($config_name, 'Test (German)'); + } + + /** + * Tests importing community translations of shipped configuration. + */ + public function testLocaleCreateTranslation() { + $config_name = 'locale_test.no_translation'; + + $this->setUpNoTranslation($config_name, 'test', 'Test'); + $this->saveLocaleTranslationData($config_name, 'test', 'Test (German)'); + $this->assertTranslation($config_name, 'Test (German)', FALSE); + } + + /** + * Tests updating translations of shipped configuration. + */ + public function testUpdateTranslation() { + $config_name = 'locale_test.translation'; + + $this->setUpTranslation($config_name, 'test', 'English test', 'German test'); + $this->saveLanguageOverride($config_name, 'test', 'Updated German test'); + $this->assertTranslation($config_name, 'Updated German test'); + } + + /** + * Tests updating translations of shipped configuration. + */ + public function testLocaleUpdateTranslation() { + $config_name = 'locale_test.translation'; + + $this->setUpTranslation($config_name, 'test', 'English test', 'German test'); + $this->saveLocaleTranslationData($config_name, 'test', 'Updated German test'); + $this->assertTranslation($config_name, 'Updated German test', FALSE); + } + + /** + * Tests deleting translations of shipped configuration. + */ + public function testDeleteTranslation() { + $config_name = 'locale_test.translation'; + + $this->setUpTranslation($config_name, 'test', 'English test', 'German test'); + $this->deleteLanguageOverride($config_name, 'test', 'English test'); + // Instead of deleting the translation, we need to keep a translation with + // the source value and mark it as customized to prevent the deletion being + // reverted by importing community translations. + $this->assertTranslation($config_name, 'English test'); + } + + /** + * Tests deleting translations of shipped configuration. + */ + public function testLocaleDeleteTranslation() { + $config_name = 'locale_test.translation'; + + $this->setUpTranslation($config_name, 'test', 'English test', 'German test'); + $this->deleteLocaleTranslationData($config_name, 'test', 'English test'); + $this->assertNoTranslation($config_name, 'English test', FALSE); + } + + /** + * Sets up a configuration string without a translation. + * + * The actual configuration is already available by installing locale_test + * module, as it is done in LocaleConfigSubscriberTest::setUp(). This sets up + * the necessary source string and verifies that everything is as expected to + * avoid false positives. + * + * @param string $config_name + * The configuration name. + * @param string $key + * The configuration key. + * @param string $source + * The source string. + */ + protected function setUpNoTranslation($config_name, $key, $source) { + // Add a source string with the configuration name as a location. This gets + // called from locale_config_update_multiple() normally. + $this->localeConfigManager->translateString($config_name, $this->langcode, $source, ''); + $this->languageManager + ->setConfigOverrideLanguage(ConfigurableLanguage::load($this->langcode)); + + $this->assertConfigValue($config_name, $key, $source); + $this->assertNoTranslation($config_name); + } + + + /** + * Sets up a configuration string with a translation. + * + * The actual configuration is already available by installing locale_test + * module, as it is done in LocaleConfigSubscriberTest::setUp(). This sets up + * the necessary source and translation strings and verifies that everything + * is as expected to avoid false positives. + * + * @param string $config_name + * The configuration name. + * @param string $key + * The configuration key. + * @param string $source + * The source string. + * @param string $translation + * The translation string. + */ + protected function setUpTranslation($config_name, $key, $source, $translation) { + // Create source and translation strings for the configuration value and add + // the configuration name as a location. This would be performed by + // locale_translate_batch_import() and locale_config_update_multiple() + // normally. + $source_object = $this->stringStorage->createString([ + 'source' => $source, + 'context' => '', + ])->save(); + $this->stringStorage->createTranslation([ + 'lid' => $source_object->getId(), + 'language' => $this->langcode, + 'translation' => $translation, + ])->save(); + $this->localeConfigManager->translateString($config_name, $this->langcode, $source, ''); + $this->languageManager + ->setConfigOverrideLanguage(ConfigurableLanguage::load($this->langcode)); + + $this->assertConfigValue($config_name, $key, $translation); + $this->assertTranslation($config_name, $translation, FALSE); + } + + /** + * Saves a language override. + * + * This will invoke LocaleConfigSubscriber through the event dispatcher. To + * make sure the configuration was persisted correctly, the configuration + * value is checked. Because LocaleConfigSubscriber temporarily disables the + * override state of the configuration factory we check that the correct value + * is restored afterwards. + * + * @param string $config_name + * The configuration name. + * @param string $key + * The configuration key. + * @param string $value + * The configuration value to save. + */ + protected function saveLanguageOverride($config_name, $key, $value) { + $translation_override = $this->languageManager + ->getLanguageConfigOverride($this->langcode, $config_name); + $translation_override + ->set($key, $value) + ->save(); + $this->configFactory->reset($config_name); + + $this->assertConfigValue($config_name, $key, $value); + } + + /** + * Saves translation data from locale module. + * + * This will invoke LocaleConfigSubscriber through the event dispatcher. To + * make sure the configuration was persisted correctly, the configuration + * value is checked. Because LocaleConfigSubscriber temporarily disables the + * override state of the configuration factory we check that the correct value + * is restored afterwards. + * + * @param string $config_name + * The configuration name. + * @param string $key + * The configuration key. + * @param string $value + * The configuration value to save. + */ + protected function saveLocaleTranslationData($config_name, $key, $value) { + $this->localeConfigManager + ->saveTranslationData($config_name, $this->langcode, [$key => $value]); + $this->configFactory->reset($config_name); + + $this->assertConfigValue($config_name, $key, $value); + } + + /** + * Deletes a language override. + * + * This will invoke LocaleConfigSubscriber through the event dispatcher. To + * make sure the configuration was persisted correctly, the configuration + * value is checked. Because LocaleConfigSubscriber temporarily disables the + * override state of the configuration factory we check that the correct value + * is restored afterwards. + * + * @param string $config_name + * The configuration name. + * @param string $key + * The configuration key. + * @param string $source_value + * The source configuration value to verify the correct value is returned + * from the configuration factory after the deletion. + */ + protected function deleteLanguageOverride($config_name, $key, $source_value) { + $translation_override = $this->languageManager + ->getLanguageConfigOverride($this->langcode, $config_name); + $translation_override + ->clear($key) + ->save(); + $this->configFactory->reset($config_name); + + $this->assertConfigValue($config_name, $key, $source_value); + } + + /** + * Deletes translation data from locale module. + * + * This will invoke LocaleConfigSubscriber through the event dispatcher. To + * make sure the configuration was persisted correctly, the configuration + * value is checked. Because LocaleConfigSubscriber temporarily disables the + * override state of the configuration factory we check that the correct value + * is restored afterwards. + * + * @param string $config_name + * The configuration name. + * @param string $key + * The configuration key. + * @param string $source_value + * The source configuration value to verify the correct value is returned + * from the configuration factory after the deletion. + */ + protected function deleteLocaleTranslationData($config_name, $key, $source_value) { + $this->localeConfigManager->deleteTranslationData($config_name, $this->langcode); + $this->configFactory->reset($config_name); + + $this->assertConfigValue($config_name, $key, $source_value); + } + + /** + * Ensures configuration was saved correctly. + * + * @param $config_name + * The configuration name. + * @param $key + * The configuration key. + * @param $value + * The configuration value. + * + * @return bool + * TRUE if the assertion succeeded, FALSE otherwise. + */ + protected function assertConfigValue($config_name, $key, $value) { + // Make sure the configuration was translated correctly. + $translation_config = $this->configFactory->get($config_name); + $passed = $this->assertIdentical($value, $translation_config->get($key)); + + // Make sure the override state of the configuration factory was not + // modified. + return $passed && $this->assertIdentical(TRUE, $this->configFactory->getOverrideState()); + } + + /** + * Ensures no translation exists. + * + * @param string $config_name + * The configuration name. + * + * @return bool + * TRUE if the assertion succeeded, FALSE otherwise. + */ + protected function assertNoTranslation($config_name) { + $strings = $this->stringStorage->getTranslations([ + 'type' => 'configuration', + 'name' => $config_name, + 'language' => $this->langcode, + 'translated' => TRUE, + ]); + return $this->assertIdentical([], $strings); + } + + /** + * Ensures a translation exists and is marked as customized. + * + * @param string $config_name + * The configuration name. + * @param string $translation + * The translation. + * @param bool $customized + * Whether or not the string should be asserted to be customized or not + * customized. + * + * @return bool + * TRUE if the assertion succeeded, FALSE otherwise. + */ + protected function assertTranslation($config_name, $translation, $customized = TRUE) { + // Make sure a string exists. + $strings = $this->stringStorage->getTranslations([ + 'type' => 'configuration', + 'name' => $config_name, + 'language' => $this->langcode, + 'translated' => TRUE, + ]); + $pass = $this->assertIdentical(1, count($strings)); + $string = reset($strings); + if ($this->assertTrue($string instanceof StringInterface)) { + /** @var \Drupal\locale\StringInterface $string */ + $pass = $pass && $this->assertIdentical($translation, $string->getString()); + $pass = $pass && $this->assertTrue($string->isTranslation()); + if ($this->assertTrue($string instanceof TranslationString)) { + /** @var \Drupal\locale\TranslationString $string */ + // Make sure the string is marked as customized so that it does not get + // overridden when the string translations are updated. + return $pass && $this->assertEqual($customized, $string->customized); + } + } + return FALSE; + } + +} diff --git a/core/modules/locale/src/Tests/LocaleConfigTranslationTest.php b/core/modules/locale/src/Tests/LocaleConfigTranslationTest.php index e0c20fc..3d26d37 100644 --- a/core/modules/locale/src/Tests/LocaleConfigTranslationTest.php +++ b/core/modules/locale/src/Tests/LocaleConfigTranslationTest.php @@ -136,8 +136,10 @@ public function testConfigTranslation() { // Check the string is unique and has no translation yet. $translations = $this->storage->getTranslations(array('language' => $langcode, 'type' => 'configuration', 'name' => 'image.style.medium')); + $this->assertEqual(count($translations), 1); $translation = reset($translations); - $this->assertTrue(count($translations) == 1 && $translation->source == $string->source && empty($translation->translation), 'Got only one string for image configuration and has no translation.'); + $this->assertEqual($translation->source, $string->source); + $this->assertTrue(empty($translation->translation)); // Translate using the UI so configuration is refreshed. $image_style_label = $this->randomMachineName(20); diff --git a/core/modules/locale/tests/modules/locale_test/config/schema/locale_test.schema.yml b/core/modules/locale/tests/modules/locale_test/config/schema/locale_test.schema.yml index dd722be..5e7e056 100644 --- a/core/modules/locale/tests/modules/locale_test/config/schema/locale_test.schema.yml +++ b/core/modules/locale/tests/modules/locale_test/config/schema/locale_test.schema.yml @@ -7,6 +7,8 @@ locale_test.no_translation: test: type: string label: 'Test' + # See \Drupal\locale\Tests\LocaleConfigSubscriberTest + translatable: true locale_test.translation: type: mapping @@ -15,3 +17,5 @@ locale_test.translation: test: type: string label: 'Test' + # See \Drupal\locale\Tests\LocaleConfigSubscriberTest + translatable: true