Skip to content

Commit 08d20a8

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 Co-authored-by: Yoshi Tashiro <tashiro@quartile.co>
1 parent 9de2fb4 commit 08d20a8

File tree

10 files changed

+225
-247
lines changed

10 files changed

+225
-247
lines changed

web_form_banner/README.rst

Lines changed: 1 addition & 1 deletion
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.

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: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
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

@@ -12,7 +10,7 @@
1210
from lxml import etree
1311
from pytz import timezone
1412

15-
from odoo import _, api, fields, models
13+
from odoo import _, api, fields, models, tools
1614
from odoo.exceptions import ValidationError
1715
from odoo.tools import html_escape
1816
from odoo.tools.float_utils import float_compare, float_is_zero, float_round
@@ -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,

web_form_banner/readme/USAGE.rst

Lines changed: 1 addition & 1 deletion
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.

web_form_banner/static/description/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ <h1 class="title">Web Form Banner</h1>
402402
<div class="section" id="usage">
403403
<h1><a class="toc-backref" href="#toc-entry-1">Usage</a></h1>
404404
<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>
405+
<li>Go to <em>Settings &gt; Technical &gt; User Interface &gt; Form Banner Rules</em> and create a rule.</li>
406406
<li>Choose Model, select Trigger Fields (optional), set Default Severity, select Views
407407
(optional), update Target XPath (insertion point) and Position, and configure the
408408
message.</li>
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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 (v && v.type === "record" && typeof v.res_id === "number") {
54+
o[k] = v.res_id;
55+
} else if (
56+
Array.isArray(v) ||
57+
(v && (Array.isArray(v.data) || Array.isArray(v.res_ids)))
58+
) {
59+
// Many2many (and possibly other x2many) values; let Python decide
60+
o[k] = v;
61+
}
62+
return o;
63+
}, {});
64+
65+
const bannersIn = (ctrl) =>
66+
qsa(root(ctrl), '.o_form_view div[role="alert"][data-rule-id]');
67+
68+
const hasBanners = (ctrl) => bannersIn(ctrl).length > 0;
69+
70+
const triggerSet = (ctrl) => {
71+
const set = Object.create(null);
72+
const els = bannersIn(ctrl);
73+
for (let i = 0; i < els.length; i++) {
74+
const el = els[i];
75+
const raw = first(el.dataset.triggerFields, "");
76+
(raw || "").split(",").forEach((n) => {
77+
if (n) set[n.trim()] = true;
78+
});
79+
}
80+
return set;
81+
};
82+
83+
// Pick only keys in `set` from `src`
84+
const pickKeys = (src, set) => {
85+
const out = {};
86+
if (!src) return out;
87+
Object.keys(src).forEach((k) => {
88+
if (set[k]) out[k] = src[k];
89+
});
90+
return out;
91+
};
92+
93+
function ensureCommitted(ctrl) {
94+
const r = ctrl && ctrl.renderer;
95+
return r && typeof r.confirmChange === "function"
96+
? r.confirmChange()
97+
: Promise.resolve();
98+
}
99+
100+
async function refreshBanners(ctrl) {
101+
await ensureCommitted(ctrl);
102+
if (!alive(ctrl)) return;
103+
const st = ctrl.model && ctrl.handle ? ctrl.model.get(ctrl.handle) : null;
104+
const resId = st && st.res_id;
105+
const snap = shrinkDraft(st && st.data) || {};
106+
const tset = triggerSet(ctrl);
107+
const hasTriggers = Object.keys(tset).length > 0;
108+
const formVals = resId ? (hasTriggers ? pickKeys(snap, tset) : {}) : snap;
109+
110+
const hideBanner = (el) => {
111+
el.style.display = "none";
112+
const sp = childSpan(el);
113+
if (sp) sp.innerHTML = "";
114+
else el.innerHTML = "";
115+
};
116+
const showBanner = (el, res) => {
117+
const sev = first(res.severity, el.dataset.defaultSeverity, "danger");
118+
const html = res.html || "";
119+
el.className = "o_form_banner alert alert-" + sev;
120+
const sp = childSpan(el);
121+
if (sp) sp.innerHTML = html;
122+
else el.innerHTML = html;
123+
el.style.display = "";
124+
};
125+
126+
const updateEl = async (el) => {
127+
const ruleId = parseInt(first(el.dataset.ruleId, el.dataset.wfbRuleId), 10);
128+
const model = first(el.dataset.model, el.dataset.wfbModel, ctrl.modelName);
129+
const res =
130+
(await rpc.query({
131+
model: "web.form.banner.rule",
132+
method: "compute_message",
133+
args: [ruleId, model, resId, formVals],
134+
})) || {};
135+
if (!alive(ctrl)) return;
136+
if (!res.visible) return hideBanner(el);
137+
showBanner(el, res);
138+
};
139+
140+
// Fire requests in parallel; resolve when all done
141+
await Promise.all(bannersIn(ctrl).map(updateEl));
142+
}
143+
144+
function withRefresh(ctrl, superFn, args) {
145+
const p = superFn.apply(ctrl, args);
146+
return after(p, function () {
147+
refreshBanners(ctrl);
148+
});
149+
}
150+
151+
FormController.include({
152+
start: function () {
153+
const p = this._super.apply(this, arguments);
154+
// Keep original Deferred/Promise for Odoo callers
155+
if (p && typeof p.always === "function") {
156+
p.always(() => refreshBanners(this));
157+
} else {
158+
Promise.resolve(p).then(() => refreshBanners(this));
159+
}
160+
return p;
161+
},
162+
reload: function () {
163+
return withRefresh(this, this._super, arguments);
164+
},
165+
saveRecord: function () {
166+
return withRefresh(this, this._super, arguments);
167+
},
168+
update: function () {
169+
return withRefresh(this, this._super, arguments);
170+
},
171+
// Onchange: refresh only when a declared trigger actually changed
172+
_onFieldChanged: function (ev) {
173+
const res = this._super.apply(this, arguments);
174+
if (!alive(this) || !hasBanners(this)) return res;
175+
const tset = triggerSet(this);
176+
if (!Object.keys(tset).length) return res;
177+
const changed = (ev && ev.data && ev.data.changes) || {};
178+
const names = Object.keys(changed);
179+
if (!names.some((n) => tset[n])) return res;
180+
after(res, () => refreshBanners(this));
181+
return res;
182+
},
183+
activate: function () {
184+
const res = this._super.apply(this, arguments);
185+
if (hasBanners(this)) after(res, () => refreshBanners(this));
186+
return res;
187+
},
188+
on_attach_callback: function () {
189+
this._super.apply(this, arguments);
190+
setTimeout(() => refreshBanners(this));
191+
},
192+
});

0 commit comments

Comments
 (0)