Web Client Development

phần này sẽ hướng dẫn cách để tùy biến cũng như tạo mới các thành phần giao diện trên Odoo Backend, từ việc tạo widget mới cho các trường cũng như tạo một giao diện mới từ ban đầu.

Do giao diện của Odoo phụ thuộc nhiều tới JavaScript nên bạn sẽ cần phải có kiến thức cơ bản về JavaScript, JQuery, Underscore.js và Scss.

Các đoạn mã trong phần này cũng sẽ được viết phụ thuộc vào module web của phiên bản Odoo Cộng đồng để đảm bảo tính tương thích giữa các phiên bản khác nhau của Odoo.

Trong phần này, chúng ta sẽ đi vào các đầu mục:

Tạo widget mới

Như trong Backend Views, chúng ta sử dụng các widget để hiển thị một số kiểu dữ liệu nhất định dưới các định dạng khác nhau. Ví dụ như widget='image' dùng để hiển thị trường Binary dưới định dạng hình ảnh. Để mô tả cho việc tạo một widget mới, chúng ta sẽ tạo widget cho trường integer nhưng thay vì hiển thị ô để nhập như mặc định thì người dùng có thể chọn màu sắc khác nhau tại trường đó. Mỗi màu sắc sẽ mang một giá trị khác nhau tương ứng để gán cho trường.

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

  1. Bổ sung thêm static/src/js/field_widget.js:

    odoo.define('color_integer_widget', function(require) {
        "use strict";
    
        var AbstractField = require('web.AbstractField');
        var fieldRegistry = require('web.field_registry');
    
  2. Tạo widget mới bằng cách mở rộng AbstractField:

    var colorField = AbstractField.extend({
    
  3. Thiết lập CSS class, thẻ phần tử gốc và các kiểu dữ liệu mà widget sẽ hỗ trợ:

    className: 'o_int_color',
    tagName: 'span',
    supportedFieldTypes: ['integer'],
    
  4. Bắt sự kiện trong JavaScript:

    events: {
        'click .o_color_pill': 'clickColorPill',
    },
    
  5. Ghi đè hàm init để thực hiện khởi tạo:

    init: function() {
        this.totalColors = 10;
        this._super.apply(this, arguments);
    },
    
  6. Ghi đè hàm _renderEdit_renderReadonly để thiết lập các phần tử DOM:

    _renderEdit: function() {
        this.$el.empty();
        for (let i = 0; i < this.totalColors; i++) {
            var className = 'o_color_pill o_color_' + i;
            if (this.value === i) {
                className += ' active';
            }
            this.$el.append($('<span>', {
                'class': className,
                'data-val': i,
            }));
        }
    },
    
    _renderReadonly: function() {
        var className = 'o_color_pill active readonly o_color_' + this.value;
        this.$el.append($('<span>', {
            'class': className,
        }))
    },
    
  7. Xử lý sự kiện mà được bắt phía trên:

        clickColorPill: function(ev) {
            var $target = $(ev.currentTarget);
            var data = $target.data();
            this._setValue(data.val.toString());
        }
    
    });
    
  8. Đăng ký cho widget:

    fieldRegistry.add('int_color', colorField);
    
  9. Trả về thông tin để các add-on khác có thể sử dụng:

        return {
            colorField: colorField,
        };
    
    });
    
  10. Bổ sung thêm SCSS vào static/src/scss/field_widget.scss:

    .o_int_color {
        .o_color_pill {
            position: relative;
            display: inline-block;
            width: 25px;
            height: 25px;
            margin: 4px;
            border-radius: 25px;
            @for $size from 1 through length($o-colors){
                &.o_color_#{$size - 1} {
                    background-color: nth($o-colors, $size);
                    &:not(.readonly):hover {
                        transform: scale(1.2);
                        transition: 0.3s;
                        cursor: pointer;
                    }
                    &.active:after {
                        content: "\f00c";
                        position: absolute;
                        display: inline-block;
                        padding: 4px;
                        font: normal 16px/1 FontAwesome;
                        color: #fff;
                    }
                }
            }
        }
    }
    
  11. Đăng ký các file vào trong asset của backend tại views/templates.xml:

    <?xml version="1.0" encoding="utf-8"?>
    <odoo>
    
        <template id="assets_backend" inherit_id="web.assets_backend">
            <xpath expr="." position="inside">
                <script src="/viin_education_chap15/static/src/js/field_widget.js" type="text/javascript" />
                <link href="/viin_education_chap15/static/src/scss/field_widget.scss" rel="stylesheet" type="text/scss" />
            </xpath>
        </template>
    
    </odoo>
    
  12. Bổ sung trường color kiểu integer tới model education.student:

    ...
    color = fields.Integer(string='Color')
    ...
    
  13. Bổ sung trường color cùng với widget="int_color" tới giao diện form của education.student:

    ...
    <field name="student_code" position="after">
        <field name="color" widget="int_color" />
    </field>
    ...
    

Cập nhật module để áp dụng các thay đổi. Sau khi cập nhật, mở giao diện form và bạn sẽ thấy thanh chọn màu, được hiển thị như ảnh bên dưới:

Ảnh trường màu sắc

Chi tiết

Qua ví dụ trên, chúng ta sẽ đi sâu vào các thành phần chính của widget:

  • init(): Đây là hàm tạo widget, được sử dụng cho mục đích khởi tạo. Khi widget được khởi tạo, phương thức này sẽ được gọi đầu tiên.

  • willStart(): Đây là phương thức được gọi tại thời điểm sau khi widget được khởi tạo và trong quá trình được thêm vào DOM, được sử dụng để khởi tạo dữ liệu không đồng bộ trong widget. Phương thức này cũng được dùng cho mục đích trả về các deferred object mà có thể lấy được dễ dàng thông qua việc gọi super(). Chúng ta sẽ sử dụng phương thức này trong các ví dụ sau.

  • start(): Phương thức này được gọi ngay sau khi widget hoàn thành việc kết xuất nhưng chưa được thêm vào DOM. Phương thức này rất hữu ích cho các công việc sau khi kết xuất và sẽ trả về deferred object. Bạn có thể truy cập phần tử đã được kết xuất trong this.$el.

  • destroy(): Phương thức này được gọi khi widget được hủy bỏ, chủ yếu được dùng trong quá trình dọn dẹp cơ bản như bỏ gắn event.

Quan trọng

Class cơ sở cho các widget là Widget (được thiết lập bởi web.Widget). Nếu bạn muốn tìm hiểu sâu hơn, bạn có thể tham khảo tại /addons/web/static/src/js/core/widget.js.

Tại bước 1, chúng ta khai báo web.AbstractFieldweb.field_registry.

Tại bước 2, chúng ta tạo colorField bằng cách mở rộng AbstractField. Thông qua cách này, colorField có thể lấy toàn bộ các thuộc tính và phương thức từ AbstractField.

Tại bước 3, chúng ta bổ sung thêm 3 thuộc tính: className được sử dụng để xác định class cho phần tử gốc của widget; tagName được sử dụng cho kiểu phần tử; và supportedFieldTypes được sử dụng để xác định kiểu trường sẽ được hỗ trợ bởi widget này. Tại ví dụ này, chúng ta đang tạo widget cho trường kiểu integer.

Tại bước 4, chúng ta gắn các sự kiện tới widget. Thông thường khóa ở đây là sự kết hợp giữa tên sự kiện và CSS selector. Sự kiện và CSS selector được phân cách bởi dấu cách và giá trị sẽ là tên phương thức của widget. Bởi vậy, khi sự kiện được kích hoạt thì phương thức được gắn với sẽ được gọi tự động. Trong ví dụ này, khi người dùng click chọn màu, chúng ta sẽ thiết lập giá trị integer cho trường. Để quản lý các sự kiện click, chúng ta đã bổ sung thêm CSS selector và tên phương thức tới khóa sự kiện đó.

Tại bước 5, chúng ta ghi đè phương thức init và thiết lập giá trị của thuộc tính this.totalColor. Chúng ta sẽ sử dụng biến này để quyết định số các thẻ màu. Tại đây chúng ta muốn hiển thị 10 thẻ màu nên giá trị được gắn sẽ là 10.

Tại bước 6, chúng ta bổ sung thêm 2 phương thức _renderEdit_renderReadonly. Như tên hàm đã mô tả, _renderEdit được gọi khi widget ở trạng thái sửa và _renderReadonly được gọi khi widget ở trạng thái chỉ đọc:

  • Tại phương thức _renderEdit, chúng ta bổ sung thêm các thẻ <span> từ giá trị totalColor được thiết lập tại phương thức init với các màu khác nhau trong widget và khi click tới thẻ thì giá trị của trường sẽ được thiết lập. Các thẻ được thêm vào giao diện form thông qua this.$el với $el là phần tử gốc của widget.

  • Tại phương thức _renderReadonly, do chúng ta chỉ muốn hiển thị màu hiện tại của trường nên tại hàm sẽ chỉ thêm một thẻ màu duy nhất.

Tuy nhiên cấu trúc các thẻ màu được thêm hiện tại vẫn đang là cố định, trong mục sau chúng ta sẽ sử dụng JavaScript QWeb template để render các thẻ.

Tại bước 7, chúng ta thêm phương thức clickPill để quản lý sự kiện click thẻ. Để thiết lập giá trị của trường, chúng ta sử dụng phương thức _setValue của class AbstractField. Khi bạn thiết lập giá trị của trường, Odoo Framework sẽ load lại widget và gọi phương thức _renderEdit lại để cập nhật giá trị của widget.

Tại bước 8, để sử dụng được widget mới tạo thì widget đó phải được thêm vào danh mục widget của web.field_registry. Tất cả các kiểu giao diện khác khác đều tìm trong danh mục này, do vậy nếu bạn muốn bổ sung thêm tại giao diện list thì bạn cũng sẽ phải thêm widget của bạn vào đây và sử dụng widget trên giao diện đó.

Cuối cùng, chúng ta trả về widget class để các addon khác có thể mở rộng hoặc kế thừa từ đó. Bằng cách gắn trường integer trên giao diện với widget="int_color", widget mặc định của trường integer sẽ được thay thế bằng widget mới.

Mở rộng

Namespace web.mixins cung cấp rất nhiều các class mixin cần thiết cho việc phát triển các widget. Như trong ví dụ chúng ta đã sử dụng AbstractField là class được kế thừa từ Widget, và chính class Widget cũng được kế thừa từ 2 class mixin khác. Đầu tiên là EventDispatcherMixin thường được dùng để cung cấp interface đơn giản cho việc gắn bộ xử lý sự kiện và kích hoạt chúng. Thứ hai là ServicesMixin được dùng để cung cấp các hàm để gọi RPC và các action.

Quan trọng

Khi bạn muốn ghi đè một phương thức, phải luôn tham khảo các class gốc để biết được hàm sẽ phải trả về thông tin gì. Một trong những lỗi rất hay xảy ra là quên không trả về deferred object dẫn tới việc gây ảnh hưởng tới các tiến trình bất đồng bộ.

Ngoài ra widget cũng chịu trách nhiệm trong việc kiểm duyệt dữ liệu. Bạn có thể tùy chỉnh việc kiểm duyệt dữ liệu thông qua hàm isValid.

Sử dụng QWeb Template

Tương tự như việc tạo mã HTML từ JavaScript, bạn chỉ nên tạo tối thiểu các phần tử DOM tại mã Javascript tại phía client. May mắn cho chúng ta là tại phía client cũng có bộ công cụ template với cú pháp tương tự như phía server.

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

  1. Bổ sung web.core và gán qweb tới biến:

    odoo.define('color_integer_widget', function(require) {
        "use strict";
    
        var AbstractField = require('web.AbstractField');
        var fieldRegistry = require('web.field_registry');
        var core = require('web.core');
    
        var qweb = core.qweb;
    
  2. Thay đổi hàm _renderEdit để đơn giản hóa quá trình render phần tử (kế thừa từ widget):

    _renderEdit: function () {
        this.$el.empty();
        var pills = qweb.render('FieldColorPills', { widget: this });
        this.$el.append(pills);
    },
    
  3. Bổ sung template tới static/src/xml/qweb_template.xml:

    <?xml version="1.0" encoding="UTF-8"?>
    <templates>
    
        <t t-name="FieldColorPills">
            <t t-foreach="widget.totalColors" t-as='pill_no'>
                <span t-attf-class="o_color_pill o_color_#{pill_no} #{widget.value === pill_no and 'active' or ''}" t-att-data-val="pill_no"/>
             </t>
        </t>
    
    </templates>
    
  4. Bổ sung thêm file QWeb tới manifest:

    'qweb': [
        'static/src/xml/qweb_template.xml',
    ],
    

Qua các bước trên, giờ các add-on khác có thể dễ dàng thay đổi mã HTML của widget sử dụng thông qua các phương thức mà QWeb cung cấp.

Chi tiết

Chúng ta đã đề cập chi tiết về việc tạo và chỉnh sửa template tại các phần trước, tuy nhiên ở đây chúng ta tập trung tới khía cạnh khác. Đầu tiên bạn phải nhận biết được template sẽ được xử lý bởi Javascript QWeb sẽ khác so với bởi Python tại phía server. Điều này có nghĩa bạn không có quyền truy cập tới các bản ghi hay môi trường, bạn chỉ có quyền truy cập duy nhất tới các tham số mà bạn truyền từ hàm qweb.render.

Trong ví dụ trên, chúng ta truyền object hiện tại thông qua khóa widget. Điều này có nghĩa bạn sẽ phải xử lý toàn bộ tác vụ trên JavaScript và cho phép template truy cập tới các thuộc tính. Bởi vì chúng ta có thể truy cập tới toàn bộ các thuộc tính có trên widget nên tại template chúng ta có thể dễ dàng kiểm tra giá trị thuộc tính totalColors.

Do QWeb phía client không liên quan tới các giao diện QWeb nên có cơ chế khác để phía client sử dụng các template đó bằng cách thêm danh sách các file tới khóa qweb tại manifest của addon.

Ghi chú

Nếu bạn không muốn liệt kê các QWeb template trong manifest, bạn có thể sử dụng khóa xmlDependencies trên snippet để lazy load tempate này. Với xmlDependencies, QWeb template chỉ được nạp khi widget được khởi tạo.

Mở rộng

Lý do lớn nhất để sử dụng QWeb ở đây là bởi tính mở rộng, và đây cũng là điểm khác biệt lớn nhất giữa QWeb phía client và server. Tại phía client, bạn không thể sử dụng XPath mà sẽ phải dùng JQuery để chỉnh sửa. Giả sử chúng ta muốn thêm icon người dùng trên widget, chúng ta sẽ phải sử dụng đoạn code sau để có icon trên mỗi thẻ:

<t t-extend="FieldColorPills">
    <t t-jquery="span" t-operation="prepend">
        <i class="fa fa-user" />
    </t>
</t>

Nếu chúng ta được sử dụng t-name tại đây, chúng ta sẽ phải tạo bản sao của template gốc và giữ nguyên chúng. Ngoài ra có một số giá trị khác của t-operationappend, before, after, innerreplace cho phép nội dung có thể được thêm vào vị trí mong muốn. Thêm vào đó với t-operation='attributes' cho phép bạn có thể thay đổi thuộc tính của phần tử, tương tự như QWeb phía server.

Điểm khác nữa là do QWeb phía client không được bắt đầu bởi tên module nên bạn sẽ phải đặt tên cho template của bạn là duy nhất trên toàn hệ thống. Đây là lý do vì sao tên template thường đặt khá dài.

Tham khảo

Nếu bạn muốn tìm hiểu thêm về QWeb template, có các điểm sau bạn sẽ cần tham khảo:

  • Bộ công cụ QWeb phía client thường có thông báo và xử lý lỗi không được thân thiện so với các phần khác của Odoo. Điều này sẽ khiến cho người mới gặp khó khăn nếu có lỗi xảy ra.

  • Tuy nhiên may mắn là có một số thiết lập để gỡ lỗi template phía client sẽ được mô tả về sau trong phần này.

Tạo yêu cầu RPC tới server

Tại mục này, chúng ta sẽ bổ sung tính năng cho phép hiện mô tả trên từng thẻ màu. Khi người dùng di chuột tới thẻ, sẽ có mô tả hiển thị số học sinh được gắn với thẻ màu đó. Thông tin này sẽ được lấy thông qua việc gọi RPC tới phía server.

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

  1. Bổ sung phương thức willStart và thiết lập colorGroupData trong yêu cầu RPC:

    willStart: function () {
        var self = this;
        this.colorGroupData = {};
        var colorDataPromise = this._rpc({
            model: this.model,
            method: 'read_group',
            domain: [],
            fields: ['color'],
            groupBy: ['color'],
        }).then(function (result) {
            _.each(result, function (r) {
                self.colorGroupData[r.color] = r.color_count;
            });
        });
        return Promise.all([this._super.apply(this, arguments), colorDataPromise]);
    },
    
  2. Cập nhật _renderEdit và thiết lập tooltip trên pills:

    _renderEdit: function () {
        this.$el.empty();
        var pills = qweb.render('FieldColorPills', {widget: this});
        this.$el.append(pills);
        this.$el.find('[data-toggle="tooltip"]').tooltip();
    },
    
  3. Cập nhật template FieldColorPills và bổ sung dữ liệu tooltip:

    <t t-name="FieldColorPills">
        <t t-foreach="widget.totalColors" t-as='pill_no'>
            <span
                t-attf-class="o_color_pill o_color_#{pill_o} #{widget.value === pill_no and 'active' or ''}"
                t-att-data-val="pill_no"
                data-toggle="tooltip"
                data-placement="top"
                t-attf-title="This color is used in #{widget.colorGroupData[pill_no] or 0 } students."
            />
        </t>
    </t>
    

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

Ảnh mô tả của trường màu sắc

Chi tiết

Hàm willStart sẽ được gọi trước khi kết xuất và quan trọng hơn, hàm trả về đối tượng Promise đã được hoàn thành trước khi quá trình kết xuất được bắt đầu. Do chúng ta cũng cần phải chạy hành động bất đồng bộ trước khi quá trình kết xuất được thực thi nên hàm này được sử dụng trong ví dụ phía trên.

Khi xử lý với việc truy cập dữ liệu, chúng ta phụ thuộc vào hàm _rpc được cung cấp bởi class ServicesMixin như đã giải thích phía trên. Hàm này cho phép bạn có thể gọi bất kỳ hàm public nào trên model như search, read, write và như ví dụ là read_group.

Tại bước 1, chúng ta thực hiện gọi RPC và thực thi phương thức read_group trên model hiện tại, với ví dụ ở đây là model education.student. Chúng ta nhóm dữ liệu dựa trên trường color, bởi vậy yêu cầu RPC sẽ trả về dữ liệu học sinh được nhóm theo color và được tính tổng tại khóa color_count. Chúng ta cũng map color_countcolor trong colorGroupData để từ đó chúng ta có thể sử dụng chúng trong template QWeb. Tại dòng cuối của hàm, chúng ta sẽ resolve willStart với super và yêu cầu RPC sử dụng $.when. Bởi vậy, quá trình kết xuất chỉ diễn ra sau khi dữ liệu được lấy về và sau tất cả các hành động bất đồng bộ từ super chưa được hoàn thành trước đó.

Bước 2 không có gì đặc biệt. Chúng ta chỉ khởi tạo chú thích.

Tại bước 3, chúng ta sử dụng colorGroupData để thiết lập các thuộc tính cần để hiển thị chú thích. Trong phương thức willStart, chúng ta gắn màu sắc thông qua this.colorGroupData để QWeb template có thể sử dụng các thông tin đó thông qua widget.colorGroupData do các thông tin đó đã được truyền xuống thông qua widget.

Mở rộng

Class AbstractField có một số thuộc tính hữu ích như trong ví dụ mà chúng ta đã sử dụng, ví dụ như thuộc tính this.model sẽ cung cấp tên của model hiện tại, hoặc this.field cung cấp các thông tin gần như tương tự với hàm fields_get() cho trường mà widget đang hiển thị.

Ngoài ra cũng có thuộc tính hữu ích khác là nodeOption chứa các thông tin được truyền qua thuộc tính options trên giao diện form. Do thông tin này đã được parse bằng JSON nên bạn có thể truy cập tương tự như các object khác. Để tìm hiểu rõ thêm các thông tin cũng như các thuộc tính khác, bạn có thể tham khảo thêm tại file abstract_field.js.

Tham khảo

Nếu bạn gặp vấn đề trong việc xử lý tiến trình bất đồng bộ, bạn có thể tham khảo thêm tại:

Tạo giao diện mới

Như Backend Views đã đề cập, Odoo cung cấp rất nhiều kiểu giao diện khác nhau như giao diện form, list và kanban. Trong phần này, chúng ta sẽ biết được cách để tạo một giao diện hoàn toàn mới. Giao diện sẽ hiển thị những người phụ trách và các học viên mà người đó quản lý.

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

  1. Kế thừa ir.ui.view để bổ sung kiểu giao diện mới:

    class View(models.Model):
        _inherit = 'ir.ui.view'
    
        type = fields.Selection(selection_add=[('student', 'Student')])
    
  2. Kế thừa ir.actions.act_window.view để bổ sung giao diện mới:

    class ActWindowView(models.Model):
        _inherit = 'ir.actions.act_window.view'
    
        view_mode = fields.Selection(
            selection_add=[('student', 'Student')],
            ondelete={'student': 'cascade'}
        )
    
  3. Bổ sung thêm file /static/src/js/student_model.js:

    odoo.define('student.Model', function (require) {
        'use strict';
    
        var AbstractModel = require('web.AbstractModel');
    
        var StudentModel = AbstractModel.extend({
            init: function () {
                this._super.apply(this, arguments);
                this.data = null;
            },
    
            get: function () {
                return this.data;
            },
    
            __load: function (params) {
                this.modelName = params.modelName;
                this.context = params.context;
                this.domain = params.domain;
                return this._fetchData();
            },
    
            __reload: function (handle, params) {
                if ('domain' in params) {
                    this.domain = params.domain;
                }
                return this._fetchData();
            },
    
            _fetchData: function () {
                var self = this;
                return this._rpc({
                    model: this.modelName,
                    method: 'search_read',
                    context: this.context,
                    domain: this.domain
                }).then(function (results) {
                    self.data = _.map(results, function (result) {
                        return {
                            id: result.id,
                            display_name: result.display_name,
                            image: result.image_1024,
                        };
                    });
                });
            },
        });
    
        return StudentModel;
    
    });
    
  4. Bổ sung thêm file /static/src/js/student_controller.js:

    odoo.define('student.Controller', function (require) {
        'use strict';
    
        var AbstractController = require('web.AbstractController');
        var core = require('web.core');
        var qweb = core.qweb;
    
        var StudentController = AbstractController.extend({
            custom_events: _.extend({}, AbstractController.prototype.custom_events, {
                'view_student': '_onClickViewButton',
            }),
    
            _onClickViewButton: function (ev) {
                this.do_action({
                    type: 'ir.actions.act_window',
                    name: this.title,
                    res_model: this.modelName,
                    views: [[false, 'form']],
                    res_id: ev.data['id'],
                });
            },
    
            _onAddButtonClick: function (ev) {
                this.do_action({
                    type: 'ir.actions.act_window',
                    name: this.title,
                    res_model: this.modelName,
                    views: [[false, 'form']],
                    target: 'new',
                });
            },
        });
    
        return StudentController;
    
    });
    
  5. Bổ sung thêm file /static/src/js/student_renderer.js:

    odoo.define('student.Renderer', function (require) {
        'use strict';
    
        var AbstractRenderer = require('web.AbstractRenderer');
        var core = require('web.core');
        var qweb = core.qweb;
    
        var StudentRenderer = AbstractRenderer.extend({
            events: _.extend({}, AbstractRenderer.prototype.events, {
                'click .o_primary_button': '_onClickButton',
            }),
    
            _render: function () {
                this.$el.empty();
                this.$el.append(qweb.render('ViewStudent', { 'data_list': this.state }));
                return this._super.apply(this, arguments);
            },
    
            _onClickButton: function (ev) {
                ev.preventDefault();
                var target = $(ev.currentTarget);
                var student_id = target.data('id');
                this.trigger_up('view_student', {
                    'id': student_id,
                });
            }
        });
    
        return StudentRenderer;
    
    });
    
  6. Bổ sung thêm file /static/src/js/student_view.js:

    odoo.define('student.View', function (require) {
        'use strict';
    
        var AbstractView = require('web.AbstractView');
        var view_registry = require('web.view_registry');
    
        var StudentController = require('student.Controller');
        var StudentModel = require('student.Model');
        var StudentRenderer = require('student.Renderer');
    
        var StudentView = AbstractView.extend({
            display_name: 'Student',
            icon: 'fa-id-card-o',
            config: _.extend({}, AbstractView.prototype.config, {
                Model: StudentModel,
                Controller: StudentController,
                Renderer: StudentRenderer,
            }),
            viewType: 'student',
            searchMenuTypes: ['filter', 'favorite'],
            accesskey: "a",
            init: function (viewInfo, params) {
                this._super.apply(this, arguments);
            },
        });
    
        view_registry.add('student', StudentView);
    
        return StudentView;
    
    });
    
  7. Bổ sung thêm file /static/src/xml/qweb_template.xml:

    <t t-name="ViewStudent">
        <div class="row ml16 mr16">
            <div t-foreach="data_list" t-as="data" class="col-2">
                <div class="card mt16">
                    <div class="card-body">
                        <img t-att-src="'data:image/png;base64,' + data['image']" class="card-img-top" />
                        <h5 class="card-title mt8">
                            <t t-esc="data['display_name']"/>
                        </h5>
                        <a href="#" class="btn btn-sm btn-primary o_primary_button" t-att-data-id="data['id']">
                            View student
                        </a>
                    </div>
                </div>
            </div>
        </div>
    </t>
    
  8. Bổ sung toàn bộ các file JS tới asset của backend:

    ...
    <script src="/viin_education_chap15/static/src/js/student_view.js" type="text/javascript" />
    <script src="/viin_education_chap15/static/src/js/student_model.js" type="text/javascript" />
    <script src="/viin_education_chap15/static/src/js/student_controller.js" type="text/javascript" />
    <script src="/viin_education_chap15/static/src/js/student_renderer.js" type="text/javascript" />
    ...
    
  9. Cuối cùng, bổ sung giao diện mới tới model education.student:

    <record id="education_student_view_student" model="ir.ui.view">
        <field name="name">education.student.student</field>
        <field name="model">education.student</field>
        <field name="arch" type="xml">
            <student></student>
        </field>
    </record>
    
  10. Bổ sung m2m_group tới action của Student:

    ...
    <record id="education_student_action_student" model="ir.actions.act_window.view">
        <field name="act_window_id" ref="viin_education.education_student_action" />
        <field name="view_id" ref="viin_education_chap15.education_student_view_student" />
        <field name="view_mode">student</field>
    </record>
    ...
    

Cập nhật module để áp dụng các thay đổi. Giao diện mới sẽ được hiển thị như phía dưới:

Ảnh mô tả giao diện mới

Chi tiết

Tại bước 1 và 2, chúng ta đăng ký kiểu view mới là m2m_group trong ir.ui.viewir.actions.act_window.view.

Trong các bước tiếp theo, chúng ta bổ sung các file JS cần thiết để hình thành giao diện. Một giao diện của Odoo sẽ bao gồm View, Model, RendererController, tương tự như mô hình MVC. Hình dưới mô tả cấu trúc của một giao diện:

Ảnh mô tả cấu trúc MVC của giao diện Odoo

Để hình thành một giao diện thì sẽ cần phải sự kết hợp của bốn thành phần là Model, Renderer, ControllerView. Trong đó:

  • Model: Nhiệm vụ của Model là lưu trữ trạng thái của giao diện. Model gửi yêu cầu RPC tới server để lấy dữ liệu, sau đó truyền dữ liệu tới ControllerRenderer. Sẽ có hai phương thức cần quan tâm ở đây là __load__reload. Khi view được khởi tạo, phương thức _load() được gọi để lấy dữ liệu, và khi điều kiện tìm kiếm thay đổi thì phương thức _reload() sẽ được gọi để cập nhật trạng thái mới cho giao diện. Đối với ví dụ ở đây, chúng ta sử dụng phương thức chung _fetchData() để tạo yêu cầu lấy dữ liệu thông qua RPC.

  • Controller: Nhiệm vụ của Controller là để quản lý sự phối hợp giữa ModelRenderer. Khi có hành động được xảy ra trong Renderer, chúng sẽ truyền các thông tin tới Controller để thực hiện hành động tương ứng, tuy nhiên trong một số trường hợp chúng sẽ gọi trực tiếp hàm. Với ví dụ trên, chúng ta bổ sung custom_events nhằm mục đích khi người dùng click tới nút xem học sinh trên giao diện thì Renderer sẽ kích hoạt sự kiện tới Controller để thực hiện hành động.

Quan trọng

Lưu ý ở đây là events khác hoàn toàn so với custom_events. events là các sự kiện thông thường trên Javascript, còn custom_events là các sự kiện từ phía Odoo Framework. Các sự kiện custom_events có thể được kích hoạt thông qua phương thức trigger_up.

  • View: Nhiệm vụ của View là để lấy toàn bộ các thông tin cơ bản yêu cầu để xây dựng giao diện như các trường, context, view arch và các tham số khác. Sau đó, view sẽ khởi tạo bộ ba Controller, RendererModel. Thông thường, View sẽ thiết lập các tham số cần thiết được yêu cầu bởi model, view và controller.

Tại bước 7, chúng ta thêm mẫu QWeb cho các giao diện. Để biết thêm chi tiết về mẫu QWeb, bạn có thể xem thêm tại Sử dụng QWeb Template trong phần này.

Quan trọng

Giao diện của Odoo có rất nhiều phương thức được dùng cho các mục đích khác nhau và chúng ta mới chỉ đang xem những phương thức quan trọng nhất trong mục này. Nếu bạn muốn tìm hiểu thêm về các giao diện, bạn có thể tham khảo tại thư mục /addons/web/static/src/js/views/. Thư mục này cũng đồng thời chứa các đoạn mã cho abstract model, controller, renderer và view.

Tại bước 8, chúng ta bổ sung thêm các file Javascript tới assets.

Cuối cùng, lại hai bước cuối, chúng ta bổ sung giao diện cho model education.student. Tại đây chúng ta khai báo giao diện mới và cập nhật giao diện mới đó tới action của model.

Mở rộng

Nếu bạn không muốn tạo kiểu giao diện mới mà chỉ muốn chỉnh sửa một số thành phần trên giao diện thì bạn có thể sử dụng js_class trên view. Ví dụ, nếu bạn muốn giao diện tương tự như giao diện kanban chúng vừa tạo, bạn có thể mở rộng như sau:

var CustomRenderer = KanbanRenderer.extend({
    ...
});

var CustomRendererModel = KanbanModel.extend({
    ...
});

var CustomRendererController = KanbanController.extend({
    ...
});

var CustomDashboardView = KanbanView.extend({
    config: _.extend({}, KanbanView.prototype.config, {
        Model: CustomDashboardModel,
        Renderer: CustomDashboardRenderer,
        Controller: CustomDashboardController,
    }),
});

var viewRegistry = require('web.view_registry');
viewRegistry.add('my_custom_view', CustomDashboardView);

Chúng ta có thể sử dụng giao diện kanban với js_class (lưu ý rằng server vẫn sẽ nhận diện đây là giao diện kanban):

...
<field name="arch" type="xml">
    <kanban js_class="my_custom_view">
        ...
    </kanban>
</field>
...

Gỡ lỗi mã nguồn phía client

Để gỗi lỗi mã nguồn phía server, chúng ta dành cả Debugging Modules để thảo luận về vấn đề đó. Với phần phía client, bạn sẽ được bắt đầu được làm quen tại mục này.

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

Điều khiến việc gỡ lỗi phía client khó hơn so với thông thường là do web client dựa vào rất nhiều các sự kiện bất đồng bộ của JQuery. Cho dù đặt breakpoint để dừng nhưng vẫn sẽ có khả năng cao các lỗi liên quan tới thời gian sẽ không xảy ra khi gỡ lỗi. Chúng ta sẽ đề cập các trường hợp này sau.

  1. Để gỡ lỗi phía client, bạn sẽ phải kích hoạt chế độ gỡ lỗi với assets. Nếu bạn không biết cách để kích hoạt, bạn có thể tham khảo lại tại Installing the Odoo Development Environment.

  2. Tại hàm JavaScript mà bạn quan tâm, gọi debugger

    debugger;
    
  3. Nếu bạn đang gặp vấn đề về thời gian, ghi lại vào console thông qua hàm JS:

    console.log("I'm in function X currently");
    
  4. Nếu bạn muốn gỡ lỗi trong quá trình kết xuất template, gọi bộ gỡ lỗi từ QWeb:

    <t t-debug="" />
    
  5. Bạn cũng có thể ghi lại giá trị tại QWeb tới console:

    <t t-log="myvalue" />
    

Tất cả chức năng này phụ thuộc vào trình duyệt của bạn cung cấp các tính năng tương ứng cho quá trình gỡ lỗi. Với mục đích mô tả, chúng ta sẽ sử dụng trình duyệt Chromium ở đây. Để có thể sử dụng công cụ gỡ lỗi, bạn có thể mở chúng bằng cách click vào menu phía trên cùng bên phải và chọn More tools | Developer tools:

Giao diện menu công cụ gỡ lỗi

Chi tiết

Khi trình gỡ lỗi được mở, bạn sẽ nhìn thấy giao diện tương tự như sau:

Giao diện công cụ gỡ lỗi

Tại đây bạn có thể truy cập tới các công cụ khác nhau trong các tab. Tab hiện tại đang hiện trên ảnh là trình gỡ lỗi JS, là nơi chúng ta sẽ thiết lập breakpoint bằng cách click vào số của dòng. Mỗi lần widget thực hiện thì đến dòng này thì tiến trình sẽ được dừng lại để bạn có thể theo dõi các giá trị thay đổi. Trong danh sách theo dõi phía bên phải, bạn cũng có thể gọi hàm để thử tác dụng của hàm mà không phải lưu lại file script và load lại trang liên tục.

Các dòng thiết lập gỡ lỗi phía trên cũng sẽ hoạt động tương tự khi công cụ phát triển được mở. Tiến trình lúc này sẽ bị dừng lại, và trình duyệt sẽ chuyển sang tab Source, cùng với file quan tâm được mở và dòng với thiết lập gỡ lỗi sẽ được bôi đậm.

Ngoài ra các thông tin lỗi sẽ được hiện trên tab Console. Đây sẽ là tab đầu tiên bạn nên kiểm tra trong trường hợp có lỗi bất kỳ bởi nếu một số đoạn mã JS không được nạp do có vấn đề thì tại đây sẽ có thông báo lỗi để cho bạn biết được vấn đề đang xảy ra.

Mở rộng

Bạn có thể sử dụng tab Element để theo dõi DOM được biểu diễn trên trang của trình duyệt đang hiển thị. Điều này sẽ giúp bạn làm quen với các đoạn mã mà widget sinh ra, và đồng thời cũng cho bạn tùy chỉnh với các class và thuộc tính CSS. Đây là nơi rất tốt để kiểm tra sự thay đổi trên giao diện.

Tab Network cho bạn thấy tổng quan các yêu cầu mà trang thực hiện và thời gian thực hiện việc đó. Điều này rất tiện dụng trong việc tìm nguyên nhân làm chậm load trang vì tại đây bạn có thể xem được nội dung chi tiết của yêu cầu. Nếu bạn chọn một yêu cầu, bạn có thể xem được dữ liệu payload đã được gửi tới server và kết quả trả về, giúp cho việc tìm nguyên nhân nếu xảy ra vấn đề tại phía client. Bạn cũng sẽ thấy được mã trạng thái cùng các yêu cầu được tạo, giả sử 404 trong trường hợp tài nguyên không thể tìm thấy do bạn đặt sai tên,...

Làm quen với tours

Sau khi phát triển một phần mềm lớn, việc giới thiệu luồng công việc tới người dùng cuối là công việc vô cùng quan trọng. Odoo Framework được tích hợp sẵn với bộ quản lý tour. Với bộ quản lý này, chúng ta sẽ tạo tour để mô tả việc tạo học sinh mới.

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

  1. Bổ sung thêm file /static/src/js/viin_education_tour.js:

    odoo.define('education.tour', function (require) {
        "use strict";
    
        var core = require('web.core');
        var tour = require('web_tour.tour');
        var _t = core._t;
    
        tour.register('education_tour', {
            url: "/web",
            rainbowManMessage: _t("Congrats, you have create a student."),
            sequence: 5,
        }, [
            tour.stepUtils.showAppsMenuItem(), {
                trigger: '.o_app[data-menu-xmlid="viin_education.education_management_menu_root"]',
                content: _t('Manage students and classes in <b>Education App</b>.'),
                position: 'right'
            }, {
                trigger: '.o-kanban-button-new',
                content: _t("Let's create new student."),
                position: 'bottom'
            }, {
                trigger: 'input[name="name"]',
                extra_trigger: '.o_form_editable',
                content: _t('Set the student name'),
                position: 'right',
            }, {
                trigger: '.o_form_button_save',
                content: _t('Save this record'),
                position: 'bottom',
            }
        ]);
    });
    
  2. Bổ sung file vào asset của backend:

    ...
    <script type="text/javascript" src="/viin_education_chap15/static/src/js/viin_education_tour.js" />
    ...
    

Cập nhật module và mở giao diện Odoo backend. Tại lúc này, bạn sẽ thấy tour như ảnh phía dưới:

Ảnh mô tả tour của module

Phải đảm bảo bạn đã tắt dữ liệu demo trong instance của Odoo do instance có dữ liệu demo không hiện tour.

Chi tiết

Bộ quản lý tour được cung cấp bởi web_tour.tour.

Tại bước đầu, chúng ta khai báo sử dụng web_tour.tour. Tại đây chúng ta có thể bổ sung tour mới thông qua hàm register(). Chúng ta đăng ký tour với tên là education_tour và truyền URL để xác định khi nào tour sẽ chạy.

Tại tham số tiếp theo là danh sách gồm bốn bước. Mỗi bước của tour yêu cầu ba giá trị, trong đó giá trị trigger là để chọn phần tử mà tour sẽ hiển thị. Chúng ta sử dụng XML ID trên menu bởi vì nó có sẵn trong DOM.

Tại tour.stepUtils.showAppsMenuItem() là lúc xác định các bước trên tour trên menu chính. Điểm mấu chốt ở đây là nội dung, và nó được hiển thị khi người dùng di chuột tới biểu tượng tour. Chúng ta sử dụng hàm _t() để dịch các nội dung, trong khi đó position được dùng để quyết định vị trí hiển thị của tour. Các giá trị có thể của khóa positiontop, right, leftbottom.

Quan trọng

Mục đích của tour để cải thiện trải nghiệm người dùng khi lần đầu tiếp xúc với tính năng, cũng như để quản lý việc kiểm thử tích hợp. Khi bạn chạy Odoo ở trạng thái kiểm thử, nó cũng sẽ đồng thời chạy tour và khiến test case bị thất bại nếu tour không được hoàn thành.