diff --git a/web_form_banner/README.rst b/web_form_banner/README.rst index 94b6c0ce6ec4..4443bb227c78 100644 --- a/web_form_banner/README.rst +++ b/web_form_banner/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 - =============== Web Form Banner =============== @@ -17,7 +13,7 @@ Web Form Banner .. |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%2Fweb-lightgray.png?logo=github @@ -220,6 +216,78 @@ trigger fields)** else: result = {"visible": False} +Client-side mode (no server round-trip) +--------------------------------------- + +For rules whose visibility condition fits Odoo's client-side expression +grammar (``py.js``), tick **Client-side** on the rule and write your +condition in **Client Condition** instead of *Message Value Code*. The +banner is then rendered as a self-contained ``
`` +in the form arch; Odoo's view compiler evaluates visibility against the +in-memory record on every reactive change — zero RPC, zero JavaScript on +your side. + +What works in ``client_condition``: + +- Comparisons, boolean ops, ``in`` / ``not in`` +- Attribute access: ``partner_id.email``, ``company_id.country_id.code`` +- Built-ins: ``len()``, ``bool()``, ``min()``, ``max()``, ``set()`` +- Ternary ``x if cond else y`` + +What does NOT work — keep using server-side mode for these: + +- Arbitrary method calls (``.filtered()``, ``.mapped()``, ``.search()``) +- Slicing, lambdas, comprehensions +- Anything touching records not loaded on the current form + +In the message you can interpolate field values either with inline +```` tags or with the shorter ``${field_name}`` +shortcut (the module rewrites the latter to a reactive ```` at +view-load time). Only flat field names are supported in ``${...}`` — +Odoo form arch doesn't accept dotted paths like +````. For related-record values, declare +a stored related field on the model +(``fields.Char(related="partner_id.email", store=True)``) and reference +the flat name. + +You don't need to add ```` placeholders to the form +view for every name your condition references. The module parses the +condition at view-load and auto-injects +```` siblings for anything that's +missing — ``customer_rank > 0 and not email`` on a partner form works +even when ``customer_rank`` isn't already on the view. + +**Example — contact missing email (works on the base partner form):** + +- Model: ``res.partner`` +- Client-side: ✓ +- Client Condition: ``not email and name`` +- HTML: ✓ +- Message: + ``Contact ${name} has no email on file. Workflows requiring email delivery will fail.`` + +Toggle email on/off in the form and the banner appears/disappears +instantly — no Network-tab activity, no ``compute_message`` RPC. + +**Example — high-value draft order (auto-loads ``state`` if missing):** + +- Model: ``sale.order`` +- Client-side: ✓ +- Client Condition: ``state == 'draft' and amount_total > 10000`` +- Message: + ``Large draft order: ${amount_total}. Manager approval recommended.`` + +**Limitations of client-side mode** + +- Severity (info/warning/danger) is baked into the alert's CSS class at + view-load time. You can't change severity per-record from + ``client_condition`` the way ``message_value_code`` can return a + dynamic ``"severity"`` key. Use server-side mode if you need + per-record severity. +- The condition has to fit py.js — no method calls outside the listed + builtins, no slicing, no lambdas, no comprehensions. +- ``${X.Y}`` in messages is rejected; only flat names work. + If we set up the rules for a partner record as shown below: |image1| @@ -263,6 +331,34 @@ Limitations of draft eval context variable boolean, integer, float, monetary, date, datetime, many2one, and many2many. **one2many/reference/other types are omitted.** +Client-side mode follow-ups +--------------------------- + +- **Dynamic severity per record.** Today the severity (info/warning/ + danger) is baked into the alert's CSS class at view-load time so + admins can't return + ``{"severity": "danger" if amount > 100000 else "warning"}`` the way + ``message_value_code`` can in server-side mode. One option: an OWL + widget that reads a hidden ``severity_expr`` from the arch and toggles + the alert class reactively. +- **Dotted-name interpolation.** ``${partner_id.email}`` is currently + rejected because ```` isn't valid form + arch. A small OWL inline-renderer that reads + ``record.data.partner_id`` reactively and substitutes the related + value would fix this without requiring a stored related field on the + model. +- **Rule builder UI.** A small wizard that lets admins compose + ``client_condition`` from a "field — operator — value" picker and + compiles to a py.js-valid string. Avoids the "you have to know the + grammar" hurdle for non-developer admins. +- **Live syntax validator.** Server-side ``ast.parse`` catches typos but + not semantic mismatches (e.g. referencing a field not in the view). An + OWL editor with ``evaluateBooleanExpr`` dry-run would surface those + immediately in the rule form. +- **Auto-detection of py.js compatibility.** If ``message_value_code`` + is a single boolean expression with no method calls, suggest promoting + it to client-side mode on save. + Bug Tracker =========== @@ -280,6 +376,7 @@ Authors ------- * Quartile +* Ledoweb Contributors ------------ @@ -289,6 +386,10 @@ Contributors - Yoshi Tashiro - Aung Ko Ko Lin +- `Ledoweb `__: + + - Dan Kendall + Maintainers ----------- @@ -302,6 +403,14 @@ 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. +.. |maintainer-dnplkndll| image:: https://github.com/dnplkndll.png?size=40px + :target: https://github.com/dnplkndll + :alt: dnplkndll + +Current `maintainer `__: + +|maintainer-dnplkndll| + This module is part of the `OCA/web `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_form_banner/__manifest__.py b/web_form_banner/__manifest__.py index 1b0c230ea546..64f7e20201e5 100644 --- a/web_form_banner/__manifest__.py +++ b/web_form_banner/__manifest__.py @@ -1,10 +1,12 @@ # Copyright 2025 Quartile (https://www.quartile.co) +# Copyright 2026 Ledoweb (Dan Kendall) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). { "name": "Web Form Banner", - "version": "18.0.1.1.0", + "version": "18.0.1.2.0", "category": "Web", - "author": "Quartile, Odoo Community Association (OCA)", + "author": "Quartile, Ledoweb, Odoo Community Association (OCA)", + "maintainers": ["dnplkndll"], "website": "https://github.com/OCA/web", "license": "AGPL-3", "depends": ["web"], diff --git a/web_form_banner/demo/web_form_banner_rule_demo.xml b/web_form_banner/demo/web_form_banner_rule_demo.xml index 64598480b727..b1ab482a09e2 100644 --- a/web_form_banner/demo/web_form_banner_rule_demo.xml +++ b/web_form_banner/demo/web_form_banner_rule_demo.xml @@ -61,4 +61,24 @@ else: {"visible": not bool(draft.category_id)} ]]> + + + Contact needs an email (client-side) + + warning + //sheet + before + + not email and name + + ${name} has no email on file. Workflows requiring email delivery will fail.]]> + diff --git a/web_form_banner/models/ir_model.py b/web_form_banner/models/ir_model.py index 4203b0acf111..60fbeadf0a0c 100644 --- a/web_form_banner/models/ir_model.py +++ b/web_form_banner/models/ir_model.py @@ -1,10 +1,71 @@ # Copyright 2025 Quartile (https://www.quartile.co) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import ast +import logging + from lxml import etree from odoo import api, models +_logger = logging.getLogger(__name__) + + +# Reserved names py.js exposes (or that look like names but aren't fields). +# Anything matching this set is NOT promoted to an injected hidden . +_PYJS_RESERVED = frozenset( + { + "True", + "False", + "None", + "uid", + "context", + "context_today", + "today", + "len", + "bool", + "min", + "max", + "set", + "list", + "dict", + "str", + "int", + "float", + "abs", + "round", + "datetime", + "time", + "relativedelta", + } +) + + +class _FieldNameCollector(ast.NodeVisitor): + """Collect identifier roots from a py.js-compatible expression. + + For ``state == 'draft' and partner_id.email`` returns + ``{'state', 'partner_id'}`` — only the leftmost name in a dotted + chain matters since the ORM auto-loads relational sub-fields. + """ + + def __init__(self): + self.names = set() + + def visit_Name(self, node): + self.names.add(node.id) + # Don't generic_visit — Name has no children worth visiting. + + def visit_Attribute(self, node): + # Walk down to the leftmost Name and add only that root. + v = node + while isinstance(v, ast.Attribute): + v = v.value + if isinstance(v, ast.Name): + self.names.add(v.id) + # Intentionally do not generic_visit — we've already captured + # what we need; recursing would double-count via visit_Name. + class Base(models.AbstractModel): _inherit = "base" @@ -35,29 +96,140 @@ def get_view(self, view_id=None, view_type="form", **options): root = etree.fromstring(res["arch"]) except Exception: return res + declared_fields = set(root.xpath("//field/@name")) for rule in rules: targets = root.xpath(rule.target_xpath or "//sheet") if not targets: continue target = targets[0] - trigger_fields = ",".join(rule.trigger_field_ids.mapped("name")) - banner = etree.Element( - "div", - { - "class": "o_form_banner alert o_invisible_modifier", - "role": "status", - "data-rule-id": str(rule.id), - "data-model": self._name, - "data-trigger-fields": trigger_fields, - }, - ) + if rule.client_side: + banner = self._build_client_banner(rule) + if banner is None: + continue + extra_fields = self._client_rule_missing_fields(rule, declared_fields) + else: + banner = self._build_server_banner(rule) + extra_fields = [] in_group = any(a.tag == "group" for a in target.iterancestors()) if in_group: - # To avoid the layout distortion issue when the target is inside a group + # Avoid layout distortion when the target sits inside a group. banner.set("colspan", "2") + for f in extra_fields: + f.set("colspan", "2") + # Inject any missing field declarations as hidden siblings so + # py.js can resolve the names in our `invisible=` expression + # even on form views that don't already render them. if rule.position == "before": + for f in extra_fields: + target.addprevious(f) target.addprevious(banner) else: target.addnext(banner) + for f in extra_fields: + target.addnext(f) + # Track newly-declared fields so later rules sharing the same + # name don't double-inject. + for f in extra_fields: + declared_fields.add(f.get("name")) res["arch"] = etree.tostring(root, encoding="unicode") return res + + def _client_rule_missing_fields(self, rule, declared_fields): + """Return a list of hidden ```` elements + for any field referenced in ``rule.client_condition`` that isn't + already declared in the form arch. + + Without this, a rule like ``customer_rank > 0 and not email`` on + a partner form that doesn't render ``customer_rank`` would raise + ``EvalError: Name 'customer_rank' is not defined`` at runtime and + bork every browser test that opens the form. + """ + condition = (rule.client_condition or "").strip() + if not condition: + return [] + try: + tree = ast.parse(condition, mode="eval") + except SyntaxError: + # The save-time _check_client_condition constraint already + # rejects malformed expressions; reaching here means the rule + # was somehow saved with a bad expression. Don't crash the + # whole view load — let _build_client_banner's try/except + # handle it. + return [] + visitor = _FieldNameCollector() + visitor.visit(tree) + model_fields = self.env[self._name]._fields + needed = [] + for name in sorted(visitor.names): + if name in _PYJS_RESERVED: + continue + if name not in model_fields: + continue + if name in declared_fields: + continue + needed.append(name) + return [ + etree.Element("field", {"name": n, "invisible": "True"}) for n in needed + ] + + def _build_server_banner(self, rule): + """Heavy-path placeholder div — populated by an ORM RPC on every + trigger-field change (see static/src/js/web_form_banner.esm.js).""" + trigger_fields = ",".join(rule.trigger_field_ids.mapped("name")) + return etree.Element( + "div", + { + "class": "o_form_banner alert o_invisible_modifier", + "role": "status", + "data-rule-id": str(rule.id), + "data-model": self._name, + "data-trigger-fields": trigger_fields, + }, + ) + + def _build_client_banner(self, rule): + """Fast-path self-contained alert div. Visibility is evaluated by + Odoo's view compiler against the in-memory record (py.js); field + interpolation is reactive via inline tags. Zero RPC. + + Returns None if the message HTML cannot be parsed. The caller + skips the rule in that case. + """ + severity = rule.severity or "danger" + condition = (rule.client_condition or "").strip() or "True" + # Build the banner element via the etree API so lxml handles + # attribute escaping. Naive f-string XML-building used to break on + # `state == 'draft' and amount_total > 1000` because the single + # quote terminated the `invisible='...'` attribute mid-expression. + banner = etree.Element( + "div", + attrib={ + "class": f"alert alert-{severity}", + "invisible": f"not ({condition})", + "role": "status", + "data-rule-id": str(rule.id), + "data-model": self._name, + "data-client": "1", + }, + ) + # Parse the message body separately and attach as children. The + # body is wrapped in a throw-away root because lxml needs a single + # top-level element to parse mixed content. + inner_html = rule._to_client_arch() + try: + body = etree.fromstring(f"{inner_html}") + except etree.XMLSyntaxError: + _logger.exception( + "web_form_banner: failed to parse client-side rule '%s' " + "message HTML; skipping. Make sure the message is valid " + "XML (use
not
, close every tag).", + rule.display_name, + ) + return None + # `text` is the leading text node, children become banner's + # children. lxml does the heavy lifting of attribute escaping. + if body.text: + banner.text = body.text + for child in body: + banner.append(child) + return banner diff --git a/web_form_banner/models/web_form_banner_rule.py b/web_form_banner/models/web_form_banner_rule.py index 626315198fb9..6f978e0664a3 100644 --- a/web_form_banner/models/web_form_banner_rule.py +++ b/web_form_banner/models/web_form_banner_rule.py @@ -1,7 +1,9 @@ # Copyright 2025 Quartile (https://www.quartile.co) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import ast import logging +import re from string import Template from dateutil import parser as dateparse @@ -146,7 +148,24 @@ class WebFormBannerRule(models.Model): "web_form_banner_rule_trigger_field_rel", domain="[('model', '=', model_name)]", string="Trigger Fields", - help="If set, the banner recomputes live when any of these fields change.", + help="If set, the banner recomputes live when any of these fields change. " + "Only consulted in server-side mode (see 'Client-side').", + ) + client_side = fields.Boolean( + help="Render the banner using Odoo's native client-side view compiler " + "(py.js + reactive tags) instead of round-tripping to the " + "server on every keystroke. Faster but limited to py.js-compatible " + "expressions: no arbitrary method calls, no slicing, no lambdas. " + "Use 'message_value_code' (server-side mode) when you need related-" + "record traversal, filtered(), or other full-Python logic.", + ) + client_condition = fields.Char( + help="py.js-compatible boolean expression. The banner is shown when " + "this evaluates to True against the current form record. Examples:\n" + " state == 'draft' and amount_total > 1000\n" + " partner_id and not partner_id.email\n" + " customer_rank > 0 and credit_limit\n" + "Only used when 'Client-side' is enabled.", ) @api.constrains("target_xpath") @@ -158,6 +177,66 @@ def _check_target_xpath(self): except (etree.XPathSyntaxError, etree.XPathEvalError) as e: raise ValidationError(_("Invalid XPath:\n%s") % e) from e + @api.constrains("client_side", "client_condition") + def _check_client_condition(self): + """When client-side mode is enabled, require a syntactically valid + Python expression in client_condition. We can't fully validate + py.js semantics from Python (no method-call whitelist enforcement + here, no slicing detection), but ast.parse catches the bulk of + typos and lets admins move on with confidence.""" + for rec in self: + if not rec.client_side: + continue + expr = (rec.client_condition or "").strip() + if not expr: + raise ValidationError( + _( + "Rule '%s' is set to client-side mode but has no " + "client_condition. Provide a py.js-compatible " + "boolean expression." + ) + % rec.display_name + ) + try: + ast.parse(expr, mode="eval") + except SyntaxError as e: + raise ValidationError( + _("Invalid client_condition for rule '%(name)s':\n%(err)s") + % {"name": rec.display_name, "err": e} + ) from e + + # ``${field_name}`` shorthand → ```` for the + # client-side fast path. Mirrors the existing server-side ``${var}`` + # substitution syntax so admins don't have to learn two templates. + # + # Only bare field names are supported — dotted paths like + # ``${partner_id.email}`` would rewrite to + # ```` which is not valid Odoo form + # arch. Admins who need a related field's value should declare it as + # a stored related field on the model and reference that flat name. + _CLIENT_VAR_RE = re.compile(r"\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}") + + def _to_client_arch(self): + """Render the message as an HTML fragment suitable for embedding in + the form arch under a client-side banner div. + + - When message_is_html is True, return the message verbatim with + ``${field_name}`` replaced by ````. + - When message_is_html is False, HTML-escape each line and join + with ``
``, then run the same ``${var}`` substitution. + """ + self.ensure_one() + msg = self.message or "" + + def _sub(match): + return f'' + + if self.message_is_html: + return self._CLIENT_VAR_RE.sub(_sub, msg) + lines = msg.split("\n") + escaped = "
".join(str(escape(line)) for line in lines) + return self._CLIENT_VAR_RE.sub(_sub, escaped) + @api.model def _build_form_url(self, rec): try: diff --git a/web_form_banner/readme/CONTRIBUTORS.md b/web_form_banner/readme/CONTRIBUTORS.md index 4d16bdc25a52..bf59b4e2c1a6 100644 --- a/web_form_banner/readme/CONTRIBUTORS.md +++ b/web_form_banner/readme/CONTRIBUTORS.md @@ -1,3 +1,5 @@ - [Quartile](https://www.quartile.co): - Yoshi Tashiro - Aung Ko Ko Lin +- [Ledoweb](https://ledoweb.com): + - Dan Kendall \ diff --git a/web_form_banner/readme/ROADMAP.md b/web_form_banner/readme/ROADMAP.md index 275f78178462..a11aa36d9178 100644 --- a/web_form_banner/readme/ROADMAP.md +++ b/web_form_banner/readme/ROADMAP.md @@ -16,3 +16,28 @@ fields may render distorted. - Only simple field types are included: char, text, html, selection, boolean, integer, float, monetary, date, datetime, many2one, and many2many. **one2many/reference/other types are omitted.** + +## Client-side mode follow-ups + +- **Dynamic severity per record.** Today the severity (info/warning/ + danger) is baked into the alert's CSS class at view-load time so admins + can't return `{"severity": "danger" if amount > 100000 else "warning"}` + the way `message_value_code` can in server-side mode. One option: an + OWL widget that reads a hidden `severity_expr` from the arch and + toggles the alert class reactively. +- **Dotted-name interpolation.** `${partner_id.email}` is currently + rejected because `` isn't valid form + arch. A small OWL inline-renderer that reads `record.data.partner_id` + reactively and substitutes the related value would fix this without + requiring a stored related field on the model. +- **Rule builder UI.** A small wizard that lets admins compose + `client_condition` from a "field — operator — value" picker and + compiles to a py.js-valid string. Avoids the "you have to know the + grammar" hurdle for non-developer admins. +- **Live syntax validator.** Server-side `ast.parse` catches typos but + not semantic mismatches (e.g. referencing a field not in the view). + An OWL editor with `evaluateBooleanExpr` dry-run would surface those + immediately in the rule form. +- **Auto-detection of py.js compatibility.** If `message_value_code` + is a single boolean expression with no method calls, suggest + promoting it to client-side mode on save. diff --git a/web_form_banner/readme/USAGE.md b/web_form_banner/readme/USAGE.md index 5e342026aad3..0d3d30a2f034 100644 --- a/web_form_banner/readme/USAGE.md +++ b/web_form_banner/readme/USAGE.md @@ -163,6 +163,73 @@ else: result = {"visible": False} ``` +## Client-side mode (no server round-trip) + +For rules whose visibility condition fits Odoo's client-side +expression grammar (`py.js`), tick **Client-side** on the rule and write +your condition in **Client Condition** instead of *Message Value Code*. +The banner is then rendered as a self-contained `
` +in the form arch; Odoo's view compiler evaluates visibility against the +in-memory record on every reactive change — zero RPC, zero JavaScript on +your side. + +What works in `client_condition`: + +- Comparisons, boolean ops, `in` / `not in` +- Attribute access: `partner_id.email`, `company_id.country_id.code` +- Built-ins: `len()`, `bool()`, `min()`, `max()`, `set()` +- Ternary `x if cond else y` + +What does NOT work — keep using server-side mode for these: + +- Arbitrary method calls (`.filtered()`, `.mapped()`, `.search()`) +- Slicing, lambdas, comprehensions +- Anything touching records not loaded on the current form + +In the message you can interpolate field values either with inline +`` tags or with the shorter `${field_name}` shortcut +(the module rewrites the latter to a reactive `` at view-load +time). Only flat field names are supported in `${...}` — Odoo form arch +doesn't accept dotted paths like ``. +For related-record values, declare a stored related field on the model +(`fields.Char(related="partner_id.email", store=True)`) and reference +the flat name. + +You don't need to add `` placeholders to the form view +for every name your condition references. The module parses the +condition at view-load and auto-injects `` +siblings for anything that's missing — `customer_rank > 0 and not email` +on a partner form works even when `customer_rank` isn't already on the +view. + +**Example — contact missing email (works on the base partner form):** + +- Model: `res.partner` +- Client-side: ✓ +- Client Condition: `not email and name` +- HTML: ✓ +- Message: `Contact ${name} has no email on file. Workflows requiring email delivery will fail.` + +Toggle email on/off in the form and the banner appears/disappears +instantly — no Network-tab activity, no `compute_message` RPC. + +**Example — high-value draft order (auto-loads `state` if missing):** + +- Model: `sale.order` +- Client-side: ✓ +- Client Condition: `state == 'draft' and amount_total > 10000` +- Message: `Large draft order: ${amount_total}. Manager approval recommended.` + +**Limitations of client-side mode** + +- Severity (info/warning/danger) is baked into the alert's CSS class at + view-load time. You can't change severity per-record from + `client_condition` the way `message_value_code` can return a dynamic + `"severity"` key. Use server-side mode if you need per-record severity. +- The condition has to fit py.js — no method calls outside the listed + builtins, no slicing, no lambdas, no comprehensions. +- `${X.Y}` in messages is rejected; only flat names work. + If we set up the rules for a partner record as shown below: ![](../static/description/partner_email_rule.png) diff --git a/web_form_banner/static/description/index.html b/web_form_banner/static/description/index.html index ac269e13984c..5c7c52eacc7e 100644 --- a/web_form_banner/static/description/index.html +++ b/web_form_banner/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Web Form Banner -
+
+

Web Form Banner

- - -Odoo Community Association - -
-

Usage

+

Usage

  1. Go to Settings > Technical > User Interface > Form Banner Rules and create a rule.
  2. @@ -419,7 +416,7 @@

    Usage

    auto-refresh after load/save/reload.
-

Usage of message fields:

+

Usage of message fields:

  • Message (message): Text shown in the banner. Supports ${placeholders} filled from values returned by message_value_code. @@ -434,7 +431,7 @@

    Usage of message fields:

-

Trigger Fields

+

Trigger Fields

Trigger Fields is an optional list of model fields that, when changed in the open form, cause the banner to recompute live. If left empty, the banner does not auto-refresh as the user edits the form.

@@ -473,7 +470,7 @@

Trigger Fields

if your rule is triggered based on an update to a trigger field.

-

Message setting examples:

+

Message setting examples:

A) Missing email on contact (warning)

+
+

Client-side mode (no server round-trip)

+

For rules whose visibility condition fits Odoo’s client-side expression +grammar (py.js), tick Client-side on the rule and write your +condition in Client Condition instead of Message Value Code. The +banner is then rendered as a self-contained <div invisible="..."/> +in the form arch; Odoo’s view compiler evaluates visibility against the +in-memory record on every reactive change — zero RPC, zero JavaScript on +your side.

+

What works in client_condition:

+
    +
  • Comparisons, boolean ops, in / not in
  • +
  • Attribute access: partner_id.email, company_id.country_id.code
  • +
  • Built-ins: len(), bool(), min(), max(), set()
  • +
  • Ternary x if cond else y
  • +
+

What does NOT work — keep using server-side mode for these:

+
    +
  • Arbitrary method calls (.filtered(), .mapped(), .search())
  • +
  • Slicing, lambdas, comprehensions
  • +
  • Anything touching records not loaded on the current form
  • +
+

In the message you can interpolate field values either with inline +<field name="..."/> tags or with the shorter ${field_name} +shortcut (the module rewrites the latter to a reactive <field/> at +view-load time). Only flat field names are supported in ${...} — +Odoo form arch doesn’t accept dotted paths like +<field name="partner_id.email"/>. For related-record values, declare +a stored related field on the model +(fields.Char(related="partner_id.email", store=True)) and reference +the flat name.

+

You don’t need to add <field name="X"/> placeholders to the form +view for every name your condition references. The module parses the +condition at view-load and auto-injects +<field name="X" invisible="True"/> siblings for anything that’s +missing — customer_rank > 0 and not email on a partner form works +even when customer_rank isn’t already on the view.

+

Example — contact missing email (works on the base partner form):

+
    +
  • Model: res.partner
  • +
  • Client-side: ✓
  • +
  • Client Condition: not email and name
  • +
  • HTML: ✓
  • +
  • Message: +Contact <strong>${name}</strong> has no email on file. Workflows requiring email delivery will fail.
  • +
+

Toggle email on/off in the form and the banner appears/disappears +instantly — no Network-tab activity, no compute_message RPC.

+

Example — high-value draft order (auto-loads ``state`` if missing):

+
    +
  • Model: sale.order
  • +
  • Client-side: ✓
  • +
  • Client Condition: state == 'draft' and amount_total > 10000
  • +
  • Message: +Large draft order: ${amount_total}. Manager approval recommended.
  • +
+

Limitations of client-side mode

+
    +
  • Severity (info/warning/danger) is baked into the alert’s CSS class at +view-load time. You can’t change severity per-record from +client_condition the way message_value_code can return a +dynamic "severity" key. Use server-side mode if you need +per-record severity.
  • +
  • The condition has to fit py.js — no method calls outside the listed +builtins, no slicing, no lambdas, no comprehensions.
  • +
  • ${X.Y} in messages is rejected; only flat names work.
  • +

If we set up the rules for a partner record as shown below:

image1

image2

@@ -577,16 +642,16 @@

Message setting examples:

-

Known issues / Roadmap

+

Known issues / Roadmap

-

Limitations of draft eval context variable

+

Limitations of draft eval context variable

+
+

Client-side mode follow-ups

+
    +
  • Dynamic severity per record. Today the severity (info/warning/ +danger) is baked into the alert’s CSS class at view-load time so +admins can’t return +{"severity": "danger" if amount > 100000 else "warning"} the way +message_value_code can in server-side mode. One option: an OWL +widget that reads a hidden severity_expr from the arch and toggles +the alert class reactively.
  • +
  • Dotted-name interpolation. ${partner_id.email} is currently +rejected because <field name="partner_id.email"/> isn’t valid form +arch. A small OWL inline-renderer that reads +record.data.partner_id reactively and substitutes the related +value would fix this without requiring a stored related field on the +model.
  • +
  • Rule builder UI. A small wizard that lets admins compose +client_condition from a “field — operator — value” picker and +compiles to a py.js-valid string. Avoids the “you have to know the +grammar” hurdle for non-developer admins.
  • +
  • Live syntax validator. Server-side ast.parse catches typos but +not semantic mismatches (e.g. referencing a field not in the view). An +OWL editor with evaluateBooleanExpr dry-run would surface those +immediately in the rule form.
  • +
  • Auto-detection of py.js compatibility. If message_value_code +is a single boolean expression with no method calls, suggest promoting +it to client-side mode on save.
  • +
+
-

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 @@ -609,25 +703,30 @@

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

  • Quartile
  • +
  • Ledoweb
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -635,11 +734,12 @@

Maintainers

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.

+

Current maintainer:

+

dnplkndll

This module is part of the OCA/web project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

-
diff --git a/web_form_banner/static/src/js/web_form_banner.esm.js b/web_form_banner/static/src/js/web_form_banner.esm.js index 8cc076082afc..7cd7344137e7 100644 --- a/web_form_banner/static/src/js/web_form_banner.esm.js +++ b/web_form_banner/static/src/js/web_form_banner.esm.js @@ -63,8 +63,12 @@ const bannersIn = (ctrl) => { const r = recRoot(ctrl), m = r && r.resModel; if (!m) return []; + // Client-side banners (data-client="1") are self-contained: visibility + // is driven by Odoo's native invisible= attribute via py.js, content is + // rendered by inline widgets. Skip them — the RPC machinery + // below is for server-driven rules only. return Array.from(document.querySelectorAll(".o_form_view [data-rule-id]")).filter( - (el) => el.dataset.model === m + (el) => el.dataset.model === m && el.dataset.client !== "1" ); }; const triggerNames = (ctrl) => { diff --git a/web_form_banner/tests/test_web_form_banner.py b/web_form_banner/tests/test_web_form_banner.py index 4754ddaa5fa3..639d17741649 100644 --- a/web_form_banner/tests/test_web_form_banner.py +++ b/web_form_banner/tests/test_web_form_banner.py @@ -16,9 +16,16 @@ def setUpClass(cls): cls.rule_name = cls.env.ref("web_form_banner.demo_rule_partner_name_length") cls.rule_email = cls.env.ref("web_form_banner.demo_rule_partner_email_missing") cls.rule_tag = cls.env.ref("web_form_banner.demo_rule_partner_tag_missing") - # Disable the email and tag rules to avoid interference in most tests + cls.rule_client_side = cls.env.ref( + "web_form_banner.demo_rule_partner_client_side" + ) + # Disable all but the partner-name-length demo rule so the sibling- + # position assertion in test_position_relative_to_sheet remains + # exact (any other active rule on res.partner would inject a second + # banner before and shift the indexes). cls.rule_email.active = False cls.rule_tag.active = False + cls.rule_client_side.active = False cls.partner_form_view = cls.env.ref("base.view_partner_form") cls.p_len3 = cls.Partner.create({"name": "Bob"}) # 3 cls.p_len12 = cls.Partner.create({"name": "Yoshi Tashiro"}) # 12 @@ -160,3 +167,235 @@ def test_compute_message_dynamic_m2m(self): form_vals={"category_id": [tag.id]}, ) self.assertFalse(out.get("visible")) + + # ------------------------------------------------------------------ + # Client-side mode (18.0.1.2.0+) + # ------------------------------------------------------------------ + def _make_client_rule(self, **overrides): + defaults = { + "name": "Client-side test", + "model_id": self.env.ref("base.model_res_partner").id, + "target_xpath": "//sheet", + "position": "before", + "severity": "warning", + "client_side": True, + "client_condition": "customer_rank > 0 and not email", + "message_is_html": True, + "message": "Customer ${name} needs an email.", + } + defaults.update(overrides) + return self.Rule.create(defaults) + + def test_client_side_arch_uses_invisible_attribute(self): + """Client-side rules emit a self-contained
+ with no o_form_banner placeholder marker class.""" + rule = self._make_client_rule() + tree = self._get_arch_tree(self.Partner, self.partner_form_view) + nodes = tree.xpath(f"//div[@data-rule-id='{rule.id}']") + self.assertEqual(len(nodes), 1) + node = nodes[0] + self.assertEqual(node.get("data-client"), "1") + invisible = node.get("invisible") + self.assertIsNotNone(invisible) + self.assertIn("customer_rank", invisible) + self.assertIn("email", invisible) + self.assertIn("alert-warning", node.get("class") or "") + # No placeholder marker class — the JS RPC machinery filters by + # data-client and would never refresh this div anyway. + self.assertNotIn("o_form_banner", node.get("class") or "") + + def test_client_side_var_sugar_expands_to_field_tag(self): + """``${field_name}`` placeholders rewrite to reactive ````.""" + rule = self._make_client_rule() + tree = self._get_arch_tree(self.Partner, self.partner_form_view) + node = tree.xpath(f"//div[@data-rule-id='{rule.id}']")[0] + field_tags = node.xpath(".//field[@name='name']") + self.assertTrue( + field_tags, + "Expected ${name} to expand into a reactive ", + ) + + def test_client_side_text_message_escapes_and_breaks_lines(self): + """Non-HTML messages are escaped and newlines become ``
``.""" + rule = self._make_client_rule( + message_is_html=False, + message="Line one \nLine two ${name}", + ) + tree = self._get_arch_tree(self.Partner, self.partner_form_view) + node = tree.xpath(f"//div[@data-rule-id='{rule.id}']")[0] + rendered = etree.tostring(node, encoding="unicode") + self.assertIn("<evil>", rendered) + self.assertIn("
", rendered) + self.assertIn('', rendered) + + def test_client_side_missing_condition_rejected(self): + from odoo.exceptions import ValidationError + + with self.assertRaises(ValidationError): + self._make_client_rule(client_condition=False) + + def test_client_side_malformed_condition_rejected(self): + from odoo.exceptions import ValidationError + + with self.assertRaises(ValidationError): + self._make_client_rule( + client_condition="state == 'draft' and (", # unbalanced paren + ) + + def test_client_side_injects_hidden_field_for_missing_reference(self): + """A client_condition referencing a field NOT in the form arch + must trigger an invisible sibling injection — otherwise + py.js raises 'Name X not defined' at render time and crashes any + browser test that opens the form. + + We use 'lang' here because it's on res.partner but is NOT in the + base partner form view (it's added by various locale modules). + """ + rule = self._make_client_rule( + client_condition="lang and not email", + message_is_html=True, + message="Contact has lang but no email", + ) + tree = self._get_arch_tree(self.Partner, self.partner_form_view) + # The banner div should exist (sanity) and the injection should + # also have added a hidden sibling somewhere + # in the arch. + self.assertTrue(tree.xpath(f"//div[@data-rule-id='{rule.id}']")) + lang_tags = tree.xpath("//field[@name='lang' and @invisible='True']") + self.assertTrue( + lang_tags, + "Expected an invisible sibling to be " + "injected so py.js can resolve 'lang' in the banner's " + "invisible= expression. Without this, the form crashes.", + ) + + def test_client_side_does_not_inject_for_already_declared_field(self): + """If the form arch already loads a field, don't duplicate it.""" + # 'name' is always on res.partner form view (it's the record's + # display field). Our message uses ${name} which already creates + # a , and the condition references it too. + # We should NOT see a duplicate invisible . + self._make_client_rule( + client_condition="name and not email", + message_is_html=True, + message="Contact ${name} has no email", + ) + tree = self._get_arch_tree(self.Partner, self.partner_form_view) + invisible_name_tags = tree.xpath("//field[@name='name' and @invisible='True']") + self.assertFalse( + invisible_name_tags, + "Should not inject an invisible when the " + "form arch already loads name through the title widget or " + "message ${name} substitution.", + ) + + def test_client_side_skips_pyjs_reserved_names(self): + """Names like ``True``, ``len``, ``context_today`` are py.js + builtins, not fields. They must never become injected ```` + tags (which would crash with 'no field named X').""" + self._make_client_rule( + client_condition="not email and bool(name) and True", + message_is_html=True, + message="${name} has no email", + ) + tree = self._get_arch_tree(self.Partner, self.partner_form_view) + self.assertFalse( + tree.xpath("//field[@name='True']"), + "py.js reserved name 'True' must not become a ", + ) + self.assertFalse( + tree.xpath("//field[@name='bool']"), + "py.js builtin 'bool' must not become a ", + ) + + def test_client_side_handles_quoted_string_literals(self): + """Single quotes inside the condition (e.g. comparing against a + string literal) must survive XML attribute serialization. An + earlier f-string-based arch builder broke on + ``state == 'draft'`` because the inner quote terminated the + ``invisible='...'`` attribute mid-expression.""" + # Use a state-style comparison. Even though res.partner doesn't + # have a `state` field by default, the assertion is that the + # arch SERIALIZES correctly — auto-injection still skips `state` + # because it isn't on the model. + rule = self._make_client_rule( + client_condition="name and lang == 'en_US'", + message_is_html=True, + message="${name} has lang en_US", + ) + tree = self._get_arch_tree(self.Partner, self.partner_form_view) + banner = tree.xpath(f"//div[@data-rule-id='{rule.id}']")[0] + invisible = banner.get("invisible") or "" + self.assertIn( + "'en_US'", + invisible, + "The single-quoted string literal must round-trip through " + "the arch — got: " + invisible, + ) + # And the arch must still be parseable as XML when serialized, + # which is implicit by the time we got `banner` back. + + def test_client_side_position_after(self): + """Banner with position='after' lands as a sibling AFTER the + target xpath node, not before.""" + rule = self._make_client_rule(position="after") + tree = self._get_arch_tree(self.Partner, self.partner_form_view) + banner = tree.xpath(f"//div[@data-rule-id='{rule.id}']")[0] + sheet = tree.xpath("//sheet")[0] + parent = sheet.getparent() + idx_sheet = list(parent).index(sheet) + idx_banner = list(parent).index(banner) + self.assertGreater( + idx_banner, + idx_sheet, + f"Banner at index {idx_banner} should be after sheet at " + f"index {idx_sheet}", + ) + + def test_client_side_multiple_rules_share_injected_fields(self): + """Two client-side rules on the same model + same missing-field + reference must result in exactly ONE hidden injection, + not duplicates. The injection tracker preserves invariants for + subsequent rules in the iteration.""" + # Both rules reference `lang` (not in base partner form view). + self._make_client_rule( + name="rule-A", + client_condition="lang == 'en_US'", + message="A", + ) + self._make_client_rule( + name="rule-B", + client_condition="lang and not email", + message="B", + ) + tree = self._get_arch_tree(self.Partner, self.partner_form_view) + lang_tags = tree.xpath("//field[@name='lang' and @invisible='True']") + self.assertEqual( + len(lang_tags), + 1, + "Two rules both referencing `lang` should share one hidden " + "injected field, not duplicate it.", + ) + + def test_client_side_rejects_dotted_var_sugar(self): + """``${partner_id.email}`` would expand to + ```` which is invalid Odoo form + arch. The regex must NOT match dotted names — they get left as + literal text in the message instead of a broken field tag.""" + rule = self._make_client_rule( + client_condition="email", + message_is_html=True, + message="Contact ${partner_id.name} wants email", + ) + tree = self._get_arch_tree(self.Partner, self.partner_form_view) + banner = tree.xpath(f"//div[@data-rule-id='{rule.id}']")[0] + rendered = etree.tostring(banner, encoding="unicode") + # The dotted form must NOT have produced a tag. + self.assertNotIn( + 'name="partner_id.name"', + rendered, + "Dotted ${X.Y} must not generate — " + "doing so emits invalid Odoo form arch.", + ) + # And the original text should still be visible (literal "${...}") + self.assertIn("partner_id.name", rendered) diff --git a/web_form_banner/views/web_form_banner_rule_views.xml b/web_form_banner/views/web_form_banner_rule_views.xml index 0697efc61f22..8255fff8d76f 100644 --- a/web_form_banner/views/web_form_banner_rule_views.xml +++ b/web_form_banner/views/web_form_banner_rule_views.xml @@ -19,10 +19,12 @@ + + @@ -59,6 +61,7 @@ context="{'active_test': False}" options="{'no_create': True}" placeholder="Recompute on change (new forms)" + invisible="client_side" /> @@ -70,6 +73,13 @@ /> + + @@ -84,6 +94,7 @@ string="Message Value Code" name="message_value_code" autofocus="autofocus" + invisible="client_side" > - + +

Help for Client-side Mode

+

In client-side mode the banner is evaluated by Odoo's + native form view compiler (py.js) — no server round-trip + per keystroke. Use this whenever your condition fits the + py.js grammar.

+

+ What works in client_condition: +

+
    +
  • Comparisons (==, !=, + < + , + > + , <=, >=)
  • +
  • Boolean ops (and, or, not)
  • +
  • in / not in on lists and strings
  • +
  • Attribute access (partner_id.email, company_id.country_id.code)
  • +
  • Built-ins: len(), bool(), min(), max(), set()
  • +
  • Ternary if: x if cond else y
  • +
+

+ What does NOT work — fall back to server-side + mode (Message Value Code): +

+
    +
  • Arbitrary method calls (.filtered(), .mapped(), custom helpers)
  • +
  • Slicing (list[0:5])
  • +
  • Lambdas, comprehensions
  • +
  • ORM searches, related-record traversal beyond what's + loaded on the form
  • +
+

+ Interpolating field values in the message: +

+

Use either inline <field name="foo"/> tags, or the shorter + ${foo} syntax — the latter is rewritten to + a reactive <field/> at view-load time. + Example message (HTML mode):

+
Order ${name} exceeds <strong>${partner_id}</strong>'s credit limit of ${partner_id.credit_limit}.
+
+

Help for Message Valude Code

Available evaluation context variables are as follows: