Way4Tech

Part 6. Extending Odoo itself · lesson 30 of 40

Client actions: a full screen of your own

After this lesson you can: Build a client action with a proper control panel, reachable from a menu and from Python.

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

A client action is a screen OWL owns completely. No model, no view, no record. Dashboards, wizards with custom interfaces, planning boards.

The JavaScript

// static/src/dashboard/dashboard.js
import { Component, useState, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Layout } from "@web/search/layout";

export class SalesDashboard extends Component {
    static template = "owl_course.SalesDashboard";
    static components = { Layout };
    static props = ["*"];

    setup() {
        this.orm = useService("orm");
        this.action = useService("action");
        this.state = useState({ period: "month", rows: [], total: 0 });

        onWillStart(() => this.load());
    }

    get display() {
        return {
            controlPanel: {},
        };
    }

    async load() {
        const groups = await this.orm.readGroup(
            "sale.order",
            [["state", "in", ["sale", "done"]], ...this.periodDomain],
            ["amount_total:sum"],
            ["partner_id"],
            { limit: 10, orderby: "amount_total desc", lazy: false },
        );
        this.state.rows = groups;
        this.state.total = groups.reduce((s, g) => s + (g.amount_total || 0), 0);
    }

    get periodDomain() {
        const now = new Date();
        const from = this.state.period === "year"
            ? new Date(now.getFullYear(), 0, 1)
            : new Date(now.getFullYear(), now.getMonth(), 1);
        return [["date_order", ">=", from.toISOString().slice(0, 10)]];
    }

    async setPeriod(period) {
        this.state.period = period;
        await this.load();
    }

    openPartner(group) {
        this.action.doAction({
            type: "ir.actions.act_window",
            name: `Orders, ${group.partner_id[1]}`,
            res_model: "sale.order",
            domain: group.__domain,
            views: [[false, "list"], [false, "form"]],
        });
    }
}

registry.category("actions").add("owl_course.sales_dashboard", SalesDashboard);
<t t-name="owl_course.SalesDashboard">
    <Layout display="display" className="'o_dashboard h-100'">
        <t t-set-slot="control-panel-create-button">
            <div class="btn-group btn-group-sm" role="group">
                <button class="btn" t-att-class="state.period === 'month' ? 'btn-primary' : 'btn-secondary'"
                        t-on-click="() => this.setPeriod('month')">This month</button>
                <button class="btn" t-att-class="state.period === 'year' ? 'btn-primary' : 'btn-secondary'"
                        t-on-click="() => this.setPeriod('year')">This year</button>
            </div>
        </t>

        <div class="p-3">
            <h2 class="mb-3">Top customers</h2>
            <table class="table table-sm">
                <thead>
                    <tr><th>Customer</th><th class="text-end">Orders</th><th class="text-end">Total</th></tr>
                </thead>
                <tbody>
                    <tr t-foreach="state.rows" t-as="row" t-key="row.partner_id[0]"
                        class="cursor-pointer" t-on-click="() => this.openPartner(row)">
                        <td t-esc="row.partner_id[1]"/>
                        <td class="text-end" t-esc="row.__count"/>
                        <td class="text-end" t-esc="row.amount_total"/>
                    </tr>
                </tbody>
                <tfoot>
                    <tr><th colspan="2" class="text-end">Total</th><th class="text-end" t-esc="state.total"/></tr>
                </tfoot>
            </table>
        </div>
    </Layout>
</t>

The Layout component

Layout gives you Odooโ€™s control panel: the breadcrumb, the title area and the slots for buttons. Without it your screen floats with no header and looks foreign.

display controls what appears. { controlPanel: {} } is the usual minimum. Setting controlPanel: false removes it entirely, which also removes the breadcrumb, so the user cannot navigate back. Rarely right.

Making it reachable

<!-- views/dashboard_views.xml -->
<odoo>
    <record id="action_sales_dashboard" model="ir.actions.client">
        <field name="name">Sales Dashboard</field>
        <field name="tag">owl_course.sales_dashboard</field>
    </record>

    <menuitem id="menu_sales_dashboard"
              name="Dashboard"
              parent="sale.sale_menu_root"
              action="action_sales_dashboard"
              sequence="1"/>
</odoo>

The tag must match the registry key exactly. A mismatch gives โ€œthe action could not be foundโ€, which is at least a clear error.

From Python:

def open_dashboard(self):
    return {
        'type': 'ir.actions.client',
        'tag': 'owl_course.sales_dashboard',
        'name': 'Sales Dashboard',
        'params': {'default_period': 'year'},
    }

Params and context

setup() {
    const params = this.props.action.params || {};
    const context = this.props.action.context || {};

    this.state = useState({
        period: params.default_period || context.default_period || "month",
    });
}

this.props.action is the whole action. params is what Python sent, context is the usual Odoo context. Declaring static props = ["*"] is the simple way to accept everything the action manager passes; a client action receives several props you do not control.

Keeping state in the URL

So a refresh does not lose the view, and the screen can be linked to.

setup() {
    this.router = useService("router");
    const initial = this.router.current.hash.period || "month";
    this.state = useState({ period: initial });
}

setPeriod(period) {
    this.state.period = period;
    this.router.pushState({ period });
    this.load();
}

Worth doing for anything a user might bookmark or send to a colleague.

A dashboard rule worth keeping

Every figure must open the records behind it. A total with no drill-down is a number people either trust blindly or ignore, and both are bad.

The corollary, learned expensively: compute the figures with the right rights. A dashboard that counts a model the reader cannot access will hard error for them, while working perfectly for you as an administrator. Either aggregate server side with sudo() and return the totals, or hide the card from groups that cannot open what is behind it. Showing a number nobody can click is the worst of both.

What is next

Extending the standard views rather than replacing them.

Back to the full course