From 6b5bb8f9f6b0281269b503310fdf096426144200 Mon Sep 17 00:00:00 2001 From: Ivan Sokolov <2234999+ivs-cetmix@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:34:50 +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. min_symbols 0 opens the list on focus and passes False, so the same widget works as a dropdown before the user types. A selection can also fill other fields that are on the record. Task 5613 --- web_widget_autocomplete/README.rst | 229 +++++++ 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 | 4 + web_widget_autocomplete/readme/CREDITS.md | 2 + web_widget_autocomplete/readme/DESCRIPTION.md | 9 + web_widget_autocomplete/readme/USAGE.md | 97 +++ .../static/description/index.html | 599 +++++++++++++++++ .../static/src/autocomplete_field.esm.js | 369 +++++++++++ .../static/src/autocomplete_field.xml | 22 + .../static/tests/autocomplete_field.test.js | 627 ++++++++++++++++++ web_widget_autocomplete/tests/__init__.py | 4 + .../tests/test_web_widget_autocomplete.py | 16 + 15 files changed, 2009 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..28d4b37d23c5 --- /dev/null +++ b/web_widget_autocomplete/README.rst @@ -0,0 +1,229 @@ +======================= +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 supported extra scalar and relational fields +that exist on the model and in the current view. ``min_symbols`` ``0`` +opens that list on focus and calls the method with ``False``, so the +field can be used before any typing. See Usage for supported value +formats. + +**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, or ``False`` when +``min_symbols`` is ``0`` and the field is focused with an empty input, +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 fields that exist on the **model +and in the same view** (visible or ``invisible``). Keys missing from the +model or view, unsupported field types, and invalid values are skipped. +The ``id`` key is always ignored. + +Supported extra values (Python values are converted to JSON by the RPC): + ++--------------------------+------------------------------------------+ +| Field type | Value returned by the method | ++==========================+==========================================+ +| Char, Text, HTML | String; use an empty string to clear | ++--------------------------+------------------------------------------+ +| Integer, Float, Monetary | A value convertible to a finite number, | +| | as with the existing Integer behavior | ++--------------------------+------------------------------------------+ +| Boolean | ``True`` or ``False`` | ++--------------------------+------------------------------------------+ +| Selection | An exact selection key, or ``False`` to | +| | clear | ++--------------------------+------------------------------------------+ +| Date | ``"YYYY-MM-DD"``, or ``False`` to clear | ++--------------------------+------------------------------------------+ +| Datetime | ``"YYYY-MM-DD HH:MM:SS"`` in UTC, or | +| | ``False`` to clear | ++--------------------------+------------------------------------------+ +| Many2one | A positive integer ID, | +| | ``[id, display_name]``, or ``False`` to | +| | clear | ++--------------------------+------------------------------------------+ +| One2many, Many2many | A list of the ORM commands described | +| | below | ++--------------------------+------------------------------------------+ + +Invalid dates are skipped. Datetimes are converted from UTC to the +client timezone by Odoo. A bare Many2one ID is resolved through Odoo to +obtain its display name; the related record must exist and be readable +by the user. + +Supported x2many commands are ``(0, 0, values)`` to create a related +record, ``(4, id)`` or ``(4, id, 0)`` to link an existing record, and +``(6, 0, ids)`` to replace the relation. For example, ``[(4, 12)]`` +links record 12 and ``[(0, 0, {"name": "Visit"})]`` creates a related +record. Create values must use the related model's server-side field +formats. + +**Replace removes relations omitted from the supplied IDs.** In +particular, ``[(6, 0, [])]`` clears the relation; for One2many fields, +Odoo's inverse field deletion policy can also delete removed records. An +empty command list ``[]`` makes no relation changes. Other opcodes and +malformed command shapes are rejected for that field as a whole. These +checks validate the payload format; the model method must enforce its +own authorization and Odoo access rights still apply when related +records are read or saved. + +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. If the call fails, +the dropdown stays empty and an error notification is shown. + +.. code:: xml + + + + + +.. code:: python + + @api.model + def address_auto_complete(self, value): + """Return autocomplete rows for ``value``. + + :param str | bool value: trimmed input, or ``False`` when ``min_symbols`` + is ``0`` and the input is empty + :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`` +=============== ================= ================================== + +``min_symbols`` ``0`` opens the dropdown as soon as the field is +focused, before the user types. The method is called immediately with +``False``, so it can return the full list. Typing still sends the +trimmed string. The debounce applies to typing, not to that focus call. + +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 +------------ + +- `Cetmix OÜ `__: + + - Ivan Sokolov + +- `Data Dance s.r.o. `__: + + - Radovan Skolnik + +Other credits +------------- + +The dropdown UI is Odoo Community ``web.AutoComplete`` +(``addons/web/static/src/core/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/__init__.py b/web_widget_autocomplete/__init__.py new file mode 100644 index 000000000000..2b7d565d8c44 --- /dev/null +++ b/web_widget_autocomplete/__init__.py @@ -0,0 +1 @@ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). diff --git a/web_widget_autocomplete/__manifest__.py b/web_widget_autocomplete/__manifest__.py new file mode 100644 index 000000000000..52c4c5bab776 --- /dev/null +++ b/web_widget_autocomplete/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2026 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +{ + "name": "Web Widget Autocomplete", + "summary": "Autocomplete widget", + "version": "18.0.1.0.0", + "development_status": "Beta", + "category": "Hidden", + "website": "https://github.com/OCA/web", + "author": "Cetmix, Odoo Community Association (OCA)", + "license": "LGPL-3", + "depends": ["web"], + "assets": { + "web.assets_backend": [ + "web_widget_autocomplete/static/src/**/*", + ], + "web.assets_unit_tests": [ + "web_widget_autocomplete/static/tests/**/*", + ], + }, +} diff --git a/web_widget_autocomplete/pyproject.toml b/web_widget_autocomplete/pyproject.toml new file mode 100644 index 000000000000..4231d0cccb3d --- /dev/null +++ b/web_widget_autocomplete/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_widget_autocomplete/readme/CONFIGURE.md b/web_widget_autocomplete/readme/CONFIGURE.md new file mode 100644 index 000000000000..47f4fee38baa --- /dev/null +++ b/web_widget_autocomplete/readme/CONFIGURE.md @@ -0,0 +1,5 @@ +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`. diff --git a/web_widget_autocomplete/readme/CONTRIBUTORS.md b/web_widget_autocomplete/readme/CONTRIBUTORS.md new file mode 100644 index 000000000000..affcc7be3628 --- /dev/null +++ b/web_widget_autocomplete/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +- [Cetmix OÜ](https://cetmix.com): + - Ivan Sokolov +- [Data Dance s.r.o.](https://www.datadance.eu/): + - Radovan Skolnik \<\> diff --git a/web_widget_autocomplete/readme/CREDITS.md b/web_widget_autocomplete/readme/CREDITS.md new file mode 100644 index 000000000000..89fc72ab1708 --- /dev/null +++ b/web_widget_autocomplete/readme/CREDITS.md @@ -0,0 +1,2 @@ +The dropdown UI is Odoo Community `web.AutoComplete` +(`addons/web/static/src/core/autocomplete/`). diff --git a/web_widget_autocomplete/readme/DESCRIPTION.md b/web_widget_autocomplete/readme/DESCRIPTION.md new file mode 100644 index 000000000000..c97c0de252af --- /dev/null +++ b/web_widget_autocomplete/readme/DESCRIPTION.md @@ -0,0 +1,9 @@ +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 supported extra scalar and relational fields that exist on the +model and in the current view. `min_symbols` `0` opens that list on focus and +calls the method with `False`, so the field can be used before any typing. +See Usage for supported value formats. diff --git a/web_widget_autocomplete/readme/USAGE.md b/web_widget_autocomplete/readme/USAGE.md new file mode 100644 index 000000000000..adab4dc296a1 --- /dev/null +++ b/web_widget_autocomplete/readme/USAGE.md @@ -0,0 +1,97 @@ +Use `widget="autocomplete"` on a `fields.Char` and name a public +`@api.model` method in `options`. + +The method receives the trimmed input string, or `False` when +`min_symbols` is `0` and the field is focused with an empty input, 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 +fields that exist on the **model and in the same view** (visible or +`invisible`). Keys missing from the model or view, unsupported field types, +and invalid values are skipped. The `id` key is always ignored. + +Supported extra values (Python values are converted to JSON by the RPC): + +| Field type | Value returned by the method | +|------------|------------------------------| +| Char, Text, HTML | String; use an empty string to clear | +| Integer, Float, Monetary | A value convertible to a finite number, as with the existing Integer behavior | +| Boolean | `True` or `False` | +| Selection | An exact selection key, or `False` to clear | +| Date | `"YYYY-MM-DD"`, or `False` to clear | +| Datetime | `"YYYY-MM-DD HH:MM:SS"` in UTC, or `False` to clear | +| Many2one | A positive integer ID, `[id, display_name]`, or `False` to clear | +| One2many, Many2many | A list of the ORM commands described below | + +Invalid dates are skipped. Datetimes are converted from UTC to the client +timezone by Odoo. A bare Many2one ID is resolved through Odoo to obtain its +display name; the related record must exist and be readable by the user. + +Supported x2many commands are `(0, 0, values)` to create a related record, +`(4, id)` or `(4, id, 0)` to link an existing record, and `(6, 0, ids)` to +replace the relation. For example, `[(4, 12)]` links record 12 and +`[(0, 0, {"name": "Visit"})]` creates a related record. Create values must +use the related model's server-side field formats. + +**Replace removes relations omitted from the supplied IDs.** In particular, +`[(6, 0, [])]` clears the relation; for One2many fields, Odoo's inverse +field deletion policy can also delete removed records. An empty command +list `[]` makes no relation changes. Other opcodes and malformed command +shapes are rejected for that field as a whole. These checks validate the +payload format; the model method must enforce its own authorization and +Odoo access rights still apply when related records are read or saved. + +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. If the call fails, the dropdown +stays empty and an error notification is shown. + +```xml + + + +``` + +```python +@api.model +def address_auto_complete(self, value): + """Return autocomplete rows for ``value``. + + :param str | bool value: trimmed input, or ``False`` when ``min_symbols`` + is ``0`` and the input is empty + :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` | + +`min_symbols` `0` opens the dropdown as soon as the field is focused, +before the user types. The method is called immediately with `False`, so +it can return the full list. Typing still sends the trimmed string. The +debounce applies to typing, not to that focus call. + +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..6b9f33fad125 --- /dev/null +++ b/web_widget_autocomplete/static/description/index.html @@ -0,0 +1,599 @@ + + + + + +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 supported extra scalar and relational fields +that exist on the model and in the current view. min_symbols 0 +opens that list on focus and calls the method with False, so the +field can be used before any typing. See Usage for supported value +formats.

+

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, or False when +min_symbols is 0 and the field is focused with an empty input, +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 fields that exist on the model +and in the same view (visible or invisible). Keys missing from the +model or view, unsupported field types, and invalid values are skipped. +The id key is always ignored.

+

Supported extra values (Python values are converted to JSON by the RPC):

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Field typeValue returned by the method
Char, Text, HTMLString; use an empty string to clear
Integer, Float, MonetaryA value convertible to a finite number, +as with the existing Integer behavior
BooleanTrue or False
SelectionAn exact selection key, or False to +clear
Date"YYYY-MM-DD", or False to clear
Datetime"YYYY-MM-DD HH:MM:SS" in UTC, or +False to clear
Many2oneA positive integer ID, +[id, display_name], or False to +clear
One2many, Many2manyA list of the ORM commands described +below
+

Invalid dates are skipped. Datetimes are converted from UTC to the +client timezone by Odoo. A bare Many2one ID is resolved through Odoo to +obtain its display name; the related record must exist and be readable +by the user.

+

Supported x2many commands are (0, 0, values) to create a related +record, (4, id) or (4, id, 0) to link an existing record, and +(6, 0, ids) to replace the relation. For example, [(4, 12)] +links record 12 and [(0, 0, {"name": "Visit"})] creates a related +record. Create values must use the related model’s server-side field +formats.

+

Replace removes relations omitted from the supplied IDs. In +particular, [(6, 0, [])] clears the relation; for One2many fields, +Odoo’s inverse field deletion policy can also delete removed records. An +empty command list [] makes no relation changes. Other opcodes and +malformed command shapes are rejected for that field as a whole. These +checks validate the payload format; the model method must enforce its +own authorization and Odoo access rights still apply when related +records are read or saved.

+

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. If the call fails, +the dropdown stays empty and an error notification is shown.

+
+<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 | bool value: trimmed input, or ``False`` when ``min_symbols``
+        is ``0`` and the input is empty
+    :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
+

min_symbols 0 opens the dropdown as soon as the field is +focused, before the user types. The method is called immediately with +False, so it can return the full list. Typing still sends the +trimmed string. The debounce applies to typing, not to that focus call.

+

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..763d6ecacfe6 --- /dev/null +++ b/web_widget_autocomplete/static/src/autocomplete_field.esm.js @@ -0,0 +1,369 @@ +import {useChildRef, useService} from "@web/core/utils/hooks"; +import {CharField, charField} from "@web/views/fields/char/char_field"; +import {deserializeDate, deserializeDateTime} from "@web/core/l10n/dates"; +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 {useInputField} from "@web/views/fields/input_field_hook"; + +const DEFAULT_MIN_SYMBOLS = 3; +const DEFAULT_DELAY = 250; + +/** + * Validate the supported x2many command shapes before passing them to Odoo. + * SET replaces existing relations; this is a format check, not access control. + * + * @param {*} command JSON ORM command + * @returns {Boolean} whether the command can be applied + */ +function isSupportedCommand(command) { + if (!Array.isArray(command)) { + return false; + } + const [operation, id, values] = command; + switch (operation) { + case 0: + return ( + command.length === 3 && + id === 0 && + values !== null && + typeof values === "object" && + !Array.isArray(values) + ); + case 4: + return ( + (command.length === 2 || (command.length === 3 && values === 0)) && + Number.isSafeInteger(id) && + id > 0 + ); + case 6: + return ( + command.length === 3 && + id === 0 && + Array.isArray(values) && + values.every( + (recordId) => Number.isSafeInteger(recordId) && recordId > 0 + ) + ); + default: + return false; + } +} + +/** + * Convert a suggestion value to the client format required by record.update. + * Unsupported types and invalid values are skipped independently of other keys. + * + * @param {Object} field field metadata from the record + * @param {*} value JSON value returned by the autocomplete method + * @returns {*} client value, or undefined to skip the field + */ +function coerceFieldValue(field, value) { + switch (field.type) { + case "char": + case "text": + case "html": + return typeof value === "string" ? value : undefined; + case "integer": + case "float": + case "monetary": { + const number = Number(value); + return Number.isFinite(number) ? number : undefined; + } + case "boolean": + return typeof value === "boolean" ? value : undefined; + case "selection": + return value === false || field.selection.some(([key]) => key === value) + ? value + : undefined; + case "date": + case "datetime": { + if (value === false) { + return false; + } + if (typeof value !== "string") { + return undefined; + } + const parsed = + field.type === "date" + ? deserializeDate(value) + : deserializeDateTime(value); + return parsed.isValid ? parsed : undefined; + } + case "many2one": + if (value === false) { + return false; + } + if (Number.isSafeInteger(value) && value > 0) { + return [value]; + } + return Array.isArray(value) && + value.length === 2 && + Number.isSafeInteger(value[0]) && + value[0] > 0 && + typeof value[1] === "string" + ? value + : undefined; + case "one2many": + case "many2many": + return Array.isArray(value) && value.every(isSupportedCommand) + ? value + : undefined; + default: + return undefined; + } +} + +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. + * + * Odoo 18 AutoComplete assigns `this.timeout = 250` in `setup()` before + * passing it to `useDebounced`. Ignore that assignment and provide the + * configured delay without duplicating core's debounced callback. + */ +export class DelayedAutoComplete extends AutoComplete { + static props = { + ...AutoComplete.props, + delay: {type: Number, optional: true}, + openOnFocus: {type: Boolean, optional: true}, + }; + static defaultProps = { + ...AutoComplete.defaultProps, + delay: DEFAULT_DELAY, + openOnFocus: false, + }; + get timeout() { + return this.props.delay; + } + set timeout(_value) { + // Keep the configured delay when Odoo 18 assigns its default in setup. + } + + /** + * With ``min_symbols`` 0 the list is the full set, loaded on focus. + * Clicking into the field focuses it and then clicks it; core toggles + * the list closed on that click, so swallow the click of the same gesture. + * + * @param {FocusEvent} ev + * @returns {void} + */ + onInputFocus(ev) { + super.onInputFocus(ev); + if (!this.props.openOnFocus || this.isOpened) { + return; + } + this.suppressNextClick = true; + this.open(false); + } + + /** + * Ignore the click that followed the focus which just opened the list. + * + * @returns {void} + */ + onInputClick() { + if (this.suppressNextClick) { + this.suppressNextClick = false; + return; + } + return super.onInputClick(); + } + + /** + * 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 { + static template = "web_widget_autocomplete.AutocompleteField"; + static components = { + ...CharField.components, + AutoComplete: DelayedAutoComplete, + }; + // Read ``CharField.props`` when OWL validates, not when this class is + // defined. Sibling addons patch that object afterwards. + static get props() { + return { + ...CharField.props, + function: {type: String, optional: true}, + minSymbols: {type: Number, optional: true}, + delay: {type: Number, optional: true}, + context: {type: Object, optional: true}, + }; + } + + setup() { + super.setup(); + this.orm = useService("orm"); + this.notification = useService("notification"); + 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) { + return []; + } + // An empty query with ``min_symbols`` 0 is the focus dropdown: Python + // receives False, not "". Any typed text is still the trimmed string. + let query = request; + if (request === "") { + if (minSymbols !== 0) { + return []; + } + query = false; + } else if (request.length < minSymbols) { + return []; + } + try { + const kwargs = {}; + if (this.props.context) { + kwargs.context = this.props.context; + } + // ``silent`` only skips the global loading bar. Typeahead calls + // this on each pause, so the bar would otherwise flash each time. + const result = await this.orm.silent.call( + this.props.record.resModel, + method, + [query], + kwargs + ); + if (!Array.isArray(result)) { + return []; + } + return this.mapSuggestions(result); + } catch (error) { + const serverMessage = error && error.data && error.data.message; + this.notification.add( + serverMessage || _t("Autocomplete suggestions could not be loaded."), + {type: "danger"} + ); + console.warn("Autocomplete suggestions could not be loaded.", error); + 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 coerced = coerceFieldValue(record.fields[key], value); + if (coerced !== undefined) { + changes[key] = coerced; + } + } + 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..83322208d3c4 --- /dev/null +++ b/web_widget_autocomplete/static/src/autocomplete_field.xml @@ -0,0 +1,22 @@ + + + + + + + + + + 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..86c1ead8c872 --- /dev/null +++ b/web_widget_autocomplete/static/tests/autocomplete_field.test.js @@ -0,0 +1,627 @@ +import {describe, expect, freezeTime, test} from "@odoo/hoot"; +import {advanceTime, animationFrame, runAllTimers} from "@odoo/hoot-mock"; +import { + contains, + defineModels, + fields, + findComponent, + mockService, + 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, 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(); + enabled = fields.Boolean(); + founded = fields.Date(); + visited = fields.Datetime(); + region = fields.Selection({selection: [["center", "Center"]]}); + description = fields.Text(); + notes = fields.Html(); + price = fields.Monetary({currency_field: "currency_id"}); + currency_id = fields.Many2one({relation: "res.currency"}); + country_id = fields.Many2one({relation: "country"}); + tag_ids = fields.Many2many({relation: "country"}); + line_ids = fields.One2many({relation: "line", relation_field: "partner_id"}); + hidden = fields.Char(); + _records = [ + { + id: 1, + address_string: "start", + address_ref: 7, + city: "Old", + amount: 1.5, + hidden: "secret", + }, + ]; +} + +class Country extends models.Model { + name = fields.Char(); + _records = [ + {id: 1, name: "Italy"}, + {id: 2, name: "France"}, + ]; +} + +class Line extends models.Model { + name = fields.Char(); + partner_id = fields.Many2one({relation: "partner"}); +} + +class Currency extends models.Model { + _name = "res.currency"; + name = fields.Char(); + _records = [{id: 1, name: "EUR"}]; +} + +defineModels([Partner, Country, Line, Currency]); + +const SUGGESTION = { + address_string: "Perugia, Italy", + address_ref: 12, + city: "Perugia", + amount: 9.9, + hidden: "nope", + unknown: "x", + id: 99, +}; + +const EXTRA_FIELDS = ` + + + + + + `; + +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("min_symbols 0 opens on focus and calls with false", async () => { + onRpc("address_auto_complete", ({args}) => { + expect(args[0]).toBe(false); + expect.step("focus"); + return [SUGGESTION]; + }); + await mountAutocompleteForm( + "{'function': 'address_auto_complete', 'min_symbols': 0}" + ); + await contains(".o_field_widget[name='address_string'] input").focus(); + await animationFrame(); + expect.verifySteps(["focus"]); + expect(queryAllTexts(".o-autocomplete--dropdown-item")).toEqual([ + "Perugia, Italy", + ]); + }); + + test("clicking a min_symbols 0 field keeps the focus dropdown open", async () => { + onRpc("address_auto_complete", ({args}) => { + expect(args[0]).toBe(false); + expect.step("focus"); + return [SUGGESTION]; + }); + await mountAutocompleteForm( + "{'function': 'address_auto_complete', 'min_symbols': 0}" + ); + await contains(".o_field_widget[name='address_string'] input").click(); + await animationFrame(); + expect.verifySteps(["focus"]); + expect(".o-autocomplete--dropdown-item").toHaveCount(1); + }); + + test("min_symbols 0 still sends the trimmed string after typing", async () => { + onRpc("address_auto_complete", ({args}) => { + expect.step(args[0] === false ? "false" : args[0]); + return [SUGGESTION]; + }); + await mountAutocompleteForm( + "{'function': 'address_auto_complete', 'min_symbols': 0}" + ); + await contains(".o_field_widget[name='address_string'] input").focus(); + await animationFrame(); + await typeAndWait("ab"); + expect.verifySteps(["false", "ab"]); + }); + + 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 supported extras 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,amount,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(9.9); + // 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("select fills and saves scalar and relational field types", async () => { + onRpc("address_auto_complete", () => [ + { + ...SUGGESTION, + enabled: true, + founded: "1540-06-01", + visited: "2026-09-18 12:30:00", + region: "center", + description: "Capital of Umbria", + notes: "

Perugia

", + price: 42.5, + currency_id: [1, "EUR"], + country_id: 1, + tag_ids: [[4, 2]], + line_ids: [[0, 0, {name: "Visit"}]], + }, + ]); + onRpc("web_save", ({args}) => { + const values = args[1]; + expect(values.amount).toBe(9.9); + expect(values.enabled).toBe(true); + expect(values.founded).toBe("1540-06-01"); + expect(values.visited).toBe("2026-09-18 12:30:00"); + expect(values.region).toBe("center"); + expect(values.description).toBe("Capital of Umbria"); + expect(values.notes).toBe("

Perugia

"); + expect(values.price).toBe(42.5); + expect(values.currency_id).toBe(1); + expect(values.country_id).toBe(1); + expect(values.tag_ids).toEqual([[4, 2]]); + expect(values.line_ids[0][0]).toBe(0); + expect(values.line_ids[0][2].name).toBe("Visit"); + expect.step("saved"); + }); + const view = await mountAutocompleteForm( + "{'function': 'address_auto_complete'}", + EXTRA_FIELDS + ); + await typeAndWait("Per"); + await contains(".o-autocomplete--dropdown-item").click(); + await animationFrame(); + const field = findComponent(view, (c) => c instanceof AutocompleteField); + expect(field.props.record.data.country_id).toEqual([1, "Italy"]); + expect(field.props.record.data.founded.isValid).toBe(true); + expect(field.props.record.data.visited.isValid).toBe(true); + await contains(".o_form_button_save").click(); + await animationFrame(); + expect.verifySteps(["saved"]); + expect(field.props.record.data.country_id).toEqual([1, "Italy"]); + expect(field.props.record.data.tag_ids.currentIds).toEqual([2]); + expect(field.props.record.data.line_ids.records[0].data.name).toBe("Visit"); + }); + + test("invalid extra values are skipped while the label is selected", async () => { + onRpc("address_auto_complete", () => [ + { + address_string: "Perugia", + amount: "not a number", + enabled: "yes", + founded: "31/12/1999", + visited: "invalid", + region: "missing", + description: 42, + notes: [], + price: "invalid", + country_id: "Italy", + tag_ids: [[6, 0, "bad"]], + line_ids: [[0, 0, []]], + }, + ]); + patchUpdateSteps(); + await mountAutocompleteForm( + "{'function': 'address_auto_complete'}", + EXTRA_FIELDS + ); + await typeAndWait("Per"); + await contains(".o-autocomplete--dropdown-item").click(); + await animationFrame(); + expect.verifySteps(["update:address_string"]); + expect(".o_field_widget[name='address_string'] input").toHaveValue("Perugia"); + expect(".o_field_widget[name='amount'] input").toHaveValue("1.50"); + }); + + test("replace and clear relations and optional scalar values", async () => { + const view = await mountAutocompleteForm( + "{'function': 'address_auto_complete'}", + ` + + ` + ); + const field = findComponent(view, (c) => c instanceof AutocompleteField); + await field.onSelect({ + values: { + country_id: [1, "Italy"], + tag_ids: [[6, 0, [1, 2]]], + founded: "2026-01-01", + visited: "2026-01-01 12:00:00", + region: "center", + enabled: true, + }, + }); + await field.onSelect({values: {tag_ids: [[6, 0, [2]]]}}); + expect(field.props.record.data.tag_ids.currentIds).toEqual([2]); + await field.onSelect({ + values: { + country_id: false, + tag_ids: [[6, 0, []]], + founded: false, + visited: false, + region: false, + enabled: false, + }, + }); + await contains(".o_form_button_save").click(); + await animationFrame(); + const data = field.props.record.data; + expect(data.country_id).toBe(false); + expect(data.tag_ids.currentIds).toEqual([]); + expect(data.founded).toBe(false); + expect(data.visited).toBe(false); + expect(data.region).toBe(false); + expect(data.enabled).toBe(false); + }); + + test("malformed relations and unsupported command opcodes leave existing values", async () => { + const view = await mountAutocompleteForm( + "{'function': 'address_auto_complete'}", + `` + ); + const field = findComponent(view, (c) => c instanceof AutocompleteField); + await field.onSelect({values: {country_id: 1, tag_ids: [[4, 1, 0]]}}); + patchUpdateSteps(); + for (const value of [0, -1, 1.5, "Italy", [], [1, 42], [1, "Italy", "extra"]]) { + await field.onSelect({values: {country_id: value}}); + } + for (const value of [ + [1, 2], + [[2, 1]], + [[5]], + [[1, 1, {name: "Changed"}]], + [[4, -1]], + [[4, 1, {}]], + [[6, 0, ["1"]]], + [[6, 1, []]], + [[0, 0, null]], + [ + [4, 2], + [2, 1], + ], + ]) { + await field.onSelect({values: {tag_ids: value}}); + } + expect.verifySteps([]); + expect(field.props.record.data.country_id).toEqual([1, "Italy"]); + expect(field.props.record.data.tag_ids.currentIds).toEqual([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(); + 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]+", + }; + }, + }); + patchWithCleanup(CharField.props, { + maxLength: {type: Number, optional: true}, + pattern: {type: String, optional: true}, + }); + 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 warns and yields an empty dropdown", async () => { + patchWithCleanup(console, { + warn(message, error) { + expect.step(`warn:${message}`); + expect(error).toBeInstanceOf(Error); + }, + }); + mockService("notification", { + add(message, options) { + expect.step(`notify:${options.type}:${message}`); + }, + }); + onRpc("address_auto_complete", () => { + throw new Error("rpc failed"); + }); + await mountAutocompleteForm(); + await typeAndWait("abc"); + expect.verifySteps([ + "notify:danger:Autocomplete suggestions could not be loaded.", + "warn:Autocomplete suggestions could not be loaded.", + ]); + 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 controls when suggestions are requested", 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 it 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 ebc172daac6b2ba5a5a4d157d74f9363036e2a62 Mon Sep 17 00:00:00 2001 From: Ivan Sokolov <2234999+ivs-cetmix@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:34:50 +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. Focusing the field shows the first five cities before any typing. Task 5613 --- web_widget_autocomplete_demo/README.rst | 109 +++++ web_widget_autocomplete_demo/__init__.py | 4 + web_widget_autocomplete_demo/__manifest__.py | 18 + .../models/__init__.py | 4 + .../models/res_partner.py | 117 +++++ web_widget_autocomplete_demo/pyproject.toml | 3 + .../readme/CONFIGURE.md | 4 + .../readme/CONTRIBUTORS.md | 2 + .../readme/CREDITS.md | 2 + .../readme/DESCRIPTION.md | 8 + web_widget_autocomplete_demo/readme/USAGE.md | 5 + .../static/description/index.html | 455 ++++++++++++++++++ .../tests/__init__.py | 4 + .../tests/test_res_partner.py | 58 +++ .../views/res_partner_views.xml | 23 + 15 files changed, 816 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..2257a54181c6 --- /dev/null +++ b/web_widget_autocomplete_demo/README.rst @@ -0,0 +1,109 @@ +============================ +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. +Focusing **City** shows the first five cities before any typing. +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**. The first five cities appear as +soon as the field is focused. Type to narrow the list (try “Per”, “Rom”, +“Par”, or “cit”). 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..750c53610e21 --- /dev/null +++ b/web_widget_autocomplete_demo/models/res_partner.py @@ -0,0 +1,117 @@ +# 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) for the partner City widget. + + ``False`` or any other empty input is the focus dropdown: the + first five demo cities, before the user types. A non-empty string + returns every demo city whose name contains that text. + + Selecting a suggestion writes ``city`` and ``zip`` on the partner. + ZIP values are demo data and may be approximate or fake. + + :param value: trimmed input, or ``False`` when the input is empty + :type value: str | bool | None + :return: list of dicts with ``city`` and ``zip`` keys + :rtype: list[dict] + """ + needle = (value or "").strip().lower() + if needle: + rows = tuple( + (name, zipcode) + for name, zipcode in _DEMO_CITIES + if needle in name.lower() + ) + else: + rows = _DEMO_CITIES[:5] + 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..3fbb873acb9c --- /dev/null +++ b/web_widget_autocomplete_demo/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +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. +Focusing **City** shows the first five cities before any typing. +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..e913ee0b90f5 --- /dev/null +++ b/web_widget_autocomplete_demo/readme/USAGE.md @@ -0,0 +1,5 @@ +Open a contact form and edit **City**. The first five cities appear as +soon as the field is focused. Type to narrow the list (try “Per”, +“Rom”, “Par”, or “cit”). 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..6f271de15215 --- /dev/null +++ b/web_widget_autocomplete_demo/static/description/index.html @@ -0,0 +1,455 @@ + + + + + +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. +Focusing City shows the first five cities before any typing. +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. The first five cities appear as +soon as the field is focused. Type to narrow the list (try “Per”, “Rom”, +“Par”, or “cit”). 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..c3d17ac597a9 --- /dev/null +++ b/web_widget_autocomplete_demo/tests/test_res_partner.py @@ -0,0 +1,58 @@ +# 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 +from odoo.addons.web_widget_autocomplete_demo.models.res_partner import ( + _DEMO_CITIES, +) + + +@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_first_five_cities(self): + """False, blank, whitespace, or None returns the first five cities.""" + first_five = [name for name, _zipcode in _DEMO_CITIES[:5]] + self.assertEqual( + first_five, + [ + "Amelia", + "Assisi", + "Bastia Umbra", + "Bevagna", + "Castiglione del Lago", + ], + ) + self.assertEqual(self._cities(False), first_five) + self.assertEqual(self._cities(""), first_five) + self.assertEqual(self._cities(" "), first_five) + self.assertEqual(self._cities(None), first_five) + + def test_filters_case_insensitive_substring(self): + """The needle is stripped and matched case-insensitively.""" + all_cities = [name for name, _zipcode in _DEMO_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..981048151be1 --- /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': 0} + + + + From 21cce2141d32f6cf3539dfd98cef67a3360bb02e Mon Sep 17 00:00:00 2001 From: Ivan Sokolov <2234999+ivs-cetmix@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:34:50 +0200 Subject: [PATCH 3/3] [FIX] web_editor_disable_chatgpt: wait for discard The tour can finish while asynchronous discard still leaves the form dirty. Odoo then rejects an otherwise successful tour during cleanup. Wait for the renderer's saved state before reporting completion. Reproduce the failure with a temporary 500 ms discard delay and verify that the same delayed run passes with the final synchronization step. Task 5613 --- .../tests/tours/html_editor_disable_chatgpt_tour.esm.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web_editor_disable_chatgpt/static/tests/tours/html_editor_disable_chatgpt_tour.esm.js b/web_editor_disable_chatgpt/static/tests/tours/html_editor_disable_chatgpt_tour.esm.js index e1409c4ab969..646e7641932e 100644 --- a/web_editor_disable_chatgpt/static/tests/tours/html_editor_disable_chatgpt_tour.esm.js +++ b/web_editor_disable_chatgpt/static/tests/tours/html_editor_disable_chatgpt_tour.esm.js @@ -74,5 +74,9 @@ registry.category("web_tour.tours").add("html_editor_disable_chatgpt_tour", { trigger: ".o_form_button_cancel", run: "click", }, + { + content: "Wait for discard to finish", + trigger: ".o_form_renderer.o_form_saved", + }, ], });