From c333d222ea42cf0f60e5f1fb32053086b0694bab Mon Sep 17 00:00:00 2001 From: Jordi Ballester Alomar Date: Tue, 30 Jun 2026 21:06:25 +0200 Subject: [PATCH 1/4] [ADD] web_html_field_translate_dialog: per-language HTML translation dialog The standard translation dialog of an HTML field splits its content into individual source terms, which is technical and hard to use. This module replaces it, for HTML fields only, with a dialog showing one full rich-text editor per installed language. --- web_html_field_translate_dialog/README.rst | 93 ++++ web_html_field_translate_dialog/__init__.py | 1 + .../__manifest__.py | 23 + .../models/__init__.py | 1 + .../models/base.py | 103 ++++ .../pyproject.toml | 3 + .../readme/CONTRIBUTORS.md | 1 + .../readme/DESCRIPTION.md | 14 + .../static/description/index.html | 439 ++++++++++++++++++ .../html_translation_dialog.esm.js | 92 ++++ .../html_translation_dialog.scss | 15 + .../html_translation_dialog.xml | 38 ++ .../translation_button.esm.js | 45 ++ .../tests/html_translation_dialog.test.js | 112 +++++ .../tests/__init__.py | 2 + .../tests/test_html_field_translation.py | 83 ++++ .../tests/test_html_translation_dialog_ui.py | 19 + 17 files changed, 1084 insertions(+) create mode 100644 web_html_field_translate_dialog/README.rst create mode 100644 web_html_field_translate_dialog/__init__.py create mode 100644 web_html_field_translate_dialog/__manifest__.py create mode 100644 web_html_field_translate_dialog/models/__init__.py create mode 100644 web_html_field_translate_dialog/models/base.py create mode 100644 web_html_field_translate_dialog/pyproject.toml create mode 100644 web_html_field_translate_dialog/readme/CONTRIBUTORS.md create mode 100644 web_html_field_translate_dialog/readme/DESCRIPTION.md create mode 100644 web_html_field_translate_dialog/static/description/index.html create mode 100644 web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.esm.js create mode 100644 web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.scss create mode 100644 web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.xml create mode 100644 web_html_field_translate_dialog/static/src/translation_button/translation_button.esm.js create mode 100644 web_html_field_translate_dialog/static/tests/html_translation_dialog.test.js create mode 100644 web_html_field_translate_dialog/tests/__init__.py create mode 100644 web_html_field_translate_dialog/tests/test_html_field_translation.py create mode 100644 web_html_field_translate_dialog/tests/test_html_translation_dialog_ui.py diff --git a/web_html_field_translate_dialog/README.rst b/web_html_field_translate_dialog/README.rst new file mode 100644 index 000000000000..6e6cddc2c320 --- /dev/null +++ b/web_html_field_translate_dialog/README.rst @@ -0,0 +1,93 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=============================== +Web HTML Field Translate Dialog +=============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:5a13aaec2fb305abd1c814fdaf78669366e648119b3e025a37fc47ebc2a5b682 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/19.0/web_html_field_translate_dialog + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-19-0/web-19-0-web_html_field_translate_dialog + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module improves the usability of translating **HTML** fields. + +The standard translation dialog of an HTML field splits its content into +individual source terms (chunks) and asks the user to translate each one +separately. This is technical and hard to use for end users, especially +on rich content. + +With this module, the translation button next to an HTML field opens a +dialog that shows one **full rich-text editor per installed language**. +The user reads and edits the whole translation of each language as a +single block, exactly as the field is edited in the form. + +The standard term-by-term dialog is kept untouched for ``char`` and +``text`` translatable fields. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ForgeFlow + +Contributors +------------ + +- Jordi Ballester + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_html_field_translate_dialog/__init__.py b/web_html_field_translate_dialog/__init__.py new file mode 100644 index 000000000000..0650744f6bc6 --- /dev/null +++ b/web_html_field_translate_dialog/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/web_html_field_translate_dialog/__manifest__.py b/web_html_field_translate_dialog/__manifest__.py new file mode 100644 index 000000000000..fa4ba13ab3c0 --- /dev/null +++ b/web_html_field_translate_dialog/__manifest__.py @@ -0,0 +1,23 @@ +{ + "name": "Web HTML Field Translate Dialog", + "summary": "Translate HTML fields with a per-language rich-text editor " + "instead of the technical term-by-term dialog", + "version": "19.0.1.0.0", + "category": "Web", + "author": "ForgeFlow, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web", + "license": "AGPL-3", + "depends": ["web", "html_editor"], + "installable": True, + "auto_install": False, + "assets": { + "web.assets_backend": [ + "web_html_field_translate_dialog/static/src/**/*.js", + "web_html_field_translate_dialog/static/src/**/*.xml", + "web_html_field_translate_dialog/static/src/**/*.scss", + ], + "web.assets_unit_tests": [ + "web_html_field_translate_dialog/static/tests/**/*.test.js", + ], + }, +} diff --git a/web_html_field_translate_dialog/models/__init__.py b/web_html_field_translate_dialog/models/__init__.py new file mode 100644 index 000000000000..0e44449338cf --- /dev/null +++ b/web_html_field_translate_dialog/models/__init__.py @@ -0,0 +1 @@ +from . import base diff --git a/web_html_field_translate_dialog/models/base.py b/web_html_field_translate_dialog/models/base.py new file mode 100644 index 000000000000..da67fe0b597d --- /dev/null +++ b/web_html_field_translate_dialog/models/base.py @@ -0,0 +1,103 @@ +# Copyright 2025 ForgeFlow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from psycopg2.extras import Json + +from odoo import models +from odoo.tools import SQL + + +class Base(models.AbstractModel): + _inherit = "base" + + def _web_translation_langs(self): + """Return the language codes a translation dialog should expose. + + These are the installed (active) languages plus ``en_US``, which is + always the source language even when it is not active. + """ + langs = [code for code, _name in self.env["res.lang"].get_installed()] + if "en_US" not in langs: + langs.insert(0, "en_US") + return langs + + def web_get_field_translations_full(self, field_name): + """Return the *whole* field value for each language. + + Unlike :meth:`~odoo.models.Model.get_field_translations`, which for + ``html`` fields (whose ``translate`` attribute is a callable) splits the + content into individual source terms, this returns the full value per + language so it can be edited as a single rich-text block. + + :param str field_name: name of the translatable field. + :return: list of ``{"lang": code, "value": html}`` dictionaries, one per + language. + """ + self.ensure_one() + self.check_access("read") + record = self.with_context(check_translations=True, prefetch_langs=True) + return [ + {"lang": lang, "value": record.with_context(lang=lang)[field_name] or ""} + for lang in self._web_translation_langs() + ] + + def web_set_field_translations_full(self, field_name, translations): + """Write the full value of ``field_name`` for each given language. + + :param str field_name: name of the translatable field. + :param dict translations: mapping ``{lang: value}`` with the full value + for each language. + + Each language is stored as an independent full value. We write the + ``jsonb`` translations column directly (as :meth:`update_field_translations` + does for ``translate=True`` fields) instead of writing the field per + language context. The latter would, for model-term translated (``html``) + fields, re-synchronise terms and overwrite the ``en_US`` source whenever + a translation no longer shares the source structure. + """ + self.ensure_one() + field = self._fields[field_name] + if not field.translate: + return False + self.check_access("write") + self._check_field_access(field, "write") + valid_langs = set(self._web_translation_langs()) + + # Sanitise every value through the field itself before storing it. + values = {} + for lang, value in translations.items(): + if lang not in valid_langs: + continue + record_lang = self.with_context(lang=lang) + values[lang] = field.convert_to_cache(value, record_lang) or None + if not values: + return False + + # Keep a meaningful ``en_US`` fallback so the source is never lost. + fallback = ( + values.get("en_US") + or self.with_context(lang="en_US")[field_name] + or next((v for v in values.values() if v is not None), None) + ) + self.invalidate_recordset([field_name]) + self.env.cr.execute( + SQL( + """ UPDATE %(table)s + SET %(field)s = NULLIF( + jsonb_strip_nulls( + %(fallback)s + || COALESCE(%(field)s, '{}'::jsonb) + || %(value)s + ), + '{}'::jsonb) + WHERE id = %(id)s + """, + table=SQL.identifier(self._table), + field=SQL.identifier(field_name), + fallback=Json({"en_US": fallback}), + value=Json(values), + id=self.id, + ) + ) + self.modified([field_name]) + return True diff --git a/web_html_field_translate_dialog/pyproject.toml b/web_html_field_translate_dialog/pyproject.toml new file mode 100644 index 000000000000..4231d0cccb3d --- /dev/null +++ b/web_html_field_translate_dialog/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_html_field_translate_dialog/readme/CONTRIBUTORS.md b/web_html_field_translate_dialog/readme/CONTRIBUTORS.md new file mode 100644 index 000000000000..00987e563a42 --- /dev/null +++ b/web_html_field_translate_dialog/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Jordi Ballester \<\> diff --git a/web_html_field_translate_dialog/readme/DESCRIPTION.md b/web_html_field_translate_dialog/readme/DESCRIPTION.md new file mode 100644 index 000000000000..4dfd596feafd --- /dev/null +++ b/web_html_field_translate_dialog/readme/DESCRIPTION.md @@ -0,0 +1,14 @@ +This module improves the usability of translating **HTML** fields. + +The standard translation dialog of an HTML field splits its content into +individual source terms (chunks) and asks the user to translate each one +separately. This is technical and hard to use for end users, especially on +rich content. + +With this module, the translation button next to an HTML field opens a dialog +that shows one **full rich-text editor per installed language**. The user reads +and edits the whole translation of each language as a single block, exactly as +the field is edited in the form. + +The standard term-by-term dialog is kept untouched for `char` and `text` +translatable fields. diff --git a/web_html_field_translate_dialog/static/description/index.html b/web_html_field_translate_dialog/static/description/index.html new file mode 100644 index 000000000000..7e9501deaae4 --- /dev/null +++ b/web_html_field_translate_dialog/static/description/index.html @@ -0,0 +1,439 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Web HTML Field Translate Dialog

+ +

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

This module improves the usability of translating HTML fields.

+

The standard translation dialog of an HTML field splits its content into +individual source terms (chunks) and asks the user to translate each one +separately. This is technical and hard to use for end users, especially +on rich content.

+

With this module, the translation button next to an HTML field opens a +dialog that shows one full rich-text editor per installed language. +The user reads and edits the whole translation of each language as a +single block, exactly as the field is edited in the form.

+

The standard term-by-term dialog is kept untouched for char and +text translatable fields.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ForgeFlow
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/web project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.esm.js b/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.esm.js new file mode 100644 index 000000000000..045548c1afce --- /dev/null +++ b/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.esm.js @@ -0,0 +1,92 @@ +/* Copyright 2025 ForgeFlow S.L. + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +import {Component, onWillStart} from "@odoo/owl"; +import {Dialog} from "@web/core/dialog/dialog"; +import { + MAIN_PLUGINS, + NO_EMBEDDED_COMPONENTS_FALLBACK_PLUGINS, +} from "@html_editor/plugin_sets"; +import {Wysiwyg} from "@html_editor/wysiwyg"; +import {_t, loadLanguages} from "@web/core/l10n/translation"; +import {useService} from "@web/core/utils/hooks"; +import {user} from "@web/core/user"; + +/** + * Dialog that lets the user translate an HTML field by showing one full + * rich-text editor per installed language, instead of the standard dialog that + * splits the content into technical source terms. + */ +export class HtmlTranslationDialog extends Component { + static template = "web_html_field_translate_dialog.HtmlTranslationDialog"; + static components = {Dialog, Wysiwyg}; + static props = { + fieldName: String, + fieldString: {type: String, optional: true}, + resId: Number, + resModel: String, + onSave: Function, + close: Function, + }; + + setup() { + this.orm = useService("orm"); + this.title = this.props.fieldString + ? _t("Translate: %s", this.props.fieldString) + : _t("Translate"); + // Editor instances, keyed by language code. + this.editors = {}; + this.translations = []; + + onWillStart(async () => { + const languages = await loadLanguages(this.orm); + const translations = await this.orm.call( + this.props.resModel, + "web_get_field_translations_full", + [[this.props.resId], this.props.fieldName] + ); + this.translations = translations.map((term) => { + const language = languages.find((lang) => lang[0] === term.lang); + return { + lang: term.lang, + langName: language ? language[1] : term.lang, + value: term.value || "", + }; + }); + this.translations.sort((a, b) => a.langName.localeCompare(b.langName)); + }); + } + + isCurrentLang(lang) { + return lang === user.lang; + } + + getConfig(term) { + return { + content: term.value, + Plugins: [...MAIN_PLUGINS, ...NO_EMBEDDED_COMPONENTS_FALLBACK_PLUGINS], + baseContainers: ["DIV", "P"], + }; + } + + onEditorLoad(lang, editor) { + this.editors[lang] = editor; + } + + async onSave() { + const translations = {}; + for (const term of this.translations) { + const editor = this.editors[term.lang]; + if (editor) { + translations[term.lang] = editor.getContent(); + } + } + await this.orm.call(this.props.resModel, "web_set_field_translations_full", [ + [this.props.resId], + this.props.fieldName, + translations, + ]); + await this.props.onSave(); + this.props.close(); + } +} diff --git a/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.scss b/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.scss new file mode 100644 index 000000000000..92d4878897d1 --- /dev/null +++ b/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.scss @@ -0,0 +1,15 @@ +.o_html_translation_dialog { + .o_html_translation_editor { + // Give every language editor a comfortable, scrollable working area. + max-height: 18rem; + overflow-y: auto; + + .o-wysiwyg { + min-height: 6rem; + } + } + + .o_html_translation_lang.o_language_current > .o_form_label { + color: var(--primary); + } +} diff --git a/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.xml b/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.xml new file mode 100644 index 000000000000..753dd2471568 --- /dev/null +++ b/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.xml @@ -0,0 +1,38 @@ + + + + + +
+ +
+
+
+
+ + + + +
+
+ +
diff --git a/web_html_field_translate_dialog/static/src/translation_button/translation_button.esm.js b/web_html_field_translate_dialog/static/src/translation_button/translation_button.esm.js new file mode 100644 index 000000000000..22befa7432bc --- /dev/null +++ b/web_html_field_translate_dialog/static/src/translation_button/translation_button.esm.js @@ -0,0 +1,45 @@ +/* Copyright 2025 ForgeFlow S.L. + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +import {HtmlTranslationDialog} from "../html_translation_dialog/html_translation_dialog.esm"; +import {Record} from "@web/model/relational_model/record"; +import {TranslationButton} from "@web/views/fields/translation_button"; +import {patch} from "@web/core/utils/patch"; +import {useOwnedDialogs} from "@web/core/utils/hooks"; + +patch(TranslationButton.prototype, { + setup() { + super.setup(); + this.addDialog = useOwnedDialogs(); + }, + + get isHtmlField() { + return this.props.record.fields[this.props.fieldName].type === "html"; + }, + + async onClick() { + // Keep the standard term-by-term dialog for char/text fields; only HTML + // fields get the per-language rich-text dialog. + if (!this.isHtmlField) { + return super.onClick(...arguments); + } + const {fieldName, record} = this.props; + // In a DynamicList the model root is the list itself, not a Record. + const saved = + record.model.root instanceof Record + ? await record.model.root.save() + : await record.save(); + if (!saved) { + return; + } + this.addDialog(HtmlTranslationDialog, { + fieldName: fieldName, + fieldString: record.fields[fieldName].string, + resId: record.resId, + resModel: record.resModel, + onSave: async () => { + await record.load(); + }, + }); + }, +}); diff --git a/web_html_field_translate_dialog/static/tests/html_translation_dialog.test.js b/web_html_field_translate_dialog/static/tests/html_translation_dialog.test.js new file mode 100644 index 000000000000..b00c4a3207fd --- /dev/null +++ b/web_html_field_translate_dialog/static/tests/html_translation_dialog.test.js @@ -0,0 +1,112 @@ +/* Copyright 2025 ForgeFlow S.L. + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +import {expect, test} from "@odoo/hoot"; +import { + defineModels, + fields, + models, + mountView, + onRpc, + serverState, +} from "@web/../tests/web_test_helpers"; +import {click, queryAll} from "@odoo/hoot-dom"; +import {animationFrame} from "@odoo/hoot-mock"; + +class Partner extends models.Model { + txt = fields.Html({string: "Txt", translate: true}); + _records = [{id: 1, txt: "

Hello

"}]; +} + +class User extends models.Model { + _name = "res.users"; + has_group() { + return true; + } +} + +defineModels([Partner, User]); + +test("html translatable field opens a per-language rich-text dialog", async () => { + serverState.lang = "en_US"; + serverState.multiLang = true; + + onRpc("has_group", () => true); + onRpc("res.lang", "get_installed", () => [ + ["en_US", "English"], + ["fr_FR", "French"], + ]); + onRpc("web_get_field_translations_full", ({args}) => { + expect(args[1]).toBe("txt"); + return [ + {lang: "en_US", value: "

Hello

"}, + {lang: "fr_FR", value: "

Bonjour

"}, + ]; + }); + + await mountView({ + type: "form", + resModel: "partner", + resId: 1, + arch: /* xml */ `
`, + }); + + expect(".o_field_html .btn.o_field_translate").toHaveCount(1, { + message: "the translate button should be displayed next to the HTML field", + }); + + await click(".o_field_html .btn.o_field_translate"); + await animationFrame(); + + expect(".modal .o_html_translation_dialog").toHaveCount(1, { + message: "the per-language HTML translation dialog should open", + }); + expect(".o_translation_dialog").toHaveCount(0, { + message: "the standard term-by-term dialog should not be used for HTML fields", + }); + expect(".o_html_translation_editor").toHaveCount(2, { + message: "there should be one full editor per installed language", + }); +}); + +test("the per-language dialog saves the full value of every language", async () => { + serverState.lang = "en_US"; + serverState.multiLang = true; + + onRpc("has_group", () => true); + onRpc("res.lang", "get_installed", () => [ + ["en_US", "English"], + ["fr_FR", "French"], + ]); + onRpc("web_get_field_translations_full", () => [ + {lang: "en_US", value: "

Hello

"}, + {lang: "fr_FR", value: "

Bonjour

"}, + ]); + onRpc("web_set_field_translations_full", ({args}) => { + expect(args[1]).toBe("txt"); + expect(Object.keys(args[2]).sort()).toEqual(["en_US", "fr_FR"], { + message: "the full value of every language should be sent on save", + }); + expect.step("web_set_field_translations_full"); + return true; + }); + + await mountView({ + type: "form", + resModel: "partner", + resId: 1, + arch: /* xml */ `
`, + }); + + await click(".o_field_html .btn.o_field_translate"); + await animationFrame(); + + const saveButton = queryAll(".modal footer .btn-primary")[0]; + await click(saveButton); + await animationFrame(); + + expect.verifySteps(["web_set_field_translations_full"]); + expect(".modal .o_html_translation_dialog").toHaveCount(0, { + message: "the dialog should close after saving", + }); +}); diff --git a/web_html_field_translate_dialog/tests/__init__.py b/web_html_field_translate_dialog/tests/__init__.py new file mode 100644 index 000000000000..59d42a3dbbf0 --- /dev/null +++ b/web_html_field_translate_dialog/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_html_field_translation +from . import test_html_translation_dialog_ui diff --git a/web_html_field_translate_dialog/tests/test_html_field_translation.py b/web_html_field_translate_dialog/tests/test_html_field_translation.py new file mode 100644 index 000000000000..ba2355db3ff8 --- /dev/null +++ b/web_html_field_translate_dialog/tests/test_html_field_translation.py @@ -0,0 +1,83 @@ +# Copyright 2025 ForgeFlow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests import Form, TransactionCase + + +class TestHtmlFieldTranslation(TransactionCase): + """Exercise the per-language full-value translation helpers on a + translatable HTML field that is shown in a form view. + + A small custom model with a translatable ``html`` field (which becomes a + ``html_translate`` callable field, i.e. exactly the kind whose standard + dialog splits the content into terms) and its own form view are created so + the behaviour can be reproduced through a :class:`~odoo.tests.Form`. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env["res.lang"]._activate_lang("fr_FR") + model = cls.env["ir.model"].create( + {"name": "HTML Translate Test", "model": "x_html_translate_test"} + ) + cls.env["ir.model.fields"].create( + { + "name": "x_body", + "field_description": "Body", + "model_id": model.id, + "ttype": "html", + "translate": True, + } + ) + cls.view = cls.env["ir.ui.view"].create( + { + "name": "x_html_translate_test.form", + "model": "x_html_translate_test", + "type": "form", + "arch": """ +
+ + + """, + } + ) + cls.TestModel = cls.env["x_html_translate_test"] + # Set the source (en_US) value the way a user would: through a Form. + with Form(cls.TestModel.with_context(lang="en_US"), view=cls.view) as form: + form.x_body = "

Hello world

" + cls.record = form.record + + def test_get_field_translations_full_returns_value_per_language(self): + result = self.record.web_get_field_translations_full("x_body") + by_lang = {term["lang"]: term["value"] for term in result} + # Both the source language and the activated language are present. + self.assertIn("en_US", by_lang) + self.assertIn("fr_FR", by_lang) + # The full value is returned, not chunked source terms. + self.assertIn("Hello world", by_lang["en_US"]) + # Without a translation yet, the field falls back to the source value. + self.assertIn("Hello world", by_lang["fr_FR"]) + + def test_set_field_translations_full_writes_each_language(self): + self.record.web_set_field_translations_full( + "x_body", + { + "en_US": "

Hello world

", + "fr_FR": "

Bonjour le monde

", + }, + ) + record_en = self.record.with_context(lang="en_US") + record_fr = self.record.with_context(lang="fr_FR") + self.assertIn("Hello world", record_en.x_body) + self.assertIn("Bonjour le monde", record_fr.x_body) + # Each language keeps its own full value; the source is not corrupted by + # a structurally different translation. + self.assertNotIn("Bonjour", record_en.x_body) + self.assertNotIn("Hello", record_fr.x_body) + + def test_set_field_translations_full_ignores_non_translatable(self): + # ``display_name`` is not translatable here: the method must be a no-op + # returning False rather than writing garbage. + result = self.record.web_set_field_translations_full("x_name", {"fr_FR": "Nom"}) + self.assertFalse(result) diff --git a/web_html_field_translate_dialog/tests/test_html_translation_dialog_ui.py b/web_html_field_translate_dialog/tests/test_html_translation_dialog_ui.py new file mode 100644 index 000000000000..571c2f06e53d --- /dev/null +++ b/web_html_field_translate_dialog/tests/test_html_translation_dialog_ui.py @@ -0,0 +1,19 @@ +# Copyright 2025 ForgeFlow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests import HttpCase, tagged + + +@tagged("post_install", "-at_install") +class TestHtmlTranslationDialogUI(HttpCase): + def test_html_translation_dialog_ui(self): + """Run the dialog's web unit (hoot) tests through a headless browser.""" + self.browser_js( + "/web/tests?headless&loglevel=2&preset=desktop" + "&timeout=15000&filter=per-language", + "", + "", + login="admin", + timeout=900, + success_signal="[HOOT] Test suite succeeded", + ) From 4c0dccdbd16e4e32a8e5797296214839047629a2 Mon Sep 17 00:00:00 2001 From: Jordi Ballester Alomar Date: Tue, 30 Jun 2026 21:14:57 +0200 Subject: [PATCH 2/4] [FIX] web_html_field_translate_dialog: render HTML in the editors The per-language editors received the field value as a plain string, which the editor sets as text content (showing the raw HTML tags). Flag it as safe HTML with markup() so it is parsed and rendered, as HtmlField does. --- .../html_translation_dialog.esm.js | 6 ++++-- .../static/tests/html_translation_dialog.test.js | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.esm.js b/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.esm.js index 045548c1afce..2c098d3ee9a8 100644 --- a/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.esm.js +++ b/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.esm.js @@ -1,7 +1,7 @@ /* Copyright 2025 ForgeFlow S.L. * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ -import {Component, onWillStart} from "@odoo/owl"; +import {Component, markup, onWillStart} from "@odoo/owl"; import {Dialog} from "@web/core/dialog/dialog"; import { MAIN_PLUGINS, @@ -63,7 +63,9 @@ export class HtmlTranslationDialog extends Component { getConfig(term) { return { - content: term.value, + // Flag the value as safe HTML (like HtmlField does) so the editor + // renders it instead of displaying the escaped markup. + content: markup(term.value), Plugins: [...MAIN_PLUGINS, ...NO_EMBEDDED_COMPONENTS_FALLBACK_PLUGINS], baseContainers: ["DIV", "P"], }; diff --git a/web_html_field_translate_dialog/static/tests/html_translation_dialog.test.js b/web_html_field_translate_dialog/static/tests/html_translation_dialog.test.js index b00c4a3207fd..9330db9c9e37 100644 --- a/web_html_field_translate_dialog/static/tests/html_translation_dialog.test.js +++ b/web_html_field_translate_dialog/static/tests/html_translation_dialog.test.js @@ -67,6 +67,14 @@ test("html translatable field opens a per-language rich-text dialog", async () = expect(".o_html_translation_editor").toHaveCount(2, { message: "there should be one full editor per installed language", }); + // The HTML must be rendered, not displayed as escaped markup. + const editors = queryAll(".o_html_translation_editor [contenteditable=true]"); + expect(editors[0]).toHaveText("Hello", { + message: "the editor should render the HTML, not show the raw tags", + }); + expect(editors[0].querySelector("p")).not.toBe(null, { + message: "the value should be parsed as HTML (a

element is present)", + }); }); test("the per-language dialog saves the full value of every language", async () => { From ef392e07bbedee8afd3aa505c96e2a95f36c7dee Mon Sep 17 00:00:00 2001 From: Jordi Ballester Alomar Date: Tue, 30 Jun 2026 21:18:07 +0200 Subject: [PATCH 3/4] [FIX] web_html_field_translate_dialog: provide getRecordInfo to the editor Media/link editor plugins call config.getRecordInfo(); without it the editor throws "this.config.getRecordInfo is not a function" when the dialog is destroyed after saving. --- .../html_translation_dialog/html_translation_dialog.esm.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.esm.js b/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.esm.js index 2c098d3ee9a8..40a408142bcf 100644 --- a/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.esm.js +++ b/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.esm.js @@ -68,6 +68,13 @@ export class HtmlTranslationDialog extends Component { content: markup(term.value), Plugins: [...MAIN_PLUGINS, ...NO_EMBEDDED_COMPONENTS_FALLBACK_PLUGINS], baseContainers: ["DIV", "P"], + // Several editor plugins (media, link, ...) expect this callback to + // exist; without it the editor crashes on destroy. + getRecordInfo: () => ({ + resModel: this.props.resModel, + resId: this.props.resId, + field: this.props.fieldName, + }), }; } From 0c53af8b455aa7645d6287dd5513442c1ac3510f Mon Sep 17 00:00:00 2001 From: Jordi Ballester Alomar Date: Tue, 30 Jun 2026 21:24:58 +0200 Subject: [PATCH 4/4] [FIX] web_html_field_translate_dialog: use Selection value for test field translate ir.model.fields.translate is a Selection in Odoo 19; passing True logs a deprecation warning that fails OCA's checklog. Use "html_translate". --- .../tests/test_html_field_translation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web_html_field_translate_dialog/tests/test_html_field_translation.py b/web_html_field_translate_dialog/tests/test_html_field_translation.py index ba2355db3ff8..9da7e949e5e9 100644 --- a/web_html_field_translate_dialog/tests/test_html_field_translation.py +++ b/web_html_field_translate_dialog/tests/test_html_field_translation.py @@ -27,7 +27,9 @@ def setUpClass(cls): "field_description": "Body", "model_id": model.id, "ttype": "html", - "translate": True, + # In Odoo 19 ir.model.fields.translate is a Selection; + # "html_translate" yields a term-translated HTML field. + "translate": "html_translate", } ) cls.view = cls.env["ir.ui.view"].create(