Way4Tech

Part 6. Extending Odoo itself · lesson 31 of 40

Extending a standard view

After this lesson you can: Add behaviour to a standard view, scoped to the models you intend, without copying core code.

Written against Odoo 19.0 and 18.0. Last checked 2026-10-09.

Before building a view, exhaust extending one. A standard list brings sorting, grouping, filters, pagination, export, multi-edit and optional columns. Those are weeks of work you get free and must otherwise reimplement.

A widget inside a view

The lightest option, and the most scoped. A view_widget is a component you can drop into any viewโ€™s arch with a <widget> tag.

// static/src/widgets/margin_banner.js
import { Component, useState, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";

export class MarginBanner extends Component {
    static template = "owl_course.MarginBanner";
    static props = { ...standardWidgetProps };

    setup() {
        this.orm = useService("orm");
        this.state = useState({ margin: null });
        onWillStart(() => this.load());
    }

    async load() {
        const id = this.props.record.resId;
        if (!id) return;                    // a new, unsaved record
        this.state.margin = await this.orm.call(
            "sale.order", "compute_live_margin", [[id]],
        );
    }
}

registry.category("view_widgets").add("margin_banner", {
    component: MarginBanner,
    fieldDependencies: [{ name: "amount_total", type: "monetary" }],
});
<record id="view_order_form_margin" model="ir.ui.view">
    <field name="model">sale.order</field>
    <field name="inherit_id" ref="sale.view_order_form"/>
    <field name="arch" type="xml">
        <xpath expr="//sheet" position="before">
            <widget name="margin_banner"/>
        </xpath>
    </field>
</record>

Scoped to exactly the views where you put the tag. No patching, nothing else affected. Reach for this first.

A button in the control panel, for one model

Patching a controller affects every model using that view type, so guard it.

import { patch } from "@web/core/utils/patch";
import { ListController } from "@web/views/list/list_controller";

patch(ListController.prototype, {
    setup() {
        super.setup();
        this.myModuleShowImport = this.props.resModel === "my.model";
    },

    async myModuleOnImport() {
        await this.actionService.doAction("my_module.action_import_wizard", {
            onClose: () => this.model.load(),
        });
    },
});
<t t-name="my_module.ListControlPanel"
   t-inherit="web.ListView.Buttons"
   t-inherit-mode="extension">
    <xpath expr="//button[hasclass('o_list_button_add')]" position="after">
        <button t-if="myModuleShowImport" type="button"
                class="btn btn-secondary"
                t-on-click="myModuleOnImport">
            Import
        </button>
    </xpath>
</t>

Two things make this safe. The resModel check means other models see nothing, and the method name is namespaced so it cannot collide.

The cleaner alternative when it fits: cogMenu, which is designed for exactly this and needs no patch.

import { registry } from "@web/core/registry";

class ImportCogMenu extends Component {
    static template = "my_module.ImportCogMenu";
    setup() {
        this.action = useService("action");
    }
}

registry.category("cogMenu").add("my_module.import", {
    Component: ImportCogMenu,
    groupNumber: 20,
    isDisplayed: ({ config }) => config.actionType === "ir.actions.act_window"
        && config.viewType === "list",
});

isDisplayed is given the view config, so you decide per view and per model without patching anything.

Registering a whole view variant

When you want a list that behaves differently for one model, register a new view type rather than patching the shared one.

import { registry } from "@web/core/registry";
import { listView } from "@web/views/list/list_view";
import { ListController } from "@web/views/list/list_controller";

export class ApprovalListController extends ListController {
    setup() {
        super.setup();
        this.notification = useService("notification");
    }

    async approveSelected() {
        const ids = await this.getSelectedResIds();
        if (!ids.length) {
            return this.notification.add("Select some rows first", { type: "warning" });
        }
        await this.orm.call("my.approval", "action_approve", [ids]);
        await this.model.load();
        this.notification.add(`${ids.length} approved`, { type: "success" });
    }
}

registry.category("views").add("approval_list", {
    ...listView,
    Controller: ApprovalListController,
    buttonTemplate: "my_module.ApprovalListButtons",
});
<list js_class="approval_list">
    <field name="name"/>
    <field name="amount"/>
</list>

js_class on the arch opts one view in. Everything else keeps the standard list. This is the right tool whenever the change belongs to a model rather than to lists in general.

The pieces of a view

Worth knowing when deciding what to extend:

PieceResponsibility
ControllerTop level component, buttons, coordination
RendererDrawing the rows or the form
ModelLoading and writing records
ArchParserReading the XML arch into a structure

Spreading listView and replacing one of them keeps the rest.

Order of preference

  1. Change the arch in XML. No JavaScript at all.
  2. A view_widget dropped into the arch.
  3. A cogMenu entry with isDisplayed.
  4. A registered view variant with js_class.
  5. A patch to a shared controller, guarded by model.
  6. A custom view type.

Work down this list, not up it. Each step affects more of the system than the one before.

What is next

The last resort: a view type of your own.

Back to the full course