[IMP] web_editor, website: keep custom snippet translations

This commit adds translation capability to custom snippets.
Before this commit, the custom snippet was not translate friendly:
1. Neither when saving a block as custom snippet
2. Neither when dropping a custom snippet into a page.

This was a known limitation for years. But now it's time to make it
work.

Step to reproduce (part 1):
- Enable french
- Drag & drop Title snippet in a page
- Translate the Title
- Save the block as custom snippet
-> Go in the backend view of this custom snippet, in debug, there is no
   translation that followed the title.

Step to reproduce (part 2):
- Following previous steps, now add the translation manually on the
  custom snippet view
- Back in a page in edit mode, drag & drop this custom snippet in the
  page
- Switch to french
-> The title is not translated, the drag & drop copy code but no
   translations

Those are the "page/view" to "page/view" translation transfer, but it
could also be any html field (like a product description) to view
(custom snippet) or view (custom snippet) to any html field.

task-3375518
opw-3242100

X-original-commit: 2bd6c1a8d653453692211eef0f5d46a0047eb6f2
Part-of: odoo/odoo#150945
This commit is contained in:
Romain Derie
2024-01-25 18:13:03 +00:00
parent 714896a047
commit 8255fd6bff
5 changed files with 276 additions and 5 deletions
+97 -4
View File
@@ -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 = {
@@ -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()
@@ -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",
+1
View File
@@ -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
@@ -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"""
<section class="s_text_block s_custom_snippet" data-name="{data_name_attr}">
<div class="custom_snippet_website_1">English Text</div>
</section>
"""
snippet_arch2 = f"""
<section class="s_title s_custom_snippet" data-name="{data_name_attr2}">
<h1 class="custom_snippet_website_1">English Title</h1>
</section>
"""
# 2. Create a view containing a snippet and translate it
view1 = View.create({
'name': 'Specific View Test Translation 1',
'type': 'qweb',
'arch': f'''
<body><p>Hello</p><div>{snippet_arch}</div><h1>World</h1><div>{snippet_arch2}</div></body>
''',
'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': '<body><div/><div/></body>',
'key': 'test.specific_view_test_translation_2',
'website_id': website.id,
})
view2.save(f"<div>{snippet_arch}</div>", xpath='/body[1]/div[1]')
view2.save(f"<div>{snippet_arch2}</div>", 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': '<body><div/></body>',
})
view2.save(f'''
<div data-oe-xpath="/body[1]/div[1]" data-oe-model="website.menu"
data-oe-id="{mega_menu.id}" data-oe-field="mega_menu_content" data-oe-type="html"
data-oe-expression="submenu.mega_menu_content">
{snippet_arch}
</div>
''', 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'<div>{snippet_arch}</div>'
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)