Tabulator で Odoo に Excel 風の編集可能グリッドビューを構築する方法

Odoo 組み込みのリストビューは閲覧には十分だが、Excel から来た ユーザーはセル内編集、列フィルター、固定ヘッダー、タイプして 編集、範囲選択、コピー/ペーストを期待する。この記事では、 Tabulator を使ってそれをカスタム Odoo ビューとして構築する方法を示す。Odoo のビューレジストリに 配線され、検索ドメイン、アクセス権、コントロールパネルと標準 ビュー同様に連携する。

Odoo 19 でテスト済み。結果はこのようになる:

Odoo Excel like view

統合全体は ~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 ディスパッチがビューを標準 リストビューではなくあなたの記述子にルーティングする。

必要なファイル

example.txt
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

__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> に注意——これがカスタムビューをロード可能にするもの:

techoverflow_grid_view.xml
<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

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 行:

tabulator_arch_parser.js
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 行; コアを表示):

tabulator_model.js
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 セマンティクスを実装する。

ライブラリロード&グリッド初期化:

tabulator_renderer.js
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 からの勤続年数と、雇用タイプで分岐する推定 年間コスト:

tabulator_renderer.js
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 付き——下記参照)を開く:

tabulator_renderer.js
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 リスナーで、 エディターを手動コミットする:

tabulator_renderer.js
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 がコミットしない:

tabulator_renderer.js
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 行を 書かないこと、手動モデルでは無視される:

enable_tracking.py
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 に表示される:

form_view.xml
<sheet>
    <!-- フィールド -->
</sheet>
<chatter/>

オプション:Playwright エンドツーエンドテスト

~230 行の Playwright がフロー全体をカバー——ログイン、 グリッドレンダリング(列、サーバー search_count との行数、 計算列)、サーバーサイドで永続化されたセル編集、ヘッダー フィルター回帰、検索バーフィルタリング、クラシックリスト、 フォーム + chatter + 追跡エントリ:

techoverflow.spec.js
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~680Tabulator インスタンス、エディター、Excel キー/クリップボードセマンティクス
tabulator_model.js~145ORM データ層、m2o オプション、アクセスチェック
tabulator_controller.js + tabulator_view.js + arch_parser.js~130ビュー登録、arch パース、コントロールパネル
tabulator_view.xml + CSS~120OWL テンプレート + スタイリング
data/ XML + マニフェスト~135ビューレコード、アクション配線、アセット
必要合計~1,200
Playwright テスト~230オプションの E2E カバレッジ
mail.thread 追跡設定 ~20 行オプションの監査証跡

全体はビルドステップもサーバーサイド Python もない単一の インストール可能モジュール——Tabulator dist ファイルを static/lib/ に置き、<list js_class="..."> ビューを追加 すれば、Odoo のビュー機構が残りをやる。


同じカテゴリの類似記事をチェック: Odoo Javascript