Свій віджет поля в Odoo 18/19 — компонент OWL, зареєстрований під власним іменем у registry.category("fields"). Спеціального базового класу Field чи старого AbstractField не існує — реєстр перевіряє кандидата одним правилом:
// Odoo core: addons/web/static/src/views/fields/field.js
fieldRegistry.addValidation({
component: { validate: (c) => c.prototype instanceof Component },
displayName: { type: String, optional: true },
extractProps: { type: Function, optional: true },
supportedTypes: { type: Array, element: String, optional: true },
});
Обов'язковий лише component — нащадок звичайного Component з @odoo/owl. Правило однакове в 18.0 і 19.0: у 19.0 додалась лише перевірка supportedTypes проти списку validFieldTypes. Решта опціональна. Розберемо на двох модулях: kw_widget_autocomplete (автодоповнення з чотирма джерелами даних) і kw_matrix_widget (рендер таблиці з клікабельними клітинками).
Реєстрація в registry
// kw_widget_autocomplete/static/src/js/kw_autocomplete_field.js
export const kwAutocompleteField = {
component: KwAutocompleteField,
displayName: "KW Autocomplete",
supportedTypes: ["char", "text"],
extractProps: ({ attrs, options }) => ({
placeholder: attrs.placeholder,
minChars: options.min_chars,
cacheSize: options.cache_size,
sourceType: options.source_type,
sourceConfig: options.source_config || {},
dependsOn: options.depends_on,
onSelectMethod: options.on_select_method,
fieldMapping: options.field_mapping,
silentErrors: options.silent_errors || false,
}),
};
registry.category("fields").add("kw_autocomplete", kwAutocompleteField);
// kw_matrix_widget/static/src/js/matrix.js
export const kwMatrixWidget = {
component: KwMatrixWidget,
supportedTypes: ["text", "char"],
};
registry.category("fields").add("kw_matrix_widget", kwMatrixWidget);
kw_matrix_widget не описує ні displayName, ні extractProps — обидва optional: true, і без них усе працює. displayName іде тільки в debug-тултип поля, рендеру не стосується.
Props, читання і запис значення
KwAutocompleteField розширює standardFieldProps (id, name, readonly, record) власними — усі опціональні:
static props = {
...standardFieldProps,
placeholder: { type: String, optional: true },
minChars: { type: Number, optional: true },
cacheSize: { type: Number, optional: true },
sourceType: { type: String, optional: true },
sourceConfig: { type: Object, optional: true },
dependsOn: { type: Array, optional: true },
onSelectMethod: { type: String, optional: true },
fieldMapping: { type: Object, optional: true },
silentErrors: { type: Boolean, optional: true },
};
Значення читається напряму з реактивного record:
get value() {
return this.props.record.data[this.props.name] || "";
}
А пишеться через record.update() — так само, як будь-яке поле форми. fieldMapping тим самим викликом заповнює і сусідні поля:
async onSelect(option) {
this._selecting = true;
try {
const updates = { [this.props.name]: option.value };
if (this.props.fieldMapping && option.data) {
for (const [targetField, sourceKey] of Object.entries(this.props.fieldMapping)) {
if (option.data[sourceKey] !== undefined) {
updates[targetField] = option.data[sourceKey];
}
}
}
this._lastCommittedValue = this._normalizeRequest(option.value);
await this.props.record.update(updates);
if (this.props.onSelectMethod && option.data) {
try {
const result = await this.orm.call(
this.props.record.resModel,
this.props.onSelectMethod,
[this.props.record.resId || false, option.data]
);
if (result && typeof result === 'object') {
if (result[this.props.name] !== undefined) {
this._lastCommittedValue = this._normalizeRequest(
result[this.props.name]
);
}
await this.props.record.update(result);
}
} catch (error) {
console.error('onSelect method error:', error);
if (!this._silentErrors) {
this.notification.add(
_t("Error processing selection"),
{ type: "danger" }
);
}
}
}
} finally {
this._selecting = false;
}
}
_selecting тримає прапорець на час запису, щоб blur, який спрацює одразу після кліку, не встиг втрутитися; _lastCommittedValue фіксує щойно записане значення — його ж нижче звіряє onBlurFallback, щоб не переписати клікнутий варіант вдруге. Плюс опційна гілка onSelectMethod: другий виклик на сервер і ще один update() з тим, що він повернув, — усе обгорнуто в try/catch.
Шаблон і статика
static template — рядок-посилання на t-name в окремому XML, підключеному через маніфест:
'assets': {
'web.assets_backend': [
'kw_widget_autocomplete/static/src/js/sources/model_method_source.js',
'kw_widget_autocomplete/static/src/js/sources/domain_search_source.js',
'kw_widget_autocomplete/static/src/js/sources/json_endpoint_source.js',
'kw_widget_autocomplete/static/src/js/sources/rest_api_source.js',
'kw_widget_autocomplete/static/src/js/kw_autocomplete_field.js',
'kw_widget_autocomplete/static/src/xml/kw_autocomplete_templates.xml',
'kw_widget_autocomplete/static/src/scss/kw_autocomplete.scss',
],
},
Файли перелічені явно, по одному, не glob-патерном — видно в diff, що саме додалось до бандла.
Джерела даних: стратегія замість if/else
Компонент не знає, звідки беруться варіанти. _createSource() збирає одну з чотирьох стратегій за sourceType:
_createSource() {
const sourceType = this.props.sourceType || 'model_method';
const config = {
...(this.props.sourceConfig || {}),
orm: this.orm,
record: this.props.record,
dependsOn: this.props.dependsOn || [],
notification: this.notification,
silent: this._silentErrors,
};
switch (sourceType) {
case 'model_method':
return new ModelMethodSource(config);
case 'domain_search':
return new DomainSearchSource(config);
case 'json_endpoint':
return new JsonEndpointSource(config);
case 'rest_api':
return new RestApiSource(config);
default:
console.warn(`Unknown source type: ${sourceType}, using model_method`);
return new ModelMethodSource(config);
}
}
Кожна стратегія — клас з одним методом fetchOptions(query), що повертає {label, value, data}[]. ModelMethodSource викликає orm.call:
async fetchOptions(query) {
if (!this.model || !this.method) {
console.warn('ModelMethodSource: model or method not configured');
return [];
}
const depValues = {};
for (const fieldName of this.dependsOn) {
const val = this.record?.data?.[fieldName];
if (val !== undefined && val !== null) {
depValues[fieldName] = Array.isArray(val) ? val[0] : val;
}
}
try {
const results = await this.orm.call(
this.model,
this.method,
[query, depValues, ...this.extraArgs]
);
return this._formatResults(results);
} catch (error) {
console.error('ModelMethodSource error:', error);
if (this.notification && !this.silent) {
this.notification.add(
`Autocomplete error: ${error.message || 'Unknown error'}`,
{ type: "warning" }
);
}
return [];
}
}
на боці Python — звичайний @api.model-метод:
@api.model
def autocomplete_cities(self, query, dep_values=None):
"""Autocomplete method - searches countries as demo."""
if not query or len(query) < 2:
return []
domain = [('name', 'ilike', query)]
countries = self.env['res.country'].search_read(
domain,
['name', 'code'],
limit=10
)
return [{
'label': '{} ({})'.format(c['name'], c['code']),
'value': c['name'],
'data': c,
} for c in countries]
у в'юсі — звичайний widget="kw_autocomplete" з options:
<field name="city_model_method" widget="kw_autocomplete"
options="{'source_type': 'model_method',
'source_config': {'model': 'kw.autocomplete.test', 'method': 'autocomplete_cities'},
'depends_on': ['country_id'], 'min_chars': 2}"/>
JsonEndpointSource замість orm.call робить fetch() на довільний URL — джерелом може бути будь-який зовнішній JSON API. RestApiSource у браузері REST API не викликає: іде через orm.call на kw_autocomplete_proxy_rest моделі kw.autocomplete.mixin, а HTTP-запит виконується на сервері — докстрінг методу так і каже: «Proxy method for REST API calls (security wrapper)». Ключ стороннього API в браузер не потрапляє.
Де стандартного механізму не вистачило
І тут починається цікаве: шаблон компонента передає в <AutoComplete> тільки onSelect — жодного onChange чи onBlur:
<AutoComplete value="props.record.data[props.name] or ''"
sources="sources" onSelect.bind="onSelect"
input="inputRef" placeholder="props.placeholder or ''"/>
Комміт у record гарантований лише тоді, коли обрано варіант зі списку. Ввели довільний текст і клацнули повз — стандартного шляху до запису вже нема: useInputField розрахований на звичайний <input>, не на внутрішній інпут AutoComplete. Тому в модулі є власний слухач blur:
const onBlurFallback = () => {
Promise.resolve().then(async () => {
if (this._destroyed || this._selecting || this.props.readonly) return;
if (this.rootRef.el?.querySelector(".o-autocomplete--dropdown-menu")) return;
const inputVal = this._normalizeRequest(inputEl.value);
if (inputVal === this._lastCommittedValue) return;
this._lastCommittedValue = inputVal;
await this.props.record.update({ [this.props.name]: inputEl.value || "" });
});
};
Важливо! Це Promise.resolve().then(), а не requestAnimationFrame — коментар у коді пояснює чому: rAF прив'язаний до циклу відмальовки браузера, а Odoo-тести запускають Chrome з --headless --disable-gpu, де цей цикл ненадійний. Затриманий rAF міг спрацювати вже після Save і повторно позначити щойно збережений запис як «брудний». Той клас бага, який на своїй машині не ловиш — тільки на headless CI.
У 19.0 AutoComplete отримав проп selectOnBlur: на blur сам обирає першу підказку зі списку, якщо користувач нічого не вибрав — інший кейс, ніж комміт довільного тексту, якого серед підказок не було. Для полів на кшталт city_model_method, де валідне й значення поза списком, selectOnBlur цю задачу не закриває.
kw_matrix_widget: рендер, а не редагована таблиця
Назва обіцяє «матричне введення», але код показує інше: KwMatrixWidget — рендер, record.update() тут не викликається жодного разу. Значення поля — JSON-текст, який компонент лише парсить:
get matrixData() {
const value = this.props.record.data[this.props.name];
try { return value ? JSON.parse(value) : {}; }
catch (e) { console.error("Error parsing matrix data:", e); return {}; }
}
QWeb-шаблон розкладає header / body / footer з JSON у <thead> / <tbody> / <tfoot>, а кожен елемент tds — у <td> з атрибутами прямо з даних:
<td t-att-class="getCellClass(cell) + (isClickable(cell) ? ' clickable_matrix_cell' : '')"
t-att-colspan="getCellColspan(cell)" t-att-rowspan="getCellRowspan(cell)"
t-att-data-matrix="getCellData(cell)"
t-on-click="isClickable(cell) and onCellClick">
<t t-esc="cell.value"/>
</td>
Нетривіальна частина — як відбувається «введення», якщо запис нема куди писати. Клітинка з data отримує клас clickable_matrix_cell, а сам data — base64 від JSON-опису екшена:
onCellClick(ev) {
const matrixData = ev.currentTarget.dataset.matrix;
if (matrixData) {
try {
const actionData = JSON.parse(atob(matrixData));
this.action.doAction(actionData);
} catch (e) {
console.error("Error executing matrix action:", e);
}
}
}
У модулі оренди обладнання (kw_equipment_rental) так побудована матриця бронювання: рядки — обладнання, колонки — години, вільна клітинка відкриває візард із заповненим контекстом:
data = {
'name': _('Register reservation'), 'view_mode': 'form',
'res_model': 'kw.matrix.reserve.wizard', 'type': 'ir.actions.act_window',
'views': [(self.env.ref(
'kw_equipment_rental.kw_equipment_rental_kw_matrix_reserve_wizard_form').id,
'form')],
'context': {
'default_kw_equipment_id': equipment_id.id,
'default_kw_location_id': self.location_id.id,
'default_hour': hour,
'default_date': self.reservation_date.strftime('%Y-%m-%d'),
}}
data = base64.b64encode(json.dumps(data).encode()).decode()
Очевидний перший інстинкт для «матричного введення» — редагована таблиця з двостороннім зв'язуванням клітинок і власним dirty-tracking. Це громіздко — окремий об'ємний віджет з усіма проблемами редагованих ґрідів. Тут чистіше: таблиця тільки показує стан, а запис завжди йде через звичайний Odoo-візард з context.default_*. Я б там окрему редаговану таблицю не писав — досить координатного кліку.
Значення в клітинках виводяться через t-esc, не t-raw — екрановано, HTML усередину value не покласти. Генерувати JSON вручну не обов'язково: міксин kw.matrix.compute.mixin дає kw_generate_matrix_json (список списків → JSON з класами за замовчуванням) і kw_generate_matrix_value (плоский список (row, col, value) → двовимірна сітка).