Problem/Motivation
The SourceValueItem field type currently supports asynchronous translations out of the box (default Drupal behavior).
We need synchronized translations for use cases where the field’s tree structure remains identical across languages and only selected values become language-specific.
Proposed resolution
- Add a second storage column (
translations) that stores language-specific payloads as JSON, keyed by a stable identifier. - Introduce a new sibling key
source_uuidalongside existingsource_idin the canonical tree:source_idstays exactly as it is today (no behavior change).source_uuidis assigned indirectly on save and remains stable/deterministic per element.
- Do not make all fields translatable. Which values are translatable is decided by the configuration schema. Only values flagged by the schema are extracted into the
translationscolumn. - Canonical/default-language content:
- The tree (default language) continues to be stored in the main
treecolumn. translationsis a JSON map<source_uuid> => <source payload subset>for schema-marked fields only.
- The tree (default language) continues to be stored in the main
- Modes:
- Asynchronous (current default): the new
translationscolumn is ignored. - Synchronized: when the field is marked translatable, a storage setting enables synchronized mode so lookups prefer the language-specific payload from
translationsbysource_uuid, with fallback to the canonical tree.
- Asynchronous (current default): the new
- Backwards compatibility: existing data keeps working;
source_idis untouched;source_uuidis additive.
Remaining tasks
- Add the
translationsstorage column. - On save, assign stable
source_uuidnext to everysource_id. - Use the configuration schema to extract only schema-marked translatable values from the tree into
translations. - Add storage setting to toggle synchronized mode (active only when the field is translatable).
- Ensure read path resolves via
source_uuidin synchronized mode, with fallback to the canonical tree. - Tests for multilingual scenarios (async vs. sync; schema-driven extraction; fallback behavior).
User interface changes
- Field storage settings: new option to enable synchronized translations (only effective if the field is translatable).
API changes
- Tree nodes gain a new sibling key
source_uuidalongside existingsource_id. - Lookup/renderers can resolve language-specific payloads via
translations[source_uuid]when synchronized mode is enabled.
Data model changes
- Add one additional database column
translations(long text JSON) toSourceValueItemstorage.
Example A — Canonical tree (default language) with source_uuid
Excerpt based on the provided UI Patterns structure. source_id is unchanged. New source_uuid is added as a sibling. UUIDs shown are examples.
ui_patterns: component_id: 'daisy_cms_daisyui:layout_columns' variant_id: null props: items_count: source: value: '1' source_id: enum_widget source_uuid: 'c8d7e8c2-1b22-4b5f-9a21-2b8198fb1f03' attributes: source: layout_layout: 'grid-1--sm-left container-md' source_id: ui_styles_css_widget source_uuid: 'a7b3d67e-0ab1-4ef1-9fbc-8d7c95fd2d90' display_header: source_id: checkbox source_uuid: 'f2244286-1c64-4a70-bcdd-62b2b6c8e9c1' source: value: false theme: source: value: light source_id: enum_widget source_uuid: '23b195e3-3c0a-4d39-8f70-0a77fcc86f2f' spacing_attributes: source: spacing_padding_top: pt-auto spacing_padding_bottom: pb-sm spacing_margin_bottom: mb-auto source_id: ui_styles_css_widget source_uuid: 'dd6a7a8a-4c9c-4f42-9f3d-0a5b2da8d8a1' slots: background: sources: - source: media: media_library_selection: '' source_id: media source_uuid: 'bbfae2a7-55a2-4d80-bb51-6cd4322e9c2f'
Example B — translations column (JSON, synchronized mode)
Only entries marked as translatable by the configuration schema are included here.
For illustration, assume the schema marks theme.value and the top-level items_count.value as translatable; others (e.g. CSS classes, booleans) are not.
{ "f0d9cbe6-1a2f-4c5d-8b7e-19e7420b3f77": { "value": "light" }, "b1e3d2a4-0f3c-4fd6-9f33-cc0c1e20a6f4": { "value": "1" } }
In synchronized mode, renderers/widgets resolve values by source_uuid:
if a matching key exists in translations for the current language, that payload is used; otherwise the canonical tree.source value applies.
Asynchronous mode does not use the translations column at all.
Issue fork ui_patterns-3548884
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
christian.wiedemann commentedComment #3
christian.wiedemann commentedComment #4
pdureau commentedFeedbacks
Why not using a serialized blob like we already do?
We have it already
_node_idin Display Builder, so let's harmonize it:https://git.drupalcode.org/project/display_builder/-/blob/1.0.x/src/Enti...
We picked "Node ID" to fit with the expected vocabulary in data tree structure.
We picked
uniqid()instead of UUID because it stays in must be unique only in the scope of the stored tree, and it is not shared between environment.We added an underscore at the start of the key because it was something custom from Display Builder, if it became official at the UIP2 level, we can drop it.
Exactly, configuration schema of each source plugin.
Notes
Talking with Christian:
Comment #7
pdureau commentedJust asking: why do we need an other storage column dedicated to translations?
Both columns are never used at the same time:
source_idsourcetranslationAnd it is odd to have a field item dedicated to translations when there is already a translation mechanism at the Field API level.
Would it be better to keep 2 columns:
source_idsourceThen, the job is to take the sources of the translation, loop on the flat list and replace the source in the tree according to the
node_id.We will still face two challenges:
sourceproperty is currently not accepting a list of source but a single source. What do we do? A special "translation bag" source? That sounds interesting if it eases the translatibility of config: #3548640: [trans] Sources should support configuration translationComment #8
pdureau commentedComment #11
pdureau commentedAlso, discussed in #3584856: Source field model and storage, let's move the
ui_patterns_fieldout of the experimental status in the scope of this issueComment #12
pdureau commentedIt is better to merge #3584856: Source field model and storage first because it will already ship some changes in
SourceValueItemand will make this MR a bit simpler.Comment #13
pdureau commentedI will squash and review
Comment #14
pdureau commentedI have rebased and squashed the linting commits.
The MR is adding 439 LOC (tests excluded) which is OK for such a change.
in
ui_patterns_field:5 classes in newly added
src/ComponentTree/directory: looks not very drupally but I guess it may be necessarynode_idin props. That's cool, but a big subject, let's check how it fits with #3574820: Unexpected properties in ComponentSource data and the plan for the new Source plugin systemStill reviewing.
Comment #15
pdureau commented(removing
third_party_settingsin all examples for clarity)Thanks you Christian for this amazing work. It is impressive and already 90% OK in my opinion.
Symmetric translation
1. Just translating without messing with the source tree
settings: { synchronized_translation: true }, am I right?) in article content typeResults:
/node/1: ❌ KO, it is French/fr/node/1: ✅ OKThen:
Results:
/node/1: ✅, OK it is now English/fr/node/1: ✅ OK it still French2. Moving stuff around in the translated language
I simply move the second field item before the first, only in the French translation:
Results:
Here is the current stored content:
a:1:{s:5:"value";s:10:"🇫🇷🥐🍷 2";}a:1:{s:9:"component";a:6:{s:12:"component_id";s:23:"ui_suite_daisyui:button";s:10:"variant_id";a:3:{…a:1:{s:12:"translations";a:1:{s:26:"6a0f441718b50:source.value";s:10:"🇫🇷🥐🍷 2";}}a:1:{s:12:"translations";a:4:{s:26:"6a0f441717b01:source.value";s:0:"";s:26:"6a0f441717f70:source.va…The French strings (🇫🇷🥐🍷) are stored in the English translation.
3. Changing a non translatable value from original language
Before anything, I fix the previous translation issue by manually updating the strings in the English node. Then, I change the component variant in the English translation:
Results:
Technical analysis
In the translated field, each field item as a
/source/translationsproperty:This complexity may be the source of the errors because we are hiding a translation system inside a property data instead of working at the Field API level.
I know we have already talked about that, but is it possible to do this instead?
sourceandthird_party_settingshost the translations.source_idis missing because never translatable.And then, in
FieldIitemInterface::getValue()andFieldIListitemInterface::getValue()we rebuild the full tree by merging the content of each translated node into the node positioned in the original language tree (because there are only the translated values to merge, no risk of overriding an unexpected value):Asymmetric translations
Same test with same data:
/node/2: ✅ OK/fr/node/2: ✅ OKI move stuff around in the translation:
I change a not translatable value in the original language:
Misc
To discuss:
node_idin asymmetric translations. I know there are not useful in this situation, but is it the opportunity of promote thenode_idas a standard, expected, property for every stored source?SourceValueItem extends MapItemis wrong,MapDataDefinition::create('map')is confusing and not useful. Proposal:MapDataDefinition::create()and some empty sources are created at the root level, but I hope #3584856: Source field model and storage is already addressing all thisAlso, to discuss with Jean (@mogtofu33) once the rest is OK: How
src/ComponentTree/SourceTree.phpcould be related to\Drupal\display_builder\SourceTree?Comment #16
pdureau commentedComment #20
pdureau commentedThanks. I will review.
Comment #22
pdureau commentedBefore starting the test, i want to share i really like your new idea. Content translation is asymmetric by nature, that means:
By adding symmetric translation mechanism, we alter some (and only some) load & save of the of the content, but we don't mess with the expected storage.
It can be surprising at first to store the full identical source tree in all translations when doing symmetric translations, but it starts to make sense to me.
I hope we will adopt the same mindset to fix the Display Builder part: #3555110: Symmetric translation where we will store in config in the way translated config is stored and managed, and only do alteration in our own load/publish proxy layer.
Setup
I have 2 languages: English (default) and French. I add a Source field with multiple values and symmetric translation (
settings: { synchronized_translation: true }) in article content type.Feedback about the field settings admin pages:
Like last time, I create a node in English, with 2 items in the source field:
(removing
third_party_settingsin all examples for clarity)Symmetric translation
1. Just translating without messing with the source tree
I create the French translation with "🇫🇷🥐🍷 1" instead of "🏴🍺🍲 1" and "🇫🇷🥐🍷 2" instead of "🏴🍺🍲 2", without moving anything around for now
Results:
/node/1: ✅ OK/fr/node/1: ✅ OKThen I edit again default language /node/2/edit and I save without changing anything
Results:
/node/1: ✅, OK it is still English/fr/node/1: ✅ OK it still French2. Moving stuff around in the translated language
I simply move the second field item before the first, only in the French translation:
Results:
3. Changing a non translatable value from original language
I change the component variant in the English version.
Results:
Asymmetric translations
Not tested again. I believe it is still OK :)
Follow-up
As asked by Christian, we will do a follow-up issue for this task.
Comment #23
pdureau commentedOnly one little KO :)
Comment #24
christian.wiedemann commentedHi Mikael, so now I am happy with architecture. Have a look and let me know what you think
Comment #25
pdureau commentedHi Christian,
a few things to change before sending to review for the last time.
1.
SourceValueItem::mainPropertyName()must not return NULLIt breaks the entity queries (DatabaseExceptionWrapper). We need to pick one of the 4 properties:
source_id,source,node_idorthird_party_settings. I don't know which one.2. The pipeline is red
cspell:
phpunit: Twice
Drupal\Tests\ui_patterns\Kernel\SchemaManagerTest::testResolve(), I don't know if it is related to the MR:3. Module status
We decided a few weeks ago this ticket was the opportunity to move the module out of experimental status, didn't we?
Comment #26
pdureau commentedI will do the 3 changes
Comment #27
pdureau commentedPipeline is green. Ready to be reviewed.
Careful: in the last commit, there is a a change in phpunit fixtures which is not related to the MR, but to a Drupal Core dependency upgrade:
You may encounter the same issue in other MR. I don't know if this change is "safe" or not.
Comment #28
pdureau commentedBack to Christian just to rename
/src/ComponentTree/*to/src/SourceTree/*Comment #29
pdureau commentedi will rebase, squash, rename ComponentTree and send to review
Comment #30
pdureau commentedDone. Excluding the tests, the MR is only adding 361 lines of PHP code.
Comment #34
just_like_good_vibes