Way4Tech

Part 5. Services and registries · lesson 22 of 40

Notifications, dialogs and confirmation

After this lesson you can: Show toasts and dialogs, including a dialog containing your own component.

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

Two services cover almost all user feedback: notification for things that do not need an answer, dialog for things that do.

Notifications

setup() {
    this.notification = useService("notification");
}

save() {
    this.notification.add("Saved", { type: "success" });
}

Types are success, warning, danger, and the default info.

this.notification.add("The invoice was posted", {
    title: "Done",
    type: "success",
    sticky: false,       // auto dismiss, the default
});

sticky: true stays until dismissed. Use it only for something the user must act on, because a pile of sticky toasts is its own problem.

Buttons on a toast

this.notification.add("3 invoices posted", {
    type: "success",
    buttons: [{
        name: "View them",
        onClick: () => this.action.doAction({
            type: "ir.actions.act_window",
            res_model: "account.move",
            domain: [["id", "in", ids]],
            views: [[false, "list"], [false, "form"]],
        }),
    }],
});

This is the right pattern after a bulk operation: confirm it happened and offer the way to see the result.

Removing one yourself

const close = this.notification.add("Uploading", { sticky: true });
await this.upload();
close();

Confirmation

window.confirm does not work in the Odoo client. It is blocking, it cannot be styled, and in several embedded contexts it never appears. Use the dialog service.

import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";

setup() {
    this.dialog = useService("dialog");
}

askThenDelete(id) {
    this.dialog.add(ConfirmationDialog, {
        title: "Delete this invoice",
        body: "This cannot be undone.",
        confirmLabel: "Delete",
        confirm: () => this.doDelete(id),
        cancel: () => {},
    });
}

confirm and cancel are callbacks, not a returned promise. If you prefer await, wrap it once:

// static/src/hooks/use_confirm.js
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { useService } from "@web/core/utils/hooks";

export function useConfirm() {
    const dialog = useService("dialog");
    return (title, body, confirmLabel = "Confirm") =>
        new Promise((resolve) => {
            dialog.add(ConfirmationDialog, {
                title, body, confirmLabel,
                confirm: () => resolve(true),
                cancel: () => resolve(false),
            });
        });
}
setup() {
    this.confirm = useConfirm();
}

async onDelete(id) {
    if (await this.confirm("Delete this invoice", "This cannot be undone.")) {
        await this.doDelete(id);
    }
}

That hook pays for itself the second time you need it.

Your own component in a dialog

Any component can be a dialog. Wrap its template in the Dialog component.

import { Component } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";

export class PickDateDialog extends Component {
    static template = "owl_course.PickDateDialog";
    static components = { Dialog };
    static props = {
        close: Function,                       // supplied by the dialog service
        onPick: { type: Function, optional: true },
        title: { type: String, optional: true },
    };
    static defaultProps = { title: "Pick a date" };

    setup() {
        this.state = useState({ date: "" });
    }

    confirm() {
        this.props.onPick?.(this.state.date);
        this.props.close();
    }
}
<t t-name="owl_course.PickDateDialog">
    <Dialog title="props.title" size="'md'">
        <input type="date" class="form-control" t-model="state.date"/>
        <t t-set-slot="footer">
            <button class="btn btn-primary" t-on-click="confirm">Use this date</button>
            <button class="btn btn-secondary" t-on-click="() => this.props.close()">Cancel</button>
        </t>
    </Dialog>
</t>
this.dialog.add(PickDateDialog, {
    title: "Invoice date",
    onPick: (date) => this.setDate(date),
});

Two things to note. The service passes a close prop automatically, and your component must declare and call it. And the Dialog component provides the chrome, header, footer slot, sizing and escape handling, so you do not build a modal yourself.

Sizes are sm, md, lg, xl and fs for fullscreen.

Closing from outside

const remove = this.dialog.add(MyDialog, { ... });
// later
remove();

Useful when a dialog should close because something else finished.

Blocking the interface

setup() {
    this.ui = useService("ui");
}

async heavyThing() {
    this.ui.block();
    try {
        await this.orm.call("my.model", "slow_method", [[]]);
    } finally {
        this.ui.unblock();
    }
}

Always in a finally. A blocked interface that never unblocks because something threw is a page the user has to reload.

In practice you rarely need this: the ORM service already shows a loading indicator for calls the user triggered.

What to use when

SituationUse
It workednotification success, not sticky
It worked, here is the resultnotification with a button
It failed, they can carry onnotification danger
It failed, they cannot carry onLet the error dialog show
Needs a yes or noConfirmationDialog
Needs inputYour own component in a Dialog
Long operationORMโ€™s own indicator, or ui.block

What is next

The action service: opening views, running server actions and navigating.

Back to the full course