|
| 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