Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions document_page_reference/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
"web.assets_backend": [
"document_page_reference/static/src/js/**/*",
],
"web.assets_tests": [
"document_page_reference/static/tests/**/*",
],
},
"maintainers": ["etobella"],
}
54 changes: 44 additions & 10 deletions document_page_reference/models/document_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -52,25 +54,57 @@ 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")
# sanitize=False keeps the rendered oe_direct_line anchors
# (data-oe-model/-id) the widget binds to, which html_sanitize strips.
# Safe: the source ``content`` is still sanitized and page names are
# html_escape'd, so no unsanitized user input reaches this field.
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 = [
Markup("<li>") + subpage._get_page_index() + Markup("</li>")
for subpage in self.child_ids
]
r = Markup("")
if link:
r = (
Markup(
'<a href="#" class="oe_direct_line"'
' data-oe-model="%s" data-oe-id="%s">'
)
% (
self._name,
self.id,
)
+ html_escape(self.name)
+ Markup("</a>")
)
if index:
r += Markup("<ul>") + Markup("").join(index) + Markup("</ul>")
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 == "<p>" and self.content != "<p>":
_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 == "<p>" and record.content != "<p>":
_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):
Expand Down
42 changes: 42 additions & 0 deletions document_page_reference/static/src/js/editor.esm.js
Original file line number Diff line number Diff line change
@@ -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);
34 changes: 0 additions & 34 deletions document_page_reference/static/src/js/editor.js

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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");
}
},
},
]
);
1 change: 1 addition & 0 deletions document_page_reference/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import test_document_reference
from . import test_document_reference_tour
39 changes: 38 additions & 1 deletion document_page_reference/tests/test_document_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,41 @@ def test_get_formview_action(self):
def test_compute_content_parsed(self):
self.page1.content = "<p>"
self.page1._compute_content_parsed()
self.assertEqual(str(self.page1.content_parsed), "<p></p>")
self.assertEqual(str(self.page1.content_parsed), "<p>")

def test_content_parsed_category(self):
"""A category page exposes its raw content as content_parsed."""
category = self.page_obj.create(
{
"name": "Category",
"type": "category",
"content": "<p>category body</p>",
"reference": "cat_ref",
}
)
self.assertEqual(category.content_parsed, category.content)

def test_get_page_index(self):
"""_get_page_index renders an oe_direct_line anchor and nests
children in a list; a leaf page with link=False renders nothing."""
parent = self.page_obj.create(
{"name": "Index Parent", "content": "x", "reference": "idx_parent"}
)
self.page_obj.create(
{
"name": "Index Child",
"content": "y",
"reference": "idx_child",
"parent_id": parent.id,
}
)
rendered = str(parent._get_page_index())
self.assertIn('class="oe_direct_line"', rendered)
self.assertIn('data-oe-id="%s"' % parent.id, rendered)
self.assertIn("Index Parent", rendered)
self.assertIn("<ul>", rendered)
self.assertIn("Index Child", rendered)
leaf = self.page_obj.create(
{"name": "Leaf", "content": "z", "reference": "leaf_ref"}
)
self.assertEqual(str(leaf._get_page_index(link=False)), "")
58 changes: 58 additions & 0 deletions document_page_reference/tests/test_document_reference_tour.py
Original file line number Diff line number Diff line change
@@ -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": "<p>Go to ${r2} for details.</p>",
"reference": "r1",
}
)
cls.page2 = page_obj.create(
{
"name": "Test Ref Page 2",
"parent_id": cls.category.id,
"content": "<p>Back to ${r1} for overview.</p>",
"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,
)
19 changes: 11 additions & 8 deletions document_page_reference/views/document_page.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,36 @@
/>
</h2>
</xpath>
<field name="content" position="attributes">
<attribute name="class">oe_edit_only</attribute>
</field>
<field name="content" position="before">
<field
name="content_parsed"
class="oe_read_only"
readonly="1"
widget="document_page_reference"
/>
</field>
<field name="content" position="attributes">
<attribute name="attrs">
{'invisible': [('type', '=', 'category')],
'required': [('type', '!=', 'category')]}
</attribute>
</field>
</field>
</record>
<record id="view_wiki_menu_form" model="ir.ui.view">
<field name="name">document.page.menu.form</field>
<field name="model">document.page</field>
<field name="inherit_id" ref="document_page.view_wiki_menu_form" />
<field name="arch" type="xml">
<field name="content" position="attributes">
<attribute name="class">oe_edit_only</attribute>
</field>
<field name="content" position="before">
<field
name="content_parsed"
class="oe_read_only"
readonly="1"
widget="document_page_reference"
/>
</field>
<field name="content" position="attributes">
<attribute name="invisible">1</attribute>
</field>
</field>
</record>
<record model="ir.ui.view" id="document_page_search_view">
Expand Down
Loading