Skip to Content

Свій віджет поля в Odoo: розбір kw_widget_autocomplete і kw_matrix_widget

Свій віджет поля в 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) → двовимірна сітка).

Свій віджет поля в Odoo: розбір kw_widget_autocomplete і kw_matrix_widget
KitWorks, Volodymyr Karabanov 16 липня 2026 р.
Поділитися цією публікацією
Теги
Архів