Client framework 101¶
This two-part tutorial introduces the Odoo web framework, the JavaScript code behind the web client. Whether you are new to the framework or have some prior experience, it will give you a solid foundation for your own projects.
The first part is about Owl, the component framework the web client is built on. Owl has its own interactive tutorials, which run in the browser, so this part is short: an overview of the main ideas, then two tutorials to follow in the Owl playground. The second part builds a dashboard in an Odoo addon. It is mostly an excuse to discover and practice many features of the web framework: the common layout, plugins, server calls, lazy loading, registries, and dialogs.
This tutorial assumes that you have some basic knowledge of development with Odoo in general (models, controllers, QWeb, ...). If you are new to Odoo, we recommend that you start with the Server framework 101 tutorial before proceeding with this one.
Ghi chú
Each part of this tutorial is an independent project. If you feel comfortable with Owl, you can start directly with part 2.
Part 1: Owl components¶
The Odoo web client is built with Owl, a JavaScript framework developed by Odoo, in the same family as React and Vue. Everything on screen in Odoo is an Owl component: the navbar, the views, each field of a form. Learning Owl is the first step to working with the web framework.
This part starts with a short overview of Owl. The exercises themselves are in the Owl playground, a page that runs Owl code in the browser, with nothing to install. The playground comes with two tutorials: Getting Started, then Todo List.
Owl in a nutshell¶
Owl provides four things:
A
Componentclass. Each part of the user interface is a component: a JavaScript class and a template.Templates, written in QWeb, the same template language as on the server, with a few additions for components.
Reactivity, built on signals. When a signal changes, Owl re-renders the components that read it.
Hooks, functions that plug into the lifecycle of a component, such as
onWillStartoronMounted.
Example: a Counter component¶
The Counter component below displays a number, and increments it each time the user clicks on the
button.
counter.js¶import { Component, signal } from "@odoo/owl";
export class Counter extends Component {
static template = "my_module.Counter";
count = signal(0);
increment() {
this.count.set(this.count() + 1);
}
}
counter.xml¶<templates xml:space="preserve">
<t t-name="my_module.Counter">
<p>Counter: <t t-out="this.count()"/></p>
<button class="btn btn-primary" t-on-click="this.increment">Increment</button>
</t>
</templates>
A few things to notice:
static templategives the name of the template. That name is what ties the two files together.Owl evaluates the template with the component as
this. Each directive value is a JavaScript expression:this.count()reads the signal, andthis.incrementis the method to call on click.signal(0)creates a reactive value. Call it to read it, callsetto write it. During rendering, a component records the signals it reads, and re-renders when one of them changes. There is nothing else to declare.
In an Odoo addon, the template lives in an XML file of the addon, and the manifest adds both files
to an assets bundle. In the playground, a template can also be written inline in the JavaScript
file, with the xml tagged template from @odoo/owl.
Xem thêm
Playground exercise 1: Getting Started¶
Open the Getting Started tutorial in the Owl playground. You can also open the playground and pick Getting Started on its home page.
On the left, the playground shows the instructions of the current step and an editor for its files. On the right, it runs the code: click Run to see the result. The navigation bar above the editor moves between steps, and each step has hints and a solution that you can reveal with Solve if you get stuck.
This first tutorial covers the fundamentals of Owl: signals to hold reactive state, templates and
event handlers, splitting the interface into sub components, passing data down with props and
validating them, deriving values with computed and binding inputs with t-model, and running code
at key moments of a component's life with lifecycle hooks.
Playground exercise 2: Todo List¶
Once you are done with Getting Started, open the Todo List tutorial.
This second tutorial builds a complete todo application and introduces the concepts a real
application needs: rendering lists of components, handling user input, dynamic classes, references
to DOM elements and custom hooks, callback props, effects that react to data changes, plugins to
separate the business logic from the interface, computed values, persisting data, and when to use a
proxy rather than signals.
Mẹo
The playground has a third project, Hibou OS, which builds a small desktop environment. It goes further into plugins and registries, if you want more practice before part 2.
Part 2: Build a dashboard¶
The first part of this tutorial introduced you to Owl. It is now time to learn about the Odoo JavaScript framework in its entirety, as used by the web client. This part is a complete standalone project, in which we implement a dashboard client action. It is mostly an excuse to discover and practice many features of the Odoo web framework!
For this part, we will start from the empty dashboard provided by the awesome_dashboard addon. We
will progressively add features to it, using the Odoo JavaScript framework.
Goal
Each exercise comes with a solution, hidden in a Solution block. Each solution starts from the solution of the previous exercise. Try to solve the exercises first without looking at the solution!
Mẹo
The Odoo JavaScript files downloaded by the browser are minified. For debugging purposes, switch to debug mode with assets, so that the files are not minified.
Setup¶
To get started, you need a running Odoo server and a development environment setup, with the
awesome_dashboard addon installed:
Clone the official Odoo tutorials repository and switch to the branch
20.0.Add the cloned repository to your
--addons-path.Start a new Odoo database and install the
awesome_dashboardmodule.
1. A common layout¶
Most screens in the Odoo web client use a common layout: a control panel on top, with some buttons,
and a main content zone just below. This is done using the Layout component, available in @web/search/layout.
Update the
AwesomeDashboardcomponent located inawesome_dashboard/static/src/to use theLayoutcomponent. You can use{controlPanel: {} }for thedisplayprops of theLayoutcomponent.Add a
classNameprop toLayout:className="'o_dashboard h-100'".Add a
dashboard.scssfile in which you set thebackground-colorof.o_dashboardto gray (or your favorite color).
Open http://localhost:8069/odoo, then open the Awesome Dashboard app, and see the result.
Xem thêm
awesome_dashboard/static/src/dashboard.js¶import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { Layout } from "@web/search/layout";
class AwesomeDashboard extends Component {
static template = "awesome_dashboard.AwesomeDashboard";
static components = { Layout };
}
registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboard);
awesome_dashboard/static/src/dashboard.xml¶<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="awesome_dashboard.AwesomeDashboard">
<Layout display="{controlPanel: {} }" className="'o_dashboard h-100'">
some content
</Layout>
</t>
</templates>
awesome_dashboard/static/src/dashboard.scss¶.o_dashboard {
background-color: gray;
}
Theory: plugins¶
Owl destroys a component when it leaves the screen, and its state goes with it. This is fine in many cases, but some code has to outlive a screen. For example, Discuss should not reload all its messages every time we display a channel. Also, some code is not a component at all: the code that processes barcodes, or the code that listens to the server bus.
In Odoo, this code lives in plugins. A plugin is a class that extends Plugin. The web client
starts it once, and keeps it alive until the browser tab is closed. The following example defines a
plugin that displays a notification every 5 seconds:
my_addon/static/src/tick_tock_plugin.js¶import { Plugin, onWillDestroy, usePlugin } from "@odoo/owl";
import { NotificationPlugin } from "@web/core/notifications/notification_plugin";
import { services } from "@web/core/services";
class TickTockPlugin extends Plugin {
notification = usePlugin(NotificationPlugin);
setup() {
let counter = 1;
const id = setInterval(() => {
this.notification.add(`Tick Tock ${counter++}`);
}, 5000);
onWillDestroy(() => clearInterval(id));
}
}
services.add(TickTockPlugin);
services.addregisters the plugin, and the web client starts it with all the others. There is no manifest entry to write, and no registration order to care about.A plugin uses other plugins with
usePlugin, as a component does. Here,NotificationPlugindisplays the notifications.setupis where a plugin starts its timers and listeners. Owl cleans up signals and computed values by itself. For anything else, such as the interval above, useonWillDestroy.
Components reach a plugin with the same usePlugin function, in a class field or in setup:
my_addon/static/src/uploader.js¶import { Component, usePlugin } from "@odoo/owl";
import { ORM } from "@web/core/orm_plugin";
import { NotificationPlugin } from "@web/core/notifications/notification_plugin";
export class Uploader extends Component {
static template = "my_addon.Uploader";
orm = usePlugin(ORM);
notification = usePlugin(NotificationPlugin);
async onUpload(data) {
const [id] = await this.orm.create("ir.attachment", [data]);
this.notification.add(`Uploaded ${id}`);
}
}
usePlugin takes the plugin class, and returns the plugin instance. There is only one instance per
browser tab, so two components that use the same plugin share its state. When a plugin holds
signals, a component that reads them re-renders when they change, as it does with its own signals.
The web client starts many plugins. Here are some of the most common ones:
Plugin |
Purpose |
Imported from |
|---|---|---|
|
Read and write records |
|
|
Display notifications in the top right corner |
|
|
Open a dialog over the current screen |
|
|
Display the rainbow man, and other effects |
|
|
Register keyboard shortcuts |
|
|
Execute actions, the ones that open a screen |
|
Ghi chú
Before Owl 3, services played this role. A service was an object registered in the services
category of the registry, and components reached it with useService("name"). Many services are
still written that way in the Odoo codebase, and the ones converted to plugins keep a
useService alias, so that older code keeps working. Write plugins for new code.
Xem thêm
3. Add a dashboard item¶
Let us now improve the content of this dashboard.
Create a generic
DashboardItemcomponent that displays its default slot in a nice card layout. It should take an optionalsizenumber prop, which defaults to1. The width should be hardcoded to(18*size)rem.Add two cards to the dashboard. One with no size, and the other with a size of 2.
Xem thêm
awesome_dashboard/static/src/dashboard_item.js¶import { Component, useProps, types as t } from "@odoo/owl";
export class DashboardItem extends Component {
static template = "awesome_dashboard.DashboardItem";
props = useProps({
size: t.number().optional(1),
});
}
awesome_dashboard/static/src/dashboard_item.xml¶<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="awesome_dashboard.DashboardItem">
<div class="card m-2 border-dark" t-attf-style="width: {{18*this.props.size}}rem;">
<div class="card-body">
<t t-call-slot="default"/>
</div>
</div>
</t>
</templates>
awesome_dashboard/static/src/dashboard.xml¶<Layout display="{controlPanel: {buttons: true} }" className="'o_dashboard h-100'">
...
<div class="d-flex flex-wrap">
<DashboardItem>
some content
</DashboardItem>
<DashboardItem size="2">
I love milk
</DashboardItem>
</div>
</Layout>
dashboard.js imports DashboardItem and adds it to static components, the same way it
already does for Layout.
4. Call the server, add some statistics¶
Let us improve the dashboard by adding a few dashboard items to display real business data. The
awesome_dashboard addon provides a /awesome_dashboard/statistics route that is meant to return
some interesting information.
To call a specific controller, we need to use the rpc function. A call to rpc(route,
params, settings) sends a JSON-RPC request to the server, and returns a promise with the result. A
basic request could look like this:
import { onWillStart } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
...
setup() {
onWillStart(async () => {
const result = await rpc("/my/controller", { a: 1, b: 2 });
// ...
});
}
...
onWillStart runs before the first render of the component, and the render waits for the promise it
returns. Use it for the data that the screen cannot render without.
Update
AwesomeDashboardso that it uses therpcfunction.Call the statistics route
/awesome_dashboard/statisticsin theonWillStarthook, and store the result in a signal.Display a few cards in the dashboard containing:
Number of new orders this month
Total amount of new orders this month
Average amount of t-shirt by order this month
Number of cancelled orders this month
Average time for an order to go from 'new' to 'sent' or 'cancelled'
Xem thêm
awesome_dashboard/static/src/dashboard.js¶import { Component, onWillStart, signal, usePlugin } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
...
class AwesomeDashboard extends Component {
...
action = usePlugin(ActionPlugin);
statistics = signal(null);
setup() {
onWillStart(async () => {
const data = await rpc("/awesome_dashboard/statistics");
this.statistics.set(data);
});
}
...
}
awesome_dashboard/static/src/dashboard.xml¶<div class="d-flex flex-wrap">
<DashboardItem>
Average amount of t-shirt by order this month
<div class="fs-1 fw-bold text-success text-center">
<t t-out="this.statistics().average_quantity"/>
</div>
</DashboardItem>
<DashboardItem>
Average time for an order to go from 'new' to 'sent' or 'cancelled'
<div class="fs-1 fw-bold text-success text-center">
<t t-out="this.statistics().average_time"/>
</div>
</DashboardItem>
<DashboardItem>
Number of new orders this month
<div class="fs-1 fw-bold text-success text-center">
<t t-out="this.statistics().nb_new_orders"/>
</div>
</DashboardItem>
<DashboardItem>
Number of cancelled orders this month
<div class="fs-1 fw-bold text-success text-center">
<t t-out="this.statistics().nb_cancelled_orders"/>
</div>
</DashboardItem>
<DashboardItem>
Total amount of new orders this month
<div class="fs-1 fw-bold text-success text-center">
<t t-out="this.statistics().total_amount"/>
</div>
</DashboardItem>
</div>
5. Cache network calls, create a plugin¶
If you open the Network tab of your browser's dev tools, you will see that the call to
/awesome_dashboard/statistics is done every time the client action is displayed. This is because
the onWillStart hook is called each time the AwesomeDashboard component is mounted. But in this
case, we would prefer to do it only the first time, so we actually need to maintain some state
outside of the component. This is a nice use case for a plugin!
Create a
ReportingPluginclass that extendsPlugin, in a newstatic/src/reporting_plugin.jsfile, and register it withservices.add.Give it a
loadStatisticsmethod that performs the actual rpc, and always returns the same information.Use the cache of the
rpcfunction:rpc(route, {}, { cache: true })caches the result by URL and parameters.Use this plugin in the
AwesomeDashboardcomponent, withusePlugin(ReportingPlugin).Check that it works as expected: there should be only one call to
/awesome_dashboard/statisticsin the Network tab, even after leaving and reopening the dashboard.
Xem thêm
awesome_dashboard/static/src/reporting_plugin.js¶import { Plugin } from "@odoo/owl";
import { services } from "@web/core/services";
import { rpc } from "@web/core/network/rpc";
export class ReportingPlugin extends Plugin {
loadStatistics() {
return rpc("/awesome_dashboard/statistics", {}, { cache: true });
}
}
services.add(ReportingPlugin);
awesome_dashboard/static/src/dashboard.js¶import { ReportingPlugin } from "./reporting_plugin";
...
reporting = usePlugin(ReportingPlugin);
statistics = signal(null);
setup() {
onWillStart(async () => {
const data = await this.reporting.loadStatistics();
this.statistics.set(data);
});
}
The rpc import moves from dashboard.js to the plugin.
6. Cleanup¶
The plugin owns the network call now, but the component still holds the data: a statistics signal,
and an onWillStart whose only job is to fill it. Two dashboards on screen would each keep their
own copy of the same figures. The data belongs where the call already is.
Move the signal into
ReportingPlugin:stats = signal(null).Have
loadStatisticsset it, instead of returning the data.In
AwesomeDashboard, drop thestatisticssignal.onWillStartonly has to wait for the load.Read the plugin directly from the template: add
<t t-set="stats" t-value="this.reporting.stats"/>at the top of the template, and usestats().xxxin each card.
The component no longer holds anything of its own: no copy of the data, and no line that only exists to pass it along.
awesome_dashboard/static/src/reporting_plugin.js¶import { Plugin, signal } from "@odoo/owl";
...
export class ReportingPlugin extends Plugin {
stats = signal(null);
async loadStatistics() {
const data = await rpc("/awesome_dashboard/statistics", {}, { cache: true });
this.stats.set(data);
}
}
awesome_dashboard/static/src/dashboard.js¶ reporting = usePlugin(ReportingPlugin);
setup() {
onWillStart(() => this.reporting.loadStatistics());
}
awesome_dashboard/static/src/dashboard.xml¶<t t-name="awesome_dashboard.AwesomeDashboard">
<t t-set="stats" t-value="this.reporting.stats"/>
<Layout display="{controlPanel: {buttons: true} }" className="'o_dashboard h-100'">
...
<div class="d-flex flex-wrap">
<DashboardItem>
Average amount of t-shirt by order this month
<div class="fs-1 fw-bold text-success text-center">
<t t-out="stats().average_quantity"/>
</div>
</DashboardItem>
<!-- four more, one per statistic -->
</div>
</Layout>
</t>
dashboard.js no longer imports signal. The t-set runs at the top of the template on
each render, and the variable is in scope everywhere below it, inside the slots of Layout too.
onWillStart still waits for the first load, so the first render has its data.
7. Display a pie chart¶
Everyone likes charts (!), so let us add a pie chart in our dashboard. It will display the proportions of t-shirts sold for each size: S/M/L/XL/XXL.
For this exercise, we will use Chart.js. It is the chart library used by the graph view. However, it is not loaded by default, so we will need to either add it to our assets bundle, or lazy load it. Lazy loading is usually better, since our users will not have to load the Chart.js code every time if they don't need it.
Create a
PieChartcomponent.In its
onWillStarthook, load Chart.js. You can use the loadJS function to load/web/static/lib/Chart/Chart.js.Use the
PieChartcomponent in aDashboardItemto display a pie chart that shows the quantity of t-shirts sold in each size (that information is available in the/statisticsroute). Note that you can use thesizeprop to make it look larger.Give the canvas a real height to draw into. Chart.js needs one to size itself, and the
cardof aDashboardItemonly sizes to its content. Add apie_chart.scssfile with a wrapperdivsized byaspect-ratio, and the canvas absolutely positioned to fill it:.o_pie_chart { position: relative; width: 100%; aspect-ratio: 16 / 9; } .o_pie_chart canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
Do not put the
aspect-ratioon the canvas itself. Chart.js resizes the canvas from the size of its parent, and the two would feed off each other and grow without limit. Try it if you like, it is a fun way to freeze a browser tab once. But don't ship it.The
PieChartcomponent needs to render a canvas, and draw on it using Chart.js. The sizes are a fixed, known set, so give each one its own color. You can use this code to create the pie chart:const SIZE_COLORS = { s: "#36A2EB", m: "#FF6384", l: "#4BC0C0", xl: "#FF9F40", xxl: "#9C6DF4", }; ... renderChart(canvas) { const keys = Object.keys(this.props.data); const data = Object.values(this.props.data); const color = keys.map((key) => SIZE_COLORS[key]); return new Chart(canvas, { type: "pie", data: { labels: keys, datasets: [ { label: this.props.label, data: data, backgroundColor: color, }, ], }, }); }
Make it work!
awesome_dashboard/static/src/pie_chart/pie_chart.js¶import { loadJS } from "@web/core/assets";
import { Component, useProps, types as t, signal, onWillStart, useEffect } from "@odoo/owl";
const SIZE_COLORS = {
s: "#36A2EB",
m: "#FF6384",
l: "#4BC0C0",
xl: "#FF9F40",
xxl: "#9C6DF4",
};
export class PieChart extends Component {
static template = "awesome_dashboard.PieChart";
props = useProps({
label: t.string(),
data: t.object(),
});
canvasRef = signal.ref();
setup() {
onWillStart(() => loadJS("/web/static/lib/Chart/Chart.js"));
useEffect(() => {
const canvas = this.canvasRef();
if (canvas) {
const chart = this.renderChart(canvas);
return () => chart.destroy();
}
});
}
renderChart(canvas) {
const keys = Object.keys(this.props.data);
const data = Object.values(this.props.data);
const color = keys.map((key) => SIZE_COLORS[key]);
return new Chart(canvas, {
type: "pie",
data: {
labels: keys,
datasets: [
{
label: this.props.label,
data: data,
backgroundColor: color,
},
],
},
options: {
maintainAspectRatio: false,
},
});
}
}
awesome_dashboard/static/src/pie_chart/pie_chart.xml¶<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="awesome_dashboard.PieChart">
<div class="o_pie_chart">
<canvas t-ref="this.canvasRef"/>
</div>
</t>
</templates>
awesome_dashboard/static/src/pie_chart/pie_chart.scss¶.o_pie_chart {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
}
.o_pie_chart canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
awesome_dashboard/static/src/dashboard.xml¶<div class="d-flex flex-wrap">
...
<DashboardItem size="2">
Shirt orders by size
<PieChart data="stats().orders_by_size" label="'Shirt orders by size'"/>
</DashboardItem>
</div>
dashboard.js imports PieChart and adds it to static components, next to
DashboardItem.
A reference to a DOM element is a signal.ref() class field. The template binds it with
t-ref="this.canvasRef", and the code reads it by calling it: this.canvasRef() returns the
canvas, or null before the component is mounted.
A single useEffect manages the chart. The effect reads the ref and the props, so it runs once
the canvas exists, and again whenever the data changes. The function it returns is the cleanup:
Owl calls chart.destroy() before the next run, and when the component is destroyed. Nothing is
stored on this. The chart only exists as long as the effect that created it.
Why position the canvas absolutely? To decide the size of the canvas, Chart.js measures its
parent. If the aspect-ratio of the canvas gave that parent its height, each resize would read a
parent that just grew because of the canvas, then grow the canvas to match, forever. A tooltip
redraw on hover is enough to start the loop. With position: absolute, the canvas leaves the
normal flow, and its size no longer affects the size of its parent.
8. Make it a generic hook¶
PieChart holds two different things. One is what a pie chart of t-shirt sizes looks like. The
other is the plumbing that every chart needs: load Chart.js, keep a ref to a canvas, create the
chart once that canvas exists, and destroy it when the data changes or the component goes away. Only
the first one is about pie charts.
Create a
useChart(descFn)hook instatic/src/use_chart.js. It owns the ref, theonWillStartthat loads Chart.js, and the effect. It returns the ref, so that a template can bind it witht-ref.descFnis called inside the effect, and whatever it returns is given tonew Chart(canvas, ...). Reading props indescFnis what makes the chart redraw when they change.Rewrite
PieCharton top of it. It should be left with its props and the description of a pie chart, and nothing else.
Another type of chart is then just a component with a different descFn.
awesome_dashboard/static/src/use_chart.js¶import { loadJS } from "@web/core/assets";
import { signal, onWillStart, useEffect } from "@odoo/owl";
export function useChart(descFn) {
const canvasRef = signal.ref();
onWillStart(() => loadJS("/web/static/lib/Chart/Chart.js"));
useEffect(() => {
const canvas = canvasRef();
if (canvas) {
const chart = new Chart(canvas, descFn());
return () => chart.destroy();
}
});
return canvasRef;
}
awesome_dashboard/static/src/pie_chart/pie_chart.js¶import { Component, useProps, types as t } from "@odoo/owl";
import { useChart } from "../use_chart";
const SIZE_COLORS = { ... };
export class PieChart extends Component {
static template = "awesome_dashboard.PieChart";
props = useProps({
label: t.string(),
data: t.object(),
});
canvasRef = useChart(() => {
const keys = Object.keys(this.props.data);
const data = Object.values(this.props.data);
const color = keys.map((key) => SIZE_COLORS[key]);
return {
type: "pie",
data: {
labels: keys,
datasets: [
{
label: this.props.label,
data: data,
backgroundColor: color,
},
],
},
options: {
maintainAspectRatio: false,
},
};
});
}
The template does not change: useChart returns the same kind of ref that t-ref was already
bound to. PieChart has no setup left. The description is a function, so it only runs inside
the effect, where reading this.props.data subscribes the chart to it.
9. Periodic updates¶
Since we moved the data loading in a cache, it never updates. But let us say that we are looking at fast moving data, so we want to periodically (for example, every 10 minutes) reload fresh data.
This is quite simple to implement, with a setTimeout or setInterval in the reporting plugin.
However, here is the tricky part: if the dashboard is currently being displayed, it should be
updated immediately.
The plugin already owns the stats signal, and the template reads it. A component re-renders when a
signal it read changes, with no subscription to set up. So the reload only has to set the signal
again.
Update
ReportingPluginto reload data every 10 minutes (to test it, use 5 seconds instead!).Drop
loadStatistics: the plugin loads the data in its ownsetup, then on a timer, and setsstatseach time. Clear that timer in anonWillDestroyhook.statsstarts atnull, so the template has to check whether the data is loaded: guard the cards witht-if="stats()".
awesome_dashboard/static/src/reporting_plugin.js¶import { Plugin, signal, onWillDestroy } from "@odoo/owl";
import { services } from "@web/core/services";
import { rpc } from "@web/core/network/rpc";
export class ReportingPlugin extends Plugin {
stats = signal(null);
setup() {
this.loadData();
const id = setInterval(() => this.loadData(), 5 * 1000);
onWillDestroy(() => clearInterval(id));
}
async loadData() {
const data = await rpc("/awesome_dashboard/statistics");
this.stats.set(data);
}
}
services.add(ReportingPlugin);
The plugin now pushes fresh data on its own schedule, instead of waiting to be asked. The rpc
cache from step 5 would keep serving the first response forever, so the plugin uses a plain rpc
call, sent again on each tick. Each response replaces the value of the signal, and every
component that reads it re-renders. The null initial value is enough to know whether the data
has arrived.
The interval is the one thing Owl cannot clean up by itself, hence onWillDestroy. A plugin
started with the web client lives as long as the browser tab, so this rarely runs in practice.
But a plugin can also be destroyed, in a test for example.
awesome_dashboard/static/src/dashboard.js¶import { Component, usePlugin } from "@odoo/owl";
...
reporting = usePlugin(ReportingPlugin);
The component has no setup and no onWillStart anymore. The plugin loads the data on its own
schedule, and the template reads stats() on each render.
awesome_dashboard/static/src/dashboard.xml¶<div class="d-flex flex-wrap" t-if="stats()">
<DashboardItem>
Average amount of t-shirt by order this month
<div class="fs-1 fw-bold text-success text-center">
<t t-out="stats().average_quantity"/>
</div>
</DashboardItem>
<!-- four more, one per statistic, and the pie chart -->
</div>
Nothing blocks the first render anymore, so the wrapping div needs t-if="stats()" to skip the
cards until the first load is done.
10. Lazy loading the dashboard¶
Let us imagine that our dashboard is getting quite big, and is only of interest to some of our users. In that case, it could make sense to lazy load our dashboard, and all related assets, so we only pay the cost of loading the code when we actually want to look at it.
One way to do this is to use LazyComponent (from @web/core/lazy_component) as an intermediate
that will load an asset bundle before displaying our component.
Example
example_action.js:
export class ExampleComponentLoader extends Component {
static components = { LazyComponent };
static template = xml`
<LazyComponent bundle="'example_module.example_assets'" Component="'ExampleComponent'" />
`;
}
registry.category("actions").add("example_module.example_action", ExampleComponentLoader);
Move all dashboard assets into a sub folder
/dashboardto make it easier to add to a bundle.Create an
awesome_dashboard.dashboardassets bundle containing all content of the/dashboardfolder.Modify
dashboard.jsto register itself to thelazy_componentsregistry instead ofactions.In
src/dashboard_loader.js, create an intermediate component that usesLazyComponentand register it to theactionsregistry.
Move every existing file from static/src/ to static/src/dashboard/. The relative
imports between them do not change.
awesome_dashboard/__manifest__.py¶'assets': {
'web.assets_backend': [
'awesome_dashboard/static/src/**/*',
('remove', 'awesome_dashboard/static/src/dashboard/**/*'),
],
'awesome_dashboard.dashboard': [
'awesome_dashboard/static/src/dashboard/**/*',
],
},
awesome_dashboard/static/src/dashboard_loader.js¶import { registry } from "@web/core/registry";
import { LazyComponent } from "@web/core/lazy_component";
import { Component, xml, useProps } from "@odoo/owl";
class AwesomeDashboardLoader extends Component {
static components = { LazyComponent };
static template = xml`
<LazyComponent bundle="'awesome_dashboard.dashboard'" Component="'AwesomeDashboard'" props="this.props"/>
`;
props = useProps();
}
registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboardLoader);
awesome_dashboard/static/src/dashboard/dashboard.js¶registry.category("lazy_components").add("AwesomeDashboard", AwesomeDashboard);
In Owl 3, a component has no this.props unless it declares it with useProps. Called without a
schema, useProps() accepts all the props of the client action, and props="this.props" passes
them on to the dashboard.
11. Making our dashboard generic¶
So far, we have a nice working dashboard. But it is currently hardcoded in the dashboard template. What if we want to customize our dashboard? Maybe some users have different needs and want to see other data.
So, the next step is to make our dashboard generic: instead of hard-coding its content in the template, it can just iterate over a list of dashboard items. But then, many questions come up: how to represent a dashboard item, how to register it, what data should it receive, and so on. There are many different ways to design such a system, with different trade-offs.
For this tutorial, we will say that a dashboard item is an object with the following structure:
const item = {
id: "average_quantity",
description: "Average amount of t-shirt",
Component: StandardItem,
// size and props are optional
size: 3,
props: (data) => ({
title: "Average amount of t-shirt by order this month",
value: data.average_quantity,
}),
};
The description value will be useful in a later exercise to show the name of items that the user
can add to their dashboard. The size number is optional, and simply describes the size of the
dashboard item that will be displayed. Finally, the props function is optional. If not given, we
will simply give the statistics object as data. But if it is defined, it will be used to compute
specific props for the component.
The goal is to replace the content of the dashboard with something that looks like the following snippet:
<t t-foreach="this.items" t-as="item" t-key="item.id">
<DashboardItem size="item.size || 1">
<t t-set="itemProp" t-value="item.props ? item.props(stats()) : {'data': stats()}"/>
<t t-component="item.Component" t-props="itemProp"/>
</DashboardItem>
</t>
Note that the above example features two advanced features of Owl: dynamic components and dynamic props.
We currently have two kinds of item components: number cards with a title and a number, and pie cards with a title and a pie chart.
Create and implement two components:
NumberCardandPieChartCard, with the corresponding props.Create a
dashboard_items.jsfile in which you define and export a list of items, usingNumberCardandPieChartCardto recreate our current dashboard.Import that list of items in our
AwesomeDashboardcomponent, add it to the component, and update the template to use at-foreachlike shown above.
And now, our dashboard template is generic!
awesome_dashboard/static/src/dashboard/number_card/number_card.js¶import { Component, useProps, types as t } from "@odoo/owl";
export class NumberCard extends Component {
static template = "awesome_dashboard.NumberCard";
props = useProps({
title: t.string(),
value: t.number(),
});
}
awesome_dashboard/static/src/dashboard/number_card/number_card.xml¶<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="awesome_dashboard.NumberCard">
<t t-out="this.props.title"/>
<div class="fs-1 fw-bold text-success text-center">
<t t-out="this.props.value"/>
</div>
</t>
</templates>
awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js¶import { Component, useProps, types as t } from "@odoo/owl";
import { PieChart } from "../pie_chart/pie_chart";
export class PieChartCard extends Component {
static template = "awesome_dashboard.PieChartCard";
static components = { PieChart };
props = useProps({
title: t.string(),
values: t.object(),
});
}
awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml¶<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="awesome_dashboard.PieChartCard">
<t t-out="this.props.title"/>
<PieChart data="this.props.values" label="''"/>
</t>
</templates>
awesome_dashboard/static/src/dashboard/dashboard_items.js¶import { NumberCard } from "./number_card/number_card";
import { PieChartCard } from "./pie_chart_card/pie_chart_card";
export const items = [
{
id: "average_quantity",
description: "Average amount of t-shirt",
Component: NumberCard,
props: (data) => ({
title: "Average amount of t-shirt by order this month",
value: data.average_quantity,
}),
},
{
id: "average_time",
description: "Average time for an order",
Component: NumberCard,
props: (data) => ({
title: "Average time for an order to go from 'new' to 'sent' or 'cancelled'",
value: data.average_time,
}),
},
{
id: "number_new_orders",
description: "New orders this month",
Component: NumberCard,
props: (data) => ({
title: "Number of new orders this month",
value: data.nb_new_orders,
}),
},
{
id: "cancelled_orders",
description: "Cancelled orders this month",
Component: NumberCard,
props: (data) => ({
title: "Number of cancelled orders this month",
value: data.nb_cancelled_orders,
}),
},
{
id: "amount_new_orders",
description: "Amount of orders this month",
Component: NumberCard,
props: (data) => ({
title: "Total amount of new orders this month",
value: data.total_amount,
}),
},
{
id: "pie_chart",
description: "Shirt orders by size",
Component: PieChartCard,
size: 2,
props: (data) => ({
title: "Shirt orders by size",
values: data.orders_by_size,
}),
},
];
awesome_dashboard/static/src/dashboard/dashboard.js¶import { items } from "./dashboard_items";
...
static components = { Layout, DashboardItem };
items = items;
The template can only read this.items, not the imported items, so the list has to be put on
the component first. A class field is enough: the name on the left is the field, the one on the
right is the import.
awesome_dashboard/static/src/dashboard/dashboard.xml¶<div class="d-flex flex-wrap" t-if="stats()">
<t t-foreach="this.items" t-as="item" t-key="item.id">
<DashboardItem size="item.size || 1">
<t t-set="itemProp" t-value="item.props ? item.props(stats()) : {'data': stats()}"/>
<t t-component="item.Component" t-props="itemProp"/>
</DashboardItem>
</t>
</div>
12. Making our dashboard extensible¶
However, the content of our item list is still hardcoded. Let us fix that by using a registry. We
will use the Registry class of Owl 3, rather than the global registry of @web/core/registry. The
global registry still works, and most of the Odoo code still uses it, but it accepts anything under
any key. An Owl Registry can take a validation schema, and checks each entry against it when it
is added.
Instead of exporting a list, create and export a
Registry(from@odoo/owl), with a validation schema that describes a dashboard item. Register each item on it withaddById, since each item already has anid.Read all the registered items from that registry in the
AwesomeDashboardcomponent.
The dashboard is now easily extensible. Any other Odoo addon that wants to add a new item to the dashboard can import this registry and add to it. And if what it adds does not look like a dashboard item, it gets a clear error right away.
Xem thêm
awesome_dashboard/static/src/dashboard/dashboard_items.js¶import { Registry, Component, types as t } from "@odoo/owl";
import { NumberCard } from "./number_card/number_card";
import { PieChartCard } from "./pie_chart_card/pie_chart_card";
export const dashboardItems = new Registry({
name: "awesome_dashboard",
validation: t.object({
id: t.string(),
description: t.string(),
Component: t.constructor(Component),
size: t.number().optional(),
props: t.function().optional(),
}),
});
const items = [
...
];
items.forEach((item) => dashboardItems.addById(item));
t.constructor(Component) checks that Component is the Component class of Owl, or a class
that extends it. It checks the class itself, not an instance of it.
awesome_dashboard/static/src/dashboard/dashboard.js¶import { dashboardItems } from "./dashboard_items";
...
items = dashboardItems.items;
awesome_dashboard/static/src/dashboard/dashboard.xml¶<t t-foreach="this.items()" t-as="item" t-key="item.id">
dashboardItems.items is a computed value, so the template has to call it, like the stats
signal of the plugin. And since it is reactive, the dashboard re-renders when another addon adds
an item.
13. Add and remove dashboard items¶
Let us see how we can make our dashboard customizable. To make it simple, we will save the user dashboard configuration in the local storage, so that we don't have to deal with the server for now.
The dashboard configuration will be saved as a list of removed item ids.
Add a button in the control panel with a gear icon to indicate that it is a settings button.
Clicking on that button should open a dialog.
In that dialog, we want to see a list of all existing dashboard items, each with a checkbox.
Each checkbox should immediately update the list of unchecked item ids, and store it in the local storage. A Done button in the footer just closes the dialog.
Modify the
AwesomeDashboardcomponent to filter the current items by removing the ids of items from the configuration.
awesome_dashboard/static/src/dashboard/dashboard.js¶import { Component, signal, useProps, usePlugin } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { Layout } from "@web/search/layout";
import { DialogPlugin } from "@web/core/dialog/dialog_plugin";
import { ActionPlugin } from "@web/webclient/actions/action_plugin";
import { Dialog } from "@web/core/dialog/dialog";
import { CheckBox } from "@web/core/checkbox/checkbox";
import { ReportingPlugin } from "./reporting_plugin";
import { dashboardItems } from "./dashboard_items";
import { DashboardItem } from "./dashboard_item";
class AwesomeDashboard extends Component {
static template = "awesome_dashboard.AwesomeDashboard";
static components = { Layout, DashboardItem };
disabledItems = signal(
window.localStorage.getItem("disabledDashboardItems")?.split(",") || []
);
action = usePlugin(ActionPlugin);
reporting = usePlugin(ReportingPlugin);
dialog = usePlugin(DialogPlugin);
items = dashboardItems.items;
openConfiguration() {
this.dialog.add(ConfigurationDialog, {
items: this.items(),
disabledItems: this.disabledItems(),
onUpdateConfiguration: this.updateConfiguration.bind(this),
});
}
updateConfiguration(newDisabledItems) {
this.disabledItems.set(newDisabledItems);
window.localStorage.setItem("disabledDashboardItems", newDisabledItems);
}
openCustomerView() { ... }
openLeads() { ... }
}
class ConfigurationDialog extends Component {
static template = "awesome_dashboard.ConfigurationDialog";
static components = { Dialog, CheckBox };
props = useProps(["close", "items", "disabledItems", "onUpdateConfiguration"]);
disabledItems = signal(this.props.disabledItems);
isEnabled(item) {
return !this.disabledItems().includes(item.id);
}
done() {
this.props.close();
}
onChange(checked, item) {
const disabled = new Set(this.disabledItems());
if (checked) {
disabled.delete(item.id);
} else {
disabled.add(item.id);
}
const newDisabledItems = [...disabled];
this.disabledItems.set(newDisabledItems);
this.props.onUpdateConfiguration(newDisabledItems);
}
}
registry.category("lazy_components").add("AwesomeDashboard", AwesomeDashboard);
useProps also accepts a plain list of prop names, without type validation. The dialog plugin
gives the close prop to the dialog component it opens.
The dialog keeps a single source of truth: the list of disabled ids, in a plain signal. Whether
an item is enabled is derived from that list. Keeping the items in a signal.Array with an
enabled flag on each one would not work: Owl tracks the changes to the content of a
signal.Array (adding, removing or replacing elements), but not the changes to the properties of
its elements.
awesome_dashboard/static/src/dashboard/dashboard.xml¶<t t-name="awesome_dashboard.AwesomeDashboard">
<t t-set="stats" t-value="this.reporting.stats"/>
<Layout display="{controlPanel: {buttons: true} }" className="'o_dashboard h-100'">
<t t-set-slot="control-panel-buttons">
<button class="btn btn-primary" t-on-click="this.openCustomerView">Customers</button>
<button class="btn btn-primary" t-on-click="this.openLeads">Leads</button>
</t>
<t t-set-slot="control-panel-additional-actions">
<button t-on-click="this.openConfiguration" class="btn p-0 ms-1 border-0">
<i class="oi" data-icon="settings"/>
</button>
</t>
<div class="d-flex flex-wrap" t-if="stats()">
<t t-foreach="this.items()" t-as="item" t-key="item.id">
<DashboardItem t-if="!this.disabledItems().includes(item.id)" size="item.size || 1">
<t t-set="itemProp" t-value="item.props ? item.props(stats()) : {'data': stats()}"/>
<t t-component="item.Component" t-props="itemProp"/>
</DashboardItem>
</t>
</div>
</Layout>
</t>
<t t-name="awesome_dashboard.ConfigurationDialog">
<Dialog title="'Dashboard items configuration'">
Which cards do you wish to see?
<t t-foreach="this.props.items" t-as="item" t-key="item.id">
<CheckBox value="this.isEnabled(item)" onChange="(checked) => this.onChange(checked, item)">
<t t-out="item.description"/>
</CheckBox>
</t>
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="this.done">Done</button>
</t>
</Dialog>
</t>
14. Going further¶
Here is a list of some small (and not so small) improvements you could try to do if you have the time:
Make sure your application can be translated (with
_t).Clicking on a section of the pie chart should open a list view of all orders that have the corresponding size.
Save the content of the dashboard in a user setting on the server!
Make it responsive: in mobile mode, each card should take 100% of the width.
Update the dashboard in real time, by using the bus (hard).
Xem thêm