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..40a408142bcf --- /dev/null +++ b/web_html_field_translate_dialog/static/src/html_translation_dialog/html_translation_dialog.esm.js @@ -0,0 +1,101 @@ +/* Copyright 2025 ForgeFlow S.L. + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +import {Component, markup, 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 { + // 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"], + // 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, + }), + }; + } + + 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..9330db9c9e37 --- /dev/null +++ b/web_html_field_translate_dialog/static/tests/html_translation_dialog.test.js @@ -0,0 +1,120 @@ +/* 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", + }); + // 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 () => { + 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..9da7e949e5e9 --- /dev/null +++ b/web_html_field_translate_dialog/tests/test_html_field_translation.py @@ -0,0 +1,85 @@ +# 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", + # 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( + { + "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", + )