Skip to content

Commit

Permalink
Merge pull request #255 from openeuropa/EWPP-3263
Browse files Browse the repository at this point in the history
EWPP-3263: Translation multivalue submodule
  • Loading branch information
upchuk authored Nov 13, 2023
2 parents 470534c + aafc327 commit 91417bb
Show file tree
Hide file tree
Showing 53 changed files with 2,588 additions and 4 deletions.
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,18 @@
"drupal/config_devel": "^1.2",
"drupal/core-composer-scaffold": "^9.4 || ^10",
"drupal/core-dev": "^9.4 || ^10",
"drupal/description_list_field": "^1.0@alpha",
"drupal/entity_version": "^1.0-beta8",
"drupal/link_description": "^1.0",
"drupal/metatag": "^1.16",
"drupal/paragraphs": "^1.13",
"drupal/typed_link": "^2.0",
"drush/drush": "^11.1",
"openeuropa/code-review": "^2.0",
"openeuropa/epoetry-client": "1.x-dev || 2.x-dev",
"openeuropa/oe_multilingual": "^1.13",
"openeuropa/oe_content": "^3.0.0-beta2",
"openeuropa/oe_editorial": "^2.0",
"openeuropa/oe_multilingual": "dev-master",
"openeuropa/task-runner-drupal-project-symlink": "^1.0-beta6",
"phpspec/prophecy-phpunit": "^2",
"symfony/property-access": "^4 || ^5.4 || ^6",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# OpenEuropa Translation Multivalue

This submodule can be used to solve a very particular, but annoying, issue with the way the local translation system works.

## The problem (scenario)

* You have a node with a translatable multivalue field, with 2 values. The key here is that it needs to be multivalue, but not an entity reference. A regular one like Textfield or Link.
* You translate the node and its multivalue field values.
* You edit the node and you reorder the values in the multivalue field.
* You translate again using local translation and the pre-filled translation values no longer matches the delta for the multivalue field. Because in the previous version the deltas were reversed. So if you save the translation without paying
attention, you'll end up with mixed up translation values.

This problem is caused by the fact on the local translation form, the system does a best effort to pre-fill with translation values from the previous content version. Most of the time, it manages. Even with values that are within
multiple referenced entities. It cannot, however, do so on simple multivalue fields because it cannot guess that they were reversed or any added. All it can check is the delta.

## The solution

Installing the current module gives the possibility to add a new column to multivalue fields called `translation_id`. So whenever a value is saved, a unique ID is generated for it. And based on this ID, the
system can then track which value is at which delta to prefill.

## How it works

It works by overriding the field item class for a given field and adding a new table column and property to track this translation ID. Moreover, it does the handling for saving this ID when synchronising the translations
as well.

## How to use

Go to the storage settings of a multivalue translatable field and check the box `Translation multivalue`. This will create the column and add the property.

Note that this only works for fields which don't have any data inside.

If you already have data in the field, or you have an existing site where you need to turn this feature one, you need to do an update path. For this, there is a helper method: `TranslationMultivalueColumnInstaller::installColumn`.

To this helper you need to pass the field name in the format `entity_type.field_name`. If you do this on multiple fields with lots of data, make sure you batch this per field to avoid problems.

**Note that for the update, the process creates a backup table, truncates the field tables, adds the column and then sets
back the values. So make sure you thoroughly test your process before deploying to production to avoid any issues**.

## Support

Currently, only certain field types are supported. You can check `oe_translation_multivalue_field_types()` for which field types are altered for this.

If you need support for other field types, open an issue or a ticket on the EWPP board.

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
field.storage_settings.description_list_field:
type: mapping
label: 'Description list storage settings'
mapping:
translation_multivalue:
type: boolean
label: Translation multivalue

field.storage_settings.address:
type: mapping
label: 'Address storage settings'
mapping:
translation_multivalue:
type: boolean
label: Translation multivalue

field.storage_settings.timeline_field:
type: mapping
label: 'Timeline storage settings'
mapping:
translation_multivalue:
type: boolean
label: Translation multivalue
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
name: OpenEuropa Translation Local Multivalue
description: Provides IDs for the values of multivalue fields to aid in the pre-filling of translation values in the local translation form
package: OpenEuropa

type: module
core_version_requirement: ^9.4 || ^10
dependencies:
- oe_translation:oe_translation_local
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
<?php

/**
* @file
* Contains hook implementations for oe_translation_multivalue.
*/

declare(strict_types = 1);

use Drupal\Component\Uuid\Uuid;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\oe_translation_multivalue\AddressTranslationMultivalueSourceFieldProcessor;
use Drupal\oe_translation_multivalue\DescriptionListTranslationMultivalueSourceFieldProcessor;
use Drupal\oe_translation_multivalue\FieldItemOverrides\AddressItemMultiple;
use Drupal\oe_translation_multivalue\FieldItemOverrides\DescriptionListItemMultiple;
use Drupal\oe_translation_multivalue\FieldItemOverrides\LinkDescriptionItemMultiple;
use Drupal\oe_translation_multivalue\FieldItemOverrides\LinkItemMultiple;
use Drupal\oe_translation_multivalue\FieldItemOverrides\StringItemMultiple;
use Drupal\oe_translation_multivalue\FieldItemOverrides\TimelineItemMultiple;
use Drupal\oe_translation_multivalue\FieldItemOverrides\TypedLinkItemMultiple;
use Drupal\oe_translation_multivalue\Form\LocalTranslationRequestForm;
use Drupal\oe_translation_multivalue\MultivalueTranslationSourceFieldProcessor;
use Drupal\oe_translation_multivalue\TimelineTranslationMultivalueSourceFieldProcessor;

/**
* Implements hook_entity_type_alter().
*/
function oe_translation_multivalue_entity_type_alter(array &$entity_types) {
if (!isset($entity_types['oe_translation_request'])) {
return;
}

$entity_type = $entity_types['oe_translation_request'];
$entity_type->setFormClass('local_translation', LocalTranslationRequestForm::class);
}

/**
* Implements hook_element_info_alter().
*/
function oe_translation_multivalue_element_info_alter(array &$info) {
if (isset($info['address'])) {
$info['address']['#process'][] = 'oe_translation_multivalue_address_process';
}
}

/**
* Processor for the Address element.
*
* We need to add a hidden form value on the element with a default value
* we set in the widget alter.
*
* @param array $element
* The element.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
* @param array $complete_form
* The form.
*
* @return array
* The element.
*/
function oe_translation_multivalue_address_process(array &$element, FormStateInterface $form_state, array &$complete_form) {
if (!isset($element['#default_value']['translation_id'])) {
return $element;
}
$element['translation_id'] = [
'#type' => 'hidden',
'#value' => $element['#default_value']['translation_id'],
];
return $element;
}

/**
* Get a list of the form widgets that should be altered.
*
* These are the widgets we alter the form of in order to put a hidden
* translation_id form element with a random ID.
*
* @return string[]
* The widget IDs.
*/
function oe_translation_multivalue_widgets() {
$field_types = oe_translation_multivalue_field_types();
$options = [];
$plugin_manager = \Drupal::service('plugin.manager.field.widget');
foreach ($field_types as $field_type) {
$options += $plugin_manager->getOptions($field_type);
}

return array_keys($options);
}

/**
* Get a list of the field types we are altering.
*
* @todo make this extendable.
*
* @return string[]
* The field IDs.
*/
function oe_translation_multivalue_field_types() {
return [
'link',
'address',
'description_list_field',
'link_description',
'string',
'timeline_field',
'typed_link',
];
}

/**
* Implements hook_field_widget_complete_form_alter().
*/
function oe_translation_multivalue_field_widget_complete_form_alter(&$field_widget_complete_form, FormStateInterface $form_state, $context) {
$widgets = oe_translation_multivalue_widgets();
$widget = $context['widget'];
if (!in_array($widget->getPluginId(), $widgets)) {
return;
}
$items = $context['items'];
$property_definitions = $items->getFieldDefinition()->getItemDefinition()->getPropertyDefinitions();
if (!isset($property_definitions['translation_id'])) {
return;
}

$id_generator = \Drupal::service('oe_translation_multivalue.translation_id_generator');
$field_id = $items->getFieldDefinition()->getFieldStorageDefinition()->id();
foreach ($items as $delta => $item) {
// If the field delta doesn't yet have a translation ID, generate the next
// one and set that. However, if we do generate a new one, we need to also
// increment it here with each iteration because each delta needs to
// receive a new one.
$translation_id = $item->translation_id && Uuid::isValid($item->translation_id) ? $item->translation_id : NULL;
if (!$translation_id) {
$translation_id = $id_generator->generateTranslationUuid($field_id);
}

// For address, we have an exception because it uses a form element.
if ($widget->getPluginId() === 'address_default') {
$field_widget_complete_form['widget'][$delta]['address']['#default_value']['translation_id'] = $translation_id;
continue;
}
$field_widget_complete_form['widget'][$delta]['translation_id'] = [
'#type' => 'hidden',
'#value' => $translation_id,
];
}
}

/**
* Implements hook_entity_presave().
*
* When we save a content entity that may have fields that contain the
* translation_id column, check if by any chance an attempt is being made to
* save them without one. If so, set a value. This can happen when entities
* are created programmatically.
*/
function oe_translation_multivalue_entity_presave(EntityInterface $entity) {
if (!$entity instanceof ContentEntityInterface) {
return;
}

$field_types = oe_translation_multivalue_field_types();

$field_definitions = $entity->getFieldDefinitions();
$fields = [];
foreach ($field_definitions as $field_name => $field_definition) {
if (in_array($field_definition->getType(), $field_types)) {
$fields[] = $field_name;
}
}

$id_generator = \Drupal::service('oe_translation_multivalue.translation_id_generator');

foreach ($fields as $field) {
if ($entity->get($field)->isEmpty()) {
continue;
}

if (!in_array('translation_id', $entity->get($field)->getFieldDefinition()->getFieldStorageDefinition()->getPropertyNames())) {
continue;
}

$field_id = $entity->getEntityTypeId() . '.' . $field;
foreach ($entity->get($field) as $item) {
if (!isset($item->getProperties()['translation_id'])) {
continue;
}

if ($item->translation_id) {
continue;
}

$item->translation_id = $id_generator->generateTranslationUuid($field_id);
}
}
}

/**
* Implements hook_field_info_alter().
*/
function oe_translation_multivalue_field_info_alter(&$info) {
if (isset($info['link'])) {
$info['link']['class'] = LinkItemMultiple::class;
$info['link']['oe_translation_source_field_processor'] = MultivalueTranslationSourceFieldProcessor::class;
}

if (isset($info['description_list_field'])) {
$info['description_list_field']['class'] = DescriptionListItemMultiple::class;
$info['description_list_field']['oe_translation_source_field_processor'] = DescriptionListTranslationMultivalueSourceFieldProcessor::class;
}

if (isset($info['timeline_field'])) {
$info['timeline_field']['class'] = TimelineItemMultiple::class;
$info['timeline_field']['oe_translation_source_field_processor'] = TimelineTranslationMultivalueSourceFieldProcessor::class;
}

if (isset($info['address'])) {
$info['address']['class'] = AddressItemMultiple::class;
$info['address']['oe_translation_source_field_processor'] = AddressTranslationMultivalueSourceFieldProcessor::class;
}

if (isset($info['typed_link'])) {
$info['typed_link']['class'] = TypedLinkItemMultiple::class;
$info['typed_link']['oe_translation_source_field_processor'] = MultivalueTranslationSourceFieldProcessor::class;
}

if (isset($info['string'])) {
$info['string']['class'] = StringItemMultiple::class;
$info['string']['oe_translation_source_field_processor'] = MultivalueTranslationSourceFieldProcessor::class;
}

if (isset($info['link_description'])) {
$info['link_description']['class'] = LinkDescriptionItemMultiple::class;
$info['link_description']['oe_translation_source_field_processor'] = MultivalueTranslationSourceFieldProcessor::class;
}
}

/**
* Implements hook_config_schema_info_alter().
*/
function oe_translation_multivalue_config_schema_info_alter(&$definitions) {
$field_types = oe_translation_multivalue_field_types();
foreach ($field_types as $plugin_id) {
$schema_id = 'field.storage_settings.' . $plugin_id;
if (!isset($definitions[$schema_id])) {
// If the schema doesn't exist already, we cannot add it as we get an
// exception. For those, we need to define them in the schema yml.
continue;
}
$definitions[$schema_id]['mapping']['translation_multivalue'] = [
'type' => 'boolean',
'label' => 'Translation multivalue',
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
services:
oe_translation_multivalue.translation_id_generator:
class: Drupal\oe_translation_multivalue\MultivalueTranslationIdGenerator
arguments: ['@entity_type.manager', '@uuid', '@database']
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types = 1);

namespace Drupal\oe_translation_multivalue;

use Drupal\Core\Field\FieldItemListInterface;
use Drupal\oe_translation\TranslationSourceFieldProcessor\AddressFieldProcessor;

/**
* Translation source manager field processor for the Address field.
*/
class AddressTranslationMultivalueSourceFieldProcessor extends AddressFieldProcessor {

use MultivalueFieldProcessorTrait;

/**
* {@inheritdoc}
*/
public function setTranslations($field_data, FieldItemListInterface $field): void {
$this->setMultivalueTranslations($field_data, $field);
}

}
Loading

0 comments on commit 91417bb

Please sign in to comment.