From 7a205532721002f61739c1095a9bbfc6a5399063 Mon Sep 17 00:00:00 2001 From: Marc Durepos Date: Tue, 6 Oct 2026 21:58:55 -0400 Subject: [PATCH 1/2] [FIX] web_m2x_options: field_color on 19.0 The field_color/colors options had no effect on many2one fields in 19.0: - Many2OneField.m2oProps and the computeM2OProps override forwarded searchLimit but not fieldColor/fieldColorOptions, so the options never reached Many2XAutocomplete; Many2One did not declare them either. - loadOptionsSource matched suggestions on option.value, which 19.0 suggestions no longer have: the record is in option.data.record. Adds a hoot test covering the dropdown colouring. Co-Authored-By: Claude Opus 5.5 --- web_m2x_options/__manifest__.py | 5 +- .../static/src/components/form.esm.js | 12 +++- .../static/tests/field_color.test.js | 63 +++++++++++++++++++ web_m2x_options/tests/__init__.py | 1 + web_m2x_options/tests/test_js.py | 16 +++++ 5 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 web_m2x_options/static/tests/field_color.test.js create mode 100644 web_m2x_options/tests/test_js.py diff --git a/web_m2x_options/__manifest__.py b/web_m2x_options/__manifest__.py index fad710c79b91..12105175b3b0 100644 --- a/web_m2x_options/__manifest__.py +++ b/web_m2x_options/__manifest__.py @@ -30,7 +30,10 @@ "web_m2x_options/static/src/views/fields/standard_field_props.esm.js", ), "web_m2x_options/static/src/components/base.xml", - ] + ], + "web.assets_unit_tests": [ + "web_m2x_options/static/tests/**/*", + ], }, "installable": True, } diff --git a/web_m2x_options/static/src/components/form.esm.js b/web_m2x_options/static/src/components/form.esm.js index b285dd1179e7..6d832c729626 100644 --- a/web_m2x_options/static/src/components/form.esm.js +++ b/web_m2x_options/static/src/components/form.esm.js @@ -19,6 +19,7 @@ Many2XAutocomplete.props = { Many2One.props = { ...Many2One.props, + ...fieldColorProps, searchLimit: {type: Number, optional: true}, }; @@ -115,6 +116,8 @@ many2one.computeM2OProps = (fieldProps) => { return { ..._super, searchLimit: fieldProps.searchLimit, + fieldColor: fieldProps.fieldColor, + fieldColorOptions: fieldProps.fieldColorOptions, }; }; @@ -141,6 +144,8 @@ patch(many2OneField.Many2OneField.prototype, { get m2oProps() { const props = super.m2oProps; props.searchLimit = this.props.searchLimit; + props.fieldColor = this.props.fieldColor; + props.fieldColorOptions = this.props.fieldColorOptions; return props; }, }); @@ -260,7 +265,10 @@ patch(Many2XAutocomplete.prototype, { this.field_color = this.props.fieldColor; this.colors = this.props.fieldColorOptions; if (this.colors && this.field_color) { - var value_ids = options.map((result) => result.value); + // Record suggestions carry their record in data; the "Search + // More" and "Create" entries have none. + const recordId = (option) => option.data?.record?.id; + var value_ids = options.map(recordId).filter(Boolean); const objects = await this.orm.call( this.props.resModel, "search_read", @@ -272,7 +280,7 @@ patch(Many2XAutocomplete.prototype, { ); for (var index in objects) { for (var index_value in options) { - if (options[index_value].value === objects[index].id) { + if (recordId(options[index_value]) === objects[index].id) { // Find value in values by comparing ids var option = options[index_value]; // Find color with field value as key diff --git a/web_m2x_options/static/tests/field_color.test.js b/web_m2x_options/static/tests/field_color.test.js new file mode 100644 index 000000000000..6b24f8adfd52 --- /dev/null +++ b/web_m2x_options/static/tests/field_color.test.js @@ -0,0 +1,63 @@ +import { + contains, + defineModels, + fields, + models, + mountView, + patchWithCleanup, +} from "@web/../tests/web_test_helpers"; +import {describe, expect, test} from "@odoo/hoot"; +import {queryAll} from "@odoo/hoot-dom"; +import {runAllTimers} from "@odoo/hoot-mock"; +import {session} from "@web/session"; + +describe.current.tags("desktop"); + +class Partner extends models.Model { + name = fields.Char(); + type = fields.Selection({ + selection: [ + ["contact", "Contact"], + ["delivery", "Delivery"], + ], + }); + parent_id = fields.Many2one({relation: "partner"}); + + _records = [ + {id: 1, name: "Acme", type: "contact"}, + {id: 2, name: "Acme Warehouse", type: "delivery"}, + {id: 3, name: "Jane", type: "contact", parent_id: 1}, + ]; +} + +defineModels([Partner]); + +test("many2one dropdown suggestions are coloured by field_color", async () => { + patchWithCleanup(session, {web_m2x_options: {}}); + await mountView({ + type: "form", + resModel: "partner", + resId: 3, + arch: ` +
+ + `, + }); + await contains(".o_field_many2one input").edit("Acme", {confirm: false}); + await runAllTimers(); + + const items = queryAll( + ".o_field_many2one .o-autocomplete--dropdown-item:not(.o_m2o_dropdown_option) .dropdown-item" + ); + expect(items).toHaveLength(2); + const styles = Object.fromEntries( + items.map((el) => [el.textContent.trim(), el.getAttribute("style")]) + ); + expect(styles).toEqual({ + Acme: "color:green", + "Acme Warehouse": "color:blue", + }); +}); diff --git a/web_m2x_options/tests/__init__.py b/web_m2x_options/tests/__init__.py index b472ff361438..d30fd045d1c7 100644 --- a/web_m2x_options/tests/__init__.py +++ b/web_m2x_options/tests/__init__.py @@ -1,2 +1,3 @@ # Copyright 2020 initOS GmbH. from . import test_ir_config_parameter +from . import test_js diff --git a/web_m2x_options/tests/test_js.py b/web_m2x_options/tests/test_js.py new file mode 100644 index 000000000000..7a90d695fce3 --- /dev/null +++ b/web_m2x_options/tests/test_js.py @@ -0,0 +1,16 @@ +from odoo.tests import HttpCase, tagged + + +@tagged("post_install", "-at_install") +class TestWebM2xOptionsJs(HttpCase): + def test_js(self): + self.browser_js( + "/web/tests?headless&loglevel=2&preset=desktop&timeout=15000" + "&filter=%40web_m2x_options", + "", + "", + login="admin", + timeout=1800, + success_signal="[HOOT] Test suite succeeded", + error_checker=lambda x: "[HOOT]" not in x, + ) From 5b74553c1f6f2ddb6da1e24ba47308da93bacbc8 Mon Sep 17 00:00:00 2001 From: Marc Durepos Date: Tue, 6 Oct 2026 21:59:47 -0400 Subject: [PATCH 2/2] [IMP] web_m2x_options: color_style option Adds a color_style option next to field_color/colors. "text" (the default) keeps the current text colouring unchanged. "bar" draws a thin accent bar on the left of each suggestion and leaves the text alone, so contrast does not suffer in either theme. With "bar", Bootstrap theme colour names (success, info, warning, ...) resolve through Odoo's theme CSS variables and so follow the light or dark theme; any other value is used as a CSS colour. Co-Authored-By: Claude Opus 5.5 --- web_m2x_options/README.rst | 16 +++++++ web_m2x_options/readme/USAGE.md | 16 +++++++ web_m2x_options/static/description/index.html | 14 ++++++ .../static/src/components/base.xml | 1 + .../static/src/components/form.esm.js | 29 +++++++++++- .../views/fields/standard_field_props.esm.js | 1 + .../static/tests/field_color.test.js | 46 +++++++++++++++++++ 7 files changed, 122 insertions(+), 1 deletion(-) diff --git a/web_m2x_options/README.rst b/web_m2x_options/README.rst index d97b59cbd484..e10934ceb11a 100644 --- a/web_m2x_options/README.rst +++ b/web_m2x_options/README.rst @@ -68,6 +68,16 @@ in the field's options dict A dictionary to link field value with a HTML color. This option has to be used with field_color. +``color_style`` *string* (Default: ``text``) + + How the color is shown in the drop-down panel. ``text`` colors the + record's text. ``bar`` draws a thin accent bar on the left of the + record and leaves the text untouched. With ``bar``, a Bootstrap theme + color name (``primary``, ``secondary``, ``success``, ``info``, + ``warning``, ``danger``, ``light``, ``dark``) follows the light or + dark theme; any other value is used as a HTML color. This option has + to be used with field_color and colors. + ir.config_parameter options --------------------------- @@ -116,6 +126,12 @@ Your XML form view definition could contain: ... +.. code:: xml + + ... + + ... + Known issues / Roadmap ====================== diff --git a/web_m2x_options/readme/USAGE.md b/web_m2x_options/readme/USAGE.md index c1e7aebeb32e..a9de51962dea 100644 --- a/web_m2x_options/readme/USAGE.md +++ b/web_m2x_options/readme/USAGE.md @@ -14,6 +14,16 @@ > A dictionary to link field value with a HTML color. This option has to > be used with field_color. +`color_style` *string* (Default: `text`) + +> How the color is shown in the drop-down panel. `text` colors the +> record's text. `bar` draws a thin accent bar on the left of the record +> and leaves the text untouched. With `bar`, a Bootstrap theme color name +> (`primary`, `secondary`, `success`, `info`, `warning`, `danger`, +> `light`, `dark`) follows the light or dark theme; any other value is +> used as a HTML color. This option has to be used with field_color and +> colors. + ## ir.config_parameter options Now you can disable "Create..." and "Create and Edit..." entry for all @@ -59,3 +69,9 @@ Your XML form view definition could contain: ... ``` + +``` xml +... + +... +``` diff --git a/web_m2x_options/static/description/index.html b/web_m2x_options/static/description/index.html index 28cf94667e01..c67df545a92d 100644 --- a/web_m2x_options/static/description/index.html +++ b/web_m2x_options/static/description/index.html @@ -418,6 +418,15 @@

in the field’s options dict
A dictionary to link field value with a HTML color. This option has to be used with field_color.
+

color_style string (Default: text)

+
+How the color is shown in the drop-down panel. text colors the +record’s text. bar draws a thin accent bar on the left of the +record and leaves the text untouched. With bar, a Bootstrap theme +color name (primary, secondary, success, info, +warning, danger, light, dark) follows the light or +dark theme; any other value is used as a HTML color. This option has +to be used with field_color and colors.

ir.config_parameter options

@@ -459,6 +468,11 @@

Example

<field name="partner_id" options="{'limit': 10, 'field_color':'type', 'colors':{'contact':'green', 'invoice': 'red', 'delivery': 'blue'}}"/> ... +
+...
+<field name="partner_id" options="{'field_color': 'type', 'color_style': 'bar', 'colors': {'contact': 'success', 'invoice': 'warning', 'delivery': 'info'}}"/>
+...
+
diff --git a/web_m2x_options/static/src/components/base.xml b/web_m2x_options/static/src/components/base.xml index c8df0d832c7b..cd11de508301 100644 --- a/web_m2x_options/static/src/components/base.xml +++ b/web_m2x_options/static/src/components/base.xml @@ -22,6 +22,7 @@ props.searchLimit props.fieldColor props.fieldColorOptions + props.fieldColorStyle diff --git a/web_m2x_options/static/src/components/form.esm.js b/web_m2x_options/static/src/components/form.esm.js index 6d832c729626..9ecdac01ef7d 100644 --- a/web_m2x_options/static/src/components/form.esm.js +++ b/web_m2x_options/static/src/components/form.esm.js @@ -95,6 +95,7 @@ export function m2o_options_props(props, attrs, options) { newProps = m2o_options_props_open(newProps, attrs, options); newProps.fieldColor = options.field_color; newProps.fieldColorOptions = options.colors; + newProps.fieldColorStyle = options.color_style; return newProps; } @@ -118,6 +119,7 @@ many2one.computeM2OProps = (fieldProps) => { searchLimit: fieldProps.searchLimit, fieldColor: fieldProps.fieldColor, fieldColorOptions: fieldProps.fieldColorOptions, + fieldColorStyle: fieldProps.fieldColorStyle, }; }; @@ -146,6 +148,7 @@ patch(many2OneField.Many2OneField.prototype, { props.searchLimit = this.props.searchLimit; props.fieldColor = this.props.fieldColor; props.fieldColorOptions = this.props.fieldColorOptions; + props.fieldColorStyle = this.props.fieldColorStyle; return props; }, }); @@ -187,6 +190,7 @@ patch(Many2One.prototype, { if (field_color && field_color_options) { ret_props.fieldColor = field_color; ret_props.fieldColorOptions = field_color_options; + ret_props.fieldColorStyle = this.props.fieldColorStyle; } if (!evaluateSystemParameterDefaultTrue("create")) { ret_props.quickCreate = null; @@ -251,6 +255,7 @@ patch(many2ManyTagsField, { newProps = this.m2m_options_props_limit(newProps, attrs, options); newProps.fieldColor = options.field_color; newProps.fieldColorOptions = options.colors; + newProps.fieldColorStyle = options.color_style; return newProps; }, extractProps({attrs, options, string}, dynamicInfo) { @@ -259,6 +264,28 @@ patch(many2ManyTagsField, { }, }); +// Bootstrap theme colours resolve through CSS variables, which Odoo defines +// for both the light and the dark theme. Odoo compiles Bootstrap without the +// "bs-" prefix, so the variable is --success, not --bs-success. +const BOOTSTRAP_COLORS = new Set([ + "primary", + "secondary", + "success", + "info", + "warning", + "danger", + "light", + "dark", +]); + +function colorStyle(style, color) { + if (style === "bar") { + const css = BOOTSTRAP_COLORS.has(color) ? `var(--${color})` : color; + return `box-shadow: inset 3px 0 0 ${css}`; + } + return "color:" + color; +} + patch(Many2XAutocomplete.prototype, { async loadOptionsSource(request) { var options = await super.loadOptionsSource(request); @@ -286,7 +313,7 @@ patch(Many2XAutocomplete.prototype, { // Find color with field value as key var color = this.colors[objects[index][this.field_color]] || "black"; - option.style = "color:" + color; + option.style = colorStyle(this.props.fieldColorStyle, color); break; } } diff --git a/web_m2x_options/static/src/views/fields/standard_field_props.esm.js b/web_m2x_options/static/src/views/fields/standard_field_props.esm.js index 928ba9f51af2..b2a7201f6468 100644 --- a/web_m2x_options/static/src/views/fields/standard_field_props.esm.js +++ b/web_m2x_options/static/src/views/fields/standard_field_props.esm.js @@ -4,6 +4,7 @@ import {standardFieldProps} from "@web/views/fields/standard_field_props"; export const fieldColorProps = { fieldColor: {type: String, optional: true}, fieldColorOptions: {type: Object, optional: true}, + fieldColorStyle: {type: String, optional: true}, }; patch(standardFieldProps, { diff --git a/web_m2x_options/static/tests/field_color.test.js b/web_m2x_options/static/tests/field_color.test.js index 6b24f8adfd52..6045788b2352 100644 --- a/web_m2x_options/static/tests/field_color.test.js +++ b/web_m2x_options/static/tests/field_color.test.js @@ -61,3 +61,49 @@ test("many2one dropdown suggestions are coloured by field_color", async () => { "Acme Warehouse": "color:blue", }); }); + +async function suggestionStyles(options) { + patchWithCleanup(session, {web_m2x_options: {}}); + await mountView({ + type: "form", + resModel: "partner", + resId: 3, + arch: ` +
+ + `, + }); + await contains(".o_field_many2one input").edit("Acme", {confirm: false}); + await runAllTimers(); + const items = queryAll( + ".o_field_many2one .o-autocomplete--dropdown-item:not(.o_m2o_dropdown_option) .dropdown-item" + ); + return Object.fromEntries( + items.map((el) => [el.textContent.trim(), el.getAttribute("style")]) + ); +} + +test("color_style 'text' keeps the default text colouring", async () => { + const styles = await suggestionStyles( + "{'field_color': 'type', 'color_style': 'text', 'colors': {'contact': 'success', 'delivery': 'blue'}}" + ); + expect(styles).toEqual({ + Acme: "color:success", + "Acme Warehouse": "color:blue", + }); +}); + +test("color_style 'bar' draws a theme-aware left accent bar", async () => { + const styles = await suggestionStyles( + "{'field_color': 'type', 'color_style': 'bar', 'colors': {'contact': 'success', 'delivery': '#123456'}}" + ); + expect(styles).toEqual({ + Acme: "box-shadow: inset 3px 0 0 var(--success)", + "Acme Warehouse": "box-shadow: inset 3px 0 0 #123456", + }); + // The theme variable must resolve, or the bar silently disappears. + const acme = queryAll(".o-autocomplete--dropdown-item .dropdown-item").find( + (el) => el.textContent.trim() === "Acme" + ); + expect(getComputedStyle(acme).boxShadow).toMatch(/rgb/); +});