Commit 1a8858a0 authored by alexpott's avatar alexpott

Issue #2144413 by tstoeckler, YesCT, robertdbailey, webflo, Schnitzel, Wim...

Issue #2144413 by tstoeckler, YesCT, robertdbailey, webflo, Schnitzel, Wim Leers, kfritsche, Jose Reyero, Gábor Hojtsy, prodosh: Config translation does not support text elements with a format
parent 9f6975a8
......@@ -737,3 +737,26 @@ field.float.value:
value:
type: float
label: 'Value'
# Text with a text format.
text_format:
type: mapping
label: 'Text with text format'
# We declare the entire mapping of text and text format 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'
# Mark the actual text as translatable (in addition to the entire mapping
# being marked as translatable) so that shipped configuration with
# formatted text can participate in the string translation system.
translatable: true
format:
type: string
label: 'Text format'
# The text format should not be translated as part of the string
# translation system, so this is not marked as translatable.
......@@ -9,6 +9,8 @@
/**
* Defines events for the configuration system.
*
* @see \Drupal\Core\Config\ConfigCrudEvent
*/
final class ConfigEvents {
......
......@@ -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);
......
......@@ -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($map[$type]) && !isset($definition['form_element_class'])) {
$definition['form_element_class'] = $map[$type];
}
}
}
......@@ -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;
......@@ -42,13 +37,6 @@ abstract class ConfigTranslationFormBase extends FormBase implements BaseFormIdI
*/
protected $configMapperManager;
/**
* The string translation storage object.
*
* @var \Drupal\locale\StringStorageInterface
*/
protected $localeStorage;
/**
* The mapper for configuration translation.
*
......@@ -85,19 +73,18 @@ abstract class ConfigTranslationFormBase extends FormBase implements BaseFormIdI
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.
* Creates a 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] ? ('<span lang="' . $this->sourceLanguage->getId() . '">' . nl2br($base_config_data[$key] . '</span>')) : t('(Empty)'),
'#title' => $this->t(
'!label <span class="visually-hidden">(!source_language)</span>',
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);
}
}
......
......@@ -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 <a href="@url">PHP manual</a> 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()) . '<span class="visually-hidden"> (' . $language->getName() . ')</span>',
'#description' => $description,
'#default_value' => $value,
'#attributes' => array('lang' => $language->getId()),
'#field_suffix' => ' <div class="edit-date-format-suffix"><small id="edit-date-format-suffix">' . $format . '</small></div>',
'#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);
}
/**
......
......@@ -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);
/**
* Builds a render array containg the source and translation form elements.
*
* @param \Drupal\Core\Language\LanguageInterface $source_language
* The 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|null $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 consisting of the source and translation elements 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, array $parents, $base_key = NULL);
/**
* Sets configuration based on a nested form value array.
*
* If the configuration values are the same as the source configuration, the
* override should be removed from the translation configuration.
*
* @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.
*/
public function setConfig(Config $base_config, LanguageConfigOverride $config_translation, $config_values, $base_key = NULL);
}
<?php
/**
* @file
* Contains \Drupal\config_translation\FormElement\FormElementBase.
*/
namespace Drupal\config_translation\FormElement;
use Drupal\Core\Config\Config;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\TypedData\TypedDataInterface;
use Drupal\language\Config\LanguageConfigOverride;
/**
* Provides a common base class for form elements.
*/
abstract class FormElementBase implements ElementInterface {
use StringTranslationTrait;
/**
* The schema element this form is for.
*
* @var \Drupal\Core\TypedData\TypedDataInterface
*/
protected $element;
/**
* The data definition of the element this form element is for.
*
* @var \Drupal\Core\TypedData\DataDefinitionInterface
*/
protected $definition;
/**
* Constructs a FormElementBase.
*
* @param \Drupal\Core\TypedData\TypedDataInterface $element
* The schema element this form element is for.
*/
public function __construct(TypedDataInterface $element) {
$this->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, array $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 = '<span lang="' . $source_language->getId() . '">' . nl2br($source_config) . '</span>';
}
else {
$value = $this->t('(Empty)');
}
return array(
'#type' => 'item',
'#title' => $this->t('!label <span class="visually-hidden">(!source_language)</span>', 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 <span class="visually-hidden">(!source_language)</span>', 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 the configuration values, if they are different from the source
// values in the base configuration. Otherwise remove the override.
if ($base_config->get($base_key) !== $config_values) {
$config_translation->set($base_key, $config_values);
}
else {
$config_translation->clear($base_key);
}
}
}