diff --git a/addons/web_editor/models/ir_ui_view.py b/addons/web_editor/models/ir_ui_view.py
index 8a3459b9b06..a49fd76cef0 100644
--- a/addons/web_editor/models/ir_ui_view.py
+++ b/addons/web_editor/models/ir_ui_view.py
@@ -49,10 +49,14 @@ class IrUiView(models.Model):
if value is not None:
# TODO: batch writes?
+ record = Model.browse(int(el.get('data-oe-id')))
if not self.env.context.get('lang') and self.get_default_lang_code():
- Model.browse(int(el.get('data-oe-id'))).with_context(lang=self.get_default_lang_code()).write({field: value})
+ record.with_context(lang=self.get_default_lang_code()).write({field: value})
else:
- Model.browse(int(el.get('data-oe-id'))).write({field: value})
+ record.write({field: value})
+
+ if callable(Model._fields[field].translate):
+ self._copy_custom_snippet_translations(record, field)
def save_oe_structure(self, el):
self.ensure_one()
@@ -80,10 +84,81 @@ class IrUiView(models.Model):
'mode': 'extension',
}
vals.update(self._save_oe_structure_hook())
- self.env['ir.ui.view'].create(vals)
+ oe_structure_view = self.env['ir.ui.view'].create(vals)
+ self._copy_custom_snippet_translations(oe_structure_view, 'arch_db')
return True
+ @api.model
+ def _copy_custom_snippet_translations(self, record, html_field):
+ """ Given a ``record`` and its HTML ``field``, detect any
+ usage of a custom snippet and copy its translations.
+ """
+ lang_value = record[html_field]
+ if not lang_value:
+ return
+
+ tree = html.fromstring(lang_value)
+ for custom_snippet_el in tree.xpath('//*[hasclass("s_custom_snippet")]'):
+ custom_snippet_name = custom_snippet_el.get('data-name')
+ custom_snippet_view = self.search([('name', '=', custom_snippet_name)], limit=1)
+ if custom_snippet_view:
+ self._copy_field_terms_translations(custom_snippet_view, 'arch_db', record, html_field)
+
+ @api.model
+ def _copy_field_terms_translations(self, record_from, name_field_from, record_to, name_field_to):
+ """ Copy the terms translation from a record/field ``Model1.Field1``
+ to a (possibly) completely different record/field ``Model2.Field2``.
+
+ For instance, copy the translations of a
+ ``product.template.html_description`` field to a ``ir.ui.view.arch_db``
+ field.
+
+ The method takes care of read and write access of both records/fields.
+ """
+ record_to.check_access_rights('write')
+ record_to.check_access_rule('write')
+ record_to.check_field_access_rights('write', [name_field_to])
+
+ # This will also implicitly check for `read` access rights
+ if not record_from[name_field_from] or not record_to[name_field_to]:
+ return
+
+ field_from = record_from._fields[name_field_from]
+ field_to = record_to._fields[name_field_to]
+ error_callable_msg = "'translate' property of field %r is not callable"
+ if not callable(field_from.translate):
+ raise ValueError(error_callable_msg % field_from)
+ if not callable(field_to.translate):
+ raise ValueError(error_callable_msg % field_to)
+ if not field_to.store:
+ raise ValueError("Field %r is not stored" % field_to)
+
+ lang_env = self.env.lang or 'en_US'
+ langs = set(lang for lang, _ in self.env['res.lang'].get_installed())
+
+ # 1. Get translations
+ record_from.flush_model([name_field_from])
+ existing_translation_dictionary = field_to.get_translation_dictionary(
+ record_to[name_field_to],
+ {lang: record_to.with_context(prefetch_langs=True, lang=lang)[name_field_to] for lang in langs if lang != lang_env}
+ )
+ extra_translation_dictionary = field_from.get_translation_dictionary(
+ record_from[name_field_from],
+ {lang: record_from.with_context(prefetch_langs=True, lang=lang)[name_field_from] for lang in langs if lang != lang_env}
+ )
+ existing_translation_dictionary.update(extra_translation_dictionary)
+ translation_dictionary = existing_translation_dictionary
+
+ # 2. Set translations
+ new_value = {
+ lang: field_to.translate(lambda term: translation_dictionary.get(term, {}).get(lang), record_to[name_field_to])
+ for lang in langs
+ }
+ record_to.env.cache.update_raw(record_to, field_to, [new_value], dirty=True)
+ # Call `write` to trigger compute etc (`modified()`)
+ record_to[name_field_to] = new_value[lang_env]
+
@api.model
def _save_oe_structure_hook(self):
return {}
@@ -195,6 +270,7 @@ class IrUiView(models.Model):
if not self._are_archs_equal(old_arch, new_arch):
self._set_noupdate()
self.write({'arch': etree.tostring(new_arch, encoding='unicode')})
+ self._copy_custom_snippet_translations(self, 'arch_db')
@api.model
def _view_get_inherited_children(self, view):
@@ -333,7 +409,24 @@ class IrUiView(models.Model):
'arch': xml_arch,
}
new_snippet_view_values.update(self._snippet_save_view_values_hook())
- self.create(new_snippet_view_values)
+ custom_snippet_view = self.create(new_snippet_view_values)
+ model = self._context.get('model')
+ field = self._context.get('field')
+ if field == 'arch':
+ # Special case for `arch` which is a kind of related (through a
+ # compute) to `arch_db` but which is hosting XML/HTML content while
+ # being a char field.. Which is then messing around with the
+ # `get_translation_dictionary` call, returning XML instead of
+ # strings
+ field = 'arch_db'
+ res_id = self._context.get('resId')
+ if model and field and res_id:
+ self._copy_field_terms_translations(
+ self.env[model].browse(int(res_id)),
+ field,
+ custom_snippet_view,
+ 'arch_db',
+ )
custom_section = self.search([('key', '=', template_key)])
snippet_addition_view_values = {
diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js
index e95546939bb..9a5e31e9cc9 100644
--- a/addons/web_editor/static/src/js/editor/snippets.editor.js
+++ b/addons/web_editor/static/src/js/editor/snippets.editor.js
@@ -3554,7 +3554,10 @@ var SnippetsMenu = Widget.extend({
*/
_registerDefaultTexts: function ($in) {
if ($in === undefined) {
- $in = this.$snippets.find('.oe_snippet_body');
+ // By default, we don't want the `o_default_snippet_text` class on
+ // custom snippets. Those are most likely already ready, we don't
+ // really need the auto-selection by the editor.
+ $in = this.$snippets.find('.oe_snippet_body:not(.s_custom_snippet)');
}
$in.find('*').addBack()
diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js
index df7539ef04d..ffba821ba54 100644
--- a/addons/web_editor/static/src/js/editor/snippets.options.js
+++ b/addons/web_editor/static/src/js/editor/snippets.options.js
@@ -8982,6 +8982,7 @@ registry.SnippetSave = SnippetOptionWidget.extend({
? _t("Custom %s", this.data.snippetName)
: _t("Custom Button");
const targetCopyEl = this.$target[0].cloneNode(true);
+ targetCopyEl.classList.add('s_custom_snippet');
delete targetCopyEl.dataset.name;
if (isButton) {
targetCopyEl.classList.remove("mb-2");
@@ -8991,6 +8992,18 @@ registry.SnippetSave = SnippetOptionWidget.extend({
// current widget has been destroyed and is orphaned, so this._rpc
// will not work as it can't trigger_up. For this reason, we need
// to bypass the service provider and use the global RPC directly
+
+ // Get editable parent TODO find proper method to get it directly
+ let editableParentEl;
+ for (const parentEl of this.options.getContentEditableAreas()) {
+ if (parentEl.contains(this.$target[0])) {
+ editableParentEl = parentEl;
+ break;
+ }
+ }
+ context['model'] = editableParentEl.dataset.oeModel;
+ context['field'] = editableParentEl.dataset.oeField;
+ context['resId'] = editableParentEl.dataset.oeId;
await jsonrpc(`/web/dataset/call_kw/ir.ui.view/save_snippet`, {
model: "ir.ui.view",
method: "save_snippet",
diff --git a/addons/website/tests/__init__.py b/addons/website/tests/__init__.py
index b315317b9c4..37c96645a14 100644
--- a/addons/website/tests/__init__.py
+++ b/addons/website/tests/__init__.py
@@ -9,6 +9,7 @@ from . import test_client_action
from . import test_configurator
from . import test_controllers
from . import test_converter
+from . import test_custom_snippets
from . import test_crawl
from . import test_disable_unused_snippets_assets
from . import test_fuzzy
diff --git a/addons/website/tests/test_custom_snippets.py b/addons/website/tests/test_custom_snippets.py
new file mode 100644
index 00000000000..39231ba4614
--- /dev/null
+++ b/addons/website/tests/test_custom_snippets.py
@@ -0,0 +1,161 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import Command
+from odoo.tests import TransactionCase, tagged
+
+
+@tagged('post_install', '-at_install')
+class TestCustomSnippet(TransactionCase):
+ def test_translations_custom_snippet(self):
+ ResLang = self.env['res.lang']
+ View = self.env['ir.ui.view']
+
+ # 1. Setup website and languages
+ parseltongue = ResLang.create({
+ 'name': 'Parseltongue',
+ 'code': 'pa_GB',
+ 'iso_code': 'pa_GB',
+ 'url_code': 'pa_GB',
+ })
+ ResLang._activate_lang(parseltongue.code)
+ website = self.env.ref('website.default_website')
+ website.language_ids = [Command.link(parseltongue.id)]
+ data_name_attr = "Custom Text Block Test Translations"
+ data_name_attr2 = "Custom Title Test Translations"
+ # Note that `s_custom_snippet` is supposed to be added by the JS when
+ # sending a snippet arch to the `save_snippet` python method.
+ # Adding it here to mimick the real flow, but at this point it really is
+ # just a regular snippet.
+ snippet_arch = f"""
+ English Title
+
Hello