[18.0][ADD] web_widget_autocomplete: Char autocomplete widget - #3667
ivs-cetmix wants to merge 5 commits into
Conversation
ec5664b to
977906b
Compare
9089de6 to
f5e428d
Compare
81ee7f7 to
c9e5c8c
Compare
c9e5c8c to
b68e45b
Compare
06d16a9 to
b255fcb
Compare
fcf5214 to
f0fba17
Compare
Odoo 18 has no backend Char widget that typeahead-fills from a consumer-supplied model method. This addon reuses web.AutoComplete so integrators can hook any public @api.model method. HOOT is selected with WebSuite hash ids so sibling WebWidget* suites do not pick these tests up via fuzzy matching. Task 5613 Co-authored-by: Cursor <cursoragent@cursor.com>
Keep the widget addon generic. This demo wires partner City to an in-memory list of Italian and world cities so selecting a suggestion also fills ZIP on the contact form. Task 5613 Co-authored-by: Cursor <cursoragent@cursor.com>
f0fba17 to
2fd32eb
Compare
|
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 😄 |
|
@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 |
|
@yostashiro thank you, will check! |
Enter is handled by both AutoComplete and useInputField. Core only stopPropagations, so the Char hook can still commit the typed string and overwrite the highlighted row. Stop the hook and sync the input to the selected label before onSelect. Task 5613 Co-authored-by: Cursor <cursoragent@cursor.com>
|
@yostashiro this was fixed, could you please check it again? |
| 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); | ||
| } |
There was a problem hiding this comment.
Would this do instead of the setup override?
| 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.
There was a problem hiding this comment.
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:
- 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 byrecord.updatejust as silently. Whatever shape is chosen needs documenting inUSAGE.md. - A bare
many2onepassthrough is not safe.record.update({country_id: "Italy"})is accepted into the datapoint without complaint and only dies at save, with an opaqueOdoo Server Error; a non-existent id silently becomesfalse. - The rows come from a public
@api.modelmethod, 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.
| } else if (fieldType === "integer") { | ||
| const number = Number(value); | ||
| if (Number.isFinite(number)) { | ||
| changes[key] = number; | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| } 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; | |
| } |
| 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; |
There was a problem hiding this comment.
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
0create /4link /6replace-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 opaqueOdoo Server Error. - selection: the value must exist in
field.selection. - date / datetime:
deserializeDatedoes not throw on an unparseable string — Luxon returns an invalidDateTime, which renders in the field as literalInvalid DateTime. My first draft of this helper had exactly that bug; hence theisValidcheck.
| 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; | |
| } | |
| } |
There was a problem hiding this comment.
Hi @Rad0van , thank you very much for feedback, really useful! Will check this.
Allow suggestions to fill supported scalar and relational fields so consumers can populate complete records from one selection. Validate payload shapes and normalize many2one IDs for the Odoo 18 client API. Reuse core debounce handling and report suggestion RPC failures to aid diagnosis. Document value formats and relation replacement semantics. Validate with 20 HOOT tests (73 assertions) and pre-commit checks. 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
There was a problem hiding this comment.
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.
| /** | ||
| * 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 | ||
| ) | ||
| ); |
There was a problem hiding this comment.
@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:
| /** | |
| * 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 | |
| ) | |
| ); |
There was a problem hiding this comment.
Looks like some AI over-cautiousness for me, not sure that there is any real value behind these changes.
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
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.
| 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| - [Cetmix OÜ](https://cetmix.com): | ||
| - Ivan Sokolov |
There was a problem hiding this comment.
If you take the review work above — the field-type coercion design, the isValid date fix, the x2many opcode whitelist and the x2ManyCommands operand mismatch — I'd ask for Data Dance to be listed here. Entirely your call, and no objection if you'd rather not.
| - [Cetmix OÜ](https://cetmix.com): | |
| - Ivan Sokolov | |
| - [Cetmix OÜ](https://cetmix.com): | |
| - Ivan Sokolov | |
| - [Data Dance s.r.o.](https://www.datadance.eu/): | |
| - Radovan Skolnik \<<radovan@skolnik.info>\> |
Same shape as the existing entry, and matches how the two are listed in other OCA modules (e.g. partner_firstname, base_properties_validation).
There was a problem hiding this comment.
I'm totally fine with this and I truly appreciate your input, however I'm not sure how this should work from the OCA general practices point of view. I would kindly ask @OCA/web-maintainers for their opinion on this.
|
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. |

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_dynamicis a closed select, not typeahead.This addon registers
widget="autocomplete"on Char fields, calls a public@api.modelmethod 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:
(try “Per”, “Ass”, or “cit”) to see Umbria cities.