[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:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user