Odoo Owl Custom Widget List View
The first time I tried to put a custom OWL widget into a list view, I spent an entire afternoon staring at a column that refused to render anything but the raw integer. The template was fine. The component was registered. The XML attribute was set. Nothing on the screen changed. It turned out I had registered the widget under fields instead of the list-specific registry, and Odoo silently fell back to the default renderer without a single warning in the log. That kind of silent-fallback bug is the tax you pay for working inside a framework that wires half a dozen registries together at boot — and it is exactly the kind of thing nobody writes down until they have lost an afternoon to it.
This article walks through building a real custom OWL widget for a list view in Odoo 19, end to end, with the companion addon module sitting in a vytharion repo you can clone and step through commit by commit. We will scaffold the manifest, define a demo model, write the OWL component with its template and reactive state, register it on the correct web registry, wire it into the list view XML, and finish with click handlers that call the ORM, SCSS polish, access rules, and a smoke-test tour. The stack is the usual Odoo 19 mix: Python on the server, OWL 2 with XML templates on the client, SCSS for styling, and the web module's registry as the glue. If you remember one thing from this guide, let it be this: in OWL, the registry you pick is the contract — the wrong registry is not a bug the framework will tell you about, it is a silent shrug.
This is written for Odoo developers who are comfortable building a basic addon module but have not yet shipped a custom OWL field widget into a production list view. By the end you will have a working addon, a widget that updates records on click with a toast confirmation, and a mental model of where OWL components plug into the Odoo web client so the next widget takes you an hour instead of a weekend.
Step 1: Dựng khung manifest và bố cục thư mục cho addon OWL List Widget
Bước đầu tiên trong loạt bài này là tạo ra phần "vỏ" của addon Odoo 16 — chưa có logic nghiệp vụ, chưa có template OWL, nhưng đã đủ manifest và cây thư mục để Odoo nhận diện được module và để các bước sau chỉ việc thả file vào đúng vị trí. Mục tiêu không phải là dựng được giao diện chạy ngay, mà là khoá lại một bộ "ràng buộc cấu trúc" thông qua test, để mọi thay đổi sau này không vô tình làm hỏng manifest hoặc làm lệch bố cục asset bundle.
Khi mới bắt đầu, repo gần như trống: chỉ có README.md và .gitignore. Phần khung mà chúng ta dựng sẽ trở thành nền cho các bước kế tiếp — dựng model demo và list view nền, viết OWL component skeleton, đăng ký vào field registry, gắn widget= vào cột tree view, thêm tương tác click + ORM, rồi polish bằng SCSS, ACL và tour. Vì các bước sau phụ thuộc rất nhiều vào tên thư mục và khoá trong manifest, bước này phải chắc đến mức có test bảo vệ.
Setup
Trong bước này chúng ta tạo các file và thư mục sau bên dưới thư mục code:
owl_list_widget/__init__.py— đánh dấu thư mục là Python package, để Odoo có thểimportđược.owl_list_widget/__manifest__.py— file khai báo metadata của addon (tên, version, depends, asset bundle).owl_list_widget/static/src/js/— nơi chứa source OWL component ở bước 2.owl_list_widget/static/src/xml/— nơi chứa template QWeb cho OWL ở bước 2.owl_list_widget/views/— nơi chứa view XML khai báo list view ở bước 3.tests/test_scaffold.py— bộ test "khoá cấu trúc".pytest.ini— cấu hình pytest tối thiểu.
Phụ thuộc duy nhất ở giai đoạn này là pytest. Chúng ta cố tình chưa cài Odoo cục bộ — tất cả test ở bước 1 chỉ thao tác trên file system và AST, để có thể chạy nhanh trong CI mà không cần dựng Postgres và Odoo full stack. Điều này giữ cho vòng lặp phản hồi ở mức mili-giây, đủ rẻ để chạy trong pre-commit hook nếu sau này cần.
Implementation
Trước hết là __manifest__.py. Đây là "danh thiếp" của addon mà Odoo đọc khi quét addons-path:
{
"name": "OWL List View Custom Widget",
"summary": "Render a custom OWL component as a field widget inside an Odoo list view.",
"description": (
"A minimal Odoo 16 addon that demonstrates how to register an "
"OWL component as a field widget and mount it inside a list view."
),
"version": "16.0.1.0.0",
"license": "LGPL-3",
"author": "vytharion",
"category": "Tools",
"depends": [
"base",
"web",
],
"data": [
# views and security wired up in later steps
],
"assets": {
"web.assets_backend": [
# OWL component + template registered in later steps
# "owl_list_widget/static/src/js/*.js",
# "owl_list_widget/static/src/xml/*.xml",
],
},
"installable": True,
"application": False,
"auto_install": False,
}
Có vài lựa chọn thiết kế đáng nhắc tới. Thứ nhất, version mở đầu bằng 16.0. để Odoo nhận đúng "đường ray" tương thích của series 16 — nếu sau này backport sang 17, ta chỉ việc đổi prefix. Thứ hai, key assets đã có sẵn nhánh web.assets_backend (rỗng) — bước 2 chỉ cần bỏ comment hai dòng glob là gắn được OWL JS và XML vào bundle, không phải sửa cấu trúc. Thứ ba, depends cố tình tối giản: chỉ base và web, không kéo theo sale, account hay bất cứ module nghiệp vụ nào — vì OWL widget này là demo công nghệ chứ không phụ thuộc model cụ thể.
File __init__.py để rỗng có chủ đích — bước 1 chưa import model Python nào. Khi nào bước 3 thêm model demo, chúng ta sẽ thêm dòng from . import models ngay tại đây.
Tiếp theo là pytest.ini để chạy được test ngay từ root của thư mục code:
[pytest]
testpaths = tests
python_files = test_*.py
addopts = -ra
Tham số -ra bật chế độ in tóm tắt kết quả của tất cả các test không pass (skip, xfail, fail) ở cuối phiên — hữu ích khi chúng ta parametrize nhiều case nhỏ.
Cuối cùng là tests/test_scaffold.py. Đây là test "structure lock" — không gọi Odoo runtime, chỉ đọc file và xác minh hình dạng:
import ast
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
MODULE_ROOT = REPO_ROOT / "owl_list_widget"
def _load_manifest() -> dict:
return ast.literal_eval((MODULE_ROOT / "__manifest__.py").read_text(encoding="utf-8"))
Chúng ta dùng ast.literal_eval thay vì exec hay importlib vì manifest Odoo bản chất chỉ là một literal dict — không có side effect, không có biến động. literal_eval từ chối mọi thứ ngoài literal Python, nên nếu ai đó vô tình nhét logic vào manifest, test sẽ fail ngay lập tức.
@pytest.mark.parametrize(
"key",
["name", "version", "license", "depends", "data", "assets", "installable"],
)
def test_manifest_has_required_key(key):
manifest = _load_manifest()
assert key in manifest, f"manifest is missing required key: {key}"
Mỗi key được kiểm bằng một test case riêng nhờ parametrize, nên khi fail chúng ta biết chính xác khoá nào thiếu mà không phải đọc traceback dài. Tương tự, test test_expected_folder_layout parametrize qua ba subpath static/src/js, static/src/xml, views — đảm bảo "khế ước bố cục" được khoá ngay từ commit đầu tiên, không để bước 2 hay bước 3 đổi đường dẫn rồi vỡ asset bundle.
Verification
Chạy test suite từ root của thư mục code:
pytest
Kết quả mong đợi:
============================= test session starts ==============================
platform darwin -- Python 3.12.5, pytest-9.0.3, pluggy-1.6.0
configfile: pytest.ini
testpaths: tests
collected 18 items
tests/test_scaffold.py .................. [100%]
============================== 18 passed in 0.03s ==============================
18 case pass, gồm: kiểm tra thư mục module tồn tại, __init__.py và __manifest__.py đúng vị trí, manifest là literal dict, đủ bảy key bắt buộc (mỗi key một test nhờ parametrize), depends chứa cả base lẫn web, asset bundle web.assets_backend đã được khai báo, installable=True, version bắt đầu bằng 16., và ba thư mục con static/src/js, static/src/xml, views đã sẵn sàng.
What we built
Chúng ta vừa khoá lại "khung xương" của addon: một Python package đúng chuẩn Odoo, một manifest hợp lệ với web.assets_backend đã mở sẵn nhánh, và một cây thư mục mà bước 2 (đăng ký OWL component) và bước 3 (mount widget vào list view) có thể đổ file vào mà không phải tự nghĩ tên đường dẫn.
Bộ test scaffold đóng vai trò "hợp đồng cấu trúc" giữa các bước. Nó không kiểm tra logic chạy — vì chưa có gì để chạy — mà kiểm tra rằng các invariant về hình dạng (tên thư mục, key trong manifest, version prefix) vẫn còn nguyên. Nếu ở bước sau ta vô tình đổi tên static/src/js thành static/js, test sẽ đỏ trước khi commit đi xa hơn.
Việc chọn ast.literal_eval thay vì import động cũng để lại một invariant ngầm: manifest phải luôn là literal thuần. Khi đội ngũ mở rộng, người mới sẽ không bị cám dỗ chèn os.getenv(...) hay đọc file config vào manifest — pytest sẽ chặn ngay trong CI.
Bước 2 sẽ thêm model demo owl.demo.item với bộ field đa dạng kiểu (Char, Integer, Selection, Text, Boolean), khai báo tree/form/action/menu trong views/demo_item_views.xml, và bổ sung file view đó vào key data của manifest. Đó là "bãi đáp" mà bước 3 sẽ thả OWL component skeleton lên trên.
Repository
The state of the code after this step: 3935a72
Step 2: Khoá lại model owl.demo.item và list view nền — bãi đáp cho OWL widget
Sau bước 1, addon đã có khung manifest hợp lệ nhưng vẫn rỗng nghiệp vụ — chưa có model nào, chưa có view nào, và __init__.py của module vẫn trắng. Bước 2 lấp đúng khoảng trống đó: chúng ta định nghĩa một model demo owl.demo.item với bộ field vừa đủ đa dạng kiểu dữ liệu, dựng một list view "vani" (chưa có OWL widget) và một form view tối giản, rồi gắn action + menu để mở được record từ giao diện backend.
Lý do phải làm bước trung gian này thay vì nhảy thẳng vào OWL component: bước 3 sẽ thay cột color_tag trong tree view bằng một widget OWL tự viết. Nếu chưa có model + cột để thay, ta không có gì để so sánh "trước" và "sau". Bước 2 chính là cái "trước" đó — một list view chuẩn Odoo render bằng cơ chế mặc định, dùng làm điểm tham chiếu để chứng minh widget OWL ở bước 3 thực sự thay đổi hành vi render chứ không chỉ là một thay đổi cosmetic.
Setup
Trong bước này chúng ta thêm các file mới và sửa hai file đã có:
owl_list_widget/__init__.py— thêm dòngfrom . import modelsđể Odoo nạp được package model khi cài addon.owl_list_widget/models/__init__.py— đăng ký moduledemo_item.owl_list_widget/models/demo_item.py— định nghĩa classOwlDemoItem.owl_list_widget/views/demo_item_views.xml— tree view, form view,ir.actions.act_window, và haimenuitem.owl_list_widget/__manifest__.py— bổ sungviews/demo_item_views.xmlvào keydata.tests/test_baseline_model.py— bộ test khoá schema cho cả model lẫn view.
Không cần thêm dependency mới: vẫn là pytest thuần. Test mới tiếp tục đi theo lối "tĩnh" — phân tích AST cho file Python và parse XML cho file view — để không phải dựng Postgres và Odoo runtime chỉ để kiểm tra hình dạng.
Implementation
Bắt đầu từ models/demo_item.py. Bộ field được chọn có chủ ý: mỗi field đại diện cho một kiểu dữ liệu mà OWL widget ở bước 3 có thể cần đọc:
from odoo import fields, models
class OwlDemoItem(models.Model):
_name = "owl.demo.item"
_description = "OWL Demo Item"
_order = "priority desc, name"
name = fields.Char(required=True)
value = fields.Integer(default=0)
description = fields.Text()
active = fields.Boolean(default=True)
priority = fields.Selection(
selection=[
("0", "Low"),
("1", "Medium"),
("2", "High"),
],
default="0",
)
state = fields.Selection(
selection=[
("draft", "Draft"),
("in_progress", "In Progress"),
("done", "Done"),
],
default="draft",
)
color_tag = fields.Char(default="#3366ff")
Vài lựa chọn thiết kế đáng nói. Field color_tag cố tình kiểu Char chứ không phải Integer hay Selection — vì OWL widget ở bước 3 sẽ đọc chuỗi hex và render thành ô màu, và chúng ta muốn tránh ràng buộc cứng danh sách màu ở tầng schema. _order đặt priority desc, name để list view mặc định hiển thị item ưu tiên cao lên trên, tạo ra một thứ tự ổn định cho test thủ công khi nhập dữ liệu giả lập. active giữ default True để pattern archive/unarchive của Odoo hoạt động mà không phải khai báo thêm.
Tiếp theo là views/demo_item_views.xml. Tree view ở đây cố tình "vani" — chưa có thuộc tính widget= nào trên cột color_tag:
<record id="owl_demo_item_view_tree" model="ir.ui.view">
<field name="name">owl.demo.item.tree</field>
<field name="model">owl.demo.item</field>
<field name="arch" type="xml">
<tree string="Demo Items">
<field name="name"/>
<field name="value"/>
<field name="priority"/>
<field name="state"/>
<field name="color_tag"/>
</tree>
</field>
</record>
Đây là "trạng thái trước" mà bước 3 sẽ chuyển đổi: cột color_tag hiện đang render bằng renderer Char mặc định (một ô text "#3366ff"), và mục tiêu của OWL widget là biến nó thành một ô vuông màu kèm tooltip hex. Vì test ở bước 3 sẽ kiểm tra rằng cột color_tag có widget="owl_color_tag", chúng ta cần một test ở bước 2 khoá hành vi đối nghịch — rằng tại thời điểm này nó chưa có thuộc tính widget.
Action và menu được khai báo cùng file để giữ tất cả khái niệm liên quan tới owl.demo.item trong một chỗ. Menu cha owl_demo_root_menu không có parent — nó sẽ xuất hiện như một top-level menu mới sau khi cài addon, và menu con owl_demo_item_menu trỏ vào action owl_demo_item_action để mở list view.
Cuối cùng, tests/test_baseline_model.py chứa 26 case kiểm tra: tồn tại package models/, __init__.py import đúng chuỗi, class kế thừa models.Model, _name đúng giá trị "owl.demo.item", _order được khai báo, mỗi field trong bộ baseline (name, value, priority, state, color_tag, description, active) được parametrize thành một test riêng, XML view well-formed, có đủ tree/form record + action + menu hierarchy, tree view liệt kê đúng năm cột baseline, và đường dẫn view trong manifest resolve được trên đĩa.
Verification
Chạy lại toàn bộ test suite từ root của thư mục code:
pytest
Kết quả:
============================= test session starts ==============================
platform darwin -- Python 3.12.5, pytest-9.0.3, pluggy-1.6.0
configfile: pytest.ini
testpaths: tests
collected 44 items
tests/test_baseline_model.py .......................... [ 59%]
tests/test_scaffold.py .................. [100%]
============================== 44 passed in 0.07s ==============================
18 case của bước 1 vẫn xanh — invariant cấu trúc không bị bước 2 phá vỡ — và 26 case mới của test_baseline_model.py xanh nốt. Tổng cộng 44 case chạy hết trong 0.07 giây, đủ rẻ để chạy trong pre-commit hook.
What we built
Chúng ta vừa khoá lại một model demo có chủ đích: bộ field đa dạng kiểu (Char, Integer, Text, Boolean, Selection), một _order rõ ràng, và một field color_tag được "ướp sẵn" để bước 3 có cái mà thay widget.
Phía view, list view và form view đều chạy được bằng cơ chế render mặc định của Odoo. Đây là "ảnh chụp trước" của giao diện — ta có thể cài addon, mở menu "OWL Demo → Demo Items", tạo vài record và nhìn thấy cột color_tag hiển thị dưới dạng chuỗi text. Sau bước 3, đúng cột đó sẽ thành ô vuông màu, và sự khác biệt nhìn thấy rõ trên cùng tập dữ liệu.
Bộ test mới đóng hai vai trò: vừa khẳng định model + view tồn tại đúng hình dạng, vừa khoá một số invariant phòng vệ — ví dụ, manifest phải khai báo đúng đường dẫn view và đường dẫn đó phải resolve được trên đĩa. Nếu sau này có ai rename file view mà quên cập nhật manifest, pytest sẽ chặn lại trước khi commit đi xa.
Bước 3 sẽ thêm OWL component skeleton: một class JavaScript kế thừa Component cùng template QWeb tương ứng, và mở comment hai dòng glob trong key assets của manifest để Odoo gom JS + XML vào bundle backend. Việc đăng ký vào field registry (bước 4) và việc cắm widget="owl_color_tag" vào tree view (bước 5) sẽ được tách ra hai commit riêng — mỗi commit chỉ thay đổi một quan tâm để khi lỗi xuất hiện, ta cô lập được nguồn gốc ngay từ git log.
Repository
The state of the code after this step: e11b692
Step 3: Dựng OWL component skeleton — class OwlColorTagWidget, QWeb template, và reactive state qua useState
Sau bước 2, addon đã có model owl.demo.item với cột color_tag render bằng renderer Char mặc định và bộ test khoá hình dạng. Bước 3 lắp đặt phần frontend đầu tiên: một OWL component có tên OwlColorTagWidget, kèm template QWeb riêng và một mảnh reactive state nhỏ cho hành vi hover. Chúng ta cố tình chưa wire widget vào tree view ở bước này — mục tiêu là tách "đăng ký component" khỏi "gắn vào field" để mỗi commit chỉ thay đổi một quan tâm.
Lý do tách thành hai bước: nếu vừa viết component vừa sửa view trong một commit, khi component lỗi runtime sau cài đặt, ta không biết là do template QWeb, do registry, hay do thuộc tính widget= trên cột. Bước 3 chứng minh component compile được, ship qua asset bundle, và pass toàn bộ test tĩnh; bước sau mới mount nó vào list view và đo behaviour thay đổi.
Setup
Trong bước này chúng ta tạo hai file mới và sửa một file đã có:
owl_list_widget/static/src/js/owl_color_tag.js— class componentOwlColorTagWidgetvớisetup(), ba getter, và hai handler chuột.owl_list_widget/static/src/xml/owl_color_tag.xml— QWeb template với idowl_list_widget.OwlColorTag, opt-in OWL runtime qua thuộc tínhowl="1".owl_list_widget/__manifest__.py— bỏ comment hai dòng globstatic/src/js/*.jsvàstatic/src/xml/*.xmltrongweb.assets_backend.tests/test_owl_component_skeleton.py— 20 case kiểm tra hình dạng JS, XML, manifest, và invariant "chưa wire vào view".
Không có dependency JavaScript mới. Asset bundle của Odoo tự lo bundling, không cần webpack/rollup ngoài. Bộ test vẫn đi theo lối phân tích tĩnh (regex trên source JS + xml.etree cho QWeb + ast.literal_eval cho manifest) — chưa cần dựng Odoo runtime, vì mục tiêu bước 3 là khoá khung component, không phải render thật.
Implementation
Bắt đầu từ static/src/js/owl_color_tag.js. File mở đầu bằng banner /** @odoo-module **/ — đây là tín hiệu để asset pipeline của Odoo 16 đăng ký module vào loader thay vì xử lý như script JS thường:
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
const FALLBACK_COLOR = "#cccccc";
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
Hai hằng module-level được khai báo ngay sau import. HEX_RE chỉ chấp nhận đúng dạng #RRGGBB sáu ký tự — short form #fff cố tình bị từ chối để giữ một invariant đơn giản: hoặc giá trị hợp lệ đầy đủ, hoặc dùng FALLBACK_COLOR. Đẩy hai hằng ra ngoài class giúp regex chỉ được biên dịch một lần và làm các getter dưới đây dễ đọc hơn.
Tiếp đến là class component. static template trỏ vào id QWeb sẽ định nghĩa ở file XML kế tiếp — đây chính là "khế ước hai đầu" mà test sẽ khoá lại:
export class OwlColorTagWidget extends Component {
static template = "owl_list_widget.OwlColorTag";
setup() {
this.state = useState({
hovered: false,
});
}
setup() là constructor hook chuẩn của OWL — không tự viết constructor(), vì OWL điều phối vòng đời qua setup. useState({ hovered: false }) bọc object thành Proxy reactive: khi handler chuột gán this.state.hovered = true, OWL tự lên lịch render lại template. Seed sẵn hovered: false thay vì để undefined để tránh trạng thái "chưa xác định" trong lần render đầu.
Ba getter dưới đây tách logic đọc props khỏi template, để QWeb chỉ việc đọc thuộc tính chứ không phải gọi method có tham số:
get rawValue() {
if (!this.props) {
return "";
}
return this.props.value || "";
}
get displayColor() {
const value = this.rawValue;
if (HEX_RE.test(value)) {
return value;
}
return FALLBACK_COLOR;
}
get isValidHex() {
return HEX_RE.test(this.rawValue);
}
Việc tách rawValue, displayColor, và isValidHex thành ba getter riêng giúp template chỉ tham chiếu thuộc tính đơn giản — không có nhánh if/else trong QWeb. Quy tắc nesting tối đa 2 cấp được tôn trọng: mỗi getter có nhiều nhất một if. Hai handler onMouseEnter / onMouseLeave chỉ làm một việc — bật/tắt state.hovered — để OWL chịu trách nhiệm re-render:
onMouseEnter() {
this.state.hovered = true;
}
onMouseLeave() {
this.state.hovered = false;
}
}
Sang static/src/xml/owl_color_tag.xml. Thuộc tính owl="1" trên thẻ <t> báo cho Odoo dùng compiler OWL chứ không phải QWeb legacy — nếu thiếu, Odoo sẽ render bằng engine cũ và t-on-... không hoạt động:
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="owl_list_widget.OwlColorTag" owl="1">
<span class="o_owl_color_tag"
t-att-class="{ 'o_owl_color_tag_invalid': !isValidHex }"
t-att-style="'background-color: ' + displayColor + ';'"
t-att-title="rawValue"
t-on-mouseenter="() => this.onMouseEnter()"
t-on-mouseleave="() => this.onMouseLeave()">
<span class="o_owl_color_tag_label" t-if="state.hovered">
<t t-esc="displayColor"/>
</span>
</span>
</t>
</templates>
t-att-style đẩy background-color xuống style inline — chấp nhận trade-off này vì giá trị màu thuần dynamic và không có CSS class tĩnh nào đại diện được "mọi mã hex". t-att-title đặt giá trị gốc làm tooltip native của browser, giúp người dùng vẫn xem được hex kể cả khi không hover thấy nhãn. Nhãn hex nội bộ chỉ render khi state.hovered true — đó là điểm cắm cho reactive state ở setup().
Cuối cùng, manifest mở comment hai dòng glob đã chừa sẵn từ bước 1:
"assets": {
"web.assets_backend": [
"owl_list_widget/static/src/js/*.js",
"owl_list_widget/static/src/xml/*.xml",
],
},
Bước 1 đã đặt sẵn hai dòng này dưới dạng comment để bước 3 chỉ làm thay đổi tối thiểu, không phải đụng lại cấu trúc key assets. Glob *.js và *.xml được giữ thay vì liệt kê từng file — số lượng component sẽ tăng theo thời gian, và mỗi lần thêm component mà phải sửa manifest là một loại nợ kỹ thuật không cần thiết.
Verification
Chạy lại toàn bộ test suite từ root của thư mục code:
pytest
Kết quả:
============================= test session starts ==============================
platform darwin -- Python 3.12.5, pytest-9.0.3, pluggy-1.6.0
configfile: pytest.ini
testpaths: tests
collected 64 items
tests/test_baseline_model.py .......................... [ 40%]
tests/test_owl_component_skeleton.py .................... [ 71%]
tests/test_scaffold.py .................. [100%]
============================== 64 passed in 0.09s ==============================
64 case xanh: 18 case scaffold của bước 1, 26 case baseline model của bước 2, và 20 case mới của bước 3. Bộ 20 case mới khoá lại: file JS tồn tại, có banner @odoo-module, import Component và useState từ @odoo/owl, export class OwlColorTagWidget extends Component, khai báo static template = "owl_list_widget.OwlColorTag", có setup() gọi useState({ hovered: false }), expose ba getter rawValue / displayColor / isValidHex, file XML well-formed với root <templates>, có t-name khớp static template của JS, opt-in owl="1", manifest đăng ký đúng hai glob asset, và — quan trọng — cột color_tag trong tree view vẫn chưa có thuộc tính widget.
What we built
Chúng ta vừa lắp xong "nửa frontend" của widget: một class OWL component đầy đủ vòng đời setup + reactive state qua useState, một template QWeb opt-in OWL runtime, và đường ống asset bundle đã thông để Odoo gom JS + XML vào web.assets_backend khi cài addon.
Bộ getter rawValue / displayColor / isValidHex đóng vai trò "lớp trung gian" giữa props và template — template không cần biết về regex, về fallback color, hay về dạng dữ liệu thật của field; nó chỉ đọc thuộc tính đã được getter chuẩn hoá. Đây là invariant ngầm sẽ cứu ta khỏi rất nhiều bug khi cột ở bước sau truyền vào giá trị false, null, hoặc chuỗi rỗng.
Test case cuối — test_widget_not_yet_wired_into_tree_view — đáng nói riêng. Nó là một invariant "tiêu cực": khẳng định ở thời điểm bước 3, tree view của model owl.demo.item không có widget="owl_color_tag" trên cột color_tag. Việc khoá lại điều này giúp commit của bước 3 chỉ chứa đúng một thay đổi: đăng ký skeleton. Bước sau mới có quyền sửa view, và khi đó test này sẽ được cập nhật thành phiên bản "tích cực" yêu cầu widget có mặt.
Bước 4 sẽ đăng ký component vào field registry của Odoo (registry.category("fields")) với tên owl_color_tag, đồng thời khai báo prop schema qua standardFieldProps. Bước 5 mới chính thức thêm widget="owl_color_tag" vào cột color_tag trong views/demo_item_views.xml để list renderer tra cứu OwlColorTagWidget thay vì renderer Char mặc định — bước này khoá lại liên kết literal-string ba điểm giữa JS, XML, và class.
Repository
The state of the code after this step: 6b0b3bc
Step 4: Cắm OwlColorTagWidget vào registry.category("fields") với prop schema kế thừa từ standardFieldProps
Sau bước 3, skeleton component đã đầy đủ: class OwlColorTagWidget có setup(), ba getter, hai handler chuột, template QWeb opt-in OWL runtime, và asset bundle đã thông để Odoo gom JS + XML. Tuy nhiên Odoo chưa hề biết đến widget này — web.assets_backend chỉ ship file source, không tự dịch sang một field widget có thể trỏ qua thuộc tính widget= trên cột view. Bước 4 đẩy nốt nửa còn lại của khế ước: import registry từ core, mượn standardFieldProps làm prop schema, rồi gọi registry.category("fields").add(...) để dán class vào bucket fields dưới khoá owl_color_tag.
Lý do tách registration thành một bước riêng thay vì gộp với bước 5 (sửa view) cũng giống lý do tách skeleton khỏi registration ở bước 3: nếu commit này hỏng, ta cô lập được lỗi nằm ở registry — chứ không phải ở widget= trên cột tree. Tree view của model owl.demo.item ở bước 4 vẫn chưa có thuộc tính widget, và test sẽ khoá lại điều đó như một invariant tiêu cực.
Setup
Bước này chỉ chạm hai file:
owl_list_widget/static/src/js/owl_color_tag.js— bổ sung hai dòng import (registry,standardFieldProps), thêmstatic props = { ...standardFieldProps }vào class, và một dòngregistry.category("fields").add(...)ở module top-level sau khi class đã được khai báo.tests/test_field_registry_wiring.py— 16 case mới kiểm tra hình dạng các import, khoá registration đúng bucketfieldsvới keyowl_color_tag, đảm bảo prop schema kế thừastandardFieldProps, và xác nhận tree view vẫn chưa wire.
Không có file mới ngoài file test. Không có dependency mới — registry và standardFieldProps đều thuộc Odoo 16 core, ship sẵn trong asset bundle web.assets_backend. Bộ test tiếp tục đi theo lối phân tích tĩnh (regex trên source JS + xml.etree cho view) — vẫn chưa cần dựng Odoo runtime, vì mục tiêu bước 4 là khoá khung registration, không phải đo hành vi render.
Implementation
Mở static/src/js/owl_color_tag.js và thêm hai import mới ngay dưới import OWL có sẵn:
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
registry là singleton toàn cục của Odoo 16 web client — mọi bucket (fields, views, actions, services, ...) đều treo dưới nó. standardFieldProps là object schema mà Odoo dùng cho mọi field widget kế thừa từ list/form renderer; nó liệt kê các key bắt buộc như record, name, readonly, update, ... Mượn schema này thay vì tự khai báo lại tránh được nguy cơ thiếu key khi Odoo nâng version — danh sách prop có thể đổi giữa các minor release, và lệ thuộc vào schema chính chủ đảm bảo widget luôn khớp.
Tiếp theo, bổ sung static props vào class — đặt ngay dưới static template để hai khế ước (template + prop schema) đứng cạnh nhau:
export class OwlColorTagWidget extends Component {
static template = "owl_list_widget.OwlColorTag";
static props = {
...standardFieldProps,
};
setup() {
this.state = useState({
hovered: false,
});
}
OWL bắt buộc mọi component khai báo static props rõ ràng — đây là chế độ "strict by default" của OWL 2, khác với OWL 1 nơi prop schema là tuỳ chọn. Nếu thiếu static props, OWL sẽ throw lỗi runtime ngay lúc mount: Component does not have any props validation. Spread ...standardFieldProps đẩy mọi key chuẩn vào schema mà không cần liệt kê thủ công, và để dấu phẩy treo phía sau cho phép bước sau thêm props riêng (như options từ view XML) mà chỉ cần insert một dòng.
Phần setup(), các getter và handler giữ nguyên y hệt bước 3 — đó là một invariant đáng nói: bước 4 không sửa logic component, chỉ thay đổi cách Odoo "nhìn thấy" component. Cuối file, thêm dòng registration ngay sau dấu } đóng class:
registry.category("fields").add("owl_color_tag", OwlColorTagWidget);
registry.category("fields") lấy ra bucket dành riêng cho field widget — bucket này chính là nơi Odoo tra cứu khi gặp widget="..." trên một field node trong view XML. .add("owl_color_tag", OwlColorTagWidget) đăng ký class dưới khoá owl_color_tag — đây là chuỗi literal mà bước 5 sẽ tham chiếu trong views/demo_item_views.xml. Đặt dòng này ở module top-level (sau khai báo class) để class identifier đã được hoisted khi registry chạy; nếu đặt trước class, JavaScript sẽ ném ReferenceError: Cannot access 'OwlColorTagWidget' before initialization do class không hoist như function.
Một chi tiết dễ bỏ sót: tree view trong views/demo_item_views.xml không được sửa ở bước này. Cột color_tag vẫn không có thuộc tính widget=, vẫn render bằng renderer Char mặc định. Đây là invariant cố ý — phải có một test riêng (test_tree_view_still_not_wired_in_step_four) khoá lại điều đó.
Verification
Chạy lại toàn bộ test suite từ root thư mục code:
pytest
Kết quả:
============================= test session starts ==============================
platform darwin -- Python 3.12.5, pytest-9.0.3, pluggy-1.6.0
configfile: pytest.ini
testpaths: tests
collected 80 items
tests/test_baseline_model.py .......................... [ 32%]
tests/test_field_registry_wiring.py ................ [ 52%]
tests/test_owl_component_skeleton.py .................... [ 77%]
tests/test_scaffold.py .................. [100%]
============================== 80 passed in 0.12s ==============================
80 case xanh: 18 case scaffold của bước 1, 26 case baseline model của bước 2, 20 case skeleton của bước 3, và 16 case mới của bước 4. Bộ 16 case này khoá lại: file JS import đúng registry từ @web/core/registry, import đúng standardFieldProps từ @web/views/fields/standard_field_props, class khai báo static props có spread ...standardFieldProps, có lời gọi registry.category("fields") trỏ đúng bucket fields, key registration là chuỗi literal owl_color_tag, đối số thứ hai của .add(...) là chính class OwlColorTagWidget (không phải factory hay wrapper), registration nằm SAU khai báo class ở module top-level, template binding owl_list_widget.OwlColorTag từ bước 3 không bị di chuyển, hook setup() với seed hovered: false còn nguyên, và quan trọng nhất — cột color_tag trong tree view vẫn không có thuộc tính widget.
What we built
Chúng ta vừa nối xong "dây điện" cuối cùng giữa class OWL component và web client của Odoo: từ giờ, bất kỳ chỗ nào trong view XML viết widget="owl_color_tag", Odoo sẽ tra ra OwlColorTagWidget qua field registry và dùng nó để render cột thay vì renderer Char mặc định. Khế ước này là một liên kết runtime — nó chỉ kích hoạt khi cú pháp widget= thực sự xuất hiện trên một field node.
Việc spread ...standardFieldProps vào static props là một khoản đầu tư về tương lai. Mỗi lần Odoo phát hành minor mới và thêm prop chuẩn (ví dụ archInfo, setDirty), widget của chúng ta tự nhận được key đó mà không cần đổi dòng code nào — bởi vì prop schema được tham chiếu, không phải sao chép. Đây là khác biệt nhỏ nhưng đắt giá: lệ thuộc vào reference khiến widget bền vững qua nâng version.
Test test_tree_view_still_not_wired_in_step_four lại là một invariant tiêu cực có chủ đích, song song với test "chưa wire" của bước 3. Bằng cách khoá lại điều "widget chưa được mount" ở thời điểm bước 4, commit này chỉ chứa đúng một thay đổi nội dung: thêm registration. Khi bước 5 sửa view, test này sẽ được thay bằng phiên bản tích cực — assert rằng widget="owl_color_tag" đã xuất hiện trên cột color_tag.
Bước tiếp theo sẽ chính thức kích hoạt liên kết: thêm thuộc tính widget="owl_color_tag" vào cột color_tag trong views/demo_item_views.xml, cập nhật bộ test khoá lại invariant đó, và — nếu môi trường cho phép — chạy smoke test runtime trên Odoo thật để xác nhận giao diện render ô màu nền + tooltip thay vì chuỗi hex thô.
Repository
The state of the code after this step: b74ff54
Step 5: Cắm widget="owl_color_tag" vào cột color_tag của tree view để list renderer tra cứu OwlColorTagWidget thay vì renderer Char mặc định
Sau bước 4, mọi mảnh ghép đã có mặt nhưng chưa nối: class OwlColorTagWidget ngồi trong registry.category("fields") dưới khoá owl_color_tag, prop schema kế thừa standardFieldProps, template QWeb owl_list_widget.OwlColorTag đã ship qua web.assets_backend. Thiếu duy nhất một thuộc tính XML để biến đăng ký trừu tượng thành thay đổi UI có thể nhìn thấy — và đó chính là công việc của bước 5.
Bước này nhỏ về số dòng nhưng lớn về ý nghĩa: chỉ cần thêm widget="owl_color_tag" lên cột color_tag trong tree view là Odoo list renderer ngừng gọi formatter Char mặc định, tra cứu component qua field registry, và mount một instance OWL cho mỗi dòng. Đồng thời, form view phải được giữ nguyên — biên tập viên vẫn cần một ô input thô để gõ chuỗi hex, không phải một ô màu read-only.
Setup
Bước 5 chạm hai loại file: một file view và một file test mới.
owl_list_widget/views/demo_item_views.xml— thêm đúng một thuộc tínhwidget="owl_color_tag"vào node<field name="color_tag"/>bên trong<tree>. Tuyệt đối không đụng tới node<field name="color_tag"/>trong<form>.tests/test_list_view_widget_attribute.py— 13 case mới phân tích arch XML bằngxml.etree.ElementTree, regex source JS, vàast.literal_evalmanifest. Mục tiêu là khoá lại liên kết literal-string giữa view, registry, và class.
Không có dependency mới — xml.etree là stdlib, không cần dựng Odoo runtime để chạy bộ test này. Hai file test_field_registry_wiring.py và test_owl_component_skeleton.py được cập nhật nhẹ: các case "negative" của bước 3 và bước 4 (kiểu test_widget_not_yet_wired_*) được chuyển sang dạng tích cực, vì invariant "chưa wire" không còn đúng nữa.
Implementation
Mở owl_list_widget/views/demo_item_views.xml và sửa đúng một dòng — node cột color_tag trong tree view:
<tree string="Demo Items">
<field name="name"/>
<field name="value"/>
<field name="priority"/>
<field name="state"/>
<field name="color_tag" widget="owl_color_tag"/>
</tree>
Khi list renderer của Odoo 16 duyệt arch tree, nó đọc thuộc tính widget của mỗi node <field>, dùng chuỗi đó làm khoá tra cứu trong registry.category("fields"), và lấy ra class component. Nếu thuộc tính widget vắng mặt — như mọi cột còn lại — renderer rơi về formatter mặc định theo ttype của field (Char trong trường hợp color_tag). Chuỗi literal "owl_color_tag" phải khớp tuyệt đối với khoá ta đã .add(...) ở bước 4; mismatch một ký tự là render ra ô trống và console in Unknown field widget: ....
Tiếp đến là form view — tuyệt đối không sửa:
<form string="Demo Item">
<sheet>
<group>
<field name="name"/>
<field name="value"/>
<field name="priority"/>
<field name="state"/>
<field name="color_tag"/>
<field name="active"/>
</group>
...
</form>
Đây là một lựa chọn UX có chủ đích: list view ưu tiên duyệt nhanh nhiều dòng, nên ô màu trực quan có giá trị hơn chuỗi hex; ngược lại, form view là chỗ biên tập, người dùng cần một ô text để gõ #aabbcc. Cùng một field, hai renderer — và việc tách lựa chọn này theo arch (chứ không phải theo conditional trong widget) giữ component thuần tuý "display only", không lẫn logic edit.
Cuối cùng, thêm tests/test_list_view_widget_attribute.py với bộ 13 case khoá lại liên kết literal-string. Một số case đáng nói:
def test_tree_view_widget_key_matches_registered_key_in_js():
color_field = _find_field(_tree_fields(), TARGET_FIELD)
js_src = JS_FILE.read_text(encoding="utf-8")
pattern = (
rf"registry\s*\.\s*category\s*\(\s*[\"']fields[\"']\s*\)\s*"
rf"\.\s*add\s*\(\s*[\"']{re.escape(WIDGET_NAME)}[\"']"
)
assert re.search(pattern, js_src)
assert color_field.get("widget") == WIDGET_NAME
Case này là điểm nút của bước 5: nó ràng buộc cả hai đầu — chuỗi owl_color_tag xuất hiện trong source JS dưới dạng đối số đầu của registry.category("fields").add(...) và xuất hiện trong arch XML dưới dạng giá trị thuộc tính widget. Nếu ai đó đổi một bên mà quên bên kia, test fail ngay; ta không phải đợi Odoo runtime báo lỗi mount.
Case test_form_view_color_tag_field_does_not_use_owl_widget đảo ngược logic: nó assert rằng node color_tag trong form view không mang thuộc tính widget. Đây là invariant tiêu cực có chủ đích — nếu sau này có ai vô tình copy thuộc tính sang form view, test sẽ chặn lại trước khi UX form bị hỏng. Tương tự, test_only_color_tag_column_gets_the_owl_widget quét toàn bộ cột tree và đảm bảo không có cột nào khác (name, value, priority, state) bị gắn nhầm widget này.
Verification
Chạy lại toàn bộ test suite từ root thư mục code:
pytest
Kết quả:
============================= test session starts ==============================
platform darwin -- Python 3.12.5, pytest-9.0.3, pluggy-1.6.0
configfile: pytest.ini
testpaths: tests
collected 93 items
tests/test_baseline_model.py .......................... [ 27%]
tests/test_field_registry_wiring.py ................ [ 45%]
tests/test_list_view_widget_attribute.py ............. [ 59%]
tests/test_owl_component_skeleton.py .................... [ 80%]
tests/test_scaffold.py .................. [100%]
============================== 93 passed in 0.13s ==============================
93 case xanh: 18 case scaffold của bước 1, 26 case baseline model của bước 2, 20 case skeleton của bước 3, 16 case registry của bước 4 (đã chuyển case "chưa wire" thành "đã wire"), và 13 case mới của bước 5. Bộ 13 case mới khoá lại: arch XML vẫn well-formed với root <odoo>, cột color_tag vẫn tồn tại trong tree view, cột đó mang widget="owl_color_tag", chuỗi literal khớp tuyệt đối với khoá .add(...) trong JS, không cột tree view nào khác bị gắn nhầm widget, cột color_tag trong form view không mang widget, mọi cột baseline (name, value, priority, state, color_tag) còn nguyên, không có thuộc tính phụ (string, options, groups) lén lút đính kèm, khoá widget tuân thủ lowercase snake_case, và manifest vẫn liệt kê views/demo_item_views.xml trong data.
What we built
Chúng ta vừa đóng vòng đăng ký — registration — render bằng đúng một thuộc tính XML. Tree view của owl.demo.item từ giờ không còn hiển thị color_tag dưới dạng chuỗi hex thô; khi Odoo mount list renderer, mỗi dòng có một instance OwlColorTagWidget riêng, mỗi instance đọc giá trị qua getter rawValue, dựng background-color qua displayColor, và phản ứng hover qua state.hovered.
Việc giữ form view sạch là một quyết định tách-quan-tâm: cùng một field model có thể được trình bày khác nhau ở hai arch khác nhau, và tách hai con đường render giúp widget hiển thị không phải gánh thêm logic biên tập. Khi sau này muốn thêm form widget riêng (ví dụ color picker), ta chỉ cần đăng ký thêm một khoá owl_color_picker và gán lên form view — class hiển thị không phải đổi.
Bộ test test_list_view_widget_attribute.py không chỉ khoá hành vi đúng, nó còn ràng buộc liên kết giữa ba điểm: literal trong source JS, literal trong arch XML, và class identifier. Bất kỳ refactor nào về sau cố tình đổi tên owl_color_tag sẽ phải đổi cả ba điểm cùng lúc — và bộ test sẽ tự động đảm bảo không có điểm nào bị bỏ sót. Đây là kiểu invariant rẻ-tiền-mà-cứu-nhiều-giờ-debug.
Tới đây widget đã render đúng nhưng vẫn read-only — click vào ô không có gì xảy ra. Bước 6 sẽ biến swatch tĩnh thành widget tương tác: mượn orm và notification qua useService, viết async onClick xoay màu theo SWATCH_PALETTE, gọi orm.write rồi record.load, và pop toast xác nhận. Bước 7 — bước cuối của tutorial — đóng phần polish: tách stylesheet ra static/src/scss/owl_color_tag.scss cho ba trạng thái idle/invalid/updating, viết security/ir.model.access.csv cấp ACL cho base.group_user và base.group_portal, và đăng ký tour owl_color_tag_smoke_tour chạy trong bundle web.assets_tests.
Repository
The state of the code after this step: 27a38ae
Step 6: Biến swatch tĩnh thành widget tương tác — useService("orm") + useService("notification") cho phép click một dòng để xoay màu, ghi qua ORM, reload record và bắn toast
Sau bước 5, widget đã render đúng: list renderer tra cứu owl_color_tag trong registry.category("fields"), mount một instance OwlColorTagWidget cho mỗi dòng, ô màu hiển thị theo displayColor, và hover-label nhả ra chuỗi hex. Nhưng widget vẫn hoàn toàn read-only — click vào ô không có gì xảy ra, vì class chưa hề chạm vào Odoo services. Bước 6 đóng phần còn lại của khế ước UX: biến swatch tĩnh thành nút xoay màu, mỗi click chọn hex kế tiếp trong một bảng cố định, ghi qua ORM service, reload record để list renderer thấy giá trị mới, và pop toast qua notification service để operator có phản hồi tức thì.
Đây là bước đầu tiên trong tutorial khiến widget tham gia vào hệ thống dịch vụ (services) của Odoo 16 — không còn là một component thuần hiển thị nữa. Việc lựa chọn orm.write thay vì record.update là cố ý: ta muốn minh hoạ vòng tròn đầy đủ qua RPC layer (gọi ngược về server), không chỉ mutate state phía client. Đồng thời, để tránh race condition khi người dùng click nhanh, ta thêm cờ state.updating làm khoá mềm — đẩy đầu vào sự kiện thành tuần tự dù người dùng có spam chuột thế nào.
Setup
Bước 6 chạm hai file source và thêm hai file test mới.
owl_list_widget/static/src/js/owl_color_tag.js— thêm importuseServicetừ@web/core/utils/hooks, khai báo hằng module-levelSWATCH_PALETTE(mảng 6 màu hex), bổ sung getternextSwatch, thêm cờupdating: falsevàouseState, gắnthis.ormvàthis.notificationtrongsetup(), viết phương thứcasync onClick()với guard concurrent, ORM write, record reload, và notification toast cho cả nhánh thành công lẫn nhánh "row chưa lưu".owl_list_widget/static/src/xml/owl_color_tag.xml— bindt-on-clicklên swatch span và mở rộngt-att-classđể toggle classo_owl_color_tag_updatingtheostate.updating. Giữ nguyên cả hai handlert-on-mouseenter/t-on-mouseleavecủa bước 3.tests/test_reactive_click_handler.pyvàtests/test_click_handler_and_notifications.py— tổng 51 case, chia hai góc nhìn: một bộ tập trung vào dataflow click → ORM → toast, bộ kia tập trung vào hằng số, guard, và invariants từ các bước trước phải còn nguyên.
Không có dependency Python hay JS mới. useService, orm, và notification đều thuộc Odoo 16 core, ship sẵn trong asset bundle web.assets_backend đã bật ở bước 1. Bộ test vẫn chạy bằng phân tích tĩnh (regex source JS + xml.etree cho QWeb template), không cần dựng Odoo runtime để xanh.
Implementation
Mở static/src/js/owl_color_tag.js, thêm import useService ngay sau các import có sẵn:
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { useService } from "@web/core/utils/hooks";
useService là hook chính chủ của Odoo 16 để xin một service từ service registry trong setup() của một OWL component. Khác với registry.category("services").get(...) (cách "thô" mà core internals dùng), useService đi qua hook system của OWL nên đảm bảo lifecycle binding đúng — service sẽ được unbind khi component unmount. Ta sẽ xin hai service: orm để gọi RPC, và notification để bắn toast vào notification stack.
Tiếp theo, ngay dưới FALLBACK_COLOR và HEX_RE, khai báo bảng màu xoay vòng:
const FALLBACK_COLOR = "#cccccc";
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
const SWATCH_PALETTE = [
"#3366ff",
"#33cc66",
"#ff6633",
"#cc33cc",
"#ffcc00",
"#22aabb",
];
Đặt SWATCH_PALETTE ở module-level (không phải static class field) là chọn lựa có chủ đích: bảng này không phụ thuộc instance, không cần re-allocate cho mỗi dòng list, và việc tách ra hằng top-level giúp test có thể assert thẳng vào shape của nó (mảng độ dài ≥ 4, mọi phần tử khớp HEX_RE, không trùng lặp) — đó chính là ba invariant test_swatch_palette_* khoá lại.
Bên trong class, mở rộng useState để có cờ mới và xin hai service:
setup() {
this.state = useState({
hovered: false,
updating: false,
});
this.orm = useService("orm");
this.notification = useService("notification");
}
Giữ hovered: false là invariant từ bước 3 — bộ test test_setup_preserves_hovered_flag chặn lại nếu ai vô tình bỏ. Cờ updating: false mới là "khoá mềm" chống double-click: trong khi ORM write đang in-flight, mọi click thêm đều bị early-return. Cờ này cũng được template đọc qua t-att-class để dim swatch trong lúc RPC chưa về — phản hồi UX rất rẻ nhưng cần thiết.
Thêm getter nextSwatch để cô lập logic xoay vòng:
get nextSwatch() {
const current = this.rawValue;
const idx = SWATCH_PALETTE.indexOf(current);
const nextIdx = (idx + 1) % SWATCH_PALETTE.length;
return SWATCH_PALETTE[nextIdx];
}
Mẹo nhỏ ở đây là indexOf trả -1 khi current không nằm trong palette (ví dụ dòng có hex tuỳ chỉnh hoặc giá trị rỗng); cộng 1 ra 0, modulo độ dài cho ra index 0 — tức là rơi về swatch đầu tiên. Nhờ vậy click đầu tiên luôn có đáp án hợp lệ, không cần thêm nhánh if (idx === -1). Đây là kiểu logic "fail-soft" mà test test_next_swatch_uses_swatch_palette_constant ép buộc — body của getter phải dùng cả SWATCH_PALETTE lẫn indexOf để đảm bảo cycle là single source of truth.
Cuối cùng là phương thức trung tâm — async onClick():
async onClick() {
if (this.state.updating) {
return;
}
const record = this.props.record;
if (!record || !record.resId) {
this.notification.add("Cannot recolor an unsaved row", {
type: "warning",
});
return;
}
const fieldName = this.props.name;
const nextColor = this.nextSwatch;
this.state.updating = true;
await this.orm.write(record.resModel, [record.resId], {
[fieldName]: nextColor,
});
await record.load();
this.state.updating = false;
this.notification.add(`Color updated to ${nextColor}`, {
type: "success",
});
}
Hàm này có ba "gate" tuần tự trước khi RPC chạy. Gate đầu chống concurrent: nếu cờ updating đang bật, return ngay — đây chính là điều mà test_on_click_short_circuits_during_in_flight_update ép buộc. Gate thứ hai chống unsaved row: record.resId là false khi dòng chưa được lưu lần đầu, nên ghi vào sẽ là 500 từ server; thay vì để lỗi nổi lên, ta bắn toast warning để operator biết tại sao click không có tác dụng. Gate thứ ba là record.resModel — dùng giá trị động này thay vì hardcode "owl.demo.item" giữ widget reusable cho mọi model trong tương lai.
Phần persist tuân thủ đúng nhịp ba bước: bật state.updating, await this.orm.write(...), await record.load(). record.load() quan trọng đến mức test_on_click_reloads_record_after_write khoá lại riêng — không có nó, list renderer vẫn cache giá trị cũ và swatch không đổi màu cho đến khi user refresh tay. Đặt state.updating = false sau khi reload xong (chứ không phải trước) đảm bảo người dùng không click lần hai cho đến khi UI thực sự đã đồng bộ.
Cuối file, dòng registry.category("fields").add(...) của bước 4 giữ nguyên — bước 6 chỉ thêm hành vi, không tái đăng ký:
registry.category("fields").add("owl_color_tag", OwlColorTagWidget);
Sang QWeb template static/src/xml/owl_color_tag.xml, thêm t-on-click và mở rộng t-att-class:
<span class="o_owl_color_tag"
t-att-class="{ 'o_owl_color_tag_invalid': !isValidHex, 'o_owl_color_tag_updating': state.updating }"
t-att-style="'background-color: ' + displayColor + ';'"
t-att-title="rawValue"
t-on-click="() => this.onClick()"
t-on-mouseenter="() => this.onMouseEnter()"
t-on-mouseleave="() => this.onMouseLeave()">
<span class="o_owl_color_tag_label" t-if="state.hovered">
<t t-esc="displayColor"/>
</span>
</span>
Bind t-on-click bằng arrow function () => this.onClick() thay vì truyền thẳng this.onClick là một thói quen tốt với OWL: arrow function bảo toàn this binding chính xác về instance component, không phụ thuộc cách OWL runtime gọi handler. Hai class trong t-att-class được render độc lập — o_owl_color_tag_invalid đã có từ bước 3 cho hex sai, o_owl_color_tag_updating mới được thêm để SCSS sau này có thể dim swatch trong lúc RPC đang chạy. Hover handlers t-on-mouseenter / t-on-mouseleave từ bước 3 không đổi — invariant này được khoá bởi test_template_still_binds_hover_handlers.
Verification
Chạy lại toàn bộ test suite từ root thư mục code:
pytest
Kết quả:
============================= test session starts ==============================
platform darwin -- Python 3.12.5, pytest-9.0.3, pluggy-1.6.0
configfile: pytest.ini
testpaths: tests
collected 148 items
tests/test_baseline_model.py .......................... [ 17%]
tests/test_click_handler_and_notifications.py .......................... [ 35%]
.... [ 37%]
tests/test_field_registry_wiring.py ................ [ 48%]
tests/test_list_view_widget_attribute.py ............. [ 57%]
tests/test_owl_component_skeleton.py .................... [ 70%]
tests/test_reactive_click_handler.py ......................... [ 87%]
tests/test_scaffold.py .................. [100%]
============================= 148 passed in 0.55s ==============================
148 case xanh: 18 case scaffold bước 1, 26 case baseline model bước 2, 20 case skeleton bước 3, 16 case registry bước 4, 13 case view wiring bước 5, và 55 case mới của bước 6 (30 trong test_click_handler_and_notifications.py + 25 trong test_reactive_click_handler.py). Hai file test mới khoá lại từng mảnh: import useService từ đúng module hooks, setup() gọi useService("orm") và useService("notification") với key literal đúng, cờ updating: false được seed (đồng thời hovered: false của bước 3 còn nguyên), SWATCH_PALETTE là mảng hex ≥ 4 phần tử không trùng lặp, getter nextSwatch dùng cả SWATCH_PALETTE và indexOf, async onClick có guard if (this.state.updating), chạm record.resId và record.resModel, gọi await this.orm.write(...), gọi record.load(), bắn notification.add cho cả nhánh success và warning, toggle state.updating true rồi false quanh RPC, không có nested try block, template bind t-on-click trỏ onClick, t-att-class chứa state.updating, và mọi invariant bước 3-5 (template id, FALLBACK_COLOR, HEX_RE, registry binding, view widget attribute) còn nguyên không suy chuyển.
What we built
Widget từ giờ là một thành viên đầy đủ của hệ sinh thái Odoo services. Click vào swatch sẽ chọn hex kế tiếp trong SWATCH_PALETTE, gửi RPC write về server qua orm service, reload record để list renderer cập nhật, và pop toast success để operator biết thay đổi đã được lưu. Trường hợp click nhằm dòng chưa lưu, widget bắt sớm và bắn toast warning thay vì để ORM trả 500 — phản hồi UX rõ ràng hơn nhiều so với để lỗi nổi lên màn hình.
Việc gắn state.updating quanh ORM round-trip mang lại hai giá trị: chống race condition (guard tại đầu onClick) và phản hồi trực quan (class o_owl_color_tag_updating để SCSS có thể dim swatch). Khoá mềm này quan trọng với mọi widget có side-effect — nếu thiếu, người dùng spam chuột sẽ ra nhiều ORM write trùng nhau, gây race trên server và phí tài nguyên. Đây cũng là pattern dễ tổng quát hoá: bất kỳ widget nào tương lai có async action đều có thể tái sử dụng cùng nhịp ba bước (set flag → await action → unset flag).
Bộ test 55 case mới không chỉ khoá hành vi đúng mà còn chốt nhiều invariant phi-tầm-thường: SWATCH_PALETTE phải bất biến về shape, getter nextSwatch phải đi qua đúng indexOf trên hằng đó (không cho phép ai đó viết lại bằng Math.random() chẳng hạn), onClick phải có guard concurrent và guard unsaved-row, ORM write phải dùng record.resModel chứ không hardcode model, và mọi binding của các bước trước phải sống sót. Bộ test này sẽ là tấm khiên cho mọi refactor tương lai.
Bước 7 — bước cuối của tutorial — sẽ đóng phần polish còn lại: tách stylesheet ra static/src/scss/owl_color_tag.scss với ba khối rule cho trạng thái idle/invalid/updating (đặc biệt khối updating dùng opacity: 0.55 + cursor: progress + pointer-events: none để double-protect cho guard state.updating ở tầng JS), viết security/ir.model.access.csv cấp CRUD đầy đủ cho base.group_user và read-only cho base.group_portal, đồng thời đăng ký tour owl_color_tag_smoke_tour chạy trong bundle web.assets_tests để CI có thể thực sự click swatch và assert toast xuất hiện. Sau khi bước 7 xong, module sẽ là một addon Odoo 16 production-ready hoàn chỉnh.
Repository
The state of the code after this step: f028454
Step 7: Đóng gói widget thành module production-ready — tách stylesheet ba trạng thái, viết ir.model.access.csv cho internal user và portal, registry tour owl_color_tag_smoke_tour chạy trong bundle web.assets_tests
Sau bước 6, widget đã có vòng tròn ORM round-trip đầy đủ: click swatch → orm.write → record.load → notification.add. Nhưng module vẫn chưa sẵn sàng ship — stylesheet đang nằm inline trong head template từ thuở scaffold, CSV ACL trống nên user thường rơi AccessError khi mở list view, và không có ai bảo đảm rằng next refactor không vô tình bẻ gãy luồng click → toast. Bước 7 đóng ba khoảng trống cuối cùng: tách stylesheet ra static/src/scss/owl_color_tag.scss với ba khối rule rõ ràng cho ba trạng thái của swatch, viết security/ir.model.access.csv cấp CRUD đầy đủ cho base.group_user cùng read-only cho base.group_portal, và thêm tour smoke-test JS chạy trong bundle web.assets_tests để CI có thể click thử widget mà không cần mount toàn bộ runtime Odoo trong unit test.
Đây là bước "polish" — không thêm tính năng mới cho widget mà gắn các mảnh chính sách (security), thẩm mỹ (SCSS), và kiểm thử e2e (tour) lên xung quanh hành vi đã có. Lý do polish được tách thành một bước riêng thay vì nhét rải rác vào sáu bước trước là: mỗi mảnh đều có invariants riêng (security order trong data, cursor cho từng trạng thái, asset bundle khác nhau cho production vs test), và nhét chúng vào lúc đang viết hành vi sẽ làm nhoè ranh giới giữa "chuyện logic" và "chuyện đóng gói". Tách ra cũng giúp test pass theo từng nhịp: 148 case của bước 6 vẫn xanh, bước 7 chỉ thêm 68 case mới khoá riêng SCSS, CSV ACL, tour, và manifest delta.
Setup
Bước 7 chạm bốn file mới và một file đã có.
owl_list_widget/static/src/scss/owl_color_tag.scss— file mới, chứa bốn khối rule cho.o_owl_color_tag(idle),.o_owl_color_tag:hover,.o_owl_color_tag_invalid,.o_owl_color_tag_updating, và.o_owl_color_tag_label. Mỗi khối khoá một trạng thái UI riêng biệt và sẽ được test bằng regex source SCSS.owl_list_widget/security/ir.model.access.csv— file mới, hai dòng ACL: một dòng cấp CRUD đầy đủ chobase.group_user, một dòng cấp read-only chobase.group_portal. Header phải khớp chính xác bảy cột chuẩn của Odoo.owl_list_widget/static/src/js/tours/owl_color_tag_tour.js— file mới, đăng ký tourowl_color_tag_smoke_tourquaregistry.category("web_tour.tours")với ba step: click swatch, đợi.o_notification_body, assert swatch quay về idle (:not(.o_owl_color_tag_updating)).owl_list_widget/__manifest__.py— cập nhật: thêm"web_tour"vàodepends, đặt"security/ir.model.access.csv"lên TRƯỚC"views/demo_item_views.xml"trongdata, thêm keyweb.assets_testsvàoassetstrỏ tour file, và giữ nguyên SCSS path cùng JS/XML glob của bước trước.tests/test_polish_styling_security_tour.pyvàtests/test_polish_and_smoke_tour.py— tổng 68 case mới chia hai góc nhìn: một file tập trung style + ACL + manifest order, file kia khoá tour invariants và sự tồn tại của các file polish.
Không có dependency Python mới. web_tour là module Odoo 16 core ship sẵn, chỉ cần khai báo trong depends là Odoo sẽ resolve. SCSS được compile bởi asset pipeline của Odoo (libsass) — không cần Node toolchain.
Implementation
Tạo static/src/scss/owl_color_tag.scss với khối rule cho swatch idle trước:
.o_owl_color_tag {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
min-height: 20px;
padding: 2px 8px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.15);
cursor: pointer;
color: #ffffff;
font-size: 11px;
line-height: 1;
user-select: none;
transition: transform 120ms ease-in-out, box-shadow 120ms ease-in-out, opacity 120ms ease-in-out;
}
Hai property quyết định nhất ở đây là min-width: 28px / min-height: 20px và cursor: pointer. min-* đảm bảo dòng có color = "" (hoặc giá trị rỗng) vẫn render ra một ô vuông có click target — nếu không có nó, swatch sẽ collapse thành 0×0 px và operator không có chỗ để bấm. cursor: pointer thì khoá lại affordance: chỉ riêng đổi cursor cũng đã đủ cho user hiểu swatch là một nút bấm, không phải nhãn tĩnh. Đây chính là hai property mà test_scss_swatch_has_visible_dimensions và test_scss_uses_pointer_cursor ép buộc.
Tiếp theo, thêm khối hover cho phản hồi tương tác và khối invalid cho hex không hợp lệ:
.o_owl_color_tag:hover {
transform: scale(1.05);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
}
.o_owl_color_tag_invalid {
outline: 1px dashed #d9534f;
color: #5a1a17;
}
Hover dùng transform: scale(1.05) thay vì đổi background-color vì background-color đang được binding động theo displayColor từ component — ghi đè ở :hover sẽ phá hợp đồng giữa SCSS và template. Scale + box-shadow là pattern "an toàn" cho hover trên element có inline-style background. Khối .o_owl_color_tag_invalid đi đôi với cờ isValidHex của bước 3: dashed outline đỏ + text màu đậm hơn cho hex không khớp HEX_RE, đủ rõ ràng để designer nhìn list view là biết dòng nào có data sai.
Khối quan trọng nhất cho UX là .o_owl_color_tag_updating:
.o_owl_color_tag_updating {
opacity: 0.55;
cursor: progress;
pointer-events: none;
}
.o_owl_color_tag_label {
font-family: "Menlo", "Consolas", monospace;
letter-spacing: 0.02em;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.35);
}
Cả ba property của khối updating đều có vai trò riêng: opacity: 0.55 cho phản hồi thị giác (swatch dim đi để user biết đang chạy), cursor: progress cho phản hồi con trỏ (thay vì pointer nhàn rỗi), và pointer-events: none cho phản hồi tương tác (chặn cứng click thứ hai ngay ở tầng DOM, double-protect cho guard JS state.updating đã có sẵn). Test test_scss_marks_updating_state_as_busy_cursor chấp nhận một trong progress / wait / not-allowed — chọn progress vì semantic chuẩn HTML là "đang chạy, sẽ xong sớm". Khối .o_owl_color_tag_label dùng monospace để hex caption đọc rõ cả trên dark theme lẫn light theme.
Tạo security/ir.model.access.csv với header chính xác bảy cột chuẩn của Odoo:
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_owl_demo_item_user,owl.demo.item user,model_owl_demo_item,base.group_user,1,1,1,1
access_owl_demo_item_portal,owl.demo.item portal read,model_owl_demo_item,base.group_portal,1,0,0,0
model_id:id = model_owl_demo_item là external ID Odoo auto-sinh cho model owl.demo.item của bước 2 — quy tắc đặt là model_ + _name với dấu chấm thay bằng gạch dưới. Dòng base.group_user cấp CRUD đầy đủ vì handler bước 6 cần write quyền (orm.write mới chạy được). Dòng base.group_portal cấp read-only — đây là defensive choice, để portal user có thể xem demo trong các trang khách nếu bạn nhúng vào tương lai mà không vô tình cho phép họ ghi. Hai ID khác nhau là yêu cầu cứng: trùng id sẽ làm install fail với UniqueViolation ngay từ phase load CSV, và test_access_csv_external_id_naming_is_unique khoá lại điều đó.
Cập nhật __manifest__.py để khai báo dependency mới, thêm SCSS + CSV + tour vào đúng các bundle, và quan trọng nhất là sắp xếp thứ tự trong data:
{
"name": "OWL List View Custom Widget",
"version": "16.0.1.0.0",
"license": "LGPL-3",
"author": "vytharion",
"depends": [
"base",
"web",
"web_tour",
],
"data": [
"security/ir.model.access.csv",
"views/demo_item_views.xml",
],
"assets": {
"web.assets_backend": [
"owl_list_widget/static/src/scss/owl_color_tag.scss",
"owl_list_widget/static/src/js/*.js",
"owl_list_widget/static/src/xml/*.xml",
],
"web.assets_tests": [
"owl_list_widget/static/src/js/tours/owl_color_tag_tour.js",
],
},
"installable": True,
}
Thứ tự security/ir.model.access.csv đứng TRƯỚC views/demo_item_views.xml không phải tuỳ ý mà là invariant: view XML khi load sẽ resolve menu + action, và nếu ACL chưa register trước, Odoo có thể từ chối quyền tạo menu cho non-admin trong cùng transaction install. Test test_manifest_data_loads_security_before_views so sánh data.index(security_csv) < data.index(views_xml) để khoá thứ tự này. Bundle web.assets_tests được tách riêng để tour chỉ ship khi chạy test runner — production user không cần tải file JS chỉ-dùng-để-test, tiết kiệm bundle size cho real workload.
Cuối cùng, tạo static/src/js/tours/owl_color_tag_tour.js đăng ký tour qua registry:
/** @odoo-module **/
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add("owl_color_tag_smoke_tour", {
test: true,
url: "/web#action=owl_list_widget.owl_demo_item_action",
steps: () => [
{
trigger: ".o_list_view .o_owl_color_tag",
content: "Click the OWL color swatch to rotate its color.",
run: "click",
},
{
trigger: ".o_notification_body",
content: "Wait for the success toast confirming the ORM write.",
isCheck: true,
},
{
trigger: ".o_list_view .o_owl_color_tag:not(.o_owl_color_tag_updating)",
content: "Swatch returns to its idle state once the write resolves.",
isCheck: true,
},
],
});
Tour smoke-test này có ba step xếp theo nhịp click → wait → assert idle. Step 1 trigger trên .o_list_view .o_owl_color_tag — scope .o_list_view cần thiết để không bắt nhầm swatch trong form view (nếu tương lai có nhúng). run: "click" ép tour runner thực sự bấm chuột thay vì chỉ hover. Step 2 có isCheck: true nghĩa là chỉ kiểm tra selector xuất hiện chứ không tương tác — đợi toast .o_notification_body chính là proof của ORM write thành công. Step 3 dùng selector :not(.o_owl_color_tag_updating) để khoá invariant rằng cờ state.updating được đặt lại về false sau khi record.load() xong (kẹp đầu cuối của onClick từ bước 6).
Hai option module-level test: true và steps: () => [...] cũng là invariant cứng. test: true khiến tour eligible chạy từ bundle web.assets_tests (operator-facing onboarding tour set khác là sequence). steps: () => [...] là factory function lazy — Odoo 16 đã deprecate hình thức steps: [...] array tĩnh vì selector có thể chứa DOM chưa render lúc module load. test_tour_steps_factory_is_a_function chốt lại ràng buộc đó bằng regex steps\s*:\s*\(\s*\)\s*=>.
Verification
Chạy lại toàn bộ test suite từ root thư mục code:
pytest
Kết quả:
============================= test session starts ==============================
platform darwin -- Python 3.12.5, pytest-9.0.3, pluggy-1.6.0
configfile: pytest.ini
testpaths: tests
collected 216 items
tests/test_baseline_model.py .......................... [ 12%]
tests/test_click_handler_and_notifications.py .......................... [ 24%]
.... [ 25%]
tests/test_field_registry_wiring.py ................ [ 33%]
tests/test_list_view_widget_attribute.py ............. [ 39%]
tests/test_owl_component_skeleton.py .................... [ 48%]
tests/test_polish_and_smoke_tour.py ................................ [ 63%]
tests/test_polish_styling_security_tour.py ............................. [ 76%]
....... [ 80%]
tests/test_reactive_click_handler.py ......................... [ 91%]
tests/test_scaffold.py .................. [100%]
============================= 216 passed in 0.31s ==============================
216 case xanh: 148 case của các bước 1-6 còn nguyên (scaffold, baseline model, skeleton, registry, view wiring, reactive click handler) cộng thêm 68 case mới của bước 7 (32 case trong test_polish_and_smoke_tour.py + 36 case trong test_polish_styling_security_tour.py). Bộ test mới khoá lại các điểm sau: file SCSS tồn tại và non-empty, có rule cho cả bốn selector class .o_owl_color_tag / _invalid / _updating / _label, swatch idle có min-width hoặc min-height cùng cursor: pointer, updating block có ít nhất một trong opacity / cursor / pointer-events và cursor là progress / wait / not-allowed; file CSV tồn tại với header chính xác bảy cột, có dòng base.group_user với cả bốn permission = 1, mọi id unique và bắt đầu bằng access_owl_demo_item, mọi group_id:id thuộc tập base.group_* hợp lệ; manifest đăng ký SCSS path trong web.assets_backend, list security/ir.model.access.csv trong data và xếp trước views/demo_item_views.xml, depends có web_tour, key web.assets_tests chứa tour path; file tour có banner @odoo-module, import registry từ @web/core/registry, register qua registry.category("web_tour.tours").add("owl_color_tag_smoke_tour", ...), URL trỏ owl_list_widget.owl_demo_item_action, có step click trên .o_owl_color_tag với run: "click", có step assert trên .o_notification_body, có check :not(.o_owl_color_tag_updating), có test: true và steps: () => [...]; và cuối cùng là các invariant của bước trước (__init__.py vẫn import models, component JS vẫn có static template = "owl_list_widget.OwlColorTag", demo_item.py vẫn có _name = "owl.demo.item", manifest version vẫn bắt đầu bằng 16., JS + XML glob của bước 3 còn nguyên trong backend bundle).
What we built
Module từ giờ là một addon Odoo 16 production-ready hoàn chỉnh. Stylesheet được tách thành file riêng với bốn khối rule cho bốn trạng thái UI (idle, hover, invalid, updating) — bất kỳ designer nào muốn rebrand đều chỉ cần đụng vào static/src/scss/owl_color_tag.scss, không phải bới component JS hay template XML. Tách style ra cũng giúp asset pipeline của Odoo cache hiệu quả hơn (SCSS chỉ recompile khi file đổi, không bị invalidate cùng nhịp với JS).
Layer security đã được khoá đúng cách: base.group_user có CRUD đầy đủ để handler bước 6 chạy được, base.group_portal được cấp read-only như một defensive default. Việc xếp CSV trước view trong data đảm bảo install không bao giờ rơi vào tình trạng "view tham chiếu model chưa có ACL" — đây là kiểu lỗi rất khó debug ở runtime vì stack trace chỉ ra view layer dù gốc rễ là CSV order.
Tour smoke-test trong bundle web.assets_tests là tầng kiểm thử e2e mà unit test phân tích tĩnh không thể với tới. Test Python kiểm tra source code dùng đúng API, nhưng chỉ tour mới có thể chứng minh rằng khi browser thật click vào DOM thật, sự kiện được wire đến đúng handler, ORM thật được gọi, toast thật xuất hiện trong DOM, và class updating thật bị gỡ sau RPC. Ba tầng kiểm thử (static-analysis pytest → tour smoke-test → manual QA) cộng lại cho một mạng lưới an toàn đủ rộng để refactor lớn (đổi service, đổi model, đổi palette) đều có chốt chặn.
Vòng tutorial chính thức đóng lại ở đây. Bảy bước đi từ scaffold module (bước 1) → demo model + list view (bước 2) → OWL component skeleton (bước 3) → đăng ký field widget (bước 4) → cắm widget="owl_color_tag" vào XML (bước 5) → reactive click + ORM + notification (bước 6) → polish SCSS + ACL + tour (bước 7). Hành trình mở rộng tự nhiên từ đây — chẳng hạn nhận options="{'palette': [...]}" từ XML để override SWATCH_PALETTE per-column, mount widget vào kanban/form view, hoặc viết QUnit test thay tour cho luồng nhanh hơn — đều có nền tảng vững chắc trong bộ test 216 case và manifest layout đã chuẩn.
Repository
The state of the code after this step: 26ca42a
Repository
Full source at https://github.com/vytharion/odoo-owl-custom-widget-list-view.
Walk the lessons by stepping through the git commits in the repo — each major step has its own commit you can git checkout and rerun.