Odoo 組み込みのリストビューは閲覧には十分だが、Excel から来た ユーザーはセル内編集、列フィルター、固定ヘッダー、タイプして 編集、範囲選択、コピー/ペーストを期待する。この記事では、 Tabulator を使ってそれをカスタム Odoo ビューとして構築する方法を示す。Odoo のビューレジストリに 配線され、検索ドメイン、アクセス権、コントロールパネルと標準 ビュー同様に連携する。
Odoo 19 でテスト済み。結果はこのようになる:
統合全体は ~1,150 行:5つの小さな JS ファイル、1つの XML
ビュー、1つの CSS ファイルを持つ1つの Odoo モジュール——
加えて(オプションで)Playwright エンドツーエンドテストと
mail.thread 経由のサーバーサイド変更追跡。
アーキテクチャ
Odoo のビューシステムには埋めるべき4つのスロットがある:
ArchParser(<list> XML arch を列記述子に変換)、
Model(ORM サービス経由でレコードを読み書き)、
Controller(Layout + SearchBar をホスト)、Renderer
(Tabulator インスタンスを所有する OWL コンポーネント)。
最初に必要なトリックが1つある:Odoo はカスタム
ir.ui.view.type 値を許可しないので、サーバーサイドで
まったく新しいビュータイプを登録できない。標準的な回避策は、
ビューを js_class="techoverflow_grid" 付きの <list> arch
として宣言し、その名前をクライアント側 views レジストリに
登録すること。Odoo の js_class ディスパッチがビューを標準
リストビューではなくあなたの記述子にルーティングする。
必要なファイル
techoverflow_grid/
├── __manifest__.py
├── data/techoverflow_grid_view.xml
└── static/src/
├── tabulator_view.js # ビュー記述子、js_class として登録
├── tabulator_arch_parser.js # <list> arch → 列定義
├── tabulator_model.js # ORM 読み書き
├── tabulator_controller.js # Layout + SearchBar ホスト
├── tabulator_renderer.js # Tabulator インスタンス + 編集ロジック
├── tabulator_view.xml # OWL テンプレート
├── tabulator_view.css
└── lib/tabulator/ # vendored tabulator.min.js + css__manifest__.py
{
"name": "TechOverflow Grid",
"summary": "Excel-like editable grid view (Tabulator) for Odoo",
"version": "19.0.1.0.1",
"license": "LGPL-3",
"depends": ["web"],
"data": ["data/techoverflow_grid_view.xml"],
"assets": {
"web.assets_backend": [
"techoverflow_grid/static/src/tabulator_arch_parser.js",
"techoverflow_grid/static/src/tabulator_model.js",
"techoverflow_grid/static/src/tabulator_renderer.js",
"techoverflow_grid/static/src/tabulator_controller.js",
"techoverflow_grid/static/src/tabulator_view.js",
"techoverflow_grid/static/src/tabulator_view.xml",
"techoverflow_grid/static/src/tabulator_view.css",
],
},
"installable": True,
}Tabulator 自体は static/lib/tabulator/ に vendored され、
実行時に遅延ロードされる(下記レンダラー参照)ため、npm
ビルドステップは不要。
ビューレコード — data/techoverflow_grid_view.xml
arch ルートに js_class を付けた <field name="type">list</field>
に注意——これがカスタムビューをロード可能にするもの:
<record id="view_employee_tabulator" model="ir.ui.view">
<field name="name">x_techoverflow_employee.tabulator</field>
<field name="model">x_techoverflow_employee</field>
<field name="type">list</field>
<field name="priority">16</field>
<field name="arch" type="xml">
<list js_class="techoverflow_grid" string="Employees">
<field name="x_name" width="170"/>
<field name="x_job_title" width="190"/>
<field name="x_department" width="135"/>
<field name="x_annual_salary" width="130"/>
<field name="x_currency_id" width="90"/>
<!-- ... -->
</list>
</field>
</record>
<!-- アクションのリストビューとして配線 -->
<record id="action_view_employees_tabulator" model="ir.actions.act_window.view">
<field name="sequence" eval="1"/>
<field name="view_mode">list</field>
<field name="view_id" ref="view_employee_tabulator"/>
<field name="act_window_id" ref="your_action_id"/>
</record>ビュー登録 — tabulator_view.js
import { registry } from "@web/core/registry";
import { TabulatorArchParser } from "./tabulator_arch_parser";
import { TabulatorController } from "./tabulator_controller";
import { TabulatorModel } from "./tabulator_model";
import { TabulatorRenderer } from "./tabulator_renderer";
export const tabulatorView = {
type: "list",
Controller: TabulatorController,
Renderer: TabulatorRenderer,
Model: TabulatorModel,
ArchParser: TabulatorArchParser,
searchMenuTypes: ["filter", "favorite"],
props: (genericProps, view) => {
const archInfo = new view.ArchParser().parse(
genericProps.arch,
genericProps.fields
);
return {
...genericProps,
archInfo,
modelParams: {
resModel: genericProps.resModel,
fields: genericProps.fields,
archInfo,
},
Model: view.Model,
Renderer: view.Renderer,
};
},
};
registry.category("views").add("techoverflow_grid", tabulatorView);Arch パーサー — tabulator_arch_parser.js
各 <field> ノードとモデルのフィールドメタデータを列記述子に
マップ。~50 行:
import { visitXML } from "@web/core/utils/xml";
function truthy(attr) {
return attr !== null && !["0", "false", "False"].includes(attr);
}
export class TabulatorArchParser {
parse(xmlDoc, fields) {
const root = xmlDoc.documentElement || xmlDoc;
const columns = [];
visitXML(root, (node) => {
if (node.tagName !== "field") return;
const name = node.getAttribute("name");
const meta = (fields || {})[name];
if (!name || !meta) return;
columns.push({
name,
type: meta.type,
title: node.getAttribute("string") || meta.string || name,
readonly: truthy(node.getAttribute("readonly")),
invisible: truthy(node.getAttribute("invisible")),
required: Boolean(meta.required),
width: node.getAttribute("width")
? parseInt(node.getAttribute("width"), 10) : undefined,
frozen: truthy(node.getAttribute("frozen")),
relation: meta.relation,
selection: meta.selection,
currencyField: meta.currency_field,
});
});
return {
columns,
editable: root.getAttribute("editable") !== "0",
create: root.getAttribute("create") !== "0",
delete: root.getAttribute("delete") !== "0",
};
}
}モデル — tabulator_model.js
すべてのデータアクセスは orm サービスを経由するため、
アクセス権、レコードルール、そして重要な mail.thread 追跡が
変更なしで適用される。モデルは many2one オプションリストと
フォーマット用の通貨シンボルもプリフェッチする(合計 ~145 行;
コアを表示):
import { Model } from "@web/model/model";
export class TabulatorModel extends Model {
static services = ["orm"];
setup(params) {
this.resModel = params.resModel;
this.fieldsMeta = params.fields;
this.archInfo = params.archInfo;
this.records = [];
this.optionLists = {}; // m2o フィールド名 -> [{id, name}]
this.currencySymbols = {}; // 通貨 id -> {symbol, position}
}
async load(params) {
const { domain = [], context = {}, orderBy = [] } = params || {};
const order = orderBy
.map((o) => `${o.name || o} ${o.asc === false ? "desc" : "asc"}`)
.join(", ") || "id asc";
this.records = await this.orm.searchRead(
this.resModel, domain, this.fieldNames,
{ context, order, limit: 10000 }
);
if (this.isReady) this.notify();
}
async saveField(id, name, value) {
await this.orm.write(this.resModel, [id], { [name]: value });
const rec = this.records.find((r) => r.id === id);
if (rec) rec[name] = value;
}
// createRecord/deleteRecords は orm.create/orm.unlink + reload() をラップ
}searchRead がコントロールパネルからドメインを受け取るため、
標準の検索バー、フィルター、お気に入りが無料で動作——
ビュー記述子の searchMenuTypes: ["filter", "favorite"] が
それらを有効化。
コントローラー + テンプレート
コントローラーは定型文——useModelWithSampleData が Model を
OWL リアクティビティに配線する。1つの微妙な点:SearchBar は
controlPanel.layoutActions が真の場合のみレンダリングされ、
display getter がそれを強制的にオンにする。テンプレートは
layout-actions スロットに SearchBar、その下に Renderer を
持つ <Layout>(~40 + ~40 行)。
レンダラー — tabulator_renderer.js(~680 行)
これが面白いファイル。Tabulator を遅延ロードし、フィールド タイプごとに列定義を構築し、Tabulator 単体では Odoo 内で 得られない Excel セマンティクスを実装する。
ライブラリロード&グリッド初期化:
onWillStart(async () => {
await Promise.all([
loadJS("/techoverflow_grid/static/lib/tabulator/tabulator.min.js"),
loadCSS("/techoverflow_grid/static/lib/tabulator/tabulator_simple.min.css"),
]);
await this.props.model.loadMeta();
});
// ...
this.table = new Tabulator(this.gridRef.el, {
index: "id",
data: this.rows,
columns: this.buildColumns(),
layout: "fitDataTable",
height: "100%", // 固定高 => スティッキーヘッダー + スクロール
movableColumns: true,
editTriggerEvent: "dblclick",
selectableRange: 1, // Excel 風セル範囲選択
clipboard: true,
history: true,
});
this.table.on("cellEdited", (cell) => this.onCellEdited(cell));フィールドタイプ対応列。 buildColumns() は Odoo フィールド
タイプを Tabulator エディター/フォーマッターにマップ:
many2one/selection → プリロード値付き list エディター、
monetary → 通貨対応フォーマッターと bottomCalc: "sum"
フッター付き number エディター(スクリーンショットの合計行)、
boolean → tickCross、date → プレーン <input type="date">
カスタムエディター、text → textarea。編集不可列は editor
キーを持たない;編集可能列は cssClass: "o_tabulator_editable"
を持ち、スタイル可能。
計算列はフィールド列の後に追加されるクライアント側関数——
例:x_hire_date からの勤続年数と、雇用タイプで分岐する推定
年間コスト:
function estAnnualCost(row) {
if (row.x_employment_type === "freelancer") {
const util = (row.x_utilization || 100) / 100;
return (row.x_hourly_rate || 0) * 8 * 220 * util;
}
return (row.x_annual_salary || 0) * 1.25; // +25% 雇用主オーバーヘッド
}レコードを開くには action サービスを使う。
target: "current" はモーダルではなくフルページフォーム
(chatter 付き——下記参照)を開く:
openRecord(id) {
this.action.doAction({
type: "ir.actions.act_window",
res_model: this.props.model.resModel,
res_id: id,
views: [[false, "form"]],
target: "current",
});
}実際のデバッグ時間を要した落とし穴
2つの非自明な Tabulator の問題は指摘する価値がある——両方
ともレンダラーで修正済みで、selectableRange を有効にすると
咬まれる:
1. cellEdited が Enter/Tab で発火しない。 Tabulator 6.4
で selectableRange が有効な場合、range モジュールの keydown
ハンドラが Enter/Tab でナビゲートし、コミットの代わりに開いて
いるエディターをキャンセルする——そして Tabulator 自身の
コミット経路は、エディター要素がすでにデタッチされているため
layoutElement 内で NotFoundError でクラッシュする。修正は
グリッドルートのキャプチャフェーズ keydown リスナーで、
エディターを手動コミットする:
commitCurrentCell() {
const editMod = this.table?.modules?.edit;
const raw = editMod?.currentCell;
if (!raw) return false;
// currentCell は内部 Cell の場合がある——CellComponent に正規化
const comp = raw.getComponent ? raw.getComponent() : raw;
const input = comp.getElement().querySelector("input, textarea, select");
const value = input
? (input.type === "checkbox" ? input.checked : input.value)
: comp.getValue();
try { comp.cancelEdit?.(); } catch {}
try { comp.setValue(value); } catch {}
this.onCellEdited(comp, value); // -> model.saveField -> orm.write
return true;
}2. ヘッダーフィルターのキーストロークが選択セルを編集する。
同じキャプチャフェーズハンドラは、キーイベントが input、
textarea、select、contenteditable 要素、.tabulator-col
内で発生した場合に bail out しなければならない——さもなければ
列フィルターボックスへのタイピングが以前選択されたセルへの
編集を開始する。順序が重要:このチェックは開いているエディター
分岐の後に来なければならない。さもなければ実際のエディター内の
Enter がコミットしない:
onGridKeydown(e) {
const editMod = this.table?.modules?.edit;
const rangeMod = this.table?.modules?.selectRange;
if (!editMod || !rangeMod || e.ctrlKey || e.metaKey || e.altKey) return;
if (editMod.currentCell) {
// エディターが開いている:Enter/Tab はコミット、他はすべてエディターへ。
if (e.key === "Enter" || e.key === "Tab") {
if (this.commitCurrentCell()) {
e.preventDefault();
e.stopPropagation();
}
}
return;
}
// ヘッダーフィルター内のキーストロークはセルを編集してはならない。
if (e.target?.closest?.(
"input, textarea, select, [contenteditable=true], .tabulator-col")) {
return;
}
// ... アクティブ範囲セルでの Enter/F2/タイプして編集
}同じハンドラが閉じたセルでの Excel キーセマンティクスを実装:
読み取り専用セルで Enter は下に移動、F2/ダブルクリック/
タイピングでエディターが開き、タイピングは押された文字で
エディターの入力をシードする。選択範囲のコピーもキャプチャ
フェーズで傍受され(ビュールートの copy リスナー)、
text/plain に TSV を書き込み、Excel/LibreOffice にきれいに
ペーストできる。
オプション:mail.thread による完全な変更追跡
すべての編集が orm.write を経由するため、Odoo 組み込みの
監査証跡がそのまま動作する。DB 定義/手動モデルの場合、
ir.model にフラグを設定して有効化——ir.model.inherit 行を
書かないこと、手動モデルでは無視される:
env["ir.model"].browse(model_id).write({
"is_mail_thread": True,
"is_mail_activity": True,
})
# フィールドを追跡対象に
env["ir.model.fields"].search([
("model", "=", "x_techoverflow_employee"),
("name", "like", "x_%"),
]).write({"tracking": 1})次にフォームビューに <chatter/> を追加すると、すべての
セル編集が mail.tracking.value エントリ(旧値 → 新値、
ユーザー、タイムスタンプ)を持つ mail.message を生成し、
レコードの chatter に表示される:
<sheet>
<!-- フィールド -->
</sheet>
<chatter/>オプション:Playwright エンドツーエンドテスト
~230 行の Playwright がフロー全体をカバー——ログイン、
グリッドレンダリング(列、サーバー search_count との行数、
計算列)、サーバーサイドで永続化されたセル編集、ヘッダー
フィルター回帰、検索バーフィルタリング、クラシックリスト、
フォーム + chatter + 追跡エントリ:
test("grid cell edit persists to the server", async ({ page }) => {
await gotoEmployees(page);
// ... セルでエディターを開き、タイプ、コミット
expect(
(await odooRpc(page, EMP, "read",
[[parseInt(rowId, 10)], ["x_phone"]]))[0].x_phone
).toBe(newPhone);
});UI 編集後に(DOM チェックではなく)RPC read で検証すると
サイレントな書き込み失敗を捕捉——上記の cellEdited バグは
こうして見つかった。npx playwright test で実行;/tmp が
小さな tmpfs の場合、Chrome がフル tmpfs にプロファイルを
書けないため TMPDIR=$HOME/tmp-pw を使う。
まとめ
| コンポーネント | 行数 | 目的 |
|---|---|---|
tabulator_renderer.js | ~680 | Tabulator インスタンス、エディター、Excel キー/クリップボードセマンティクス |
tabulator_model.js | ~145 | ORM データ層、m2o オプション、アクセスチェック |
tabulator_controller.js + tabulator_view.js + arch_parser.js | ~130 | ビュー登録、arch パース、コントロールパネル |
tabulator_view.xml + CSS | ~120 | OWL テンプレート + スタイリング |
data/ XML + マニフェスト | ~135 | ビューレコード、アクション配線、アセット |
| 必要合計 | ~1,200 | |
| Playwright テスト | ~230 | オプションの E2E カバレッジ |
mail.thread 追跡 | 設定 ~20 行 | オプションの監査証跡 |
全体はビルドステップもサーバーサイド Python もない単一の
インストール可能モジュール——Tabulator dist ファイルを
static/lib/ に置き、<list js_class="..."> ビューを追加
すれば、Odoo のビュー機構が残りをやる。
