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 Text
+
+ """ + snippet_arch2 = f""" +
+

English Title

+
+ """ + + # 2. Create a view containing a snippet and translate it + view1 = View.create({ + 'name': 'Specific View Test Translation 1', + 'type': 'qweb', + 'arch': f''' +

Hello

{snippet_arch}

World

{snippet_arch2}
+ ''', + 'key': 'test.specific_view_test_translation_1', + 'website_id': website.id, + }) + view1.update_field_translations('arch_db', { + parseltongue.code: { + 'English Text': 'Texte Francais', + 'English Title': 'Titre Francais', + } + }) + self.assertIn('Titre Francais', view1.with_context(lang=parseltongue.code).arch) + self.assertIn('Texte Francais', view1.with_context(lang=parseltongue.code).arch) + + # 3. Save the snippet as custom snippet and ensure it is translated + self.env['ir.ui.view'].with_context( + website_id=website.id, + model=view1._name, + # `arch` is not the field in DB (it's a compute), this is also + # testing that it works in such cases (raw sql query would fail) + field='arch', + resId=view1.id, + ).save_snippet( + name=data_name_attr, + arch=snippet_arch, + thumbnail_url='/website/static/src/img/snippets_thumbs/s_text_block.svg', + snippet_key='s_text_block', + template_key='website.snippets' + ) + custom_snippet_view = View.search([('name', '=', data_name_attr)], limit=1) + self.assertIn( + 'Texte Francais', + custom_snippet_view.with_context(lang=parseltongue.code).arch) + + self.env['ir.ui.view'].with_context( + website_id=website.id, + model=view1._name, + # `arch` is not the field in DB (it's a compute), this is also + # testing that it works in such cases (raw sql query would fail) + field='arch', + resId=view1.id, + ).save_snippet( + name=data_name_attr2, + arch=snippet_arch2, + thumbnail_url='/website/static/src/img/snippets_thumbs/s_text_block.svg', + snippet_key='s_text_block', + template_key='website.snippets' + ) + custom_snippet_view = View.search([('name', '=', data_name_attr2)], limit=1) + self.assertIn( + 'Titre Francais', + custom_snippet_view.with_context(lang=parseltongue.code).arch) + + # 4. Simulate snippet being dropped in another page/view and ensure + # it is translated + view2 = View.create({ + 'name': 'Specific View Test Translation 2', + 'type': 'qweb', + 'arch': '
', + 'key': 'test.specific_view_test_translation_2', + 'website_id': website.id, + }) + view2.save(f"
{snippet_arch}
", xpath='/body[1]/div[1]') + view2.save(f"
{snippet_arch2}
", xpath='/body[1]/div[2]') + self.assertIn( + 'Titre Francais', + view2.with_context(lang=parseltongue.code).arch) + self.assertIn( + 'Texte Francais', + view2.with_context(lang=parseltongue.code).arch) + + # 5. Simulate snippet being dropped in another model field and ensure + # it is translated + mega_menu = self.env['website.menu'].create({ + 'name': 'Meaga Menu Test Translation 1', + 'mega_menu_content': '
', + }) + view2.save(f''' +
+ {snippet_arch} +
+ ''', xpath='/body[1]/div[1]') + self.assertIn( + 'English Text', + mega_menu.mega_menu_content) + + # Side test: this is testing that saving a custom snippet from a record + # which is not an ir.ui.view works fine. + # Indeed, it's a more complexe case as it's basically copying + # translations from Model1.Field1 to Model2.Field2 -> different model + # and different field. + mega_menu.mega_menu_content = f'
{snippet_arch}
' + mega_menu.update_field_translations('mega_menu_content', { + parseltongue.code: { + 'English Text': 'Texte Francais', + } + }) + + self.env['ir.ui.view'].with_context( + website_id=website.id, + model=mega_menu._name, + field='mega_menu_content', + resId=mega_menu.id, + ).save_snippet( + name='Test Translation MegaMenu', + arch=snippet_arch, + thumbnail_url='/website/static/src/img/snippets_thumbs/s_text_block.svg', + snippet_key='s_text_block', + template_key='website.snippets' + ) + custom_snippet_view = View.search([('name', '=', 'Test Translation MegaMenu')], limit=1) + self.assertIn( + 'Texte Francais', + custom_snippet_view.with_context(lang=parseltongue.code).arch)