Chapter 2: Build a dashboard

本教程的第一部分向您介绍了大部分Owl的概念。现在是时候全面了解Odoo JavaScript框架了,因为它是Web客户端所使用的。

../../../_images/previously_learned.svg

To get started, you need a running Odoo server and a development environment setup. Before getting into the exercises, make sure you have followed all the steps described in this tutorial introduction. For this chapter, 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.

目标

../../../_images/overview_02.png

The solutions for each exercise of the chapter are hosted on the official Odoo tutorials repository.

1. 新布局

Most screens in the Odoo web client uses 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.

  1. Update the AwesomeDashboard component located in awesome_dashboard/static/src/ to use the Layout component. You can use {controlPanel: {} } for the display props of the Layout component.

  2. Add a className prop to Layout: className="'o_dashboard h-100'"

  3. Add a dashboard.scss file in which you set the background-color of .o_dashboard to gray (or your favorite color)

Open http://localhost:8069/web, then open the Awesome Dashboard app, and see the result.

../../../_images/new_layout.png

Theory: Services

In practice, every component (except the root component) may be destroyed at any time and replaced (or not) with another component. This means that each component internal state is not persistent. This is fine in many cases, but there certainly are situations where we want to keep some data around. For example, all Discuss messages should not be reloaded every time we display a channel.

Also, it may happen that we need to write some code that is not a component. Maybe something that process all barcodes, or that manages the user configuration (context, etc.).

The Odoo framework defines the idea of a service, which is a persistent piece of code that exports state and/or functions. Each service can depend on other services, and components can import a service.

下面的示例注册了一个简单的服务,每5秒显示一次通知:

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

const myService = {
    dependencies: ["notification"],
    start(env, { notification }) {
        let counter = 1;
        setInterval(() => {
            notification.add(`Tick Tock ${counter++}`);
        }, 5000);
    },
};

registry.category("services").add("myService", myService);

Services can be accessed by any component. Imagine that we have a service to maintain some shared state:

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

const sharedStateService = {
    start(env) {
        let state = {};
        return {
            getValue(key) {
                return state[key];
            },
            setValue(key, value) {
                state[key] = value;
            },
        };
    },
};

registry.category("services").add("shared_state", sharedStateService);

Then, any component can do this:

import { useService } from "@web/core/utils/hooks";

setup() {
   this.sharedState = useService("shared_state");
   const value = this.sharedState.getValue("somekey");
   // do something with value
}

2. 添加一些快速导航按钮

One important service provided by Odoo is the action service: it can execute all kind of standard actions defined by Odoo. For example, here is how one component could execute an action by its xml id:

import { useService } from "@web/core/utils/hooks";
...
setup() {
      this.action = useService("action");
}
openSettings() {
      this.action.doAction("base_setup.action_general_configuration");
}
...

Let us now add two buttons to our control panel:

  1. A button Customers, which opens a kanban view with all customers (this action already exists, so you should use its xml id).

  2. A button Leads, which opens a dynamic action on the crm.lead model with a list and a form view. Follow the example of this use of the action service.

../../../_images/navigation_buttons.png

另请参阅

Code: action service

3. Add a dashboard item

Let us now improve our content.

  1. Create a generic DashboardItem component that display its default slot in a nice card layout. It should take an optional size number props, that default to 1. The width should be hardcoded to (18*size)rem.

  2. Add two cards to the dashboard. One with no size, and the other with a size of 2.

../../../_images/dashboard_item.png

另请参阅

Owl’s slot system

4. Call the server, add some statistics

Let’s 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 service. It only exports a single function that perform the request: rpc(route, params, settings). A basic request could look like this:

setup() {
      this.rpc = useService("rpc");
      onWillStart(async () => {
         const result = await this.rpc("/my/controller", {a: 1, b: 2});
         // ...
      });
}
  1. Update Dashboard so that it uses the rpc service.

  2. Call the statistics route /awesome_dashboard/statistics in the onWillStart hook.

  3. 在仪表板中显示几张包含以下内容的卡片:

    • 本月新订单数量

    • 本月新订单的总金额

    • 本月每个订单的T恤平均数量

    • 本月取消订单数量

    • 从“新建”到“已发送”或“已取消”的订单平均处理时间

../../../_images/statistics.png

另请参阅

Code: rpc service

5. Cache network calls, create a service

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 Dashboard 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 Dashboard component. This is a nice use case for a service!

  1. Register and import a new awesome_dashboard.statistics service.

  2. 它应该提供一个名为 loadStatistics 的函数,一旦调用,就执行实际的rpc,并始终返回相同的信息。

  3. Use the memoize utility function from @web/core/utils/functions that allows caching the statistics.

  4. Dashboard 组件中使用此服务。

  5. Check that it works as expected.

6. Display a pie chart

每个人都喜欢图表(!),所以让我们在我们的仪表板中添加一个饼图。它将显示每个尺码(S/M/L/XL/XXL)销售的T恤比例。

对于这个练习,我们将使用 Chart.js。它是图表视图使用的图表库。然而,默认情况下它不会被加载,所以我们需要将它添加到我们的资源包中,或者进行懒加载。懒加载通常更好,因为我们的用户如果不需要它,就不必每次都加载 chartjs 代码。

  1. Create a PieChart component.

  2. In its onWillStart method, load chartjs, you can use the loadJs function to load /web/static/lib/Chart/Chart.js.

  3. Use the PieChart component in a DashboardItem to display a pie chart that shows the quantity for each sold t-shirts in each size (that information is available in the /statistics route). Note that you can use the size property to make it look larger.

  4. The PieChart component will need to render a canvas, and draw on it using chart.js.

  5. 让它工作起来!

../../../_images/pie_chart.png

7. Real life update

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 10min) reload fresh data.

This is quite simple to implement, with a setTimeout or setInterval in the statistics service. However, here is the tricky part: if the dashboard is currently being displayed, it should be updated immediately.

To do that, one can use a reactive object: it is just like the proxy returned by useState, but not linked to any component. A component can then do a useState on it to subscribe to its changes.

  1. Update the statistics service to reload data every 10 minutes (to test it, use 10s instead!)

  2. Modify it to return a reactive object. Reloading data should update the reactive object in place.

  3. The Dashboard component can now use it with a useState

8. 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/assets) 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);
  1. Move all dashboard assets into a sub folder /dashboard to make it easier to add to a bundle.

  2. Create a awesome_dashboard.dashboard assets bundle containing all content of the /dashboard folder.

  3. Modify dashboard.js to register itself to the lazy_components registry instead of actions.

  4. In src/dashboard_action.js, create an intermediate component that uses LazyComponent and register it to the actions registry.

9. 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 optionals
   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 the following snippet:

<t t-foreach="items" t-as="item" t-key="item.id">
   <DashboardItem size="item.size || 1">
      <t t-set="itemProp" t-value="item.props ? item.props(statistics) : {'data': statistics}"/>
      <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 some label and a pie chart.

  1. Create and implement two components: NumberCard and PieChartCard, with the corresponding props.

  2. Create a file dashboard_items.js in which you define and export a list of items, using NumberCard and PieChartCard to recreate our current dashboard.

  3. Import that list of items in our Dashboard component, add it to the component, and update the template to use a t-foreach like shown above.

    setup() {
       this.items = items;
    }
    

And now, our dashboard template is generic!

10. Making our dashboard extensible

However, the content of our item list is still hardcoded. Let us fix that by using a registry:

  1. Instead of exporting a list, register all dashboard items in a awesome_dashboard registry

  2. Import all the items of the awesome_dashboard registry in the Dashboard component

The dashboard is now easily extensible. Any other Odoo addon that wants to register a new item to the dashboard can just add it to the registry.

11. 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 it is persistent, but we don’t have to deal with the server for now.

The dashboard configuration will be saved as a list of removed item ids.

  1. Add a button in the control panel with a gear icon to indicate that it is a settings button.

  2. Clicking on that button should open a dialog.

  3. In that dialog, we want to see a list of all existing dashboard items, each with a checkbox.

  4. There should be a Apply button in the footer. Clicking on it will build a list of all item ids that are unchecked.

  5. We want to store that value in the local storage.

  6. And modify the Dashboard component to filter the current items by removing the ids of items from the configuration.

../../../_images/items_configuration.png

12. Going further

以下是一些小的改进建议,如果您有时间可以尝试一下:

  1. 确保你的应用程序可以通过 env._t 进行 翻译

  2. Clicking on a section of the pie chart should open a list view of all orders that have the corresponding size.

  3. Save the content of the dashboard in a user setting on the server!

  4. Make it responsive: in mobile mode, each card should take 100% of the width.