Skip to content

[18.0][ADD] web_widget_autocomplete: Char autocomplete widget - #3667

Open
ivs-cetmix wants to merge 3 commits into
OCA:18.0from
cetmix:18.0-add-web_widget_autocomplete
Open

ivs-cetmix wants to merge 3 commits into
OCA:18.0from
cetmix:18.0-add-web_widget_autocomplete

Conversation

@ivs-cetmix

@ivs-cetmix ivs-cetmix commented Sep 14, 2026 •

Copy link
Copy Markdown
Member

Depends on:

Odoo 18 has no backend Char widget that typeahead-fills from a consumer-supplied model method. Partner autocomplete is IAP-only, and web_widget_dropdown_dynamic is a closed select, not typeahead.

This addon registers widget="autocomplete" on Char fields, calls a public @api.model method with the typed string, and on select writes the Char plus any extra Char/Integer keys that exist on the model and in the view.

Task 5613

Made with AI assistance - mostly Cursor - checked manually. If someone is going to call this "yet another AI slope" - do it without using your Claude while reviewing this PR 😆

There is also a dedicated module for the functional testing, it's installed automatically on runboat deployments.
Test flow is really simple and also allows you to discover beautiful cities of Umbria:

  • Open a contact form and edit City. Type at least three characters
    (try “Per”, “Ass”, or “cit”) to see Umbria cities.
  • Selecting a row writes that name into City. You can still type a city that is not in the list.

@OCA-git-bot OCA-git-bot added series:18.0 mod:web_widget_autocomplete Module web_widget_autocomplete labels Sep 14, 2026
@ivs-cetmix
ivs-cetmix marked this pull request as draft September 14, 2026 19:04
@ivs-cetmix
ivs-cetmix force-pushed the 18.0-add-web_widget_autocomplete branch 5 times, most recently from ec5664b to 977906b Compare September 14, 2026 21:16
@OCA-git-bot OCA-git-bot added the mod:web_widget_autocomplete_demo Module web_widget_autocomplete_demo label Sep 14, 2026
@ivs-cetmix
ivs-cetmix force-pushed the 18.0-add-web_widget_autocomplete branch from 9089de6 to f5e428d Compare September 14, 2026 21:51
@ivs-cetmix
ivs-cetmix marked this pull request as ready for review September 14, 2026 22:00
@ivs-cetmix
ivs-cetmix force-pushed the 18.0-add-web_widget_autocomplete branch 3 times, most recently from 81ee7f7 to c9e5c8c Compare September 15, 2026 19:31
@OCA-git-bot OCA-git-bot added the mod:web_widget_pattern Module web_widget_pattern label Sep 15, 2026
@ivs-cetmix
ivs-cetmix force-pushed the 18.0-add-web_widget_autocomplete branch from c9e5c8c to b68e45b Compare September 15, 2026 19:53
@OCA-git-bot OCA-git-bot removed the mod:web_widget_pattern Module web_widget_pattern label Sep 15, 2026
@ivs-cetmix
ivs-cetmix force-pushed the 18.0-add-web_widget_autocomplete branch from 06d16a9 to b255fcb Compare September 15, 2026 20:11

@Aldeigja Aldeigja left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Functional LGTM

@ivs-cetmix
ivs-cetmix force-pushed the 18.0-add-web_widget_autocomplete branch 2 times, most recently from f0fba17 to 2fd32eb Compare September 16, 2026 09:15
@ivs-cetmix

Copy link
Copy Markdown
Member Author

Hey @OCA/web-maintainers don't get me wrong - not asking for a fast track here, but would appreciate someone having a look at this. It's already being in use, however I cannot review my code myself 😄

@yostashiro

Copy link
Copy Markdown
Member

Nice feature! Tried it on Runboat, and the value selection didn't always seem to work when I picked a value using the up/down arrow keys + Enter. It worked fine when I clicked on the value.
2026-09-16_22-05-19

@hbrunn

hbrunn commented Sep 16, 2026

Copy link
Copy Markdown
Member

@ivs-cetmix much less pushiness please. I planned to review this on Friday, but will bump it down the list in order not to reward such behavior

@ivs-cetmix

Copy link
Copy Markdown
Member Author

@yostashiro thank you, will check!
@hbrunn I wasn't aware of your list, noted.

@ivs-cetmix

Copy link
Copy Markdown
Member Author

@yostashiro this was fixed, could you please check it again?

@yostashiro yostashiro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Functional test. LGTM.

Comment on lines +37 to +56
setup() {
super.setup();
this.debouncedProcessInput = useDebounced(async () => {
const currentPromise = this.pendingPromise;
this.pendingPromise = null;
this.props.onInput({
inputValue: this.inputRef.el.value,
});
try {
await this.open(true);
currentPromise.resolve();
} catch {
currentPromise.reject();
} finally {
if (currentPromise === this.loadingPromise) {
this.loadingPromise = null;
}
}
}, this.props.delay);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this do instead of the setup override?

Suggested change
setup() {
super.setup();
this.debouncedProcessInput = useDebounced(async () => {
const currentPromise = this.pendingPromise;
this.pendingPromise = null;
this.props.onInput({
inputValue: this.inputRef.el.value,
});
try {
await this.open(true);
currentPromise.resolve();
} catch {
currentPromise.reject();
} finally {
if (currentPromise === this.loadingPromise) {
this.loadingPromise = null;
}
}
}, this.props.delay);
}
get timeout() {
return this.props.delay;
}
set timeout(_value) {}

This depends on how core keeps this.timeout in 18.0 (18.0 implementation seems to be an outlier), so we might want to mention that.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yostashiro Noted, will check, thank you!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. DelayedAutoComplete has a timeout getter that returns props.delay, and a setter that ignores the this.timeout = 250 write in Odoo 18 AutoComplete.setup().

@Rad0van Rad0van left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edited: the suggestions now cover every field type, not just the relational ones. Details below.

Tested this on a clean 18.0 CE database, with a model carrying one field of every type in the form view and a lookup method returning a value for each of them.

onSelect filters targets by field type before it ever looks at the value, so only char and integer are ever written. Everything else — float, boolean, date, datetime, selection, text, html, monetary, many2one, one2many, many2many — is dropped silently: no write, no warning, no console error, no log line.

That restriction is the widget's own, not Odoo's. Calling record.update() directly on the same record, every one of these applies and persists:

{area: 449.51}  {is_capital: true}  {founded: <DateTime>}  {region: "center"}
{description: "..."}  {country_id: 109}  {country_id: [109, "Italy"]}
{tag_ids: [[6, 0, [1, 2]]]}  {line_ids: [[0, 0, {name: "x", qty: 3}]]}

The two suggestions below replace the type chain with a coerceFieldValue helper that handles the full set. With them applied, a single suggestion row filled all eleven fields at once and every one survived the save (verified in Postgres):

target value written
char / text 06131 / Capital of Umbria
integer / float 165683 / 449.51
boolean true
date 1540-06-01
selection center
many2one Italy
many2many two tags
one2many one line

Rejection was tested just as explicitly. A row carrying area: "not-a-number", is_capital: "yes", founded: "31/12/1999", region: "nonexistent-option", description: 12345 left every one of those fields untouched while the valid zip on the same row still applied — no dialog, no traceback, no half-written record.

Three findings shaped the helper:

  1. Only ORM command tuples work for x2many. [[6,0,ids]] and [[0,0,vals]] apply; bare id lists [1,2], name strings and plain dict lists are dropped by record.update just as silently. Whatever shape is chosen needs documenting in USAGE.md.
  2. A bare many2one passthrough is not safe. record.update({country_id: "Italy"}) is accepted into the datapoint without complaint and only dies at save, with an opaque Odoo Server Error; a non-existent id silently becomes false.
  3. The rows come from a public @api.model method, so an unfiltered passthrough would let a payload issue [[2, id]] (unlink), [[5]] (clear) or [[4, id]] (link to a record the user may not be able to read). Hence the opcode whitelist. Tested: a row sending [(5,)] and [(2, 1)] against a record that already held two tags and a line left both intact.

One trap worth flagging for anyone reviewing the helper: deserializeDate does not throw on an unparseable string — Luxon hands back an invalid DateTime, which lands in the field as the literal text Invalid DateTime. A try/catch alone does not catch it; isValid has to be checked. My first draft had that bug and only live testing surfaced it.

Separately, in the same spirit as the silent type filter: loadSuggestions has a bare catch { return []; }, so every RPC error is indistinguishable from "no matches". Worth at least a console.warn.

Comment on lines +194 to +199
} else if (fieldType === "integer") {
const number = Number(value);
if (Number.isFinite(number)) {
changes[key] = number;
}
}

@Rad0van Rad0van Sep 17, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amending my earlier suggestion here: it only reached many2one / one2many / many2many, so float, boolean, date, datetime, selection, text, html and monetary targets were still being dropped. This replaces the whole type chain with a single call to a coerceFieldValue helper, which covers every type.

This suggestion needs the companion one on the imports/constants block at the top of the file — the two together are the complete change.

Suggested change
} else if (fieldType === "integer") {
const number = Number(value);
if (Number.isFinite(number)) {
changes[key] = number;
}
}
const coerced = coerceFieldValue(record.fields[key], value);
if (coerced !== undefined) {
changes[key] = coerced;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied with the helper in the field-type update. onSelect uses coerceFieldValue for every active field.

Comment on lines +1 to +11
import {useChildRef, useService} from "@web/core/utils/hooks";
import {CharField, charField} from "@web/views/fields/char/char_field";
import {AutoComplete} from "@web/core/autocomplete/autocomplete";
import {_t} from "@web/core/l10n/translation";
import {getActiveHotkey} from "@web/core/hotkeys/hotkey_service";
import {registry} from "@web/core/registry";
import {useDebounced} from "@web/core/utils/timing";
import {useInputField} from "@web/views/fields/input_field_hook";

const DEFAULT_MIN_SYMBOLS = 3;
const DEFAULT_DELAY = 250;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Companion to the suggestion on onSelect below — this adds the coerceFieldValue helper and the one import it needs. Commit both.

Every value is validated per type and anything unusable is rejected rather than written, which matters because the rows come from a public @api.model method:

  • x2many: only ORM command tuples, and only the non-destructive opcodes 0 create / 4 link / 6 replace-all, so a suggestion can never issue [[2, id]] (unlink) or [[5]] (clear).
  • many2one: false, an integer id, or [id, display_name]. Anything else is refused — record.update({country_id: "Italy"}) is otherwise accepted into the datapoint silently and only dies at save with an opaque Odoo Server Error.
  • selection: the value must exist in field.selection.
  • date / datetime: deserializeDate does not throw on an unparseable string — Luxon returns an invalid DateTime, which renders in the field as literal Invalid DateTime. My first draft of this helper had exactly that bug; hence the isValid check.
Suggested change
import {useChildRef, useService} from "@web/core/utils/hooks";
import {CharField, charField} from "@web/views/fields/char/char_field";
import {AutoComplete} from "@web/core/autocomplete/autocomplete";
import {_t} from "@web/core/l10n/translation";
import {getActiveHotkey} from "@web/core/hotkeys/hotkey_service";
import {registry} from "@web/core/registry";
import {useDebounced} from "@web/core/utils/timing";
import {useInputField} from "@web/views/fields/input_field_hook";
const DEFAULT_MIN_SYMBOLS = 3;
const DEFAULT_DELAY = 250;
import {useChildRef, useService} from "@web/core/utils/hooks";
import {CharField, charField} from "@web/views/fields/char/char_field";
import {AutoComplete} from "@web/core/autocomplete/autocomplete";
import {deserializeDate, deserializeDateTime} from "@web/core/l10n/dates";
import {_t} from "@web/core/l10n/translation";
import {getActiveHotkey} from "@web/core/hotkeys/hotkey_service";
import {registry} from "@web/core/registry";
import {useDebounced} from "@web/core/utils/timing";
import {useInputField} from "@web/views/fields/input_field_hook";
const DEFAULT_MIN_SYMBOLS = 3;
const DEFAULT_DELAY = 250;
const X2M_SAFE_COMMANDS = [0, 4, 6];
/**
* Coerce a suggestion value to what ``record.update`` expects for ``field``.
*
* Rows come from a public model method, so every value is validated here and
* anything unusable is rejected rather than written. x2many values must be ORM
* command tuples, and only the non-destructive opcodes are honoured
* (0 = create, 4 = link, 6 = replace all), so a suggestion can never clear or
* unlink existing records.
*
* @param {Object} field entry of ``record.fields``
* @param {*} value raw value from the suggestion row
* @returns {*} the value to write, or ``undefined`` to skip the key
*/
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 (field.selection || []).some(([option]) => option === value)
? value
: undefined;
case "date":
case "datetime":
if (typeof value !== "string") {
return undefined;
}
try {
// Luxon returns an *invalid* DateTime rather than throwing on
// a string it cannot parse, so ``isValid`` has to be checked.
const parsed =
field.type === "date"
? deserializeDate(value)
: deserializeDateTime(value);
return parsed.isValid ? parsed : undefined;
} catch {
return undefined;
}
case "many2one":
if (value === false || Number.isInteger(value)) {
return value;
}
return Array.isArray(value) && Number.isInteger(value[0])
? [value[0], value[1]]
: undefined;
case "one2many":
case "many2many":
return Array.isArray(value) &&
value.length &&
value.every(
(command) =>
Array.isArray(command) &&
X2M_SAFE_COMMANDS.includes(command[0])
)
? value
: undefined;
default:
return undefined;
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @Rad0van , thank you very much for feedback, really useful! Will check this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is in the branch. onSelect passes each active field through coerceFieldValue.

@OCA-git-bot OCA-git-bot added the mod:web_editor_disable_chatgpt Module web_editor_disable_chatgpt label Sep 18, 2026

@Rad0van Rad0van left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-tested 485038b7 on the same 18.0 CE bed. The type coverage works as documented — one suggestion row filled all eleven field types and they persisted; a bare many2one id resolves its display name; "not-a-date-at-all" is skipped without throwing (so dropping the try/catch and keeping isValid is right); False clears many2one, selection and date; [(6, 0, [])] clears a relation. Nice.

Two follow-ups, one cosmetic and one a real incompatibility.

1. USAGE.md should build commands with odoo.fields.Command

The lookup method is Python, and the ORM is explicit about this (odoo/fields.py, Command docstring):

Via Python, we encourage developers craft new commands via the various functions of this namespace. […] Via RPC, it is impossible nor to use the functions nor the command constant names. It is required to instead write the literal 3-elements tuple where the first element is the integer identifier of the command.

So the JS validator is right to stay integer-based, but the documented consumer example should use the namespace. Verified over the wire — Command is an IntEnum, so it serialises to exactly the integers the widget already accepts:

Python in the method JSON delivered to the widget Validator
Command.create({...}) [0, 0, {...}] accepted
Command.link(12) [4, 12, 0] accepted
Command.set(ids) [6, 0, ids] accepted
Command.set([]) [6, 0, []] accepted — clears
Command.clear() [5, 0, 0] rejected

That last row is worth calling out in the docs: Command.clear() is the obvious helper to reach for and it does not work. Command.set([]) is the supported way to empty a relation.

While that paragraph is being rewritten: For example, [(4, 12)] links record 12 should not ship in the docs. Database ids are not portable, and a documented example is the one thing consumers paste verbatim — copying Command.link(12) links whatever record 12 happens to be in their database. The suggestion below derives the ids the way real code does, via a search on the target model, and keeps id / ids only as signature placeholders.

2. The validator rejects what Odoo's own JS command builders emit

@web/core/orm_service exports x2ManyCommands, whose builders use false rather than 0 for the unused operand:

create(virtualID, values) { return [x2ManyCommands.CREATE, virtualID || false, values]; }
link(id)                  { return [x2ManyCommands.LINK, id, false]; }
set(ids)                  { return [x2ManyCommands.SET, false, ids]; }

isSupportedCommand compares that operand with === 0, so every one of them is refused:

Command isSupportedCommand
[0, 0, {...}] (Python Command.create) accepted
[0, false, {...}] (JS x2ManyCommands.create) rejected
[4, 1, 0] (Python Command.link) accepted
[4, 1, false] (JS x2ManyCommands.link) rejected
[6, 0, [1, 2]] (Python Command.set) accepted
[6, false, [1, 2]] (JS x2ManyCommands.set) rejected

I checked that this is over-strict rather than protective: record.update() accepts all three false forms and applies them correctly ([6, false, [1,2]] set both tags, [0, false, {...}] created the line, [4, 1, false] linked). Suggestion below treats 0 and false as the same empty operand.

3. The tests should build commands with x2ManyCommands

import {x2ManyCommands} from "@web/core/orm_service";

tag_ids: [x2ManyCommands.set([1, 2])],
line_ids: [x2ManyCommands.create(false, {name: "Visit"})],

Not only for readability. A literal [[6, 0, [1, 2]]] in a test restates the
validator's own assumption, so the test can never disagree with the
implementation — which is precisely why the mismatch in point 2 went unnoticed.
Building the payload with core's own helper turns the test into a contract check
against Odoo: the day core's shape and the widget's expectation diverge, the
suite fails instead of a consumer. Had the tests been written this way, the
=== 0 bug would have shown up on the first run.

And the ids themselves should come from the fixture, not be literals. There
is no recordset in HOOT, but the fixture plays that role:

class Country extends models.Model {
    _records = [{id: 1, name: "Italy"}, {id: 2, name: "France"}];
}

so tag_ids: [[4, 2]] is a magic number meaning "France" with nothing at the
call site saying so. Referencing the record survives anyone renumbering or
reordering the fixture, and makes the assertion self-describing:

const [ITALY, FRANCE] = Country._records;

tag_ids: [x2ManyCommands.set([ITALY.id, FRANCE.id])],
country_id: ITALY.id,
expect(values.tag_ids).toEqual([x2ManyCommands.link(FRANCE.id)]);

This compounds with the helper change: the line stops reading [[6, 0, [1, 2]]]
and starts saying what it does. The current fixture makes the case by itself —
tag_ids is a many2many onto country, so [[4, 2]] links France as a tag,
which named constants would have made obvious. (SUGGESTION.address_ref = 12 is
not an offender: that field is a plain Integer, not a reference.)

Worth keeping a few explicit literal cases too, labelled as the shape a Python
Command.* consumer actually sends ([6, 0, ids], [4, id, 0]), so both
origins stay covered. Two footnotes for whoever writes them: x2ManyCommands
has no SET-with-ids-and-zero variant, so the literal form is the only way to
express the Python shape; and create(virtualID, values) mutates its argument
(delete values.id), which matters if a shared fixture object is reused across
assertions.

Comment on lines +13 to +48
/**
* 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
)
);

@Rad0van Rad0van Sep 18, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@web/core/orm_service builds these commands with false in the unused operand, not 0:

create(virtualID, values) { return [x2ManyCommands.CREATE, virtualID || false, values]; }
link(id)                  { return [x2ManyCommands.LINK, id, false]; }
set(ids)                  { return [x2ManyCommands.SET, false, ids]; }

The === 0 checks on lines 29, 36 and 43 therefore reject every command Odoo's own JS helpers produce. I confirmed this is over-strict rather than protective — record.update() accepts and correctly applies [6, false, [1, 2]], [0, false, {...}] and [4, 1, false].

Worth noting that core's own doc comment directly above these builders describes the unused operand as 0 ("either the related record id ... either 0 (commands create, clear and set)") while every builder emits false. Core contradicts itself, so accepting both is the only safe reading.

Treating 0 and false as the same empty operand fixes it without loosening anything else:

Suggested change
/**
* 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
)
);
/**
* Tell whether an operand is the unused placeholder of a command.
*
* Python's ``Command`` helpers put ``0`` there while the JS
* ``x2ManyCommands`` helpers put ``false``; both reach ``record.update``.
*
* @param {*} operand second or third element of a command
* @returns {Boolean} whether the operand carries no value
*/
function isEmptyOperand(operand) {
return operand === 0 || operand === false;
}
/**
* 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 &&
isEmptyOperand(id) &&
values !== null &&
typeof values === "object" &&
!Array.isArray(values)
);
case 4:
return (
(command.length === 2 ||
(command.length === 3 && isEmptyOperand(values))) &&
Number.isSafeInteger(id) &&
id > 0
);
case 6:
return (
command.length === 3 &&
isEmptyOperand(id) &&
Array.isArray(values) &&
values.every(
(recordId) => Number.isSafeInteger(recordId) && recordId > 0
)
);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like some AI over-cautiousness for me, not sure that there is any real value behind these changes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough, and you're right on reachability — I overweighted this. The payload always arrives from a Python method over JSON-RPC, where both a literal tuple and Command.* put 0 in that operand, so the false form cannot occur in the documented flow. The only way to hit it would be building test payloads with x2ManyCommands, which is circular since that was my own suggestion. Withdrawing it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for checking it and for withdrawing it. Leaving the 0 operand check as it is.

Comment on lines +29 to +33
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method is Python, so these are better built with odoo.fields.Command than with literal tuples — the ORM's own docstring encourages that namespace, and it serialises to exactly the integers the widget validates. Verified end to end on 18.0.

Suggested change
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.
Build these with `odoo.fields.Command` rather than literal tuples:
| Helper | Emitted command | Effect |
|--------|-----------------|--------|
| `Command.create(values)` | `(0, 0, values)` | create a related record |
| `Command.link(id)` | `(4, id, 0)` | link an existing record |
| `Command.set(ids)` | `(6, 0, ids)` | replace the relation |
```python
from odoo import api, models
from odoo.fields import Command
class ResPartner(models.Model):
_inherit = "res.partner"
@api.model
def city_autocomplete(self, value):
tags = self.env["res.partner.category"].search([("name", "=", "Umbria")])
return [{"city": "Perugia", "zip": "06121",
"category_id": [Command.set(tags.ids)]}]

The literal forms (0, 0, values), (4, id), (4, id, 0) and (6, 0, ids)
remain valid, and external RPC callers must use them since the helpers are not
available over RPC. Every other helper is rejected, Command.clear()
included
— it emits (5, 0, 0); use Command.set([]) to empty a relation.
Create values must use the related model's server-side field formats.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the comment, however Command is just a helper, so I think current version is totally fine. I'm personally still using tuples instead on Command myself as they are more explicit for me.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your version is correct and the table right above defines the opcodes, so this isn't a bug — it's about who the text is for.

USAGE.md is read by people who don't know this widget, and often don't know Odoo's command protocol. To them (6, 0, ids) is three magic constants: the 6 is meaningless without cross-referencing, and the 0 is a placeholder that looks like it must mean something. Command.set(ids) says what it does on sight. "More explicit for me" is the right test for your own code — you know the opcodes — but docs get read by people who aren't you, and this particular block is the bit they'll paste into their own module, so it seeds whichever idiom they carry forward.

Upstream leans the same way. The Command docstring says "Via Python, we encourage developers craft new commands via the various functions of this namespace", and core 18.0 follows its own advice — 5,754 Command.* calls across 685 addon files (create 3,498, set 1,181, link 697).

A middle ground that might suit you better than my original suggestion: keep the tuples in the reference table — that's accurate, since integers are literally what crosses the wire and what the validator checks — and use Command.* only in the worked example. The table then documents the protocol, the example demonstrates idiomatic Python, and neither misleads.

Separately, and regardless of which you pick:

For example, [(4, 12)] links record 12

That's a hardcoded database id in the example readers copy, and record 12 is something different in every database. [(4, tag.id)] with the id from a search makes the same point without inviting the paste.

Your call either way.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the tuple table. Those integers are what the widget checks and what JSON-RPC delivers. Command.* serialises to the same tuples, so either form works in Python. The table is the protocol. [(4, 12)] is only an example of that shape.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping the Command point — it is your call as author and I will not push it again. For the record I still do not like the tuple table: what I proposed was to keep it as the protocol reference and use Command.* only in the worked example, which I think reads better for someone meeting the widget for the first time. But that is a preference, and it is yours to make.

One thing in that comment is not about tuples vs Command, and I do not think it has been answered yet:

For example, [(4, 12)] links record 12

The objection there is the hardcoded id, not the syntax. Record 12 is a different record in every database, and this block is the one readers copy into their own module. [(4, tag.id)] with the id coming from a search says exactly the same thing, in the same tuple form, without inviting the paste.

Nit, not blocking.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't get me wrong, but sometimes Claude tends to post really weird things, and looks like this is just that case 😄
Anyway, thank you for the feedback!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough — that's a format illustration rather than something to paste, so the literal is fine there. Withdrawn.

Comment thread web_widget_autocomplete/readme/CONTRIBUTORS.md
@ivs-cetmix

Copy link
Copy Markdown
Member Author

Hey @Rad0van @yostashiro that you for you comments once again, code was updated, would appreciate your feedback!

@ivs-cetmix
ivs-cetmix removed the request for review from SilvioC2C September 18, 2026 10:08
@Rad0van

Rad0van commented Sep 18, 2026

Copy link
Copy Markdown

Hey @Rad0van @yostashiro that you for you comments once again, code was updated, would appreciate your feedback!

I have provided some more feedback. Please check.

@OCA-git-bot

Copy link
Copy Markdown
Contributor

This PR has the approved label and has been created more than 5 days ago. It should therefore be ready to merge by a maintainer (or a PSC member if the concerned addon has no declared maintainer). 🤖

@hbrunn hbrunn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generally, there's too much silent discarding of values going on. If something is wrong, raise, or just trust the developers using this to know what they do

then I think this can be much more versatile if the function's result looked like

[
{
    "value": "the value shown to the user/written into the field of the widget",
    "extra_values": {
        # values written on other fields in the form
    },
]

allowing for much simpler extension of the widget for ie an extra description of values, styles, whatever. And the function doesn't need to know for which field it is called, which can be useful too.

* @param {*} value JSON value returned by the autocomplete method
* @returns {*} client value, or undefined to skip the field
*/
function coerceFieldValue(field, value) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is all bullshit. call record._parseServerValues, but further leave whatever comes from the server unchanged, that's how onchange behaves, and so should this

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the direction — _parseServerValues is the right lever, and it is exactly what onchange goes through (record.js:321). It also already skips keys outside activeFields, and its x2many branch accepts a plain list of ids as well as a command list, so a good part of the current helper is reimplementing it. My own earlier comments here pushed toward the hand-rolled version; that was the wrong call.

One concrete gap if it is swapped in directly. parseServerValue's many2one branch (model/relational_model/utils.js) is:

case "many2one": {
    if (Array.isArray(value)) {
        return value;
    }
    return value ? [value.id, value.display_name] : false;
}

so a bare integer id becomes [undefined, undefined]. USAGE.md currently documents "A positive integer ID" as a supported Many2one value, and I checked that form working against the code as it stands today. Going through _parseServerValues would mean either documenting {id, display_name} instead, or keeping a small shim that turns an integer into one before handing the values over.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my own comment above first: I wrote that the x2many branch "accepts a plain list of ids as well as a command list". That is only true when there is no existing StaticList, i.e. on initial load. On an update currentValues already holds one, so it takes the else if (valueIsCommandList) path, which is false for a bare id list, and the value is ignored. Sorry for the noise.

Having then actually run it on 18.0, the direction is right for scalars but there is a real gap on x2many that I think needs deciding before this is rewritten.

_applyCommands (model/relational_model/static_list.js:449) destructures SET from x2ManyCommands, but the switch has no case SET: and no default: — only CREATE, UPDATE, DELETE, UNLINK and LINK. A (6, 0, ids) command therefore falls through and does nothing.

SET is handled elsewhere, in Record._preprocessX2manyChanges (model/relational_model/record.js:901), which is on the update() path:

case x2ManyCommands.SET:
    await list._replaceWith(command[2]);
    break;
default:
    await list._applyCommands([command]);

_replaceWith is async and fetches the records through model._loadRecords. _parseServerValues never reaches it.

Measured on a live record, same payload each time:

value record.update() record._parseServerValues()
tag_ids: [[6, 0, [1, 2]]] 2 records, names resolved 0 records
tag_ids: [[4, 1]] — 1 record
line_ids: [[0, 0, {...}]] — 1 record
country_id: 109 resolves [null, null]
country_id: {id, display_name} — resolves

Scalars all came through correctly, including date, selection and the numeric types, so parseServerValue is clearly the right tool there.

But (6, 0, ids) is what USAGE.md documents for replacing a relation, and it is the natural command for an autocomplete that fills one. Going through _parseServerValues alone would make it silently do nothing. So it needs either a SET special case routed to _replaceWith the way update() does it, or the documented payload for x2many has to change to the shape onchange actually sends rather than ORM commands.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staying on record.update after coerceFieldValue.

_parseServerValues is the read / onchange-response parser. When the x2many already has a list it calls _applyCommands, and that switch has no SET case (static_list.js). SET is applied in Record._preprocessX2manyChanges, which update() runs. parseServerValue for a many2one does [value.id, value.display_name], so a bare integer id becomes [undefined, undefined]. A positive integer id and (6, 0, ids) are both documented and both go through update().

Same answer for raising on a bad extra key, and for reshaping rows as {value, extra_values}. The method contract is field-name keys. A bad extra is skipped so the selected label still applies. That is a different API, not a bug in this one.

if (this.props.context) {
kwargs.context = this.props.context;
}
const result = await this.orm.silent.call(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why silent?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

orm.silent only passes {silent: true} on the RPC. The loading indicator skips those calls, and this fires on every pause, so without it the bar flashes while typing. It does not hide the error. The catch was what swallowed it.

}
return this.mapSuggestions(result);
} catch (error) {
console.warn("Autocomplete suggestions could not be loaded.", error);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be sensible to notify the user too

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. On failure the dropdown stays empty and a danger notification is shown, using the server message when the RPC error has one.

delay: {type: Number, optional: true},
context: {type: Object, optional: true},
// Other addons patch CharField.props after this class is defined.
"*": true,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you should be able to avoid this by returning props dynamically

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. "*": true is replaced by a static props getter that spreads CharField.props when OWL validates. Patches from web_widget_pattern and web_widget_char_size are included without accepting unknown props.

try {
const kwargs = {};
if (this.props.context) {
kwargs.context = this.props.context;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

call makeContext on this

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractProps stores dynamicInfo.context. That getter is getFieldContext, which already calls makeContext on the arch context (utils.js). The value sent on the RPC is that object, so calling makeContext again would only copy it.

=============== ================= ==================================
``function`` str (method name) none — no RPC, Char still editable
``min_symbols`` int ``3``
``debounce`` int (ms) ``250``

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's not how the option is called, this is also wrong above

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @hbrunn , thank you very much for your feedback! Could you please explain this - what's exactly is wrong here?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's delay

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

misread this, it indeed uses debounce as name in the options, but delay would be better anyways i think

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving the option name as debounce. It matches the arch option, extractProps, and USAGE. delay is only the prop on the AutoComplete subclass.

@Rad0van

Rad0van commented Sep 22, 2026

Copy link
Copy Markdown

Ran the suites and the demo on a fresh 18.0 CE database.

HOOT: 20 passed, 0 failed. Demo: 4 Python tests pass. Field-type behaviour also checks out by hand — all eleven types fill from one selection and survive the save, invalid values are skipped individually while valid keys on the same row still apply, (5,) and (2, id) are refused and leave existing relations intact, a bare many2one id resolves, and an unparseable date no longer lands in the field as Invalid DateTime.

One gap worth closing before merge: the demo doesn't demonstrate the feature this PR just added. It fills city and zip, both Char, so nothing on runboat shows a float, boolean, date, selection or relational target being filled — which is the whole point of the field-type work.

Filling country_id for every row and state_id for the Umbrian ones covers the relational case with base data only. The data rows become (city, zip, country_code, it_province):

("Perugia", "06121", "IT", "pg"),
("Roma", "00184", "IT", None),
("Paris", "75001", "FR", None),

and the method resolves them:

    @api.model
    def umbria_city_autocomplete(self, value):
        needle = (value or "").strip().lower()
        rows = (
            _DEMO_CITIES
            if not needle
            else tuple(row for row in _DEMO_CITIES if needle in row[0].lower())
        )
        countries = self.env["res.country"].search(
            [("code", "in", list({row[2] for row in rows if row[2]}))]
        )
        country_by_code = {country.code: country.id for country in countries}
        result = []
        for name, zipcode, country_code, province in rows:
            row = {"city": name, "zip": zipcode}
            country_id = country_by_code.get(country_code)
            if country_id:
                row["country_id"] = country_id
            state = province and self.env.ref(
                "base.state_it_%s" % province, raise_if_not_found=False
            )
            if state:
                row["state_id"] = state.id
            result.append(row)
        return result

One country search per request rather than per row, and raise_if_not_found=False so a missing province is skipped rather than raising.

test_rows_contain_city_and_zip needs updating with it — it asserts the exact dict, so it fails otherwise. I checked that, and the three city classes are worth asserting separately:

        italy = self.env.ref("base.it")
        perugia = self.env.ref("base.state_it_pg")
        france = self.env.ref("base.fr")
        self.assertEqual(
            self.env["res.partner"].umbria_city_autocomplete("Todi"),
            [{"city": "Todi", "zip": "06059",
              "country_id": italy.id, "state_id": perugia.id}],
        )
        self.assertEqual(
            self.env["res.partner"].umbria_city_autocomplete("Roma"),
            [{"city": "Roma", "zip": "00184", "country_id": italy.id}],
        )
        self.assertEqual(
            self.env["res.partner"].umbria_city_autocomplete("Paris"),
            [{"city": "Paris", "zip": "75001", "country_id": france.id}],
        )

Tested on 18.0 CE: the 4 demo tests pass with this, and picking Spoleto on a partner form fills City, ZIP, State = Perugia (IT) and Country = Italy, all persisted on save. The rest of the data change is mechanical, happy to push it as a PR to your branch if useful.

Not reviewed: the web_editor_disable_chatgpt commit that is also on this branch.

@ivs-cetmix

Copy link
Copy Markdown
Member Author

Hey @hbrunn , thank you for your review, that was really really useful!
Could you please have a look at this once you have a free minute?

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
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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

mod:web_editor_disable_chatgpt Module web_editor_disable_chatgpt mod:web_widget_autocomplete_demo Module web_widget_autocomplete_demo mod:web_widget_autocomplete Module web_widget_autocomplete series:18.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants