Skip to content

Commit c5f828e

Browse files
[MIG] web_form_banner: Migration to 15.0
Refactor JS: - Switch to the ESM style - Change var to const/let - Remove unused parts (diff, el.dataset.wfbTriggerFields) Miscellaneous improvements: - list view optional - Switch archive box icon to banner Fix _render_html for Odoo 15: - In Odoo 12, html_escape returned a plain string, so replacing newlines with <br/> worked as expected. - In Odoo 15, html_escape uses markupsafe.escape, which escapes inserted <br/> tags resulting in &lt;br/&gt; in the output. - Updated _render_html to escape each line individually and join with literal <br/> tags to match Odoo 12 behavior while remaining safe for HTML rendering. Co-authored-by: Yoshi Tashiro <[email protected]>
1 parent 9de2fb4 commit c5f828e

File tree

10 files changed

+245
-256
lines changed

10 files changed

+245
-256
lines changed

web_form_banner/README.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ on form load/save/reload.
4545
Usage
4646
=====
4747

48-
#. Go to *Settings > Tachnical > User Interface > Form Banner Rules* and create a rule.
48+
#. Go to *Settings > Technical > User Interface > Form Banner Rules* and create a rule.
4949
#. Choose Model, select Trigger Fields (optional), set Default Severity, select Views
5050
(optional), update Target XPath (insertion point) and Position, and configure the
5151
message.
@@ -126,7 +126,7 @@ Message setting examples:
126126
127127
{
128128
"visible": bool(record.partner_id.comment),
129-
values: {"comment": record.partner_id.comment},
129+
"values": {"comment": record.partner_id.comment},
130130
}
131131
132132
It is also possible to use "convenience placeholders" without an explicit `values` key:

web_form_banner/__manifest__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@
22
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
33
{
44
"name": "Web Form Banner",
5-
"version": "12.0.1.0.0",
5+
"version": "15.0.1.0.0",
66
"category": "Web",
77
"author": "Quartile, Odoo Community Association (OCA)",
88
"website": "https://github.com/OCA/web",
99
"license": "AGPL-3",
1010
"depends": ["web"],
1111
"data": [
1212
"security/ir.model.access.csv",
13-
"views/assets.xml",
1413
"views/web_form_banner_rule_views.xml",
1514
],
15+
"assets": {
16+
"web.assets_backend": [
17+
"web_form_banner/static/src/js/*.esm.js",
18+
"web_form_banner/static/src/scss/*.scss",
19+
],
20+
},
1621
"demo": ["demo/web_form_banner_rule_demo.xml"],
1722
"installable": True,
1823
}

web_form_banner/models/web_form_banner_rule.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,18 @@
11
# Copyright 2025 Quartile (https://www.quartile.co)
22
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
33

4-
import datetime as dt
54
import logging
6-
import time
75
from functools import lru_cache
86
from string import Template
97

108
from dateutil import parser as dateparse
119
from dateutil.relativedelta import relativedelta
1210
from lxml import etree
11+
from markupsafe import escape
1312
from pytz import timezone
1413

15-
from odoo import _, api, fields, models
14+
from odoo import _, api, fields, models, tools
1615
from odoo.exceptions import ValidationError
17-
from odoo.tools import html_escape
1816
from odoo.tools.float_utils import float_compare, float_is_zero, float_round
1917
from odoo.tools.safe_eval import safe_eval
2018

@@ -115,7 +113,6 @@ class WebFormBannerRule(models.Model):
115113
)
116114
position = fields.Selection(
117115
[("before", "Before target"), ("after", "After target")],
118-
string="Position",
119116
default="before",
120117
required=True,
121118
help="Where to insert the placeholder relative to the first matched node.",
@@ -158,7 +155,7 @@ def _check_target_xpath(self):
158155
try:
159156
etree.XPath(xp or "//sheet")
160157
except (etree.XPathSyntaxError, etree.XPathEvalError) as e:
161-
raise ValidationError(_("Invalid XPath:\n%s") % e)
158+
raise ValidationError(_("Invalid XPath:\n%s") % e) from e
162159

163160
@api.model
164161
def _build_form_url(self, rec):
@@ -179,8 +176,8 @@ def _build_form_url(self, rec):
179176
def _base_eval_ctx_static(self):
180177
# Only static, import-heavy items
181178
return {
182-
"time": time,
183-
"datetime": dt,
179+
"time": tools.safe_eval.time,
180+
"datetime": tools.safe_eval.datetime,
184181
"dateutil": {
185182
"parser": dateparse,
186183
"relativedelta": relativedelta,
@@ -258,7 +255,9 @@ def _render_html(self, rule, values, html):
258255
rendered = rule.message or ""
259256
if rule.message_is_html:
260257
return rendered
261-
return html_escape(rendered).replace("\n", "<br/>")
258+
lines = rendered.split("\n")
259+
escaped_lines = [escape(line) for line in lines]
260+
return "<br/>".join(escaped_lines)
262261

263262
@api.model
264263
def compute_message(self, rule_id, model, res_id, form_vals=None):

web_form_banner/readme/USAGE.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#. Go to *Settings > Tachnical > User Interface > Form Banner Rules* and create a rule.
1+
#. Go to *Settings > Technical > User Interface > Form Banner Rules* and create a rule.
22
#. Choose Model, select Trigger Fields (optional), set Default Severity, select Views
33
(optional), update Target XPath (insertion point) and Position, and configure the
44
message.
@@ -79,7 +79,7 @@ Message setting examples:
7979
8080
{
8181
"visible": bool(record.partner_id.comment),
82-
values: {"comment": record.partner_id.comment},
82+
"values": {"comment": record.partner_id.comment},
8383
}
8484
8585
It is also possible to use "convenience placeholders" without an explicit `values` key:

web_form_banner/static/description/index.html

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88

99
/*
1010
:Author: David Goodger ([email protected])
11-
:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $
11+
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
1212
:Copyright: This stylesheet has been placed in the public domain.
1313
1414
Default cascading style sheet for the HTML output of Docutils.
15+
Despite the name, some widely supported CSS2 features are used.
1516
1617
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
1718
customize this style sheet.
@@ -274,7 +275,7 @@
274275
margin-left: 2em ;
275276
margin-right: 2em }
276277

277-
pre.code .ln { color: grey; } /* line numbers */
278+
pre.code .ln { color: gray; } /* line numbers */
278279
pre.code, code { background-color: #eeeeee }
279280
pre.code .comment, code .comment { color: #5C6576 }
280281
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
@@ -300,7 +301,7 @@
300301
span.pre {
301302
white-space: pre }
302303

303-
span.problematic {
304+
span.problematic, pre.problematic {
304305
color: red }
305306

306307
span.section-subtitle {
@@ -402,7 +403,7 @@ <h1 class="title">Web Form Banner</h1>
402403
<div class="section" id="usage">
403404
<h1><a class="toc-backref" href="#toc-entry-1">Usage</a></h1>
404405
<ol class="arabic simple">
405-
<li>Go to <em>Settings &gt; Tachnical &gt; User Interface &gt; Form Banner Rules</em> and create a rule.</li>
406+
<li>Go to <em>Settings &gt; Technical &gt; User Interface &gt; Form Banner Rules</em> and create a rule.</li>
406407
<li>Choose Model, select Trigger Fields (optional), set Default Severity, select Views
407408
(optional), update Target XPath (insertion point) and Position, and configure the
408409
message.</li>
@@ -478,7 +479,7 @@ <h2><a class="toc-backref" href="#toc-entry-5">Message setting examples:</a></h2
478479
<pre class="code python literal-block">
479480
<span class="p">{</span><span class="w">
480481
</span> <span class="s2">&quot;visible&quot;</span><span class="p">:</span> <span class="nb">bool</span><span class="p">(</span><span class="n">record</span><span class="o">.</span><span class="n">partner_id</span><span class="o">.</span><span class="n">comment</span><span class="p">),</span><span class="w">
481-
</span> <span class="n">values</span><span class="p">:</span> <span class="p">{</span><span class="s2">&quot;comment&quot;</span><span class="p">:</span> <span class="n">record</span><span class="o">.</span><span class="n">partner_id</span><span class="o">.</span><span class="n">comment</span><span class="p">},</span><span class="w">
482+
</span> <span class="s2">&quot;values&quot;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&quot;comment&quot;</span><span class="p">:</span> <span class="n">record</span><span class="o">.</span><span class="n">partner_id</span><span class="o">.</span><span class="n">comment</span><span class="p">},</span><span class="w">
482483
</span><span class="p">}</span>
483484
</pre>
484485
<p>It is also possible to use “convenience placeholders” without an explicit <cite>values</cite> key:</p>
@@ -603,7 +604,9 @@ <h2><a class="toc-backref" href="#toc-entry-12">Contributors</a></h2>
603604
<div class="section" id="maintainers">
604605
<h2><a class="toc-backref" href="#toc-entry-13">Maintainers</a></h2>
605606
<p>This module is maintained by the OCA.</p>
606-
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
607+
<a class="reference external image-reference" href="https://odoo-community.org">
608+
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />
609+
</a>
607610
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
608611
mission is to support the collaborative development of Odoo features and
609612
promote its widespread use.</p>
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/** @odoo-module **/
2+
3+
// Copyright 2025 Quartile (https://www.quartile.co)
4+
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
5+
6+
import FormController from "web.FormController";
7+
import rpc from "web.rpc";
8+
9+
const root = (ctrl) => (ctrl && (ctrl.el || (ctrl.$el && ctrl.$el[0]))) || null;
10+
11+
const alive = (ctrl) => {
12+
const r = root(ctrl);
13+
return (
14+
r &&
15+
r.isConnected &&
16+
!(typeof ctrl.isDestroyed === "function" && ctrl.isDestroyed())
17+
);
18+
};
19+
20+
const qsa = (el, sel) => Array.from(el ? el.querySelectorAll(sel) : []);
21+
22+
const first = (...args) => {
23+
for (let i = 0; i < args.length; i++) {
24+
const v = args[i];
25+
if (v !== null && v !== undefined && v !== "") return v;
26+
}
27+
return null;
28+
};
29+
30+
const childSpan = (el) => {
31+
if (!el) return null;
32+
if (el.querySelector) {
33+
return el.querySelector(":scope > span") || null;
34+
}
35+
const c = el.firstElementChild;
36+
return c && c.tagName === "SPAN" ? c : null;
37+
};
38+
39+
const after = (p, fn) => {
40+
if (p && typeof p.always === "function") {
41+
p.always(fn);
42+
return p;
43+
}
44+
return Promise.resolve(p).finally(fn);
45+
};
46+
47+
const shrinkDraft = (d) =>
48+
Object.entries(d || {}).reduce((o, [k, v]) => {
49+
const t = typeof v;
50+
const isNullish = (x) => x === null || x === undefined;
51+
if (isNullish(v) || t === "string" || t === "number" || t === "boolean") {
52+
o[k] = v;
53+
} else if (
54+
(v && v.type === "record" && typeof v.res_id === "number") ||
55+
(v && typeof v.res_id === "number")
56+
) {
57+
// Many2one (data snapshot shape)
58+
o[k] = v.res_id;
59+
} else if (v && typeof v === "object" && typeof v.id === "number") {
60+
// Many2one (pending change as {id, display_name})
61+
o[k] = v.id;
62+
} else if (Array.isArray(v) && v.length === 2 && typeof v[0] === "number") {
63+
// Many2one (pending change as [id, name])
64+
o[k] = v[0];
65+
} else if (
66+
Array.isArray(v) ||
67+
(v && (Array.isArray(v.data) || Array.isArray(v.res_ids)))
68+
) {
69+
// Many2many (and possibly other x2many) values; let Python decide
70+
o[k] = v;
71+
}
72+
return o;
73+
}, {});
74+
75+
const bannersIn = (ctrl) =>
76+
qsa(root(ctrl), '.o_form_view div[role="alert"][data-rule-id]');
77+
78+
const hasBanners = (ctrl) => bannersIn(ctrl).length > 0;
79+
80+
const triggerSet = (ctrl) => {
81+
const set = Object.create(null);
82+
const els = bannersIn(ctrl);
83+
for (let i = 0; i < els.length; i++) {
84+
const el = els[i];
85+
const raw = first(el.dataset.triggerFields, "");
86+
(raw || "").split(",").forEach((n) => {
87+
if (n) set[n.trim()] = true;
88+
});
89+
}
90+
return set;
91+
};
92+
93+
// Pick only keys in `set` from `src`
94+
const pickKeys = (src, set) => {
95+
const out = {};
96+
if (!src) return out;
97+
Object.keys(src).forEach((k) => {
98+
if (set[k]) out[k] = src[k];
99+
});
100+
return out;
101+
};
102+
103+
async function refreshBanners(ctrl, extraChanges) {
104+
if (!alive(ctrl)) return;
105+
const st = ctrl.model && ctrl.handle ? ctrl.model.get(ctrl.handle) : null;
106+
const resId = st && st.res_id;
107+
const base = shrinkDraft(st && st.data) || {};
108+
const latest = shrinkDraft(extraChanges || {});
109+
const snap = Object.assign({}, base, latest);
110+
const tset = triggerSet(ctrl);
111+
const hasTriggers = Object.keys(tset).length > 0;
112+
const formVals = resId ? (hasTriggers ? pickKeys(snap, tset) : {}) : snap;
113+
114+
const hideBanner = (el) => {
115+
el.style.display = "none";
116+
const sp = childSpan(el);
117+
if (sp) sp.innerHTML = "";
118+
else el.innerHTML = "";
119+
};
120+
121+
const showBanner = (el, res) => {
122+
const sev = first(res.severity, el.dataset.defaultSeverity, "danger");
123+
const html = res.html || "";
124+
el.className = "o_form_banner alert alert-" + sev;
125+
const sp = childSpan(el);
126+
if (sp) sp.innerHTML = html;
127+
else el.innerHTML = html;
128+
el.style.display = "";
129+
};
130+
131+
const updateEl = async (el) => {
132+
const ruleId = parseInt(first(el.dataset.ruleId, el.dataset.wfbRuleId), 10);
133+
const model = first(el.dataset.model, el.dataset.wfbModel, ctrl.modelName);
134+
const res =
135+
(await rpc.query({
136+
model: "web.form.banner.rule",
137+
method: "compute_message",
138+
args: [ruleId, model, resId, formVals],
139+
})) || {};
140+
if (!alive(ctrl)) return;
141+
if (!res.visible) return hideBanner(el);
142+
showBanner(el, res);
143+
};
144+
145+
// Fire requests in parallel; resolve when all done
146+
await Promise.all(bannersIn(ctrl).map(updateEl));
147+
}
148+
149+
function withRefresh(ctrl, superFn, args) {
150+
const p = superFn.apply(ctrl, args);
151+
return after(p, function () {
152+
refreshBanners(ctrl);
153+
});
154+
}
155+
156+
FormController.include({
157+
start: function () {
158+
const p = this._super.apply(this, arguments);
159+
// Keep original Deferred/Promise for Odoo callers
160+
if (p && typeof p.always === "function") {
161+
p.always(() => refreshBanners(this));
162+
} else {
163+
Promise.resolve(p).then(() => refreshBanners(this));
164+
}
165+
return p;
166+
},
167+
reload: function () {
168+
return withRefresh(this, this._super, arguments);
169+
},
170+
saveRecord: function () {
171+
return withRefresh(this, this._super, arguments);
172+
},
173+
update: function () {
174+
return withRefresh(this, this._super, arguments);
175+
},
176+
// Onchange: refresh only when a declared trigger actually changed
177+
_onFieldChanged: function (ev) {
178+
const res = this._super.apply(this, arguments);
179+
if (!alive(this) || !hasBanners(this)) return res;
180+
const tset = triggerSet(this);
181+
if (!Object.keys(tset).length) return res;
182+
const changed = (ev && ev.data && ev.data.changes) || {};
183+
const names = Object.keys(changed);
184+
if (!names.some((n) => tset[n])) return res;
185+
// Defer one tick so x2many widgets commit their in-memory value first
186+
after(res, () => setTimeout(() => refreshBanners(this, changed), 0));
187+
return res;
188+
},
189+
activate: function () {
190+
const res = this._super.apply(this, arguments);
191+
if (hasBanners(this)) after(res, () => refreshBanners(this));
192+
return res;
193+
},
194+
on_attach_callback: function () {
195+
this._super.apply(this, arguments);
196+
setTimeout(() => refreshBanners(this));
197+
},
198+
});

0 commit comments

Comments
 (0)