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", + }, ], }); diff --git a/web_widget_autocomplete/README.rst b/web_widget_autocomplete/README.rst new file mode 100644 index 000000000000..eacc04fa0066 --- /dev/null +++ b/web_widget_autocomplete/README.rst @@ -0,0 +1,215 @@ +======================= +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. 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 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. + +.. 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..63bb47e14900 --- /dev/null +++ b/web_widget_autocomplete/static/description/index.html @@ -0,0 +1,586 @@ + + + + + +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. 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 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.

+
+<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..3bd08ed4f649 --- /dev/null +++ b/web_widget_autocomplete/static/src/autocomplete_field.esm.js @@ -0,0 +1,316 @@ +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}, + }; + static defaultProps = { + ...AutoComplete.defaultProps, + delay: DEFAULT_DELAY, + }; + get timeout() { + return this.props.delay; + } + set timeout(_value) { + // Keep the configured delay when Odoo 18 assigns its default in setup. + } + + /** + * 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, + }; + 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 (error) { + 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..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..a64edfd83880 --- /dev/null +++ b/web_widget_autocomplete/static/tests/autocomplete_field.test.js @@ -0,0 +1,568 @@ +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(); + 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("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]+", + }; + }, + }); + 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(message); + expect(error).toBeInstanceOf(Error); + }, + }); + onRpc("address_auto_complete", () => { + throw new Error("rpc failed"); + }); + await mountAutocompleteForm(); + await typeAndWait("abc"); + expect.verifySteps(["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() 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} + + + +