How to Create a Custom View Widget in Odoo 18: Complete Tutorial
By Braincuber Team
Published on February 27, 2026
Your purchase order list view shows nothing but raw numbers. No context. No quick insights. Your procurement team clicks into every single PO just to check how many lines it has. That's 7-12 seconds per order, multiplied by 340+ orders a month. We've watched warehouse managers waste 47 hours a year doing this. A custom view widget fixes it in one click. This complete tutorial shows you how to build one from scratch.
What You'll Learn:
- How to create an OWL JavaScript component for a view widget
- How to build a popover component using usePopover hook
- How to write XML templates for widget and popover
- How to define Python computed fields for the widget data
- How to register the widget in Odoo's view_widgets registry
- How to integrate the widget into a tree view via XML inheritance
The 3-Layer Architecture of a View Widget
Building a view widget in Odoo 18 isn't a one-file job. You need three layers working together: a JavaScript OWL component for behavior, an XML template for structure, and a Python model for data. Miss one layer, and the whole thing breaks silently. No error. Just a blank space where your widget should be.
JavaScript (OWL Component)
Defines widget behavior, popover logic, and registers the component in the view_widgets registry.
XML Templates
Controls the visual structure of the clickable icon and the popover table that displays order data.
Python Computed Fields
Provides the actual data (order_line_count, num_qty) that the widget reads and displays.
View Inheritance XML
Injects the widget and hidden fields into the existing purchase order tree view using xpath.
Step 1: Build the JavaScript OWL Component
This is the brain of your widget. We create two OWL components: a popover that shows the data, and a main widget that triggers the popover on click. The key import here is usePopover from Odoo's core hooks. Without it, your popover won't anchor to the clicked element.
Create the Popover Component
Define orderLineCountPopover extending Component. Import useService for the action service. Link it to the template your_module.OrderLineCountPopOver.
Create the Main Widget Component
Define orderLineCountWidget using the usePopover hook positioned at "top". The showPopup method opens the popover anchored to the clicked element and passes the record props.
Register in view_widgets Registry
Use registry.category("view_widgets").add() to register your widget under the name "order_line_count_widget". This is the name you'll reference in the XML view.
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { usePopover } from "@web/core/popover/popover_hook";
import { Component } from "@odoo/owl";
export class orderLineCountPopover extends Component {
setup() {
this.actionService = useService("action");
}
}
orderLineCountPopover.template =
"your_module.OrderLineCountPopOver";
export class orderLineCountWidget extends Component {
setup() {
this.popover = usePopover(
this.constructor.components.Popover,
{ position: "top" }
);
this.calcData = {};
}
showPopup(ev) {
this.popover.open(ev.currentTarget, {
record: this.props.record,
calcData: this.calcData,
});
}
}
orderLineCountWidget.components = {
Popover: orderLineCountPopover
};
orderLineCountWidget.template =
"your_module.orderLineCount";
export const OrderLineCountWidget = {
component: orderLineCountWidget,
};
registry
.category("view_widgets")
.add("order_line_count_widget",
OrderLineCountWidget);
Registry Name Must Match Exactly
The string you pass to registry.category("view_widgets").add() must exactly match the name attribute in your XML view's <widget> tag. A single typo and Odoo silently ignores your widget.
Step 2: Define the XML Templates
Two templates are needed. The first renders the clickable icon (a Font Awesome list icon). The second renders the popover table that appears on click. The t-name attribute links each template to its JavaScript component. Get this wrong, and Odoo throws a "template not found" error in the console.
Create the Widget Icon Template
Define the your_module.orderLineCount template with an anchor tag using t-on-click="showPopup" and the fa fa-list CSS class for the icon.
Create the Popover Template
Define the your_module.OrderLineCountPopOver template with a table showing order_line_count and num_qty using the t-out directive to safely render field values.
<?xml version="1.0" encoding="UTF-8" ?>
<template id="template" xml:space="preserve">
<t t-name="your_module.orderLineCount">
<a t-on-click="showPopup"
t-attf-class="fa fa-list"/>
</t>
<t t-name="your_module.OrderLineCountPopOver">
<div>
<table class="table table-borderless
table-sm">
<tr>
<td><h5>Order line count:</h5></td>
<td><h6>
<span t-out=
"props.record.data
.order_line_count"/>
</h6></td>
</tr>
<tr>
<td><h5>Number of quantity:</h5></td>
<td><h6>
<span t-out=
"props.record.data
.num_qty"/>
</h6></td>
</tr>
</table>
</div>
</t>
</template>
Step 3: Create the Python Computed Fields
Your widget is useless without data. We inherit the purchase.order model and add two computed Integer fields. The compute method loops through order lines and calculates the count and total quantity. Both fields are stored=True so they're available in the tree view without triggering a compute on every page load.
Define Python Model and Computed Fields
Inherit purchase.order, add order_line_count and num_qty as stored Integer fields with a shared compute method that maps order line IDs and sums product quantities.
# -*- coding: utf-8 -*-
from odoo import fields, models
class PurchaseOrder(models.Model):
_inherit = 'purchase.order'
order_line_count = fields.Integer(
string='Order Line Count',
compute='compute_line_count',
store=True
)
num_qty = fields.Integer(
string='Number Of Quantity',
compute='compute_line_count',
store=True
)
def compute_line_count(self):
for rec in self:
rec.order_line_count = len(
rec.order_line.mapped('id')
)
rec.num_qty = sum(
rec.order_line.mapped('product_qty')
)
Step 4: Integrate the Widget into the Tree View
The final piece. You inherit the existing purchase order tree view and inject your widget with xpath. The computed fields must be added as column_invisible="1" so they're loaded into the record but not shown as visible columns. Then add the <widget> tag referencing your registered name.
Create the Inherited View XML
Inherit purchase.purchase_order_view_tree, use xpath expr="//list" position="inside", add the two hidden fields and the <widget name="order_line_count_widget"/> tag.
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="purchase_order_view_tree"
model="ir.ui.view">
<field name="name">
purchase.order.tree.custom
</field>
<field name="model">
purchase.order
</field>
<field name="inherit_id"
ref="purchase.purchase_order_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//list" position="inside">
<field name="order_line_count"
column_invisible="1"/>
<field name="num_qty"
column_invisible="1"/>
<widget
name="order_line_count_widget"/>
</xpath>
</field>
</record>
</odoo>
Key Concepts Reference
| Concept | Purpose | Location |
|---|---|---|
| usePopover | Creates an anchored popover from OWL hooks | @web/core/popover/popover_hook |
| registry.category("view_widgets") | Registers the widget so Odoo can find it by name | @web/core/registry |
| t-out | Safely renders field values (XSS-safe output) | XML Template (QWeb) |
| column_invisible="1" | Loads field data without rendering a visible column | View XML |
| store=True | Persists computed field values in the database | Python Model |
Don't Forget __manifest__.py Assets
Your JS and XML files must be declared in the module's __manifest__.py under 'assets': {'web.assets_backend': [...]}. If they're not listed there, Odoo will never load the component.
Frequently Asked Questions
What is the difference between a field widget and a view widget in Odoo 18?
A field widget is bound to a specific model field and controls how that field renders. A view widget is standalone, registered under "view_widgets", and can access any data from the record props.
Why is my custom view widget not showing in the tree view?
Check three things: the JS/XML files are in __manifest__.py assets, the registry name matches the widget name in XML, and the module has been upgraded after changes.
Can I use usePopover in Odoo 18 form views?
Yes, usePopover works in any OWL component regardless of the view type. You can use it in form, list, or kanban views as long as the component is properly registered.
Do I need to restart the Odoo server after adding a view widget?
Yes. After adding or modifying JS/XML assets, restart the server with --update=your_module and clear the browser cache to load the new assets.
How do I pass custom data to the popover component?
In the showPopup method, pass any data as the second argument to this.popover.open(). The popover component receives it via this.props.
Need Custom Odoo Widgets Built?
Our Odoo developers build production-ready custom widgets, OWL components, and view extensions that save your team hours of clicking.
