From b50fb7047b86b3a5ede711d2fa58b1476ea23cb4 Mon Sep 17 00:00:00 2001 From: "Ignacio J. Ortega" Date: Fri, 20 Mar 2026 08:19:35 +0100 Subject: [PATCH 1/2] [FIX] document_page_reference: migrate widget to OWL for Odoo 16 The legacy JS widget registered in web.field_registry was not picked up by the OWL form view parser, causing "Missing widget: document_page_reference" warnings and the content_parsed field not rendering at all. Changes: - Migrate JS widget from legacy field_registry to OWL registry - Fix retargetLinks adding target="_blank" to internal reference links - Override _get_page_index to use oe_direct_line links in category pages - Replace oe_read_only/oe_edit_only CSS classes with proper attrs/invisible - Add tour test for reference link navigation - Fix _compute_content_parsed using self instead of record in loop --- document_page_reference/README.rst | 6 +- document_page_reference/__manifest__.py | 3 + .../models/document_page.py | 39 +++++++-- .../static/description/index.html | 37 ++++---- .../static/src/js/editor.esm.js | 42 ++++++++++ .../static/src/js/editor.js | 34 -------- .../test_document_page_reference_tour.esm.js | 84 +++++++++++++++++++ document_page_reference/tests/__init__.py | 1 + .../tests/test_document_reference_tour.py | 58 +++++++++++++ .../views/document_page.xml | 19 +++-- 10 files changed, 244 insertions(+), 79 deletions(-) create mode 100644 document_page_reference/static/src/js/editor.esm.js delete mode 100644 document_page_reference/static/src/js/editor.js create mode 100644 document_page_reference/static/tests/test_document_page_reference_tour.esm.js create mode 100644 document_page_reference/tests/test_document_reference_tour.py diff --git a/document_page_reference/README.rst b/document_page_reference/README.rst index 53b9493b62a..2d259d2844a 100644 --- a/document_page_reference/README.rst +++ b/document_page_reference/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ======================= Document Page Reference ======================= @@ -17,7 +13,7 @@ Document Page Reference .. |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 +.. |badge2| image:: https://img.shields.io/badge/licence-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%2Fknowledge-lightgray.png?logo=github diff --git a/document_page_reference/__manifest__.py b/document_page_reference/__manifest__.py index dea07a92925..0d13672558f 100644 --- a/document_page_reference/__manifest__.py +++ b/document_page_reference/__manifest__.py @@ -18,6 +18,9 @@ "web.assets_backend": [ "document_page_reference/static/src/js/**/*", ], + "web.assets_tests": [ + "document_page_reference/static/tests/**/*", + ], }, "maintainers": ["etobella"], } diff --git a/document_page_reference/models/document_page.py b/document_page_reference/models/document_page.py index 5c5fd37e267..5c67b8fae59 100644 --- a/document_page_reference/models/document_page.py +++ b/document_page_reference/models/document_page.py @@ -54,23 +54,44 @@ class DocumentPage(models.Model): ) content_parsed = fields.Html(compute="_compute_content_parsed") + def _get_page_index(self, link=True): + """Override to use oe_direct_line links compatible with the widget.""" + self.ensure_one() + index = [ + "
  • " + subpage._get_page_index() + "
  • " for subpage in self.child_ids + ] + r = "" + if link: + r = ( + '' + + html_escape(self.name) + + "" + ) + if index: + r += "" + return r + def get_formview_action(self, access_uid=None): res = super().get_formview_action(access_uid) view_id = self.env.ref("document_page.view_wiki_form").id res["views"] = [(view_id, "form")] return res - @api.depends("history_head") + @api.depends("history_head", "type") def _compute_content_parsed(self): for record in self: - content = record.get_content() - if content == "

    " and self.content != "

    ": - _logger.error( - "Template from page with id = %s cannot be processed correctly" - % self.id - ) - content = self.content - record.content_parsed = content + if record.type == "category": + record.content_parsed = record.content + else: + content = record.get_content() + if content == "

    " and record.content != "

    ": + _logger.error( + "Template from page with id = %s cannot be " + "processed correctly" % record.id + ) + content = record.content + record.content_parsed = content @api.constrains("reference") def _check_reference(self): diff --git a/document_page_reference/static/description/index.html b/document_page_reference/static/description/index.html index dfb52bcbef2..568ef4db19e 100644 --- a/document_page_reference/static/description/index.html +++ b/document_page_reference/static/description/index.html @@ -3,16 +3,15 @@ -README.rst +Document Page Reference -

    +
    +

    Document Page Reference

    - - -Odoo Community Association - -
    -

    Document Page Reference

    -

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

    +

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

    This module allows to add a reference name on documents and simplifies the link between document pages.

    Table of contents

    @@ -391,13 +385,13 @@

    Document Page Reference

    -

    Usage

    +

    Usage

    When editing a document page add elements like ${XXX} where XXX is the reference of another page. Now, when viewing the document, it will link directly to the page. Also, the name will be parsed as the display name.

    -

    Bug Tracker

    +

    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 @@ -405,25 +399,23 @@

    Bug Tracker

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

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • Creu Blanca
    -

    Maintainers

    +

    Maintainers

    This module is maintained by the OCA.

    - -Odoo Community Association - +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.

    @@ -434,6 +426,5 @@

    Maintainers

    -
    diff --git a/document_page_reference/static/src/js/editor.esm.js b/document_page_reference/static/src/js/editor.esm.js new file mode 100644 index 00000000000..98fbe7f3654 --- /dev/null +++ b/document_page_reference/static/src/js/editor.esm.js @@ -0,0 +1,42 @@ +/* @odoo-module */ + +import {HtmlField} from "@web_editor/js/backend/html_field"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; +import {onMounted, onPatched} from "@odoo/owl"; + +export class DocumentPageReferenceField extends HtmlField { + setup() { + super.setup(); + this.actionService = useService("action"); + this.orm = useService("orm"); + this._onClickDirectLink = this._onClickDirectLink.bind(this); + onMounted(() => this._bindLinks()); + onPatched(() => this._bindLinks()); + } + _bindLinks() { + const el = this.readonlyElementRef && this.readonlyElementRef.el; + if (!el) return; + // Remove target="_blank" from internal reference links + // (added by retargetLinks in HtmlField) + for (const link of el.querySelectorAll("a.oe_direct_line")) { + link.removeAttribute("target"); + link.removeAttribute("rel"); + link.removeEventListener("click", this._onClickDirectLink); + link.addEventListener("click", this._onClickDirectLink); + } + } + _onClickDirectLink(ev) { + ev.preventDefault(); + ev.stopPropagation(); + const target = ev.currentTarget; + const model = target.dataset.oeModel; + const id = parseInt(target.dataset.oeId, 10); + if (!model || !id) return; + this.orm.call(model, "get_formview_action", [[id]]).then((action) => { + this.actionService.doAction(action); + }); + } +} + +registry.category("fields").add("document_page_reference", DocumentPageReferenceField); diff --git a/document_page_reference/static/src/js/editor.js b/document_page_reference/static/src/js/editor.js deleted file mode 100644 index 852df084ca9..00000000000 --- a/document_page_reference/static/src/js/editor.js +++ /dev/null @@ -1,34 +0,0 @@ -odoo.define("document_page_reference.backend", function (require) { - "use strict"; - - var field_registry = require("web.field_registry"); - var FieldTextHtmlSimple = require("web_editor.field.html"); - var FieldDocumentPage = FieldTextHtmlSimple.extend({ - events: _.extend({}, FieldTextHtmlSimple.prototype.events, { - "click .oe_direct_line": "_onClickDirectLink", - }), - _onClickDirectLink: function (event) { - var self = this; - event.preventDefault(); - event.stopPropagation(); - var element = $(event.target).closest(".oe_direct_line")[0]; - var default_reference = element.name; - var model = $(event.target).data("oe-model"); - var id = $(event.target).data("oe-id"); - var context = this.record.getContext(this.recordParams); - if (default_reference) { - context.default_reference = default_reference; - } - this._rpc({ - model: model, - method: "get_formview_action", - args: [[parseInt(id, 10)]], - context: context, - }).then(function (action) { - self.trigger_up("do_action", {action: action}); - }); - }, - }); - field_registry.add("document_page_reference", FieldDocumentPage); - return FieldDocumentPage; -}); diff --git a/document_page_reference/static/tests/test_document_page_reference_tour.esm.js b/document_page_reference/static/tests/test_document_page_reference_tour.esm.js new file mode 100644 index 00000000000..ab44e7b88ca --- /dev/null +++ b/document_page_reference/static/tests/test_document_page_reference_tour.esm.js @@ -0,0 +1,84 @@ +/** @odoo-module */ + +import tour from "web_tour.tour"; + +/* + * Test 1: Reference widget renders ${ref} as clickable links. + */ +tour.register( + "document_page_reference_widget_tour", + { + test: true, + url: "/web#action=document_page.action_page", + }, + [ + { + content: "Open Test Ref Page 1", + trigger: '.o_data_cell[name="name"]:contains("Test Ref Page 1")', + run: "click", + }, + { + content: "Verify content_parsed renders reference as link", + trigger: + '.o_form_view .o_field_widget[name="content_parsed"] a.oe_direct_line', + timeout: 20000, + run: function () { + var link = this.$anchor[0]; + if (!link.dataset.oeModel || !link.dataset.oeId) { + throw new Error("Reference link missing data-oe-model/data-oe-id"); + } + if (link.getAttribute("target") === "_blank") { + throw new Error( + "Internal reference link should not have target=_blank" + ); + } + }, + }, + ] +); + +/* + * Test 2: Category page index uses oe_direct_line links. + */ +tour.register( + "document_page_reference_category_tour", + { + test: true, + url: "/web#action=document_page.action_page", + }, + [ + { + content: "Open Test Ref Page 1 to navigate to its category", + trigger: '.o_data_cell[name="name"]:contains("Test Ref Page 1")', + run: "click", + }, + { + content: "Navigate to category via parent_id link", + trigger: '.o_form_view .o_field_widget[name="parent_id"] a', + timeout: 20000, + run: "click", + }, + { + content: "Verify category shows child links as oe_direct_line", + trigger: + '.o_form_view .o_field_widget[name="content_parsed"] a.oe_direct_line', + timeout: 20000, + run: function () { + var links = document.querySelectorAll( + '.o_field_widget[name="content_parsed"] a.oe_direct_line' + ); + if (links.length < 1) { + throw new Error("Category should have child page links"); + } + // Verify links have correct attributes + var link = links[0]; + if (!link.dataset.oeModel || !link.dataset.oeId) { + throw new Error("Category index link missing data-oe-model/id"); + } + if (link.getAttribute("target") === "_blank") { + throw new Error("Category index link should not open in new tab"); + } + }, + }, + ] +); diff --git a/document_page_reference/tests/__init__.py b/document_page_reference/tests/__init__.py index ca802a6bb24..05ff47b536c 100644 --- a/document_page_reference/tests/__init__.py +++ b/document_page_reference/tests/__init__.py @@ -1 +1,2 @@ from . import test_document_reference +from . import test_document_reference_tour diff --git a/document_page_reference/tests/test_document_reference_tour.py b/document_page_reference/tests/test_document_reference_tour.py new file mode 100644 index 00000000000..2176767e11b --- /dev/null +++ b/document_page_reference/tests/test_document_reference_tour.py @@ -0,0 +1,58 @@ +# Copyright 2025 TRIVAX INNOVA SL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests import HttpCase, tagged + + +@tagged("post_install", "-at_install") +class TestDocumentPageReferenceTour(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Ensure admin password is known for the tour + cls.env["res.users"].browse(2).write({"password": "admin"}) + page_obj = cls.env["document.page"] + # Create a category + cls.category = page_obj.create( + { + "name": "Test Ref Category", + "type": "category", + } + ) + # Create two pages with cross-references + cls.page1 = page_obj.create( + { + "name": "Test Ref Page 1", + "parent_id": cls.category.id, + "content": "

    Go to ${r2} for details.

    ", + "reference": "r1", + } + ) + cls.page2 = page_obj.create( + { + "name": "Test Ref Page 2", + "parent_id": cls.category.id, + "content": "

    Back to ${r1} for overview.

    ", + "reference": "r2", + } + ) + + def test_reference_widget_renders_links(self): + """Test that the document_page_reference widget renders ${ref} + as clickable links and navigates without opening new tabs.""" + self.start_tour( + "/web#action=document_page.action_page", + "document_page_reference_widget_tour", + login="admin", + timeout=60, + ) + + def test_category_index_links(self): + """Test that category pages show child links as oe_direct_line + without duplicating content.""" + self.start_tour( + "/web#action=document_page.action_page", + "document_page_reference_category_tour", + login="admin", + timeout=60, + ) diff --git a/document_page_reference/views/document_page.xml b/document_page_reference/views/document_page.xml index a9459fcceb2..15997029f21 100644 --- a/document_page_reference/views/document_page.xml +++ b/document_page_reference/views/document_page.xml @@ -15,16 +15,19 @@ /> - - oe_edit_only - + + + {'invisible': [('type', '=', 'category')], + 'required': [('type', '!=', 'category')]} + + @@ -32,16 +35,16 @@ document.page - - oe_edit_only - + + 1 + From 910ad005c67476bf74ad2c5b5556e15d1ebec8cb Mon Sep 17 00:00:00 2001 From: "Ignacio J. Ortega" Date: Fri, 20 Mar 2026 11:41:02 +0100 Subject: [PATCH 2/2] [FIX] document_page_reference: fix Markup escaping in _get_page_index html_escape() returns markupsafe.Markup, which auto-escapes plain str when concatenated. Use Markup() for HTML literals to prevent the index links from being double-escaped. Also add sanitize=False to content_parsed field to match content field. --- .../models/document_page.py | 23 +++++++++++++------ .../tests/test_document_reference.py | 2 +- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/document_page_reference/models/document_page.py b/document_page_reference/models/document_page.py index 5c67b8fae59..aeee8e71e07 100644 --- a/document_page_reference/models/document_page.py +++ b/document_page_reference/models/document_page.py @@ -3,6 +3,8 @@ import logging +from markupsafe import Markup + from odoo import _, api, fields, models, tools from odoo.exceptions import ValidationError from odoo.tools.misc import html_escape @@ -52,24 +54,31 @@ class DocumentPage(models.Model): reference = fields.Char( help="Used to find the document, it can contain letters, numbers and _" ) - content_parsed = fields.Html(compute="_compute_content_parsed") + content_parsed = fields.Html(compute="_compute_content_parsed", sanitize=False) def _get_page_index(self, link=True): """Override to use oe_direct_line links compatible with the widget.""" self.ensure_one() index = [ - "
  • " + subpage._get_page_index() + "
  • " for subpage in self.child_ids + Markup("
  • ") + subpage._get_page_index() + Markup("
  • ") + for subpage in self.child_ids ] - r = "" + r = Markup("") if link: r = ( - '' + Markup( + '' + ) + % ( + self._name, + self.id, + ) + html_escape(self.name) - + "" + + Markup("") ) if index: - r += "
      " + "".join(index) + "
    " + r += Markup("
      ") + Markup("").join(index) + Markup("
    ") return r def get_formview_action(self, access_uid=None): diff --git a/document_page_reference/tests/test_document_reference.py b/document_page_reference/tests/test_document_reference.py index 4f16744490d..7e372c8e9c1 100644 --- a/document_page_reference/tests/test_document_reference.py +++ b/document_page_reference/tests/test_document_reference.py @@ -75,4 +75,4 @@ def test_get_formview_action(self): def test_compute_content_parsed(self): self.page1.content = "

    " self.page1._compute_content_parsed() - self.assertEqual(str(self.page1.content_parsed), "

    ") + self.assertEqual(str(self.page1.content_parsed), "

    ")