The Odoo Web Library (OWL)

Odoo v14 giới thiệu framework javascript mới được gọi là OWL (viết tắt của Odoo Web Library), là UI framework được thiết kế dựa trên component và sử dụng QWeb template để làm cấu trúc. OWL hoạt động tối ưu hơn so với hệ thống widget cũ của Odoo và bổ sung thêm rất nhiều các tính năng mới như hooks, reacitvity, tự động tạo các subcomponent và còn nhiều hơn nữa. Trong phần này, chúng ta sẽ tìm hiểu cách sử dụng dụng OWL component để tạo các phần tử giao diện, bắt đầu từ cách tạo một OWL component cơ bản cho đến vòng đời của mỗi của component, cuối cùng là sử dụng OWL để tạo widget cho trường trên giao diện form. Chúng ta sẽ đi vào các đầu mục:

Ghi chú

Bạn có thể tự hỏi: Tại sao Odoo lại không sử dụng các JavaScript phổ biến như React.js hay Vue.js? Bạn có thể tìm được câu trả lời cùng với các thông tin khác về OWL framework tại OWL Github.

Tạo OWL component

Mục đính của phần này là để tìm hiểu về các thành phần cơ bản của một OWL component. Để lấy ví dụ, chúng ta sẽ tạo một thanh thông báo đơn giản để hiển thị lên giao diện của Odoo Web Client.

Các bước thực hiện

  1. Bố sung file /viin_education/static/src/js/component.js và khai báo module mới:

    odoo.define('my.component', function (require) {
        "use strict";
    
        // Our code will be here
    
    });
    
  2. Bố sung file /viin_education/static/src/js/component.js tới assets phía backend:

    <template id="assets_backend" inherit_id="web.assets_backend">
        <xpath expr="." position="inside">
            <script src="/viin_education/static/src/js/component.js" type="text/javascript" />
        </xpath>
    </template>
    
  3. Import các thư viện sẽ sử dụng tại file /viin_education/static/src/js/component.js:

    const { Component } = owl;
    const { xml } = owl.tags;
    
  4. Tạo component mới bằng cách mở Component và bổ sung thêm template:

    class MyComponent extends Component {
        static template = xml`
            <div class="bg-primary text-center p-3">
                Hello, World
            </div>`
    }
    
  5. Khởi tạo và bổ sung component tới phía web client:

    owl.utils.whenReady().then(() => {
        const app = new MyComponent();
        app.mount(document.body);
    });
    

Khởi động lại server và cập nhật module viin_education để áp dụng các thay đổi. Sau khi module được nạp tới Odoo, bạn sẽ thấy thanh thông báo hiển thị như hình:

Ảnh component đơn giản

Cơ chế hoạt động

Tại bước 1 và bước 2, chúng ta tạo file JS và bổ sung file tới assets phía backend. Nếu bạn muốn tìm hiểu thêm về assets của odoo, bạn có thể tham khảo thêm tại CMS Website Development.

Tại bước 3, chúng ta import các thư viện tiện ích của OWL thông qua biến toàn cục owl. Trong ví dụ, chúng ta import thư viện Componentxml để phục vụ cho việc tạo component mới.

Tại bước 4, chúng ta tạo component MyComponent bằng cách mở rộng từ class Component. Để đơn giản ví dụ, chúng ta định nghĩa QWeb template của class MyComponent ngay trên chính class đó thông qua xml. Tuy nhiên chúng ta hoàn toàn có thể nạp template của component từ file riêng.

Ghi chú

QWeb template định nghĩa trực tiếp trên file JS không hỗ trợ dịch cũng như chỉnh sửa thông qua kế thừa. Bởi vậy phải luôn ưu tiên việc định nghĩa các QWeb template trên file riêng.

Tại bước 5, chúng ta khởi tạo component MyComponent và thêm vào body. Bởi OWL component là class viết theo chuẩn ES6 nên bạn có thể tạo object mới của class đó thông qua từ khóa new. Sau đó chúng ta sử dụng phương thức mount() để thêm component tới trang. Nếu bạn để ý, các đoạn mã phía trên được đặt trong callback whenReady(). Việc này sẽ giúp đảm bảo OWL sẽ luôn được sẵn sàng trước khi nạp các component khác.

Mở rộng

OWL là một thư viện riêng và được sử dung như một thư viện JavaScript ngoài, do vậy bạn hoàn toàn có thể sử dụng OWL tại các dự án riêng khác của bạn. Thư viện được cung cấp tại OWL Github. Ngoài ra OWL cũng cung cấp trang để bạn trải nghiệm trước khi cài đặt thiết lập trên hệ thống của bạn tại OWL Playground.

Quản lý hành động người dùng trong OWL component

Để làm tăng tính tương tác của giao diện thì các component sẽ phải xử lý được hành động của người dùng như click, hover hoặc gửi form. Trong phần này, chúng ta sẽ tìm hiểu cách để bổ sung thêm nút đóng thanh thông báo phía trên.

Các bước thực hiện

  1. Cập nhật bổ sung nút đóng tới QWeb template tại /viin_education/static/src/js/component.js:

    static template = xml`
        <div class="bg-primary text-center p-3">
            Hello, World
            <button type="button" class="btn btn-danger" t-on-click="onClose">Close Me</button>
        </div>`
    
  2. Bổ sung thêm phương thức onClose để bắt sự kiện khi ấn nút:

    class MyComponent extends Component {
        static template = xml`
            <div class="bg-primary text-center p-3">
                Hello, World
                <button type="button" class="btn btn-danger" t-on-click="onClose">Close Me</button>
            </div>`
    
        onClose(ev) {
            this.destroy();
        }
    }
    

Cập nhật module để áp dụng thay đổi. Sau khi cập nhật, bạn sẽ thấy nút đóng trên thanh thông báo hiển thị như trên hình:

Ảnh nút đóng trên component

Thanh thông báo sẽ được tự động được đóng khi bạn ấn nút và sẽ xuất hiện trở lại khi bạn reload trang.

Cơ chế hoạt động

Tại bước 1, chúng ta bổ sung thêm nút đóng tới component. Tại nút chúng ta bổ sung thêm thuộc tính t-on-click dùng để gắn tới sự kiện click của người dùng, trong đó giá trị của thuộc tính sẽ là phương thức mà sự kiện đó sẽ gọi tới. Như tại ví dụ chúng ta thiết lập thuộc tính cho nút t-on-click="onClose" có nghĩa là khi nút được click thì hàm onClose sẽ được gọi. Cú pháp tổng quan của thuộc tính bắt sự kiện là:

t-on-<tên sự kiện>="<tên phương thức trong component>"

Giả sử chúng ta muốn bắt sự kiện người dùng trỏ chuột tới thì cú pháp sẽ là:

t-on-mouseover="onMouseover"

Tại bước 2, chúng ta bổ sung thêm phương thức onClose mà sẽ được gọi khi người dùng click tới nút. Trong phương thức này, chúng ta gọi tới phương thức destroy() là phương thức mặc định của OWL component được dùng để xóa bỏ component đó trong DOM. Ngoài ra còn nhiều phương thức mặc định khác của OWL componet mà chúng ta sẽ được tìm hiểu tại các phần sau.

Mở rộng

Các sự kiện được bắt trong OWL không chỉ giới hạn bởi các sự kiện trong DOM mà còn có thể là sự kiện do chính bạn tạo ra. Giả sự bạn kích hoạt thủ công sự kiện có tên là custom-event thì bạn có thể sử dụng t-on-custom-event để bắt sự kiện đó.

Quản lý trạng thái của thành phần OWL

OWL là framework có khả năng tự động cập nhật UI dựa trên các hook. Thông qua việc cập nhật các hook, các component trên giao diện sẽ được tự động cập nhật lại khi trạng thái của chúng thay đổi. Trong phần này, chúng ta sẽ tìm hiểu cách cập nhật nội dung thông báo dựa trên hành động người dùng.

Các bước thực hiện

Tại phần này, chúng ta sẽ hiển thị một chữ số may mắn bất kỳ trên thông báo. Người dùng có thể sinh lại số đó thông qua nút Try Again bên cạnh.

  1. Cập nhật XML template của component để hiện thị thông báo và nút sinh số mới:

    static template = xml`
        <div class="bg-primary text-center p-3">
            Your lucky number today is <t t-esc="state.luckyNumber" />.
            <button type="button" class="btn btn-secondary ml-3" t-on-click="onClick">Try Again</button>
            <button type="button" class="btn btn-danger ml-3" t-on-click="onClose">Close Me</button>
        </div>`
    
  2. Import useState hook tới file JavaScript:

    const { Component, useState } = owl;
    
  3. Bổ sung phương thức constructor tới component để khởi tạo:

    constructor() {
        super(...arguments);
        this.state = useState({ luckyNumber: this.getLuckyNumber() });
    }
    
    getLuckyNumber() {
        return Math.floor(Math.random() * 100);
    }
    
  4. Bổ sung các phương thức để bắt sự kiện của người dùng:

    onClick() {
        this.state.luckyNumber = this.getLuckyNumber();
    }
    

Khởi động lại và cập nhật module để áp dụng các thay đổi. Sau khi cập nhật, bạn sẽ thấy thanh thông báo như hình:

Ảnh bổ sung nút sinh số mới trên component

Khi bạn ấn nút Try Again, số trên thông báo sẽ được tự động thay đổi thành số mới.

Cơ chế hoạt động

Tại bước 1, chúng ta cập nhật lại XML template với một chút sự thay đổi. Tại dòng thông báo, chúng ta không chỉ sử dụng chữ thuần mà đã có thêm biến state.luckyNumber được sử dụng thông qua thẻ <t t-esc="state.luckyNumber" />. Tại đây chúng ta cũng bổ sung thêm nút "Try Again" cùng với thuộc tính t-on-click để gắn sự kiện tới nút.

Tại bước 2, chúng ta nạp useState hook từ OWL. Hook này được sử dụng để quản lý các trạng thái của component.

Tại bước 3, chúng ta bổ sung thêm constructor. Constructor này sẽ được gọi khi bạn tạo một instance của object. Trong constructor này, chúng ta bổ sung thêm biến luckyNumber thông qua hàm useState để quản lý trạng thái của component. Khi giá trị của biến luckyNumber được thay đổi thông qua việc click nút thì thông báo trên giao diện cũng được đổi theo.

Quan trọng

Chỉ có một quy định duy nhất cho việc khai báo hook là các hook sẽ chỉ hoạt động khi bạn khai báo trong constructor. Bạn có thể tham khảo các kiểu hook khác tại OWL Hook.

Tại bước 4, chúng ta bổ sung thêm phương thức để xử lý sự kiện click tới nút. Khi nút được click, chúng ta sẽ thay đổi trạng thái của component. Do chúng ta sử dụng hook trên trạng thái nên giao diện của component sẽ được tự động cập nhật.

Quá trình hoạt động của OWL component

OWL cung cấp rất nhiều phương thức khác nhau để hỗ trợ cho lập trình viên để tạo ra các component mạnh mẽ và mang tính tương tác. Trong phần này, chúng ta sẽ đề cập tới một số các phương thức quan trọng và thời điểm mà chúng sẽ được gọi trong suốt quá trình hoạt động của một component.

Các bước thực hiện

  1. Tại hàm constructor, chúng ta bổ sung việc xuất tin nhắn ra console:

    constructor() {
        console.log('Called from constructor() !');
        ...
    }
    
  2. Bổ sung phương thức willStart tới component:

    async willStart() {
        console.log('Called from willStart() !');
    }
    
  3. Bổ sung phương thức mounted tới component:

    mounted() {
        console.log('Called from mounted() !');
    }
    
  4. Bổ sung phương thức willPatch tới component:

    willPatch() {
        console.log('Called from willPatch() !');
    }
    
  5. Bổ sung phương thức patched tới component:

    patched() {
        console.log('Called from patched() !');
    }
    
  6. Bổ sung phương thức willUnmount tới component:

    willUnmount() {
        console.log('Called from willUnmount() !');
    }
    

Khởi động lại và cập nhật module để áp dụng các thay đổi. Sau khi cập nhật, thực hiện các hành động như ấn nút Try Again và ấn nút Close Me rồi lọc các dòng log có chức Called trên giao diện của console. Lúc này giao diện console sẽ hiển thị như phía dưới:

Ảnh các log trên console trình duyệt

Cơ chế hoạt động

Trong phần này, chúng ta bổ sung thêm rất nhiều phương thức khác nhau và log lại tin nhắn trên console khi gọi tới phương thức đó. Mỗi phương thức sẽ được sử dụng tại các thời điểm khác nhau dựa trên yêu cầu của bạn.

constructor: Constructor được gọi đầu tiên trong suốt quá trình hoạt động của một component. Hàm sẽ được gọi khi bạn khởi tạo component. Bạn sẽ phải khởi tạo trạng thái của component tại đây.

willStart(): Phương thức willStart được gọi ngay sau constructor và trước khi phần tử được kết xuất. Đây là phương thức bất đồng bộ. Bạn có thể thực hiện các tiến trình bất đồng bộ ví dụ như RPC tại đây.

mounted(): Phương thức mounted được gọi sau khi component được kết xuất và thêm vào DOM.

willPatch(): Phương thức willPatch sẽ được gọi khi trạng thái của component được thay đổi. Phương thức này sẽ được gọi trước khi phần tử được kế xuất lại dựa trên trạng thái mới. Như trong ví dụ, phương thức sẽ được gọi khi bạn click nút Try again. Lưu ý tại đây là khi phương thức này được gọi, các giá trị trên DOM sẽ là giá trị cũ.

patched(): Phương thức patched hoạt động tương tự như phương thức willPatch. Phương thức sẽ được gọi khi trạng thái của component được thay đổi. Điều khác biệt duy nhất tại đây là phương thức patched sẽ được gọi sau khi phần tử được kết xuất lại dựa trên trạng thái mới.

willUnmount: Phương thức willUnmount sẽ được gọi ngay trước khi phần tử được loại bỏ khỏi DOM. Tại ví dụ trên, phương thức này sẽ được gọi khi bạn click nút Close Me để đóng thông báo.

Đây là gần như toàn bộ các phương thức cho suốt quá trình hoạt động của một component và chúng sẽ được sử dụng tùy theo mỗi nhu cầu sử dụng khác nhau của người dùng, ví dụ như phương thức mountedwillUnmounted có thể được sử dụng để gắn hoặc bỏ gắn các phần tử lắng nghe sự kiện.

Mở rộng

Chúng ta vẫn còn một phương thức nữa không được đề cập là willUpdateProps, được sử dụng khi bạn sử dụng các subcomponent. Phương thức này được gọi khi component gốc truyền trạng thái tới các subcomponent thông qua tham số props. Đây là phương thức bất đồng bộ, do đó bạn có thể sử dụng được các phương thức bất đồng bộ tại đây như RPC.

Bổ sung trường OWL tới form view

Qua các phần trên, chúng ta đã nắm được tất cả các điều cơ bản của OWL. Bây giờ chúng ta sẽ đi tới mảng nâng cao hơn là tạo widget cho trường trên form view, tương tự như việc tạo widget cho trường tại phần trước. Trong phần này, chúng ta sẽ tạo một thanh kéo thả giá trị, cho phép người dùng có thể kéo thanh để thiết lập giá trị cho trường đó.

Các bước thực hiện

  1. Khai báo trường rating tới model education.student:

    rating = fields.Float(string='Rating')
    
  2. Bổ sung trường lên giao diện cùng với tên widget:

    <field name="rating" widget="float_range" />
    
  3. Bổ sung QWeb template tới trường tại static/src/xml/qweb_template.xml:

    <?xml version="1.0" encoding="UTF-8"?>
    <templates>
    
        <t t-name="OWLFloatRangeInput" owl="1">
            <input class="o_float_range_input" type="range" min="0" max="10" step="0.1"
                t-att-value="props.range_value"
                t-on-input="inputRange"
                t-attf-title="There're {{ props.student_count }} students with same rating."
            />
        </t>
    
        <span class="o_float_range" t-name="OWLFieldFloatRange" t-on-range-updated="rangeUpdated" owl="1">
            <FloatRangeInput range_value="value" student_count="ratingGroupData[value]" />
            <span class="o_float_range_value">
                <t t-esc="value"/>
            </span>
        </span>
    
    </templates>
    
  4. Bổ sung file QWeb tới manifest của module:

    'qweb': [
        'static/src/xml/qweb_template.xml',
    ],
    
  5. Bổ sung file SCSS tới static/src/scss/field_widget.scss:

    .o_float_range {
        display: flex !important;
        flex-direction: row;
        align-items: center;
    }
    
    .o_float_range_value {
        margin-left: 15px;
    }
    
  6. Bổ sung thêm file static/src/js/field_widget.js:

    odoo.define('float_range', function(require) {
        "use strict;"
    
        const { Component } = owl;
        const AbstractField = require('web.AbstractFieldOwl');
        const fieldRegistry = require('web.field_registry_owl');
    
        // Our code will go here
    
    }
    
  7. Tại file field_widget.js, bổ sung thêm component FloatRangeInput:

    class FloatRangeInput extends Component {
        static template = 'OWLFloatRangeInput';
        inputRange(ev) {
            this.trigger('range-updated', {
                value: ev.target.value,
            });
        }
    }
    
  8. Tại file field_widget.js, bổ sung thêm widget mới bằng cách mở rộng AbstractField:

    class FieldFloatRange extends AbstractField {
        static supportedFieldTypes = ['float'];
        static template = 'OWLFieldFloatRange';
    
        // Our code will go here
    
    }
    
    fieldRegistry.add('float_range', FieldFloatRange);
    
  9. Bổ sung các phương thức tới class FieldFloatRange

    constructor(...args) {
        super(...args);
    }
    
    async willStart() {
        this.ratingGroupData = {};
        var ratingData = await this.rpc ({
            model: this.model,
            method: 'read_group',
            domain: [],
            fields: ['rating'],
            groupBy: ['rating'],
        });
        ratingData.forEach(res => {
            this.ratingGroupData[res.rating] = res.rating_count;
        })
    }
    
    rangeUpdated(ev) {
        this._setValue(ev.detail.value);
    }
    
  10. Bổ sung các file JavaScript và SCSS tới asset phía backend:

    <template id="assets_backend" inherit_id="web.assets_backend">
        <xpath expr="." position="inside">
            <script src="/viin_education/static/src/js/field_widget.js" type="text/javascript" />
            <link href="/viin_education/static/src/scss/field_widget.scss" rel="stylesheet" type="text/scss" />
        </xpath>
    </template>
    

Khởi động lại và cập nhật module để áp dụng các thay đổi. Tại giao diện form của thông tin học sinh sẽ được hiển thị như hình:

Ảnh widget mới sử dụng OWL

Cơ chế hoạt động

Tại bước 1, chúng ta bổ sung thêm trường float tới model education.student và bổ sung trường lên view tại bước 2.

Tại bước 3, chúng ta bổ sung thêm file QWeb template. Tại đây chúng ta thêm hai template mới tới file nhằm mục đích để lấy ví dụ về việc sử dụng subcomponent. Tại template OWLFieldFloatRange, chúng ta gọi sử dụng thẻ tag <FloatRangeInput> để gọi tới subcomponent mà chúng ta sẽ khai báo sau trong JS. Đồng thời tại đây chúng ta truyền thêm hai thuộc tính range_valuestudent_count trong thẻ tag nhằm mục đích truyền các tham số đó xuống subcomponent. Trên template của subcomponent, chúng ta dùng thuộc tính props để nhận các giá trị truyền từ component phía trên. Ngoài ra tại đây chúng ta cũng bổ sung thêm t-on-range-updated trên template để lắng nghe các sự kiện được kích hoạt tại phía subcomponent.

Quan trọng

Odoo v14 sử dụng đồng thời hệ thống widget và OWL framework. Do cả hai đều sử dụng QWeb template nên bạn sẽ phải sử dụng owl="1" để phân biệt các OWL template với QWeb template cũ.

Tại bước 4, chúng ta bổ sung QWeb template tới manifest để chúng có thể được nạp tới trình duyệt.

Tại bước 5, chúng ta bổ sung thêm SCSS để điều chỉnh giao diện cho widget.

Tại bước 6, chúng ta bổ sung thêm JavaScript tới component của trường. Tại đây chúng ta import các thư viện của OWL, AbstractFieldfieldRegistry. AbstractField là component OWL trừu tượng của trường, trong đó bao gồm tất cả các phần tử cơ bản cần thiết để tạo nên một trường. fieldRegistry được sử dụng để làm danh sách các component OWL được dùng cho các trường.

Tại bước 7, chúng ta tạo mới component FloatRangeInput. Trong component, chúng ta sử dụng biến template để nạp template từ file XML bên ngoài. Trên component chúng ta cũng thêm phương thức inputRange được gọi khi người dùng kéo thanh giá trị. Khi phương thức được gọi tới, chúng ta sẽ kích hoạt sự kiện range-updated để cập nhật lại component gốc thông qua t-on-range-updated mà chúng ta đã khai báo trên template trước đó.

Tại bước 8 và bước 9, chúng ta tạo component FieldFloatRange bằng cách mở rộng AbstractField. Tại đây vì chúng ta khai báo biến static components nhằm mục đích sử dụng subcomponent tại phía template. Ở phương thức willStart, do đây là phương thức bất đồng bộ nên chúng ta sẽ sử dụng để gọi RPC lấy các dữ liệu về học sinh. Sau khi người dùng thay đổi giá trị của trường trên thanh kéo, phương thức rangeUpdated sẽ được gọi để cập nhật giá trị cho trường trên cơ sở dữ liệu thông qua phương thức _setValue. Giá trị được cập nhật ở đây sẽ được lấy thông qua thuộc tính detail của biến event. Cuối cùng, chúng ta đăng ký widget tới fieldRegistry để có thể sử dụng được widget trên các view của odoo.

Tại bước 10, chúng ta bổ sung các file JavaScript và SCSS tới asset phía backend.