第十一章 抽象字段
抽象字段(AbstractField)是所有视图中用到的字段部件的抽象基础,这些都是视图中常用的部件,尤其是表单和树形列表。
字段部件的主要作用,渲染字段当前值在编辑和只读模式下的可视化元素,当用户修改了字段值的时候,通知系统。
注意:
- 部件不应该主动切换模式,如果需要切换模式,应该由视图负责初始化另一个部件。
- 当字段值发生变化时,通知系统并将模式切换到只读。
- 当需要采取某种操作时,例如打开一个记录,通知系统。
- 字段部件不应该进入任何一种循环中,与系统其他部件通信的方式应当使用触发事件(trigger up)。这些事件会以冒泡的方式被父组件捕获并处理。
在某些场景中,所有视图使用同一种部件不是一种推荐的做法,可以使用视图加部件的命名方式解决这个问题。例如,一个表单视图中指定many2one部件应该注册为"form.many2one".
部件属性
- fieldsDependencies: 一个由模型获取的字段对象,即使这个字段并没有出现在视图中。此对象是由一个字段名作为键,对象作为值组成的,值对象必须包含一个type的键。可以参考FieldBinaryImage字段。
- resetOnAnyFieldChange:如果此值为true,那么此字段每当视图发生变化时都将被重置。
- specialData:如果此属性被赋了一个字符,那么相关的基础模型会初始化字段需要的specialData,这些数据将在this.record.specialData[this.name]属性中被访问到。
- supportedFieldTypes:重写字段部件受支持的类型
- noLabel: 布尔值,是否显示Label标签
初始化
抽象字段在使用前第一步需要进行初始化:
init: function (parent, name, record, options) {
...
}
其构造函数接收4个参数:
- parent:父类对象
- name:被部件进行渲染展示的字段名称
- record:从model类中获取的数据点(数据集记录)
- options:可选参数
options可选值:
- mode:只读或者编辑模式(readonly、edit)
抽象字段的初始化过程
抽象字段在初始化过程中除了将构造方法的四个参数使用与之相同的名称存储起来之外,还会将数据集(record)中的默认字段(name)的值抽离出来,赋值给field属性,用户可以直接使用this.field属性来访问本字段相关的数据内容。对于其他字段的值,仍然可以使用this.record[字段名]的方式读取。
抽象字段在初始化过程中,会将options中的viewType类型赋值给this.viewType,viewType是指字段被实例化所属的视图类型,对于独立的部件来而言,其值是default。
抽象字段所有相关的字段信息会存储在this.record.fields中,而所有字段的描述信息会存在this.record.fieldInfo属性中。而在初始化的过程中,如果可选参数options中包含attrs属性信息,则会将属性信息存储在this.attrs属性中,如果options中没有attrs信息,系统则会使用fieldsInfo中的本字段(name)的描述信息替代(描述信息为空时,则attrs也为空)。这里需要说明的是,fields和fieldsInfo并非包含本对象所有的字段,而是所有出现在视图中的字段,也就是在视图文件(xml)中出现的字段。
fields和fieldsInfo的区别,fields包含的信息是定义在后台模型数据中的属性信息,例如readonly, 字段类型type等,而fieldsInfo指的是前端的描述信息如fieldDependencies,views等属性信息。
存储了字段和字段信息之后,系统会将本字段所对应的值存储在value属性中,用户可以使用this.value进行读取。事实上,与field的提权过程类似,value的值也是从this.record的data属性中提取了本字段的值,做了一个简化的"快捷方式",用户也可以通过this.record.data[name]的方式进行获取,也可以获取到其他字段的值。
我们上面提到的this.record.data属性,odoo也在抽象字段中做了一个快捷方式:recordData,目的是为了方便用户读取同一记录的其他字段。需要注意的是,这个值应该被当作只读字段,只用作读取而不能也不应该被更新。也不要使用this.recordData[this.name]的这种方式进行读取,因为如果使用在_setValue方法之后,可能会读取到脏数据。
string属性在初始化过程中会被赋值,如果attrs中有则取attrs中的值,否则取字段属性中的string的值。如果string的值也没有则使用name属性替代。
attrs属性中的optons会被赋值到nodeOptions属性中。
抽象字段也会将数据模型赋值给model属性,通常用来供rpc进行调用。
record的id会赋值到dataPointID中,作用是通知上游的视图哪条记录发生的改变。这里的id与下面的res_id没有任何的关联,纯粹是web端本地化的概念。
抽象字段部件拥有两种模式,编辑和只读,初始化过程中会赋值给model属性,部件的模式不可以被改变,如果视图模式发生了变化,部件将会被销毁并重新生成。
res_id指的是数据库中的记录id,很显然这是一个只读值。同时,当用户创建了一条记录时,res_id是空的,当记录被创建之后,字段部件将被销毁(form视图切换到只读模式时),同时会创建一个带有res_id的新的部件。
部件中有一个属性_isValid用来标识当前的值是否时合法值,合法的意思意味着可以被正确的解析和保存。例如,float字段只能接受数字而不能接受字符,当字符赋值给float字段就会被标记为false。默认值是true。
此外,还有一个lastSetValue用来记录用户最后一次设置的没有被解析的值,为了避免同一个记录被赋值两次。
抽象字段在读取和写入的过程中,需要经过格式化和解析两个步骤,以确保用户输入或系统读取的值符合部件的要求。读取时使用的是_format方法,而写入时使用的是_parseValue方法。而odoo调用这两种方法的依据就是初始化过程中的formatType属性,默认情况下,这个值等于this.field.type,而如果用户在attrs中定义了widget属性,那么使用的就是widget的值。formatOptions和parseOptions用来传递参数给格式化和解析方法使用。
如果attrs属性中定义了decorations, 那么当记录中的人任何值发生变化时,抽象字段部件都需要重新估算字段的值。
启动
当部件被添加到DOM中时,start方法将会被调用(父类Widget的Start方法),部件启动之后会调用内部的渲染方法,将部件的模板样式文件等进行渲染,输出到页面中。
start: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
self.$el.attr('name', self.name);
self.$el.addClass('o_field_widget');
return self._render();
});
}
其内部逻辑是,先调用父类方法等父类方法完成后,将当前的元素命名为字段的名称并加载样式o_field_widget,然后调用_render方法渲染部件。
公共方法
抽象字段定义了几个公共方法,用来更新或设置字段的属性。方法列表及作用如下:
activate
激活字段部件,默认情况下,激活的意思是获取到焦点并选择了合适的元素。
activate: function (options) {
if (this.isFocusable()) {
var $focusable = this.getFocusableElement();
$focusable.focus();
if ($focusable.is('input[type="text"], textarea')) {
$focusable[0].selectionStart = $focusable[0].selectionEnd = $focusable[0].value.length;
if (options && !options.noselect) {
$focusable.select();
}
}
return true;
}
return false;
}
options参数可以为下面几个值:
- noselect: 如果设置为false且input的类型是text或textarea,那么content内容也将被选择。
commitChanges
提交更改的方法,该方法是个抽象方法,具体实现需要子类部件自行实现。
commitChanges: function () {},
提交更改方法应该被那些不能通知外部环境其值已发生变化的部件实现,这些部件可能本身也没有意识到自己的值发生了变化。这种场景通常发生在试图保存字段的值的时候,所以当其值发生变化且没有通知到外部环境的时候,应该调用_setValue方法。
getFocusableElement
获取聚焦的元素
getFocusableElement: function () {
return $();
}
isFocusable
返回本部件是否可以获取焦点
isFocusable: function () {
var $focusable = this.getFocusableElement();
return $focusable.length && $focusable.is(':visible');
}
isSet
判断字段是否被设置一个有效的值,通常用来决定字段是否应该被设置成空。
isSet: function () {
return !!this.value;
}
reset
重置部件为原值,使用场景是从部件外部调用,典型的一个使用场景是onchange方法改变了部件的值,此方法将会把部件的值还原为原始值,并重新渲染。
reset: function (record, event) {
this._reset(record, event);
return this._render() || Promise.resolve();
}
此方法接收两个值,一个数据点record和一个事件event。event指的是触发重置动作的事件,这是一个可选参数,可以被用作部件从值改变的那一刻到应用还原操作时的信息共享。
从方法的内部实现可以看出,内部调用了私有方法_reset,然后使用了_render方法重新渲染了部件。
removeInvalidClass
移除无效的样式类
removeInvalidClass: function () {
this.$el.removeClass('o_field_invalid');
this.$el.removeAttr('aria-invalid');
}
setIDForLabel
给获取到焦点的元素设置给定的ID
setIDForLabel: function (id) {
this.getFocusableElement().attr('id', id);
}
setInvalidClass
与removeInvalidClass方法相对,标记元素无效的样式类
setInvalidClass: function () {
this.$el.addClass('o_field_invalid');
this.$el.attr('aria-invalid', 'true');
}
updateModifiersValue
更新modifiers的最新值。
updateModifiersValue: function(modifiers) {
this.attrs.modifiersValue = modifiers || {};
}
私有方法
虽然私有方法通常不应该被用来修改或继承,但是我们在构造新widget的时候往往会用到这些方法。
_applyDecorations
应用部件装饰器(属性中只有面向字段的装饰器被定义了)
_applyDecorations: function () {
var self = this;
this.attrs.decorations.forEach(function (dec) {
var isToggled = py.PY_isTrue(
py.evaluate(dec.expression, self.record.evalContext)
);
self.$el.toggleClass(dec.className, isToggled);
});
}
_formatValue
格式化字段的值为字符化的值。前面在初始化部分提到过,_formatValue用来格式化字段的值为部件可以兼容的值。
_formatValue: function (value) {
var options = _.extend({}, this.nodeOptions, { data: this.recordData }, this.formatOptions);
return field_utils.format[this.formatType](value, this.field, options);
}
_parseValue
与格式化相反的方法,将字符串的值转化为字段的有效值。_parseValue方法通常用来检验用户的输入是否合法。
_parseValue: function (value) {
return field_utils.parse[this.formatType](value, this.field, this.parseOptions);
}
_parseValue方法内部调用了field_utils中的parse方法。
_render
渲染部件的主方法,如果你的部件在只读和编辑模式下效果一样的化,重载这个方法。调用两次和一次的效果是一样的。
_render: function () {
if (this.attrs.decorations) {
this._applyDecorations();
}
if (this.mode === 'edit') {
return this._renderEdit();
} else if (this.mode === 'readonly') {
return this._renderReadonly();
}
}
从代码中我们可以看出_render方法内部先调用了应用装饰器的方法,然后根据当前部件的模式,分开调用_renderEdit或者_renderReadonly方法,如果我们只是想要重载其中一个模式下的效果,那么单独重载_renderEdit或者_renderReadonly方法即可。
_renderEdit
编辑模式下的渲染方法,抽象字段本身不实现任何逻辑,具体渲染方法留给子类实现。
_renderEdit: function () {
},
_renderReadonly
只读模式下的渲染方法,抽象字段本身不实现任何逻辑,具体渲染方法留给子类实现。
_renderReadonly: function () {
}
_reset
重置方法的低阶实现,可以被重载,在_render方法调用前被调用。
_reset: function (record, event) {
this.lastSetValue = undefined;
this.record = record;
this.value = record.data[this.name];
this.recordData = record.data;
}
从内部实现中,可以看出,event作为参数并未参与到具体的业务逻辑中。但是子类重载的时候,可以根据此参数进行逻辑设计。
_setValue
最后,我们来看一个重量的方法_setValue。
_setValue由部件本身调用,用来改变部件的值并通知外部环境自己的值已经改变,该方法同时对新值进行验证,以防止输入的数据不符合部件的要求。方法本身不会重新渲染部件,渲染动作应该由部件根据需要进行渲染。
_setValue: function (value, options) {
// we try to avoid doing useless work, if the value given has not
// changed. Note that we compare the unparsed values.
if (this.lastSetValue === value || (this.value === false && value === '')) {
return Promise.resolve();
}
this.lastSetValue = value;
try {
value = this._parseValue(value);
this._isValid = true;
} catch (e) {
this._isValid = false;
this.trigger_up('set_dirty', {dataPointID: this.dataPointID});
return Promise.reject({message: "Value set is not valid"});
}
if (!(options && options.forceChange) && this._isSameValue(value)) {
return Promise.resolve();
}
var self = this;
return new Promise(function (resolve, reject) {
var changes = {};
changes[self.name] = value;
self.trigger_up('field_changed', {
dataPointID: self.dataPointID,
changes: changes,
viewType: self.viewType,
doNotSetDirty: options && options.doNotSetDirty,
notifyChange: !options || options.notifyChange !== false,
allowWarning: options && options.allowWarning,
onSuccess: resolve,
onFailure: reject,
});
})
}
此方法接收两个参数value和options,value是部件的新值,options是可选参数。
options可选的值有:
- doNotSetDirty: 默认值false,如果设置为true,基础模型(basic model)将不认为该字段拥有脏数据。除非你真的需要,否则不要将此参数设置为true。
- notifyChange: 默认值true,如果设置为false,模型不会通知也不会触发onchange事件,即便部件的值已经改变。
- forceChange: 默认false,强制更新,即便新值与旧值一模一样。
从代码上可以看出,_setValue方法首先判断了_lastSetValue是否跟锻件值相等,如果相等或者部件值的不是一个有效的值,则直接返回,以免做不必要的工作。
如果_lastSetValue与部件值不相等,则将部件的值更新为value,然后方法调用_parseValue方法对value值进行合法性校验,没通过校验的话,部件将被标记为脏数据并返回。
之后检查options参数,如果没有设置强制更新或者部件的新旧值相等,则返回。否则,触发field_changed事件,通知模型部件值已经改变,执行更新数据操作。
事件处理
_onKeydown
按键事件,主要作用是阻止默认的事件,重载事件的处理方法。
_onKeydown: function (ev) {
switch (ev.which) {
case $.ui.keyCode.TAB:
var event = this.trigger_up('navigation_move', {
direction: ev.shiftKey ? 'previous' : 'next',
});
if (event.is_stopped()) {
ev.preventDefault();
ev.stopPropagation();
}
break;
case $.ui.keyCode.ENTER:
// We preventDefault the ENTER key because of two coexisting behaviours:
// - In HTML5, pressing ENTER on a <button> triggers two events: a 'keydown' AND a 'click'
// - When creating and opening a dialog, the focus is automatically given to the primary button
// The end result caused some issues where a modal opened by an ENTER keypress (e.g. saving
// changes in multiple edition) confirmed the modal without any intentionnal user input.
ev.preventDefault();
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'next_line'});
break;
case $.ui.keyCode.ESCAPE:
this.trigger_up('navigation_move', {direction: 'cancel', originalEvent: ev});
break;
case $.ui.keyCode.UP:
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'up'});
break;
case $.ui.keyCode.RIGHT:
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'right'});
break;
case $.ui.keyCode.DOWN:
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'down'});
break;
case $.ui.keyCode.LEFT:
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'left'});
break;
}
}
_onNavigationMove
使用部件的实例更新target数据。
_onNavigationMove: function (ev) {
ev.data.target = this;
},
总结
至此,我们大概了解了抽象字段的整个设计结构,因为抽象字段是基础字段类型的基础,基础字段在此基础上又进行了拓展设计,但其顶层的逻辑结构就是本章所介绍的这些内容了。