From 2a3a71bb780349dc51be8376244b487d69c550e8 Mon Sep 17 00:00:00 2001 From: Ivan Sokolov <2234999+ivs-cetmix@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:44:22 +0200 Subject: [PATCH 1/3] [ADD] web_widget_autocomplete: add Char widget Odoo 18 has no backend Char widget that typeahead-fills from a consumer-supplied model method. This addon reuses web.AutoComplete so integrators can hook any public @api.model method. HOOT is selected with WebSuite hash ids so sibling WebWidget* suites do not pick these tests up via fuzzy matching. Task 5613 Co-authored-by: Cursor --- web_widget_autocomplete/README.rst | 164 ++++++ web_widget_autocomplete/__init__.py | 1 + web_widget_autocomplete/__manifest__.py | 22 + web_widget_autocomplete/pyproject.toml | 3 + web_widget_autocomplete/readme/CONFIGURE.md | 5 + .../readme/CONTRIBUTORS.md | 2 + web_widget_autocomplete/readme/CREDITS.md | 2 + web_widget_autocomplete/readme/DESCRIPTION.md | 7 + web_widget_autocomplete/readme/USAGE.md | 59 ++ .../static/description/index.html | 522 ++++++++++++++++++ .../static/src/autocomplete_field.esm.js | 191 +++++++ .../static/src/autocomplete_field.xml | 21 + .../static/tests/autocomplete_field.test.js | 339 ++++++++++++ web_widget_autocomplete/tests/__init__.py | 4 + .../tests/test_web_widget_autocomplete.py | 16 + 15 files changed, 1358 insertions(+) create mode 100644 web_widget_autocomplete/README.rst create mode 100644 web_widget_autocomplete/__init__.py create mode 100644 web_widget_autocomplete/__manifest__.py create mode 100644 web_widget_autocomplete/pyproject.toml create mode 100644 web_widget_autocomplete/readme/CONFIGURE.md create mode 100644 web_widget_autocomplete/readme/CONTRIBUTORS.md create mode 100644 web_widget_autocomplete/readme/CREDITS.md create mode 100644 web_widget_autocomplete/readme/DESCRIPTION.md create mode 100644 web_widget_autocomplete/readme/USAGE.md create mode 100644 web_widget_autocomplete/static/description/index.html create mode 100644 web_widget_autocomplete/static/src/autocomplete_field.esm.js create mode 100644 web_widget_autocomplete/static/src/autocomplete_field.xml create mode 100644 web_widget_autocomplete/static/tests/autocomplete_field.test.js create mode 100644 web_widget_autocomplete/tests/__init__.py create mode 100644 web_widget_autocomplete/tests/test_web_widget_autocomplete.py diff --git a/web_widget_autocomplete/README.rst b/web_widget_autocomplete/README.rst new file mode 100644 index 000000000000..a9342c20bc47 --- /dev/null +++ b/web_widget_autocomplete/README.rst @@ -0,0 +1,164 @@ +======================= +Web Widget Autocomplete +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:962bbb051b41b6c023a4326613614a30d9c9aab2590bdb65e5ca0cb40cbbcda8 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/18.0/web_widget_autocomplete + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-18-0/web-18-0-web_widget_autocomplete + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds a backend Char field widget that typeahead-fills the +field from a public ``@api.model`` method on the same model. + +Type in the field; after a configurable number of characters the widget +calls the method with the trimmed input, shows matching rows, and on +select writes the Char plus any extra Char or Integer keys that exist on +the model and in the current view. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +No configuration is required. Enable the module; consuming addons +declare Char fields and the method. + +To try the widget on a partner City field (Umbria cities), install +``web_widget_autocomplete_demo``. + +Usage +===== + +Use ``widget="autocomplete"`` on a ``fields.Char`` and name a public +``@api.model`` method in ``options``. + +The method receives the trimmed input string and must return a list of +dicts. Each dict’s keys are field names. The widget field’s key is the +label shown in the dropdown and the value written to the Char. Any +number of extra keys may be present; on **select** they are written to +sibling Char or Integer fields that exist on the **model and in the same +view** (visible or ``invisible``). Keys missing from the model, missing +from the view, or whose type is not Char/Integer are skipped. + +Free typing (without selecting a row) updates only the Char. Extra +fields keep their previous values until the next select. + +A **readonly** extra is still filled in the UI on select. It is +persisted on save only if that field’s view node has ``force_save="1"``. +Without it, the fill is dropped on save with no error. + +The method must be publicly RPC-callable: not prefixed with ``_``, not +``@api.private``, and decorated with ``@api.model`` so the first +argument is the search string rather than record ids. + +.. code:: xml + + + + + +.. code:: python + + @api.model + def address_auto_complete(self, value): + """Return autocomplete rows for ``value``. + + :param str value: current Char input (trimmed by the widget) + :return: list of dicts whose keys are field names on this model + :rtype: list[dict] + """ + return [ + { + "address_string": "string1", + "address_ref": 1, + "city": "Perugia", + }, + ] + +=============== ================= ================================== +Option Type Default if omitted +=============== ================= ================================== +``function`` str (method name) none — no RPC, Char still editable +``min_symbols`` int ``3`` +``debounce`` int (ms) ``250`` +=============== ================= ================================== + +This is typeahead, not a closed ```. For a dropdown whose options +are fetched once (or when a `depending_on` context key changes), use +`web_widget_dropdown_dynamic` instead. diff --git a/web_widget_autocomplete/static/description/index.html b/web_widget_autocomplete/static/description/index.html new file mode 100644 index 000000000000..c96cd4d8619d --- /dev/null +++ b/web_widget_autocomplete/static/description/index.html @@ -0,0 +1,522 @@ + + + + + +Web Widget Autocomplete + + + +
+

Web Widget Autocomplete

+ + +

Beta License: LGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

This module adds a backend Char field widget that typeahead-fills the +field from a public @api.model method on the same model.

+

Type in the field; after a configurable number of characters the widget +calls the method with the trimmed input, shows matching rows, and on +select writes the Char plus any extra Char or Integer keys that exist on +the model and in the current view.

+

Table of contents

+ +
+

Configuration

+

No configuration is required. Enable the module; consuming addons +declare Char fields and the method.

+

To try the widget on a partner City field (Umbria cities), install +web_widget_autocomplete_demo.

+
+
+

Usage

+

Use widget="autocomplete" on a fields.Char and name a public +@api.model method in options.

+

The method receives the trimmed input string and must return a list of +dicts. Each dict’s keys are field names. The widget field’s key is the +label shown in the dropdown and the value written to the Char. Any +number of extra keys may be present; on select they are written to +sibling Char or Integer fields that exist on the model and in the same +view (visible or invisible). Keys missing from the model, missing +from the view, or whose type is not Char/Integer are skipped.

+

Free typing (without selecting a row) updates only the Char. Extra +fields keep their previous values until the next select.

+

A readonly extra is still filled in the UI on select. It is +persisted on save only if that field’s view node has force_save="1". +Without it, the fill is dropped on save with no error.

+

The method must be publicly RPC-callable: not prefixed with _, not +@api.private, and decorated with @api.model so the first +argument is the search string rather than record ids.

+
+<field
+    name="address_string"
+    widget="autocomplete"
+    options="{'function': 'address_auto_complete', 'min_symbols': 3, 'debounce': 5}"
+/>
+<field name="address_ref" readonly="1" force_save="1"/>
+<field name="city" invisible="1"/>
+
+
+@api.model
+def address_auto_complete(self, value):
+    """Return autocomplete rows for ``value``.
+
+    :param str value: current Char input (trimmed by the widget)
+    :return: list of dicts whose keys are field names on this model
+    :rtype: list[dict]
+    """
+    return [
+        {
+            "address_string": "string1",
+            "address_ref": 1,
+            "city": "Perugia",
+        },
+    ]
+
+ +++++ + + + + + + + + + + + + + + + + + + + + +
OptionTypeDefault if omitted
functionstr (method name)none — no RPC, Char still editable
min_symbolsint3
debounceint (ms)250
+

This is typeahead, not a closed <select>. For a dropdown whose +options are fetched once (or when a depending_on context key +changes), use web_widget_dropdown_dynamic instead.

+
+
+

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 +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Cetmix
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The dropdown UI is Odoo Community web.AutoComplete +(addons/web/static/src/core/autocomplete/).

+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

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_widget_autocomplete/static/src/autocomplete_field.esm.js b/web_widget_autocomplete/static/src/autocomplete_field.esm.js new file mode 100644 index 000000000000..b8db820f318d --- /dev/null +++ b/web_widget_autocomplete/static/src/autocomplete_field.esm.js @@ -0,0 +1,191 @@ +import {useChildRef, useService} from "@web/core/utils/hooks"; +import {CharField, charField} from "@web/views/fields/char/char_field"; +import {AutoComplete} from "@web/core/autocomplete/autocomplete"; +import {_t} from "@web/core/l10n/translation"; +import {registry} from "@web/core/registry"; +import {useDebounced} from "@web/core/utils/timing"; +import {useInputField} from "@web/views/fields/input_field_hook"; + +const DEFAULT_MIN_SYMBOLS = 3; +const DEFAULT_DELAY = 250; + +function coerceNonNegativeNumber(value, fallback) { + const number = Number(value); + if (!Number.isFinite(number) || number < 0) { + return fallback; + } + return number; +} + +/** + * AutoComplete subclass that honours a `delay` prop. + * + * Core AutoComplete always sets `this.timeout = 250` inside `setup()` + * before `useDebounced`, so a subclass cannot change the delay by + * assigning `this.timeout` before `super.setup()`. + */ +export class DelayedAutoComplete extends AutoComplete { + static props = { + ...AutoComplete.props, + delay: {type: Number, optional: true}, + }; + static defaultProps = { + ...AutoComplete.defaultProps, + delay: DEFAULT_DELAY, + }; + setup() { + super.setup(); + this.debouncedProcessInput = useDebounced(async () => { + const currentPromise = this.pendingPromise; + this.pendingPromise = null; + this.props.onInput({ + inputValue: this.inputRef.el.value, + }); + try { + await this.open(true); + currentPromise.resolve(); + } catch { + currentPromise.reject(); + } finally { + if (currentPromise === this.loadingPromise) { + this.loadingPromise = null; + } + } + }, this.props.delay); + } +} + +export class AutocompleteField extends CharField { + static template = "web_widget_autocomplete.AutocompleteField"; + static components = { + ...CharField.components, + AutoComplete: DelayedAutoComplete, + }; + static props = { + ...CharField.props, + function: {type: String, optional: true}, + minSymbols: {type: Number, optional: true}, + delay: {type: Number, optional: true}, + context: {type: Object, optional: true}, + // Other addons patch CharField.props after this class is defined. + "*": true, + }; + + setup() { + super.setup(); + this.orm = useService("orm"); + this.inputRef = useChildRef(); + useInputField({ + getValue: () => this.props.record.data[this.props.name] || "", + parse: (v) => this.parse(v), + ref: this.inputRef, + }); + } + + get sources() { + return [ + { + options: (request) => this.loadSuggestions(request), + }, + ]; + } + + async loadSuggestions(request) { + const method = this.props.function; + const minSymbols = this.props.minSymbols ?? DEFAULT_MIN_SYMBOLS; + if (!method || request.length < minSymbols) { + return []; + } + try { + const kwargs = {}; + if (this.props.context) { + kwargs.context = this.props.context; + } + const result = await this.orm.silent.call( + this.props.record.resModel, + method, + [request], + kwargs + ); + if (!Array.isArray(result)) { + return []; + } + return this.mapSuggestions(result); + } catch { + return []; + } + } + + mapSuggestions(rows) { + const fieldName = this.props.name; + const options = []; + for (const row of rows) { + if (!row || typeof row !== "object" || Array.isArray(row)) { + continue; + } + const label = row[fieldName]; + if (typeof label !== "string") { + continue; + } + options.push({label, values: row}); + } + return options; + } + + async onSelect(option) { + const values = option.values; + if (!values || typeof values !== "object") { + return; + } + const {record} = this.props; + const changes = {}; + for (const [key, value] of Object.entries(values)) { + if (key === "id") { + continue; + } + if (!(key in record.fields) || !(key in record.activeFields)) { + continue; + } + const fieldType = record.fields[key].type; + if (fieldType === "char") { + if (typeof value === "string") { + changes[key] = value; + } + } else if (fieldType === "integer") { + const number = Number(value); + if (Number.isFinite(number)) { + changes[key] = number; + } + } + } + if (Object.keys(changes).length) { + await record.update(changes); + } + } +} + +export const autocompleteField = { + ...charField, + component: AutocompleteField, + displayName: _t("Autocomplete"), + extractProps: (fieldInfo, dynamicInfo) => { + const {options} = fieldInfo; + const props = { + ...charField.extractProps(fieldInfo, dynamicInfo), + minSymbols: coerceNonNegativeNumber( + options.min_symbols, + DEFAULT_MIN_SYMBOLS + ), + delay: coerceNonNegativeNumber(options.debounce, DEFAULT_DELAY), + }; + if (typeof options.function === "string" && options.function) { + props.function = options.function; + } + if (fieldInfo.context && fieldInfo.context !== "{}") { + props.context = dynamicInfo.context; + } + return props; + }, +}; + +registry.category("fields").add("autocomplete", autocompleteField); diff --git a/web_widget_autocomplete/static/src/autocomplete_field.xml b/web_widget_autocomplete/static/src/autocomplete_field.xml new file mode 100644 index 000000000000..0bc642ea5840 --- /dev/null +++ b/web_widget_autocomplete/static/src/autocomplete_field.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/web_widget_autocomplete/static/tests/autocomplete_field.test.js b/web_widget_autocomplete/static/tests/autocomplete_field.test.js new file mode 100644 index 000000000000..ef34266d6d75 --- /dev/null +++ b/web_widget_autocomplete/static/tests/autocomplete_field.test.js @@ -0,0 +1,339 @@ +import {describe, expect, freezeTime, test} from "@odoo/hoot"; +import {advanceTime, animationFrame, runAllTimers} from "@odoo/hoot-mock"; +import { + contains, + defineModels, + fields, + findComponent, + models, + mountView, + onRpc, + patchWithCleanup, +} from "@web/../tests/web_test_helpers"; +import {AutocompleteField} from "@web_widget_autocomplete/autocomplete_field.esm"; +import {queryAllTexts} from "@odoo/hoot-dom"; +import {charField} from "@web/views/fields/char/char_field"; +import {Record} from "@web/model/relational_model/record"; + +class Partner extends models.Model { + address_string = fields.Char(); + address_ref = fields.Integer(); + city = fields.Char(); + amount = fields.Float(); + hidden = fields.Char(); + _records = [ + { + id: 1, + address_string: "start", + address_ref: 7, + city: "Old", + amount: 1.5, + hidden: "secret", + }, + ]; +} + +defineModels([Partner]); + +const SUGGESTION = { + address_string: "Perugia, Italy", + address_ref: 12, + city: "Perugia", + amount: 9.9, + hidden: "nope", + unknown: "x", + id: 99, +}; + +async function mountAutocompleteForm( + options = "{'function': 'address_auto_complete'}", + extraFields = "" +) { + return mountView({ + type: "form", + resModel: "partner", + resId: 1, + arch: ` +
+ + + + ${extraFields} + `, + }); +} + +async function typeAndWait(value) { + await contains(".o_field_widget[name='address_string'] input").edit(value, { + confirm: false, + }); + await runAllTimers(); + await animationFrame(); +} + +function patchUpdateSteps() { + patchWithCleanup(Record.prototype, { + async update(changes, options) { + if (this.resModel === "partner") { + expect.step(`update:${Object.keys(changes).sort().join(",")}`); + } + return super.update(changes, options); + }, + }); +} + +describe.current.tags("desktop"); + +describe("WebWidgetAutocomplete", () => { + test("below min_symbols does not RPC", async () => { + onRpc("address_auto_complete", ({args}) => { + expect.step(args[0]); + return [SUGGESTION]; + }); + await mountAutocompleteForm(); + await typeAndWait("ab"); + expect.verifySteps([]); + expect(".o-autocomplete--dropdown-item").toHaveCount(0); + }); + + test("at min_symbols calls with the trimmed string", async () => { + onRpc("address_auto_complete", ({args}) => { + expect.step(args[0]); + return [SUGGESTION]; + }); + await mountAutocompleteForm(); + await typeAndWait(" abc"); + expect.verifySteps(["abc"]); + expect(queryAllTexts(".o-autocomplete--dropdown-item")).toEqual([ + "Perugia, Italy", + ]); + }); + + test("custom min_symbols gates the RPC", async () => { + onRpc("address_auto_complete", ({args}) => { + expect.step(args[0]); + return [SUGGESTION]; + }); + await mountAutocompleteForm( + "{'function': 'address_auto_complete', 'min_symbols': 5}" + ); + await typeAndWait("abcd"); + expect.verifySteps([]); + await typeAndWait("abcde"); + expect.verifySteps(["abcde"]); + }); + + test("invalid min_symbols falls back to the default of 3", async () => { + onRpc("address_auto_complete", ({args}) => { + expect.step(args[0]); + return [SUGGESTION]; + }); + await mountAutocompleteForm( + "{'function': 'address_auto_complete', 'min_symbols': -1}" + ); + await typeAndWait("ab"); + expect.verifySteps([]); + await typeAndWait("abc"); + expect.verifySteps(["abc"]); + }); + + test("field context is forwarded to the RPC", async () => { + onRpc("address_auto_complete", ({args, kwargs}) => { + expect.step(args[0]); + expect.step(`ctx:${kwargs.context.ac_token}`); + return [SUGGESTION]; + }); + await mountView({ + type: "form", + resModel: "partner", + resId: 1, + arch: ` +
+ + `, + }); + await typeAndWait("abc"); + expect.verifySteps(["abc", "ctx:42"]); + }); + + test("select writes Char and extra Char/Integer in one update", async () => { + onRpc("address_auto_complete", () => [SUGGESTION]); + patchUpdateSteps(); + const view = await mountAutocompleteForm( + "{'function': 'address_auto_complete'}", + ` + + + ` + ); + const field = findComponent(view, (c) => c instanceof AutocompleteField); + await typeAndWait("Per"); + await contains(".o-autocomplete--dropdown-item").click(); + await animationFrame(); + expect.verifySteps(["update:address_ref,address_string,city"]); + expect(field.props.record.data.address_string).toBe("Perugia, Italy"); + expect(field.props.record.data.address_ref).toBe(12); + expect(field.props.record.data.city).toBe("Perugia"); + expect(field.props.record.data.amount).toBe(1.5); + // Fields not in the view are not on record.data (activeFields only). + expect(field.props.record.data.hidden).toBe(undefined); + expect(field.props.record.data.id).toBe(1); + }); + + test("readonly extra is included in the select update", async () => { + onRpc("address_auto_complete", () => [SUGGESTION]); + patchUpdateSteps(); + await mountView({ + type: "form", + resModel: "partner", + resId: 1, + arch: ` +
+ + + + `, + }); + await typeAndWait("Per"); + await contains(".o-autocomplete--dropdown-item").click(); + await animationFrame(); + expect.verifySteps(["update:address_ref,address_string,city"]); + expect(".o_field_widget[name='address_ref']").toHaveText("12"); + }); + + test("type without select commits Char and leaves extras unchanged", async () => { + onRpc("address_auto_complete", () => [SUGGESTION]); + patchUpdateSteps(); + await mountAutocompleteForm(); + await contains(".o_field_widget[name='address_string'] input").edit( + "typed text", + {confirm: "blur"} + ); + await runAllTimers(); + await animationFrame(); + expect.verifySteps(["update:address_string"]); + expect(".o_field_widget[name='address_string'] input").toHaveValue( + "typed text" + ); + expect(".o_field_widget[name='address_ref'] input").toHaveValue("7"); + expect(".o_field_widget[name='city'] input").toHaveValue("Old"); + }); + + test("save without leaving the field commits typed Char via useInputField", async () => { + onRpc("address_auto_complete", () => [SUGGESTION]); + patchUpdateSteps(); + await mountAutocompleteForm(); + await contains(".o_field_widget[name='address_string'] input").edit("unsaved", { + confirm: false, + }); + await runAllTimers(); + await contains(".o_form_button_save").click(); + await animationFrame(); + expect.verifySteps(["update:address_string"]); + expect(".o_field_widget[name='address_string'] input").toHaveValue("unsaved"); + expect(".o_field_widget[name='city'] input").toHaveValue("Old"); + }); + + test("extra CharField props from other addons are not rejected", async () => { + const extractProps = charField.extractProps; + patchWithCleanup(charField, { + extractProps(fieldInfo, dynamicInfo) { + return { + ...extractProps(fieldInfo, dynamicInfo), + maxLength: 64, + pattern: "[A-Za-z]+", + }; + }, + }); + await mountView({ + type: "form", + resModel: "partner", + resId: 1, + arch: ` +
+ + `, + }); + expect(".o_field_widget[name='address_string'] input").toHaveCount(1); + }); + + test("missing function does not RPC", async () => { + onRpc("address_auto_complete", ({args}) => { + expect.step(args[0]); + return [SUGGESTION]; + }); + await mountView({ + type: "form", + resModel: "partner", + resId: 1, + arch: ` +
+ + `, + }); + await typeAndWait("abc"); + expect.verifySteps([]); + }); + + test("non-list RPC result yields an empty dropdown", async () => { + onRpc("address_auto_complete", () => ({not: "a list"})); + await mountAutocompleteForm(); + await typeAndWait("abc"); + expect(".o-autocomplete--dropdown-item").toHaveCount(0); + }); + + test("RPC failure yields an empty dropdown", async () => { + onRpc("address_auto_complete", () => { + throw new Error("rpc failed"); + }); + await mountAutocompleteForm(); + await typeAndWait("abc"); + expect(".o-autocomplete--dropdown-item").toHaveCount(0); + }); + + test("skips non-object rows and rows without a string label", async () => { + onRpc("address_auto_complete", () => [ + null, + "plain", + 12, + [], + {address_string: 1}, + {address_ref: 2}, + {address_string: "kept"}, + ]); + await mountAutocompleteForm(); + await typeAndWait("abc"); + expect(queryAllTexts(".o-autocomplete--dropdown-item")).toEqual(["kept"]); + }); + + test("debounce option is used by DelayedAutoComplete.setup", async () => { + freezeTime(); + onRpc("address_auto_complete", ({args}) => { + expect.step(args[0]); + return [SUGGESTION]; + }); + await mountAutocompleteForm( + "{'function': 'address_auto_complete', 'debounce': 5, 'min_symbols': 1}" + ); + + await contains(".o_field_widget[name='address_string'] input").edit("a", { + confirm: false, + }); + await animationFrame(); + expect.verifySteps([]); + // Core AutoComplete hardcodes 250 ms; if setup ignored props.delay, + // advanceTime(5) would not fire the RPC. freezeTime() is required: + // mockedSetTimeout otherwise starts a real timer (hoot-dom time.js). + await advanceTime(4); + await animationFrame(); + expect.verifySteps([]); + await advanceTime(1); + await animationFrame(); + expect.verifySteps(["a"]); + }); +}); diff --git a/web_widget_autocomplete/tests/__init__.py b/web_widget_autocomplete/tests/__init__.py new file mode 100644 index 000000000000..adbe0b546ca2 --- /dev/null +++ b/web_widget_autocomplete/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from . import test_web_widget_autocomplete diff --git a/web_widget_autocomplete/tests/test_web_widget_autocomplete.py b/web_widget_autocomplete/tests/test_web_widget_autocomplete.py new file mode 100644 index 000000000000..3903cd121612 --- /dev/null +++ b/web_widget_autocomplete/tests/test_web_widget_autocomplete.py @@ -0,0 +1,16 @@ +# Copyright 2026 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +import odoo + +from odoo.addons.web.tests.test_js import WebSuite + + +@odoo.tests.tagged("post_install", "-at_install") +class TestWebWidgetAutocompleteHoot(WebSuite): + def get_hoot_filters(self): + self._test_params = [("+", "@web_widget_autocomplete")] + return super().get_hoot_filters() + + def test_web_widget_autocomplete(self): + self.test_unit_desktop() From 2fd32eb706baf493cbda1978ec93670d40d710fb Mon Sep 17 00:00:00 2001 From: Ivan Sokolov <2234999+ivs-cetmix@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:44:22 +0200 Subject: [PATCH 2/3] [ADD] web_widget_autocomplete_demo: city ZIP fill Keep the widget addon generic. This demo wires partner City to an in-memory list of Italian and world cities so selecting a suggestion also fills ZIP on the contact form. Task 5613 Co-authored-by: Cursor --- web_widget_autocomplete_demo/README.rst | 108 +++++ web_widget_autocomplete_demo/__init__.py | 4 + web_widget_autocomplete_demo/__manifest__.py | 18 + .../models/__init__.py | 4 + .../models/res_partner.py | 113 +++++ web_widget_autocomplete_demo/pyproject.toml | 3 + .../readme/CONFIGURE.md | 4 + .../readme/CONTRIBUTORS.md | 2 + .../readme/CREDITS.md | 2 + .../readme/DESCRIPTION.md | 7 + web_widget_autocomplete_demo/readme/USAGE.md | 5 + .../static/description/index.html | 454 ++++++++++++++++++ .../tests/__init__.py | 4 + .../tests/test_res_partner.py | 49 ++ .../views/res_partner_views.xml | 23 + 15 files changed, 800 insertions(+) create mode 100644 web_widget_autocomplete_demo/README.rst create mode 100644 web_widget_autocomplete_demo/__init__.py create mode 100644 web_widget_autocomplete_demo/__manifest__.py create mode 100644 web_widget_autocomplete_demo/models/__init__.py create mode 100644 web_widget_autocomplete_demo/models/res_partner.py create mode 100644 web_widget_autocomplete_demo/pyproject.toml create mode 100644 web_widget_autocomplete_demo/readme/CONFIGURE.md create mode 100644 web_widget_autocomplete_demo/readme/CONTRIBUTORS.md create mode 100644 web_widget_autocomplete_demo/readme/CREDITS.md create mode 100644 web_widget_autocomplete_demo/readme/DESCRIPTION.md create mode 100644 web_widget_autocomplete_demo/readme/USAGE.md create mode 100644 web_widget_autocomplete_demo/static/description/index.html create mode 100644 web_widget_autocomplete_demo/tests/__init__.py create mode 100644 web_widget_autocomplete_demo/tests/test_res_partner.py create mode 100644 web_widget_autocomplete_demo/views/res_partner_views.xml diff --git a/web_widget_autocomplete_demo/README.rst b/web_widget_autocomplete_demo/README.rst new file mode 100644 index 000000000000..f5d869de2676 --- /dev/null +++ b/web_widget_autocomplete_demo/README.rst @@ -0,0 +1,108 @@ +============================ +Web Widget Autocomplete Demo +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:10b781cac3e4b807d83bbd2b4745d5b77b9fbce594cc14f13988291ece4122c5 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/18.0/web_widget_autocomplete_demo + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-18-0/web-18-0-web_widget_autocomplete_demo + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module is a **demo** of the autocomplete Char widget +(``web_widget_autocomplete``) on the partner **City** field. + +Suggestions are an in-memory list of cities with ZIP codes: Umbria and +other well-known Italian cities first, plus a few famous world cities. +Selecting a suggestion fills **City** and **ZIP**. There is no HTTP +lookup. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Install this module. It has no settings of its own. + +The widget itself is provided by ``web_widget_autocomplete``; see that +module for the ``function``, ``min_symbols``, and ``debounce`` options. + +Usage +===== + +Open a contact form and edit **City**. Type at least one character (try +“Per”, “Rom”, “Par”, or “cit”) to see suggestions. Selecting a row +writes the city name into **City** and the matching ZIP into **ZIP**. +You can still type a city that is not in the list; that path does not +change **ZIP**. + +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 +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Cetmix + +Contributors +------------ + +- `Cetmix OÜ `__: + + - Ivan Sokolov + +Other credits +------------- + +This demo uses the autocomplete Char widget +(``web_widget_autocomplete``). The dropdown UI is Odoo Community +``web.AutoComplete``. + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +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_widget_autocomplete_demo/__init__.py b/web_widget_autocomplete_demo/__init__.py new file mode 100644 index 000000000000..c8c61089d4d3 --- /dev/null +++ b/web_widget_autocomplete_demo/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from . import models diff --git a/web_widget_autocomplete_demo/__manifest__.py b/web_widget_autocomplete_demo/__manifest__.py new file mode 100644 index 000000000000..2a493264950c --- /dev/null +++ b/web_widget_autocomplete_demo/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2026 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +{ + "name": "Web Widget Autocomplete Demo", + "summary": "Demo of the autocomplete widget on partner City", + "version": "18.0.1.0.0", + "development_status": "Beta", + "category": "Web", + "website": "https://github.com/OCA/web", + "author": "Cetmix, Odoo Community Association (OCA)", + "license": "LGPL-3", + "depends": ["web_widget_autocomplete"], + "data": [ + "views/res_partner_views.xml", + ], + "installable": True, +} diff --git a/web_widget_autocomplete_demo/models/__init__.py b/web_widget_autocomplete_demo/models/__init__.py new file mode 100644 index 000000000000..9452b602e26e --- /dev/null +++ b/web_widget_autocomplete_demo/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from . import res_partner diff --git a/web_widget_autocomplete_demo/models/res_partner.py b/web_widget_autocomplete_demo/models/res_partner.py new file mode 100644 index 000000000000..128f95bc769a --- /dev/null +++ b/web_widget_autocomplete_demo/models/res_partner.py @@ -0,0 +1,113 @@ +# Copyright 2026 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from odoo import api, models + +# Demo (city, zip) pairs for the partner City autocomplete (not official). +# Italy first (Umbria + other well-known cities), then a few world cities. +_DEMO_CITIES = ( + # Umbria + ("Amelia", "05022"), + ("Assisi", "06081"), + ("Bastia Umbra", "06083"), + ("Bevagna", "06031"), + ("Castiglione del Lago", "06061"), + ("Città della Pieve", "06062"), + ("Città di Castello", "06012"), + ("Corciano", "06073"), + ("Deruta", "06053"), + ("Foligno", "06034"), + ("Gualdo Tadino", "06023"), + ("Gubbio", "06024"), + ("Magione", "06063"), + ("Marsciano", "06055"), + ("Montefalco", "06036"), + ("Narni", "05035"), + ("Nocera Umbra", "06025"), + ("Norcia", "06046"), + ("Orvieto", "05018"), + ("Passignano sul Trasimeno", "06065"), + ("Perugia", "06121"), + ("San Gemini", "05029"), + ("Spello", "06038"), + ("Spoleto", "06049"), + ("Terni", "05100"), + ("Todi", "06059"), + ("Trevi", "06039"), + ("Umbertide", "06019"), + # Other Italy + ("Bari", "70121"), + ("Bologna", "40121"), + ("Catania", "95121"), + ("Firenze", "50122"), + ("Genova", "16121"), + ("Milano", "20121"), + ("Napoli", "80133"), + ("Padova", "35121"), + ("Palermo", "90133"), + ("Pisa", "56126"), + ("Roma", "00184"), + ("Siena", "53100"), + ("Torino", "10121"), + ("Venezia", "30124"), + ("Verona", "37121"), + # World + ("Amsterdam", "1012 AB"), + ("Athens", "10557"), + ("Barcelona", "08002"), + ("Beijing", "100000"), + ("Berlin", "10115"), + ("Brussels", "1000"), + ("Buenos Aires", "C1002"), + ("Cairo", "11511"), + ("Chicago", "60601"), + ("Dubai", "00000"), + ("Dublin", "D02 AF30"), + ("Hong Kong", "999077"), + ("Istanbul", "34122"), + ("Lisbon", "1100-148"), + ("London", "SW1A 1AA"), + ("Los Angeles", "90012"), + ("Madrid", "28013"), + ("Mexico City", "06000"), + ("New York", "10001"), + ("Paris", "75001"), + ("Prague", "11000"), + ("Rio de Janeiro", "20040-020"), + ("San Francisco", "94102"), + ("Seoul", "04524"), + ("Singapore", "018956"), + ("Stockholm", "111 57"), + ("Sydney", "2000"), + ("Tokyo", "100-0001"), + ("Toronto", "M5H 2N2"), + ("Vienna", "1010"), + ("Zurich", "8001"), +) + + +class ResPartner(models.Model): + _inherit = "res.partner" + + @api.model + def umbria_city_autocomplete(self, value): + """Return city rows (with ZIP) matching the typed City input. + + Selecting a suggestion writes ``city`` and ``zip`` on the partner. + ZIP values are demo data and may be approximate or fake. + + :param str value: current Char input (trimmed by the widget) + :return: list of dicts with ``city`` and ``zip`` keys + :rtype: list[dict] + """ + needle = (value or "").strip().lower() + rows = ( + _DEMO_CITIES + if not needle + else tuple( + (name, zipcode) + for name, zipcode in _DEMO_CITIES + if needle in name.lower() + ) + ) + return [{"city": name, "zip": zipcode} for name, zipcode in rows] diff --git a/web_widget_autocomplete_demo/pyproject.toml b/web_widget_autocomplete_demo/pyproject.toml new file mode 100644 index 000000000000..4231d0cccb3d --- /dev/null +++ b/web_widget_autocomplete_demo/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_widget_autocomplete_demo/readme/CONFIGURE.md b/web_widget_autocomplete_demo/readme/CONFIGURE.md new file mode 100644 index 000000000000..408df9f41ea1 --- /dev/null +++ b/web_widget_autocomplete_demo/readme/CONFIGURE.md @@ -0,0 +1,4 @@ +Install this module. It has no settings of its own. + +The widget itself is provided by `web_widget_autocomplete`; see that +module for the `function`, `min_symbols`, and `debounce` options. diff --git a/web_widget_autocomplete_demo/readme/CONTRIBUTORS.md b/web_widget_autocomplete_demo/readme/CONTRIBUTORS.md new file mode 100644 index 000000000000..b6413dce189f --- /dev/null +++ b/web_widget_autocomplete_demo/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Cetmix OÜ](https://cetmix.com): + - Ivan Sokolov diff --git a/web_widget_autocomplete_demo/readme/CREDITS.md b/web_widget_autocomplete_demo/readme/CREDITS.md new file mode 100644 index 000000000000..6dfb38f7210c --- /dev/null +++ b/web_widget_autocomplete_demo/readme/CREDITS.md @@ -0,0 +1,2 @@ +This demo uses the autocomplete Char widget (`web_widget_autocomplete`). +The dropdown UI is Odoo Community `web.AutoComplete`. diff --git a/web_widget_autocomplete_demo/readme/DESCRIPTION.md b/web_widget_autocomplete_demo/readme/DESCRIPTION.md new file mode 100644 index 000000000000..2baeb27d3b32 --- /dev/null +++ b/web_widget_autocomplete_demo/readme/DESCRIPTION.md @@ -0,0 +1,7 @@ +This module is a **demo** of the autocomplete Char widget +(`web_widget_autocomplete`) on the partner **City** field. + +Suggestions are an in-memory list of cities with ZIP codes: Umbria and +other well-known Italian cities first, plus a few famous world cities. +Selecting a suggestion fills **City** and **ZIP**. There is no HTTP +lookup. diff --git a/web_widget_autocomplete_demo/readme/USAGE.md b/web_widget_autocomplete_demo/readme/USAGE.md new file mode 100644 index 000000000000..ee3985c31d4d --- /dev/null +++ b/web_widget_autocomplete_demo/readme/USAGE.md @@ -0,0 +1,5 @@ +Open a contact form and edit **City**. Type at least one character +(try “Per”, “Rom”, “Par”, or “cit”) to see suggestions. Selecting a row +writes the city name into **City** and the matching ZIP into **ZIP**. +You can still type a city that is not in the list; that path does not +change **ZIP**. diff --git a/web_widget_autocomplete_demo/static/description/index.html b/web_widget_autocomplete_demo/static/description/index.html new file mode 100644 index 000000000000..0e6380da2b3d --- /dev/null +++ b/web_widget_autocomplete_demo/static/description/index.html @@ -0,0 +1,454 @@ + + + + + +Web Widget Autocomplete Demo + + + +
+

Web Widget Autocomplete Demo

+ + +

Beta License: LGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

This module is a demo of the autocomplete Char widget +(web_widget_autocomplete) on the partner City field.

+

Suggestions are an in-memory list of cities with ZIP codes: Umbria and +other well-known Italian cities first, plus a few famous world cities. +Selecting a suggestion fills City and ZIP. There is no HTTP +lookup.

+

Table of contents

+ +
+

Configuration

+

Install this module. It has no settings of its own.

+

The widget itself is provided by web_widget_autocomplete; see that +module for the function, min_symbols, and debounce options.

+
+
+

Usage

+

Open a contact form and edit City. Type at least one character (try +“Per”, “Rom”, “Par”, or “cit”) to see suggestions. Selecting a row +writes the city name into City and the matching ZIP into ZIP. +You can still type a city that is not in the list; that path does not +change ZIP.

+
+
+

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 +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Cetmix
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

This demo uses the autocomplete Char widget +(web_widget_autocomplete). The dropdown UI is Odoo Community +web.AutoComplete.

+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

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_widget_autocomplete_demo/tests/__init__.py b/web_widget_autocomplete_demo/tests/__init__.py new file mode 100644 index 000000000000..bf4ab638e086 --- /dev/null +++ b/web_widget_autocomplete_demo/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from . import test_res_partner diff --git a/web_widget_autocomplete_demo/tests/test_res_partner.py b/web_widget_autocomplete_demo/tests/test_res_partner.py new file mode 100644 index 000000000000..1246be12c02f --- /dev/null +++ b/web_widget_autocomplete_demo/tests/test_res_partner.py @@ -0,0 +1,49 @@ +# Copyright 2026 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from odoo.tests import tagged + +from odoo.addons.base.tests.common import BaseCommon + + +@tagged("post_install", "-at_install") +class TestResPartnerUmbriaCityAutocomplete(BaseCommon): + """In-memory city + ZIP suggestions for the partner City widget.""" + + def _cities(self, value): + rows = self.env["res.partner"].umbria_city_autocomplete(value) + return [row["city"] for row in rows] + + def test_empty_value_returns_known_cities(self): + """Blank, whitespace, or None input returns the full demo sample.""" + all_cities = self._cities("") + self.assertIn("Perugia", all_cities) + self.assertIn("Assisi", all_cities) + self.assertIn("Terni", all_cities) + self.assertIn("Roma", all_cities) + self.assertIn("Paris", all_cities) + self.assertGreater(len(all_cities), 10) + self.assertEqual(self._cities(" "), all_cities) + self.assertEqual(self._cities(None), all_cities) + + def test_filters_case_insensitive_substring(self): + """The needle is stripped and matched case-insensitively.""" + all_cities = self._cities("") + + def expected(needle): + return [name for name in all_cities if needle in name.lower()] + + self.assertEqual(self._cities(" PER "), expected("per")) + self.assertEqual(self._cities("ass"), expected("ass")) + self.assertEqual(self._cities("città"), expected("città")) + + def test_no_match_returns_empty_list(self): + """Unknown input yields no rows.""" + self.assertEqual(self._cities("xyzzy"), []) + + def test_rows_contain_city_and_zip(self): + """Each row carries City and ZIP so select fills both fields.""" + rows = self.env["res.partner"].umbria_city_autocomplete("Todi") + self.assertEqual(rows, [{"city": "Todi", "zip": "06059"}]) + roma = self.env["res.partner"].umbria_city_autocomplete("Roma") + self.assertEqual(roma, [{"city": "Roma", "zip": "00184"}]) diff --git a/web_widget_autocomplete_demo/views/res_partner_views.xml b/web_widget_autocomplete_demo/views/res_partner_views.xml new file mode 100644 index 000000000000..54251d88c7b5 --- /dev/null +++ b/web_widget_autocomplete_demo/views/res_partner_views.xml @@ -0,0 +1,23 @@ + + + + + res.partner.view.form.inherit.web_widget_autocomplete_demo + res.partner + + + + autocomplete + {'function': 'umbria_city_autocomplete', 'min_symbols': 1} + + + + From aea9304188c1d5ab807821099b69c7981b28878e Mon Sep 17 00:00:00 2001 From: Ivan Sokolov <2234999+ivs-cetmix@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:07:42 +0200 Subject: [PATCH 3/3] [FIX] web_widget_autocomplete: keyboard select Enter is handled by both AutoComplete and useInputField. Core only stopPropagations, so the Char hook can still commit the typed string and overwrite the highlighted row. Stop the hook and sync the input to the selected label before onSelect. Task 5613 Co-authored-by: Cursor --- .../static/src/autocomplete_field.esm.js | 40 +++++++++++++++++++ .../static/tests/autocomplete_field.test.js | 27 +++++++++++++ 2 files changed, 67 insertions(+) diff --git a/web_widget_autocomplete/static/src/autocomplete_field.esm.js b/web_widget_autocomplete/static/src/autocomplete_field.esm.js index b8db820f318d..30828ec8dce1 100644 --- a/web_widget_autocomplete/static/src/autocomplete_field.esm.js +++ b/web_widget_autocomplete/static/src/autocomplete_field.esm.js @@ -2,6 +2,7 @@ import {useChildRef, useService} from "@web/core/utils/hooks"; import {CharField, charField} from "@web/views/fields/char/char_field"; import {AutoComplete} from "@web/core/autocomplete/autocomplete"; import {_t} from "@web/core/l10n/translation"; +import {getActiveHotkey} from "@web/core/hotkeys/hotkey_service"; import {registry} from "@web/core/registry"; import {useDebounced} from "@web/core/utils/timing"; import {useInputField} from "@web/views/fields/input_field_hook"; @@ -53,6 +54,45 @@ export class DelayedAutoComplete extends AutoComplete { } }, this.props.delay); } + + /** + * Stop Enter from reaching ``useInputField``. Core AutoComplete only + * ``stopPropagation``s, so the field hook can still commit the typed + * string after (or instead of) the selected row. + * + * @param {KeyboardEvent} ev + * @returns {Promise} + */ + async onInputKeydown(ev) { + const hotkey = getActiveHotkey(ev); + if ( + hotkey === "enter" && + (this.loadingPromise || (this.isOpened && this.state.activeSourceOption)) + ) { + ev.stopImmediatePropagation(); + } + return super.onInputKeydown(ev); + } + + /** + * Keep the input in sync with the selected label before ``onSelect``. + * A later ``useInputField`` commit would otherwise still see the typed + * request string. + * + * @param {Object} option + * @param {Object} [params] + * @returns {void} + */ + selectOption(option, params = {}) { + const label = option && option.label; + if (typeof label === "string") { + this.state.value = label; + if (this.inputRef.el) { + this.inputRef.el.value = label; + } + } + return super.selectOption(option, params); + } } export class AutocompleteField extends CharField { diff --git a/web_widget_autocomplete/static/tests/autocomplete_field.test.js b/web_widget_autocomplete/static/tests/autocomplete_field.test.js index ef34266d6d75..afd6cf05ec85 100644 --- a/web_widget_autocomplete/static/tests/autocomplete_field.test.js +++ b/web_widget_autocomplete/static/tests/autocomplete_field.test.js @@ -185,6 +185,33 @@ describe("WebWidgetAutocomplete", () => { expect(field.props.record.data.id).toBe(1); }); + test("Enter on a highlighted suggestion writes the selected row", async () => { + onRpc("address_auto_complete", () => [ + SUGGESTION, + { + address_string: "Assisi, Italy", + address_ref: 2, + city: "Assisi", + }, + ]); + patchUpdateSteps(); + await mountAutocompleteForm(); + await typeAndWait("Per"); + expect(".o-autocomplete--dropdown-item").toHaveCount(2); + await contains(".o_field_widget[name='address_string'] input").press( + "ArrowDown" + ); + await animationFrame(); + await contains(".o_field_widget[name='address_string'] input").press("Enter"); + await animationFrame(); + expect.verifySteps(["update:address_ref,address_string,city"]); + expect(".o_field_widget[name='address_string'] input").toHaveValue( + "Assisi, Italy" + ); + expect(".o_field_widget[name='city'] input").toHaveValue("Assisi"); + expect(".o_field_widget[name='address_ref'] input").toHaveValue("2"); + }); + test("readonly extra is included in the select update", async () => { onRpc("address_auto_complete", () => [SUGGESTION]); patchUpdateSteps();