全书完整目录请见:Odoo 14开发者指南(Cookbook)第四版
Odoo的网页客户端或后台,是公司成员花费最多时间的地方。在第九章 后端视图中,我们学习了如何使用后台中所存在的功能。这里我们将学习如何继承和自定义这些功能。web模块包含有关Odoo用户界面的所有内容。
本章中的所有代码都依赖于web模块。读者已经知道Odoo有两个版本(企业版和社区版)。社区版的用户界面使用web模块,而企业版使用一个继承社区版web模块的版本,即web_enterprise模块。
企业版在社区版web模块的基础上提供了一些其它功能,如移动端兼容、可搜索菜单、material design等等。这里我们使用社区版。但不必担心,社区版中所开发的模块与企业版可以完美兼容,因为,在内部web_enterprise依赖于社区版的web模块,只是对其添加了一些功能。
📝重要信息:Odoo 14与此前的Odoo版本在网页客户端上有些特别之处。它包含了两套框架用于维护Odoo后台的图形界面。第一种是传统的基于微件(小部件)的框架,第二种是基于模块化的现代框架,称之后Odoo网页库(OWL)。OWL是在Odoo v14中新引入的UI框架。两者都使用QWeb模块作为结构,但在语法及框架运行方式上做出了很大的改变。
虽然Odoo 14使用了新框架OWL,但Odoo并没有在处处使用它。大多数网页客户端仍然使用老的基于微件的框架。本章中,我们将学习如何自定义基于微件框架的网页客户端。下一章中我们会学习OWL框架。
本章中,我们将学习如何创建新字段微件来获取用户输入。我们还将从零开始新建视图。在阅读完本章后,读者将能够在Odoo后台中创建自己的UI元素。
📝注:Odoo 的用户界面重度依赖于JavaScript。在本章中我们会假定你已具备JavaScript、jQuery、Underscore.js和SCSS的基础知识。
本章中,我们将讲解如下小节:
- 创建自定义微件
- 使用客户端QWeb模板
- 向服务端做RPC调用
- 新建一个视图
- 调试客户端代码
- 通过引导提升用户上手体验
- 移动应用JavaScript
技术准备
学习本章要求已经有一个Odoo在线平台。
本章中的所有代码可通过GitHub仓库进行下载。
创建自定义微件
在第九章 后端视图中已经学到,我们可以使用微件来以不同格式展示某些数据。例如,我们使用了widget=’image’将二进制字段展示为图片。要演示如何创建自己的微件,我们将编写一个用户可以选择整型字段的微件,但我们将采用不同的展现方式。使用的不是输入框,而是展示为颜色拾取器,这样我们我们可以选择颜色数值。这里的数值与相关的颜色存在映射关系。
准备工作
本节中我们将使用带有基础字段和视图的my_library模块。在GitHub仓库的Chapter15/00_initial_module目录下可以找到这一基础my_library模块。
如何实现…
我们将添加包含微件逻辑的JavaScript文件,以及负责样式的SCSS文件。然后,我们在图书表单中添加一个整型字段来使用新微件。按照如下步骤来新增一个字段微件:
- 添加一个static/src/js/field_widget.js文件。此处所使用的语法可参见第十四章 CMS网站开发中为网站扩展CSS和JavaScript一节:
1234odoo.define('my_field_widget', function (require) {"use strict";var AbstractField = require('web.AbstractField');var fieldRegistry = require('web.field_registry'); - 通过继承AbstractField来创建微件:
1var colorField = AbstractField.extend({ - 设置CSS类、根元素标签以及微件所支持的字段类型:
123className: 'o_int_colorpicker',tagName: 'span',supportedFieldTypes: ['integer'], - 捕获某些JavaScript事件:
123events: {'click .o_color_pill': 'clickPill',}, - 重载init进行初始化:
1234init: function () {this.totalColors = 10;this._super.apply(this, arguments);}, - 重载_renderEdit和_renderReadonly来设置DOM元素:
12345678910111213141516171819_renderEdit: function () {this.$el.empty();for (var 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,}));}, - 定义我们在前面所使用的handler:
123456clickPill: function (ev) {var $target = $(ev.currentTarget);var data = $target.data();this._setValue(data.val.toString());}}); // closing AbstractField - 别忘了注册该微件:
1fieldRegistry.add('int_color', colorField); - 让其在其它插件中可使用用:
1234return {colorField: colorField,};}); // closing 'my_field_widget' namespace - 在static/src/scss/field_widget.scss中添加一些SCSS:
123456789101112131415161718192021222324252627282930.o_int_colorpicker {.o_color_pill {display: inline-block;height: 25px;width: 25px;margin: 4px;border-radius: 25px;position: relative;@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";display: inline-block;font: normal normal normal 14px/1 FontAwesome;font-size: inherit;color: #fff;position: absolute;padding: 4px;font-size: 16px;}}}}} - 在views/templates.xml后台资源中注册这两个文件:
123456789<?xml version="1.0" encoding="UTF-8"?><odoo><template id="assets_end" inherit_id="web.assets_backend"><xpath expr="." position="inside"><script src="/my_field_widget/static/src/js/field_widget.js" type="text/javascript" /><link href="/my_field_widget/static/src/scss/field_widget.scss" rel="stylesheet" type="text/scss" /></xpath></template></odoo> - 最后在library.book模型中添加颜色整型字段:
1color = fields.Integer() - 在图书表单视图中添加颜色字段,同时添加widget=”int_color”:
123456...<group><field name="date_release"/><field name="color" widget="int_color"/></group>...
更新模块应用修改。在更新完成后,打开图书表单视图,就可以看到下图中所示的颜色拾取器:
实现原理…
为便于读者掌握示例,我们来通过查看组件来了解微件的生命周期:
- init():这是微件构造函数。用于进行初始化。在初始化微件时,会先调用该方法。
- willStart():这个方法在微件初始化以及在DOM中添加元素的过程中调用。它用于初始化异步数据到微件中。它还会返回一个延迟对象,只需要通过super()方法调用即可获取。我们在后面的小节中会使用到这个方法。
- start():该方法在完成微件渲染且尚未添加到DOM中时调用。这非常有助于渲染后任务,返回一个延迟对象。可以在this.$el中访问已渲染的元素。
- destroy():该方法在微件销毁时调用。多用于基本的清理操作,如取消事件绑定。
📝小贴士:微件的基本 base 类是Widget(在web.Widget中定义)。如果想要做更深入的学习,可以通过/addons/web/static/src/js/core/widget.js进行研究。
第1步中,我们导入了AbstractField和fieldRegistry。
第2步中,我们通过继承AbstractField创建了colorField。这样我们的colorField就会获取到AbstractField的所有属性和方法。
第3步中,我们添加了3个属性:className用于定义微件根元素的类,tagName用作根元素的类型,supportedFieldTypes用于决定该微件所支持的字段类型。在本例中,我们希望创建一个针对整型字段的微件。
第4步中,我们对微件的事件进行了映射。通常键为事件名和可选CSS选择器的组合。事件和CSS选择器由空格分割,值为微件中方法的名称。因此,在执行事件时,指定的方法会自动调用。在本节中,当用户点击颜色块时,我们要在字段中设置一个整型值。为管理点击事件,我们在events键中添加了一个CSS选择器和方法。
第5步中,我们重载了init方法并设置了totalColors属性的值。我们将使用该变量来决定颜色块的值。这里需要显示10个颜色块,因此将值设为了10.
第6步中,我们添加了两个方法:_renderEdit和_renderReadonly。根据名称可以知道,_renderEdit在微件处于编辑模式时调用,而_renderReadonly在微件处于只读模式时调用。在编辑方法中,我们添加了一些<span>标签,每个都在微件中表示一种颜色。在点击<span>标签时,会在字段中设置值。我们将它们添加到了this.$el中。这里的$el是微件的根元素,它会在表单视图中进行添加。在只读模式下,我们仅需展示选中的颜色,因此通过_renderReadonly()方法添加了单个颜色块。就目前而言,我们以硬编码的方式添加了色块,但在下一节中,我们将使用JavaScript Qweb模板来渲染这些色块。注意在编辑模式下,我们使用了totalColors属性,它通过init()进行的设置。
第7步中,我们添加了clickPill处理方法来管理色块的点击。使用了_setValue方法来设置字段值。该方法通过AbstractField类添加。在设置字段值时,Odoo框架会重新渲染微件并再次调用_renderEdit方法,这样就可以通过更新后的值来渲染微件了。
第8步中,在定义了新微件之后,通过web.field_registry中的表单注册表来进行注册非常之关键。注意所有的视图类型都查找这个注册表,因此如果希望在列表视图中创建另一种展现字段的形式,也需要在这里添加微件并在视图定义中对该字段设置微件属性。
最后,我们导出了微件类,这样其它的插件可以对其进行扩展和继承。然后,我们在library.book模型中新增了整型字段color。我们还通过widget=”int_color”属性在表单视图中添加了该字段。这会在表单视图中展示我们的微件,用于代替默认的整型组件。
扩展知识…
web.mixins命名空间定义了几个mixin帮助类,在开发表单微件时别忘记使用。AbstractField通过继承Widget类来创建,而Widget类继承了两个mixin。第一个为EventDispatcherMixin,它提供添加并触发事件handler的简单接口。第二个是ServicesMixin,提供RPC调用和动作的函数。
📝重要贴士:想要重载一个方法时,应先研究其基类了解它所返回的函数。常见错误是忘记返回超级用户的延迟对象,这会导致异步操作时出现问题。
微件需要负责验证。使用isValid函数实现这种自定义验证。
使用客户端QWeb模板
就像在JavaScript中创建HTML代码是一种不好的编程习惯一样,我们应在客户端JavaScript代码中创建尽量少的DOM元素。幸好在客户端也有一个模板引擎,更幸运的是,客户端的模板引擎和服务端模板语法相同。
准备工作
本节中,我们使用前一小节中的my_library模块。我们将通过把DOM元素创建迁移到QWeb来让其更加的模块化。
如何实现…
我们需要在声明文件中添加QWeb定义并修改JavaScript代码来进行使用。按照如下步骤来进行操作:
- 导入web.core并将对qweb的引用提取为一个变量,如以下代码所示:
123456789odoo.define('my_field_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;... - 修改_renderEdit函数来渲染该元素(继承自widget):
12345_renderEdit: function () {this.$el.empty();var pills = qweb.render('FieldColorPills', {widget: this});this.$el.append(pills);}, - 添加模板文件static/src/xml/qweb_template.xml:
12345678910<?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> - 在声明文件中注册QWeb文件:
123"qweb": ['static/src/xml/qweb_template.xml',],
现在,其它插件修改微件所使用的HTML代码更为容易了,因为它们可以通过QWeb模式来进行重写。
实现原理…
因为在第十四章 CMS网站开发创建或更改模板 – QWeb一节中已有对QWeb基础知识非常全面的讨论,这里会集中讲解存在不同的地方。首先,需要注意到我们处理的是JavaScript QWeb的实现,而不是服务端中的Python实现。就是说我们没有读取记录或环境的权限,仅能访问qweb.render函数中所传递的参数。
在本例中,我们通过widget键传递了当前对象。这表示应将所有的逻辑放在微件的JavaScript代码中,只让模板访问属性或可能用到的函数。既然我们能访问微件中所有可用的属性,可以通过查看totalColors属性来对模板中的值进行检测。
客户端QWeb和QWeb视图间并没有关系,所以存在不同的机制来让网页客户端识别模板 – 通过qweb键以相对插件根路径的文件名列表将它们添加到插件的声明中。
📝注:如果不希望在声明文件中列出QWeb模板,可以对小组件使用xmlDependencies键来懒加载模板。使用xmlDependencies,QWeb仅在微件初始化时进行加载。
扩展知识…
这里使用QWeb的原因是其可扩展性,它是客户端与服务端QWeb之间的另一个大区别。在客户端无法使用XPath表达式,需要使用jQuery选择器和操作。例如我们想要在另一个模块的微件中添加用户图标,需要在每个色块中添加如下代码来获取图标:
1 2 3 4 5 |
<t t-extend=“FieldColorPills”> <t t-jquery=“span” t-operation=“prepend”> <i class=“fa fa-user” /> </t> </t> |
如果在这里同时给定一个t-name 属性的话,会产生原模板的一个副本,不会动到原模板。 t-operation属性的其它可用值有append, before, after, inner和replace,会让t元素的内容要么通过append将其添加到匹配内容之后,要么通过before或after将其放到匹配元素之前或之后,要么通过inner将其替换所匹配的元素内容,又或者通过replace替换掉整个元素。还有t-operation=’attributes’,它让我们可以对匹配的元素设置属性,规则 与服务端QWeb相同。
另一个不同是客户端QWeb中的名称不会由模块名建立命名空间,因此需要选择模板的名称来让其在所安装的所有插件模块中保持唯一性,这也是开发者常常使用很长名称的原因。
其它内容
如果想要学习Qweb模板的更多内容,可参照如下各点:
- 客户端QWeb引擎的错误消息和处理较Odoo其它部分来得更为不方便。小错误常常并不代表有任何问题,让初学者很难往下使用。
- 幸而,本章稍后的调试客户端代码一节中会讲解客户端QWeb模板的一些调试语句。
向服务端做RPC调用
迟早你的微件会需要从服务端查找一些数据。本节中,我们将为颜色块添加一条提示信息。在用户将光标悬停在颜色块元素之上时,提示框中会显示与该颜色关联的图书的数量。我们将会对服务端做一个RPC调用来获取指定颜色关联数据的图书数量。
准备工作
本节我们使用上一节中的my_library模块。
如何实现…
执行如下步骤来对服务端做RPC调用并在提示框中显示结果:
- 添加willStart方法并在RPC调用中设置colorGroupData:
12345678910111213141516willStart: function () {var self = this;this.colorGroupData = {};var colorDataDef = 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 $.when(this._super.apply(this, arguments), colorDataDef);}, - 更新_renderEdit并对颜色块设置初始提示框:
123456_renderEdit: function () {this.$el.empty();var pills = qweb.render('FieldColorPills', {widget: this});this.$el.append(pills);this.$el.find('[data-toggle="tooltip"]').tooltip();}, - 更新FieldColorPills模板并添加提示数据:
1234567891011<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"data-toggle="tooltip"data-placement="top"t-attf-title="This color is used in #{widget.colorGroupData[pill_no] or 0 } books."/></t></t>
更新模块来应用修改。更新后,就可以在色块上看到如下图所示的提示信息:
图15.2 – 使用RPC获取提示数据
实现原理…
willStart函数在渲染之前调用,更重要的是,它返回一个必须在渲染开始之前解析的Promise对象。因此,在像我们这样的用例中,需要在渲染之前运行异步动作,这才是该使用的函数。
在处理数据访问时,我们依赖于前面讲解的ServicesMixin类所提供的_rpc函数。该函数允许我们调用模型上的任意公有函数,如search, read, write或本例中的read_group。
第1步中,我们做了一个RPC调用并在当前模型本例中即library.book上调用了read_group方法。我们根据color字段进行了分组,因此RPC调用会返回由color分组的图书数据并在color_count键中添加了累加。我们还在colorGroupData中建立了color_count与color索引的映射,这样就可以在QWeb模板中使用了。在该函数的最后一行中,我们通过super来解析了willStart并使用$.when来进行RPC调用。因此,渲染仅在获取到值之后及super所执行的任意异步动作完成之后发生。
第2步并没有什么特别的内容。我们只是初始化了起始提示框。
第3步中我们使用了colorGroupData设置所需的属性来显示提示框。在willStart方法中,我们通过this.colorGroupData指定了color映射 ,因而通过widget.colorGroupData.在QWeb模板中可进行访问。这是因为我们传递了微件引用,这就是qweb.render方法。
📝注:可以在微件中的任意地方使用用_rpc。注意这是一个异步调用,我们需要正确地管理延时对象来获取所要的结果。
扩展知识…
AbstractField类带有两个有趣的属性,其中一个我们刚刚使用过。在本例中,我们使用了this.model属性,它存储了当前模型的名称(如library.book)。另一个属性是this.field,大概包含微件正在显示字段的模型的fields_get()函数的输出。它会给出所有与当前字段相关联的信息。例如,对于x2x字段,fields_get()函数会给出co-model或作用域的信息。也可以使用它来查询字段的string、size或其可在模型定义时为字段所设置的其它属性。
另一个很有帮助的属性是nodeOptions,它包含通过<form>视图定义的options属性所传递的数据。它已经过JSON解析,因此可以像其它对象一样访问它。了解更多有关这些属性的知识,可以进一步挖掘abstract_field.js文件。
其它内容
如果在管理异步运算时出现问题可参照如下文档:
- Odoo的RPC返回JavaScript的原生Promise对象。在解析好Promise对象之后就可获取到所请求数据。有关Promise的更多知识请参见https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise。
新建一个视图
我们在第九章 后端视图中学习到存在不同类型的视图,如表单视图、列表视图、看板视图等。本节中,我们将创建一个全新的视图。这个视图会显示作者列表及他们所著的图书。
准备工作
本节中我们使用前一小节的my_library模块。注意视图是非常复杂的结构,并且每种已有视图都有不同的目的和实现。本节旨在让读者了解MVC模式的视图及如何创建简单视图。本节中,我们将创建一个名为m2m_group的视图,目的是在组中展示记录。将记录分成不同组,视图将使用many2many字段数据。在my_library模块中,我们有author_ids字段。这里,我们根据作者分组图书并在卡片中进行显示。
此外,我们将向控制面板添加一个新按钮。借助于这个按钮,我们将能够新增一条图书记录。我们还将在作者的卡片中添加一个按钮,这样可以将用户重定向到另一个视图中。
如何实现…
按照如下步骤来添加一个名为m2m_group新视图:
- 在ir.ui.view中添加一个新视图类型:
123class View(models.Model):_inherit = 'ir.ui.view'type = fields.Selection(selection_add=[('m2m_group', 'M2m Group')]) - 在ir.actions.act_window.view中新增一个视图模式:
12345class ActWindowView(models.Model):_inherit = 'ir.actions.act_window.view'view_mode = fields.Selection(selection_add=[('m2m_group', 'M2m group')],ondelete={'m2m_group': 'cascade'}) - 通过继承base模型新增方法。该方法会通过JavaScript模型调用(参见第4步了解更多详情)::
1234567891011121314151617181920class Base(models.AbstractModel):_inherit = 'base'@api.modeldef get_m2m_group_data(self, domain, m2m_field):records = self.search(domain)result_dict = {}for record in records:for m2m_record in record[m2m_field]:if m2m_record.id not in result_dict:result_dict[m2m_record.id] = {'name': m2m_record.display_name,'children': [],'model': m2m_record._name}result_dict[m2m_record.id]['children'].append({'name': record.display_name,'id': record.id,})return result_dict - 新增文件/static/src/js/m2m_group_model.js,并添加如下内容:
123456789101112131415161718192021222324252627282930313233343536373839odoo.define('m2m_group.Model', function (require) {'use strict';var AbstractModel = require('web.AbstractModel');var M2mGroupModel = AbstractModel.extend({get: function () {return this.data;},load: function (params) {this.modelName = params.modelName;this.domain = params.domain;this.m2m_field = params.m2m_field;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: 'get_m2m_group_data',kwargs: {domain: this.domain,m2m_field: this.m2m_field}}).then(function (result) {self.data = result;});},});return M2mGroupModel;}); - 新增文件/static/src/js/m2m_group_controller.js,并添加如下内容:
123456789101112131415161718192021222324252627282930313233343536373839404142434445odoo.define('m2m_group.Controller', function (require) {'use strict';var AbstractController = require('web.AbstractController');var core = require('web.core');var qweb = core.qweb;var M2mGroupController = AbstractController.extend({custom_events: _.extend({},AbstractController.prototype.custom_events, {'btn_clicked': '_onBtnClicked',}),renderButtons: function ($node) {if ($node) {this.$buttons =$(qweb.render('ViewM2mGroup.buttons'));this.$buttons.appendTo($node);this.$buttons.on('click', 'button',this._onAddButtonClick.bind(this));}},_onBtnClicked: function (ev) {this.do_action({type: 'ir.actions.act_window',name: this.title,res_model: this.modelName,views: [[false, 'list'], [false, 'form']],domain: ev.data.domain,});},_onAddButtonClick: function (ev) {this.do_action({type: 'ir.actions.act_window',name: this.title,res_model: this.modelName,views: [[false, 'form']],target: 'new'});},});return M2mGroupController;}); - 新增文件/static/src/js/m2m_group_renderer.js,并添加如下内容:
1234567891011121314151617181920212223242526272829303132333435363738odoo.define('m2m_group.Renderer', function (require) {'use strict';var AbstractRenderer = require('web.AbstractRenderer');var core = require('web.core');var qweb = core.qweb;var M2mGroupRenderer = AbstractRenderer.extend({events: _.extend({},AbstractRenderer.prototype.events, {'click .o_primay_button': '_onClickButton',}),_render: function () {var self = this;this.$el.empty();this.$el.append(qweb.render('ViewM2mGroup', {'groups': this.state,}));return this._super.apply(this, arguments);},_onClickButton: function (ev) {ev.preventDefault();var target = $(ev.currentTarget);var group_id = target.data('group');var children_ids =_.map(this.state[group_id].children, function (group_id) {return group_id.id;});this.trigger_up('btn_clicked', {'domain': [['id', 'in', children_ids]]});}});return M2mGroupRenderer;}); - 新增文件/static/src/js/m2m_group_view.js,并添加如下内容:
12345678910111213141516171819202122232425262728293031323334353637odoo.define('m2m_group.View', function (require) {'use strict';var AbstractView = require('web.AbstractView');var view_registry = require('web.view_registry');var M2mGroupController = require('m2m_group.Controller');var M2mGroupModel = require('m2m_group.Model');var M2mGroupRenderer = require('m2m_group.Renderer');var M2mGroupView = AbstractView.extend({display_name: 'Author',icon: 'fa-id-card-o',config: {Model: M2mGroupModel,Controller: M2mGroupController,Renderer: M2mGroupRenderer,},viewType: 'm2m_group',groupable: false,init: function (viewInfo, params) {this._super.apply(this, arguments);var attrs = this.arch.attrs;if (!attrs.m2m_field) {throw new Error('M2m view has not defined"m2m_field" attribute.');}// Model Parametersthis.loadParams.m2m_field = attrs.m2m_field;},});view_registry.add('m2m_group', M2mGroupView);return M2mGroupView;}); - 在/static/src/xml/qweb_template.xml文件中为视图添加QWeb模板:
1234567891011121314151617181920212223242526<t t-name="ViewM2mGroup"><div class="row ml16 mr16"><div t-foreach="groups" t-as="group" class="col-3"><t t-set="group_data" t-value="groups[group]" /><div class="card mt16"><img class="card-img-top" t-attfsrc="/web/image/#{group_data.model}/#{group}/image"/><div class="card-body"><h5 class="card-title mt8"><t tesc="group_data['name']"/></h5></div><ul class="list-group list-group-flush"><t t-foreach="group_data['children']" tas="child"><li class="list-group-item"><i class="fa fa-book"/> <t t-esc="child.name"/></li></t></ul><div class="card-body"><a href="#" class="btn btn-sm btn-primary o_primay_button" t-att-data-group="group">View books</a></div></div></div></div></t><div t-name="ViewM2mGroup.buttons"><button type="button" class="btn btn-primary">Add Record</button></div> - 向后台资源中添加所有的JavaScript文件:
123456...<script type="text/javascript" src="/my_library/static/src/js/m2m_group_view.js" /><script type="text/javascript" src="/my_library/static/src/js/m2m_group_model.js" /><script type="text/javascript" src="/my_library/static/src/js/m2m_group_controller.js" /><script type="text/javascript" src="/my_library/static/src/js/m2m_group_renderer.js" />... - 最后,为library.book模型添加我们的新视图:
12345678<record id="library_book_view_author" model="ir.ui.view"><field name="name">Library Book Author</field><field name="model">library.book</field><field name="arch" type="xml"><m2m_group m2m_field="author_ids" color_field="color"></m2m_group></field></record> - 在图书动作中添加m2m_group:
123...<field name="view_mode">tree,m2m_group,form</field>...
更新my_library模块以打开图书视图,然后,通过视图切换图标,打开我们刚刚添加的的新视图。会长成下面这样:
图15.3 – Many2many分组视图
📝重要信息:Odoo视图非常易于使用也很灵活。但是,通常容易和灵活的事物背后都是复杂的实现。对于Odoo的JavaScript视图正是如此:很容易使用,但实现很复杂。它包含大量的组成部分,如模型、渲染器、控制器、视图、QWeb模板等等。在接下来的部分中,我们添加了所有视图所需的构件并使用了library.book模型中的新视图。如果不想要手动做所有的工作,可从本书的GitHub仓库中获取模块示例文件。
实现原理…
第1和第2步中,我们在 ir.ui.view和ir.actions.act_window.view中注册了一个新视图类型,名为m2m_group。
第3步中,我们在base中添加了get_m2m_group_data方法。在base中添加该方法会让其在所有模型中都可以使用。这个方法会在JavaScript视图中通过RPC调用。该视图传递两个参数 – domain和m2m_field。在domain参数中,domain值会是由搜索视图域和动作域拼接所生成的作用域。m2m_field是我们希望对记录分组的字段名。这个字段在视图定义中进行设置。
在接下来的几步中,我们添加了要求构成视图的JavaScript文件。Odoo的JavaScript视图由视图、模型、渲染器和控制器组成。视图在Odoo的代码中有一些历史含义,因此在Odoo中模型、视图、控制器(MVC)变成了模型、渲染器和控制器(MRC)。总的来说,视图设置模型、渲染器和控制器,并设置MVC等级来变成下面这样:
图15.4 – 视图组件
我们来看一下模型、渲染器、控制器和视图的角色。模型、渲染器、控制器和视图的抽象版本拥有构成视图所需的所有基础部分。因此,在我们的示例中通过继承它们创建了模型、渲染器、控制器和视图。
这里有一些用于创建视图的不同部分的深入讲解:
- Model:模型的角色是存放视图状态。它向数据服务器发送RPC请求,然后向控制器和渲染器传递数据。然后我们重载__load和__reload方法。在视图初始化时,它调用__load()方法来获取数据,在搜索条件修改了或视图需要新状态时,会调用__reload()方法。在本例中,我们创建了通用的_fetchData()方法来做RPC调用获取数据。注意我们使用了在第3步中添加的get_m2m_group_data方法。会从控制器调用__get()方法来获取模型的状态。
- Controller:控制器的角色是管理模型和渲染器之间的调度、在渲染器中发生动作时,它传递该信息到控制器中并相应地执行动作。有时,它还会在模型中调用一些方法。此外,它管理控制面板中的按钮。在本例中,我们添加了一个新增记录的按钮。为进行实现,我们重载了AbstractController的renderButtons() 方法。我们还注册了custom_events,这样在点击了作者卡片中的按钮时,渲染器会触发控制器的事件来让其执行动作。
- Renderer:渲染器的角色是管理视图的DOM元素。每个视图可以按不同的方式渲染数据。在渲染器中,我们可以在变量state中获取模型的状态。它调用进行渲染的render() 方法。在本例中,我们通过显示视图的当前状态渲染了ViewM2mGroup QWeb模板。我们还将JavaScript的事件映射到了用户的动作。本节中我们还绑定了卡片中按钮的点击事件。在点击作者卡片按钮时,它会触发btn_clicked事件给控制器,并且会打开该作者是的图书列表。
📝重要信息:注意事件和custom_events是不同的。事件是常规的JavaScript事件,而custom_events事件来自Odoo的JavaScript框架。自定义事件可以由trigger_up方法调用。
- View:视图的角色是获取所有要求构建视图的基本内容,如一组字段、上下文、视图结构和一些其它参数。然后,视图会初始化控制器、渲染器和模型三剑客。它会在MVC等级中进行设置。通常,它设置模型、视图和控制器所需要的参数。在本例中,我们需要m2m_field名称来获取模型中适当的分组数据,因此在其中设置了模型参数。以同样的方式,this.controllerParams和this.rendererParams可用于设置控制器和渲染器中的参数。
第8步中,我们添加了视图和控制面板按钮的QWeb模板。要了解有关QWeb模板更多的知识,请参见本章中的使用客户端QWeb模板一节。
📝重要信息:Odoo视图有大量不同用途的方法,我们在本节中学习了一些最重要的部分。如果想要更深入地学习视图,可以进入/addons/web/static/src/js/views/目录做更进一步的探索。该目录还包含抽象模型、控制器、渲染器和视图的代码。
第9步中,我们在资源中添加了JavaScript文件。
在最后的两步中,我们添加了针对library.book模型的视图定义。第10步中,我们对视图使用了<m2m_group>标签,并且我们还传递了m2m_field属性来作为一个选项。它会传递给模型来从服务端获取数据。
扩展知识…
如果你不想要引入新视图类型,而只是想要在视图中修改一些内容,可以在视图中使用js_class。例如,如果我们想要类似我们创建的看板的视图,可以像下面这样进行继承:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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); |
然后我们可以通过js_class使用看板视图(注意服务端仍会将其看作一个看板视图):
1 2 3 4 5 6 7 |
... <field name="arch" type="xml"> <kanban js_class="my_custom_view"> ... </kanban> </field> ... |
调试客户端代码
关于调试服务端的代码,本书中有单独的一整章第七章 调试模块进行讲解。而对于客户端,本节会做一些初步讲解。
准备工作
本节并不依赖于具体的代码,但如果你希望能够重现其中的内容,请使用前一小节中的代码。
如何实现…
客户端脚本调试困难的原因在于网页客户端重度依赖于jQuery的异步事件。既然断点会中断执行,在调试时由定时问题会引起的bug很大机率不会发生。稍后我们会讨论一些策略。
- 对于客户端调试,我们需要激活带资源的调试模式。如果不知道如何启用带资源的调试模式,请参见第一章 安装Odoo开发环境中的启用Odoo开发者工具一节。
- 在需要调试的JavaScript函数中调用调试器:
1debugger; - 如果存在定时问题,通过JavaScript函数在控制台打印记录:
1console.log("I'm in function X currently"); - 如果希望在模板渲染时进行调试,在QWeb中调用调试器:
1<t t-debug="" /> - 也可以让QWeb在控制台中打印记录,如下所示:
1<t t-log="myvalue" />
所有这些依赖于浏览器为调试提供适当的功能。而所有主流浏览器都有相关支持,这里我们仅使用Chromium来作为演示。要使用调试工具,请点击右上角菜单按钮并选择More tools > Developer tools(译者注:通常可通过右击>检查元素或快捷键F12/Fn+F12进行调取):
实现原理…
打开调试器时,应该会看到类似下图的内容:
图15.6 – 暂停状态的调试器
这里我们可以在不同的标签页上访问大量的工具。下图中当前开启的标签为JavaScript调试器,我们通过点击行号在第31行设置了断点。每次我们的微件获取用户列表时,执行会停止在这一行,调试器让我们可以查看变量或修改它们的值。在右侧的查看列表中,我们还可以调用函数来测试它们的效果而无需持续保存脚本文件并重新加载页面。
我们前面所提到的debugger语句在打开开发者工具时会进行同样的中断。执行会停止,而浏览器会切换到Sources标签,打开所调试的文件并在有debugger的那一行高亮显示。
前面可能会打印日志的两处会出现在Console标签中。在遇到问题时这应该是第一个要去查看的标签,因为如果JavaScript因语法错误而完全没有加载或是类似的基本错误,都会在这个标签中看到说明情况的错误信息。
扩展知识…
使用Elements标签来查看浏览器当前显示页面的DOM结构。在对于熟悉已有微件生成的HTML代码非常有帮助,并且它通常还让我们可以调整类和CSS属性。对于测试布局调整非常有帮助。
Network标签给出对当前页面请求及时长的总览。这对于调试加载速度慢的页面很有帮助,因为在Network标签中我们可以看到请求的详情。如果选择一个具体的请求,可以查看其传递给服务器的负载及返回的结果,有助于在客户端弄清楚意料外行为产生的原因。还会看到所做请求的状态码,如因拼错文件名而找不到资源时显示404,
通过引导提升用户上手体验
在开发完大应用之后,向终端用户说明软件流程非常关键。Odoo框架包含一个内置的导览管理器。通过它,我们可以让用户通过了解具体的流程来得到指导。本节中,我们将创建一个在图书馆中创建图书的引导。
准备工作
我们将使用前面小节的my_library模块。导览仅在没有演示数据的数据库中显示,因此如果你使用的是带有demo数据的数据库,请为本节创建一个没有演示数据的数据库。
如何实现…
执行如下步骤来为图书馆添加引导:
- 新增一个/static/src/js/my_library_tour.js文件并添加如下代码:
123456789101112131415161718192021222324252627odoo.define('my_library.tour', function (require) {"use strict";var core = require('web.core');var tour = require('web_tour.tour');var _t = core._t;tour.register('library_tour', {url: "/web",}, [tour.STEPS.SHOW_APPS_MENU_ITEM, {trigger: '.o_app[data-menuxmlid="my_library.library_base_menu"]',content: _t('Manage books and authors in<b>Library app</b>.'),position: 'right'}, {trigger: '.o_list_button_add',content: _t("Let's create new book."),position: 'bottom'}, {trigger: 'input[name="name"]',extra_trigger: '.o_form_editable',content: _t('Set the book title'),position: 'right',}, {trigger: '.o_form_button_save',content: _t('Save this book record'),position: 'bottom',}]);}); - 在后台资源中添加导览JavaScript文件:
123...<script type="text/javascript" src="/my_library/static/src/js/my_library_tour.js" />...
更新该模块并打开Odoo后台。此时就会看到一个如下图所示的引导:
请确保在Odoo实例中禁用了演示数据。带有演示数据的实例不会显示导览。
译者注:命令行安装可使用--without-demo=all -i base,my_library
来不安装演示数据并且自动安装图书应用
实现原理…
引导管理器在web_tour.tour命名空间下。
第1步中,我们导入了web_tour.tour。然后就可以通过register()函数新增引导了。我们通过library_tour名称注册了导览并传递了运行导览的URL。
下一个参数是一个导览步骤的列表。每个导览步骤需要3个值。trigger用于选择引导显示在哪个元素中。这是一个JavaScript选择器。我们使用了菜单的XML ID, 因为它存在于DOM中。
第1步tour.stepUtils.showAppsMenuItem()是为主菜单预定义的导览步骤。下一个键是content,在用户鼠标悬停在引导水滴图标上时显示该内容。我们使用了_t()函数,因为希望翻译该字符串,而position键用于决定引导图标的位置。可以选择的值有top, right, left和bottom。
📝重要信息:引导会提升用于使用体验以及对集成测试的管理。在内部以测试模式运行Odoo时,也会运行引导并在导览未能完成时引发测试用例失败。
移动应用JavaScript(企业版)
Odoo 10引入了Odoo移动应用。它提供执行移动端动作的小工具,如震动手机、显示toast弹出消息、扫描二维码等等。
准备工作
我们将使用前一节的my_library模块。我们将通过移动应用在修改了color字段值时显示toast消息。
⚠️警告:Odoo移动应用仅支持企业版,因此如果你没有企业版,则无法进行测试。
如何实现…
执行如下步骤来在Odoo移动应用中显示弹出消息:
- 在field_widget.js中导入web_mobile.rpc:
1var mobile = require('web_mobile.rpc'); - 修改clickPill方法来让用户在移动设备上修改颜色时显示弹出消息::
12345678clickPill: function (ev) {var $target = $(ev.currentTarget);var data = $target.data();this._setValue(data.val.toString());if (mobile.methods.showToast) {mobile.methods.showToast({'message': 'Color changed'});}}
升级模块并在移动应用中打开library.book模型的表单视图。在修改颜色时,会看到如下所示的弹出消息:
图15.8 – 修改颜色的Toast消息
译者注:企业版自定义模块需在menu_item中指定web_icon才能正常显示应用的图标。
实现原理…
web_mobile.rpc提供了移动设备与Odoo JavaScript之间的桥梁。它暴露一些基本移动工具。本例中,我们使用了showToast方法来在移动应用中显示弹窗信息。我们还需要检查该函数的可用性。背后的原因是一些手机可能不支持某些功能,例如在设备没有摄像头时,就无法使用scanBarcode()方法。在这种情况下,为避免错误,我们需要将代码包裹在 if 条件中。
扩展知识…
Odoo中的移动端工具有如下这些:
- showToast(): 显示弹出消息
- vibrate(): 手机震动
- showSnackBar(): 显示带有按钮的滑动栏
- showNotification(): 显示手机提示消息
- addContact(): 在电话薄中新增联系人
- scanBarcode(): 扫描二维码
- switchAccount(): 在Android中打开账号切换窗口
学习更多有关移动端的JavaScript知识,请参见:https://www.odoo.com/documentation/14.0/developer/reference/mobile.html。