1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
|
odoo.define('web.AbstractFieldOwl', function (require) {
"use strict";
const field_utils = require('web.field_utils');
const { useListener } = require('web.custom_hooks');
const { onMounted, onPatched } = owl.hooks;
/**
* This file defines the Owl version of the AbstractField. Specific fields
* written in Owl should override this component.
*
* =========================================================================
*
* /!\ This api works almost exactly like the legacy one but
* /!\ it still could change! There are already a few methods that will be
* /!\ removed like setIdForLabel, setInvalidClass, etc..
*
* =========================================================================
*
* This is the basic field component used by all the views to render a field in a view.
* These field components are mostly common to all views, in particular form and list
* views.
*
* The responsabilities of a field component are mainly:
* - render a visual representation of the current value of a field
* - that representation is either in 'readonly' or in 'edit' mode
* - notify the rest of the system when the field has been changed by
* the user (in edit mode)
*
* Notes
* - the component is not supposed to be able to switch between modes. If another
* mode is required, the view will take care of instantiating another component.
* - notify the system when its value has changed and its mode is changed to 'readonly'
* - notify the system when some action has to be taken, such as opening a record
* - the Field component should not, ever, under any circumstance, be aware of
* its parent. The way it communicates changes with the rest of the system is by
* triggering events. These events bubble up and are interpreted
* by the most appropriate parent.
*
* Also, in some cases, it may not be practical to have the same component for all
* views. In that situation, you can have a 'view specific component'. Just register
* the component in the registry prefixed by the view type and a dot. So, for example,
* a form specific many2one component should be registered as 'form.many2one'.
*
* @module web.AbstractFieldOwl
*/
class AbstractField extends owl.Component {
/**
* Abstract field class
*
* @constructor
* @param {Component} parent
* @param {Object} props
* @param {string} props.fieldName The field name defined in the model
* @param {Object} props.record A record object (result of the get method
* of a basic model)
* @param {Object} [props.options]
* @param {string} [props.options.mode=readonly] should be 'readonly' or 'edit'
* @param {string} [props.options.viewType=default]
*/
constructor() {
super(...arguments);
this._isValid = true;
// this is the last value that was set by the user, unparsed. This is
// used to avoid setting the value twice in a row with the exact value.
this._lastSetValue = undefined;
useListener('keydown', this._onKeydown);
useListener('navigation-move', this._onNavigationMove);
onMounted(() => this._applyDecorations());
onPatched(() => this._applyDecorations());
}
/**
* Hack: studio tries to find the field with a selector base on its
* name, before it is mounted into the DOM. Ideally, this should be
* done in the onMounted hook, but in this case we are too late, and
* Studio finds nothing. As a consequence, the field can't be edited
* by clicking on its label (or on the row formed by the pair label-field).
*
* TODO: move this to mounted at some point?
*
* @override
*/
__patch() {
const res = super.__patch(...arguments);
this.el.setAttribute('name', this.name);
this.el.classList.add('o_field_widget');
return res;
}
/**
* @async
* @param {Object} [nextProps]
* @returns {Promise}
*/
async willUpdateProps(nextProps) {
this._lastSetValue = undefined;
return super.willUpdateProps(nextProps);
}
//----------------------------------------------------------------------
// Getters
//----------------------------------------------------------------------
/**
* This contains the attributes to pass through the context.
*
* @returns {Object}
*/
get additionalContext() {
return this.options.additionalContext || {};
}
/**
* This contains the attributes of the xml 'field' tag, the inner views...
*
* @returns {Object}
*/
get attrs() {
const fieldsInfo = this.record.fieldsInfo[this.viewType];
return this.options.attrs || (fieldsInfo && fieldsInfo[this.name]) || {};
}
/**
* Id corresponding to the current record in the model.
* Its intended use is to be able to tag any messages going upstream,
* so the view knows which records was changed for example.
*
* @returns {string}
*/
get dataPointId() {
return this.record.id;
}
/**
* This is a description of all the various field properties,
* such as the type, the comodel (relation), ...
*
* @returns {string}
*/
get field() {
return this.record.fields[this.name];
}
/**
* Returns the main field's DOM element which can be focused by the browser.
*
* @returns {HTMLElement|null} main focusable element inside the component
*/
get focusableElement() {
return null;
}
/**
* Returns the additional options pass to the format function.
* Override this getter to add options.
*
* @returns {Object}
*/
get formatOptions() {
return {};
}
/**
* Used to determine which format (and parse) functions
* to call to format the field's value to insert into the DOM (typically
* put into a span or an input), and to parse the value from the input
* to send it to the server. These functions are chosen according to
* the 'widget' attrs if is is given, and if it is a valid key, with a
* fallback on the field type, ensuring that the value is formatted and
* displayed according to the chosen widget, if any.
*
* @returns {string}
*/
get formatType() {
return this.attrs.widget in field_utils.format ?
this.attrs.widget : this.field.type;
}
/**
* Returns whether or not the field is empty and can thus be hidden. This
* method is typically called when the component is in readonly, to hide it
* (and its label) if it is empty.
*
* @returns {boolean}
*/
get isEmpty() {
return !this.isSet;
}
/**
* Returns true if the component has a visible element that can take the focus
*
* @returns {boolean}
*/
get isFocusable() {
const focusable = this.focusableElement;
// check if element is visible
return focusable && !!(focusable.offsetWidth ||
focusable.offsetHeight || focusable.getClientRects().length);
}
/**
* Determines if the field value is set to a meaningful
* value. This is useful to determine if a field should be displayed as empty
*
* @returns {boolean}
*/
get isSet() {
return !!this.value;
}
/**
* Tracks if the component is in a valid state, meaning that the current
* value represented in the DOM is a value that can be parsed and saved.
* For example, a float field can only use a number and not a string.
*
* @returns {boolean}
*/
get isValid() {
return this._isValid;
}
/**
* Fields can be in two modes: 'edit' or 'readonly'.
*
* @returns {string}
*/
get mode() {
return this.options.mode || "readonly";
}
/**
* Useful mostly to trigger rpcs on the correct model.
*
* @returns {string}
*/
get model() {
return this.record.model;
}
/**
* The field name displayed by this component.
*
* @returns {string}
*/
get name() {
return this.props.fieldName;
}
/**
* Component can often be configured in the 'options' attribute in the
* xml 'field' tag. These options are saved (and evaled) in nodeOptions.
*
* @returns {Object}
*/
get nodeOptions() {
return this.attrs.options || {};
}
/**
* @returns {Object}
*/
get options() {
return this.props.options || {};
}
/**
* Returns the additional options passed to the parse function.
* Override this getter to add options.
*
* @returns {Object}
*/
get parseOptions() {
return {};
}
/**
* The datapoint fetched from the model.
*
* @returns {Object}
*/
get record() {
return this.props.record;
}
/**
* Tracks the values for the other fields for the same record.
* note that it is expected to be mostly a readonly property, you cannot
* use this to try to change other fields value, this is not how it is
* supposed to work. Also, do not use this.recordData[this.name] to get
* the current value, this could be out of sync after a _setValue.
*
* @returns {Object}
*/
get recordData() {
return this.record.data;
}
/**
* If this flag is set to true, the field component will be reset on
* every change which is made in the view (if the view supports it).
* This is currently a form view feature.
*
* /!\ This getter could be removed when basic views (form, list, kanban)
* are converted.
*
* @returns {boolean}
*/
get resetOnAnyFieldChange() {
return !!this.attrs.decorations;
}
/**
* The res_id of the record in database.
* When the user is creating a new record, there is no res_id.
* When the record will be created, the field component will
* be destroyed (when the form view switches to readonly mode) and a
* new component with a res_id in mode readonly will be created.
*
* @returns {Number}
*/
get resId() {
return this.record.res_id;
}
/**
* Human readable (and translated) description of the field.
* Mostly useful to be displayed in various places in the
* UI, such as tooltips or create dialogs.
*
* @returns {string}
*/
get string() {
return this.attrs.string || this.field.string || this.name;
}
/**
* Tracks the current (parsed if needed) value of the field.
*
* @returns {any}
*/
get value() {
return this.record.data[this.name];
}
/**
* The type of the view in which the field component is instantiated.
* For standalone components, a 'default' viewType is set.
*
* @returns {string}
*/
get viewType() {
return this.options.viewType || 'default';
}
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
/**
* Activates the field component. By default, activation means focusing and
* selecting (if possible) the associated focusable element. The selecting
* part can be disabled. In that case, note that the focused input/textarea
* will have the cursor at the very end.
*
* @param {Object} [options]
* @param {boolean} [options.noselect=false] if false and the input
* is of type text or textarea, the content will also be selected
* @param {Event} [options.event] the event which fired this activation
* @returns {boolean} true if the component was activated, false if the
* focusable element was not found or invisible
*/
activate(options) {
if (this.isFocusable) {
const focusable = this.focusableElement;
focusable.focus();
if (focusable.matches('input[type="text"], textarea')) {
focusable.selectionStart = focusable.selectionEnd = focusable.value.length;
if (options && !options.noselect) {
focusable.select();
}
}
return true;
}
return false;
}
/**
* This function should be implemented by components that are not able to
* notify their environment when their value changes (maybe because their
* are not aware of the changes) or that may have a value in a temporary
* state (maybe because some action should be performed to validate it
* before notifying it). This is typically called before trying to save the
* component's value, so it should call _setValue() to notify the environment
* if the value changed but was not notified.
*
* @abstract
* @returns {Promise|undefined}
*/
commitChanges() {}
/**
* Remove the invalid class on a field
*
* This function should be removed when BasicRenderer will be rewritten in owl
*/
removeInvalidClass() {
this.el.classList.remove('o_field_invalid');
this.el.removeAttribute('aria-invalid');
}
/**
* Sets the given id on the focusable element of the field and as 'for'
* attribute of potential internal labels.
*
* This function should be removed when BasicRenderer will be rewritten in owl
*
* @param {string} id
*/
setIdForLabel(id) {
if (this.focusableElement) {
this.focusableElement.setAttribute('id', id);
}
}
/**
* add the invalid class on a field
*
* This function should be removed when BasicRenderer will be rewritten in owl
*/
setInvalidClass() {
this.el.classList.add('o_field_invalid');
this.el.setAttribute('aria-invalid', 'true');
}
//----------------------------------------------------------------------
// Private
//----------------------------------------------------------------------
/**
* Apply field decorations (only if field-specific decorations have been
* defined in an attribute).
*
* This function should be removed when BasicRenderer will be rewritten in owl
*
* @private
*/
_applyDecorations() {
for (const dec of this.attrs.decorations || []) {
const isToggled = py.PY_isTrue(
py.evaluate(dec.expression, this.record.evalContext)
);
const className = this._getClassFromDecoration(dec.name);
this.el.classList.toggle(className, isToggled);
}
}
/**
* Converts the value from the field to a string representation.
*
* @private
* @param {any} value (from the field type)
* @returns {string}
*/
_formatValue(value) {
const options = Object.assign({}, this.nodeOptions,
{ data: this.recordData }, this.formatOptions);
return field_utils.format[this.formatType](value, this.field, options);
}
/**
* Returns the className corresponding to a given decoration. A
* decoration is of the form 'decoration-%s'. By default, replaces
* 'decoration' by 'text'.
*
* @private
* @param {string} decoration must be of the form 'decoration-%s'
* @returns {string}
*/
_getClassFromDecoration(decoration) {
return `text-${decoration.split('-')[1]}`;
}
/**
* Compares the given value with the last value that has been set.
* Note that we compare unparsed values. Handles the special case where no
* value has been set yet, and the given value is the empty string.
*
* @private
* @param {any} value
* @returns {boolean} true iff values are the same
*/
_isLastSetValue(value) {
return this._lastSetValue === value || (this.value === false && value === '');
}
/**
* This method check if a value is the same as the current value of the
* field. For example, a fieldDate component might want to use the moment
* specific value isSame instead of ===.
*
* This method is used by the _setValue method.
*
* @private
* @param {any} value
* @returns {boolean}
*/
_isSameValue(value) {
return this.value === value;
}
/**
* Converts a string representation to a valid value.
*
* @private
* @param {string} value
* @returns {any}
*/
_parseValue(value) {
return field_utils.parse[this.formatType](value, this.field, this.parseOptions);
}
/**
* This method is called by the component, to change its value and to notify
* the outside world of its new state. This method also validates the new
* value. Note that this method does not rerender the component, it should be
* handled by the component itself, if necessary.
*
* @private
* @param {any} value
* @param {Object} [options]
* @param {boolean} [options.doNotSetDirty=false] if true, the basic model
* will not consider that this field is dirty, even though it was changed.
* Please do not use this flag unless you really need it. Our only use
* case is currently the pad component, which does a _setValue in the
* renderEdit method.
* @param {boolean} [options.notifyChange=true] if false, the basic model
* will not notify and not trigger the onchange, even though it was changed.
* @param {boolean} [options.forceChange=false] if true, the change event will be
* triggered even if the new value is the same as the old one
* @returns {Promise}
*/
_setValue(value, options) {
// we try to avoid doing useless work, if the value given has not changed.
if (this._isLastSetValue(value)) {
return Promise.resolve();
}
this._lastSetValue = value;
try {
value = this._parseValue(value);
this._isValid = true;
} catch (e) {
this._isValid = false;
this.trigger('set-dirty', {dataPointID: this.dataPointId});
return Promise.reject({message: "Value set is not valid"});
}
if (!(options && options.forceChange) && this._isSameValue(value)) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const changes = {};
changes[this.name] = value;
this.trigger('field-changed', {
dataPointID: this.dataPointId,
changes: changes,
viewType: this.viewType,
doNotSetDirty: options && options.doNotSetDirty,
notifyChange: !options || options.notifyChange !== false,
allowWarning: options && options.allowWarning,
onSuccess: resolve,
onFailure: reject,
});
});
}
//----------------------------------------------------------------------
// Handlers
//----------------------------------------------------------------------
/**
* Intercepts navigation keyboard events to prevent their default behavior
* and notifies the view so that it can handle it its own way.
*
* @private
* @param {KeyEvent} ev
*/
_onKeydown(ev) {
switch (ev.which) {
case $.ui.keyCode.TAB:
this.trigger('navigation-move', {
direction: ev.shiftKey ? 'previous' : 'next',
originalEvent: ev,
});
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('navigation-move', {direction: 'next_line'});
break;
case $.ui.keyCode.ESCAPE:
this.trigger('navigation-move', {direction: 'cancel', originalEvent: ev});
break;
case $.ui.keyCode.UP:
ev.stopPropagation();
this.trigger('navigation-move', {direction: 'up'});
break;
case $.ui.keyCode.RIGHT:
ev.stopPropagation();
this.trigger('navigation-move', {direction: 'right'});
break;
case $.ui.keyCode.DOWN:
ev.stopPropagation();
this.trigger('navigation-move', {direction: 'down'});
break;
case $.ui.keyCode.LEFT:
ev.stopPropagation();
this.trigger('navigation-move', {direction: 'left'});
break;
}
}
/**
* Updates the target data value with the current AbstractField instance.
* This allows to consider the parent field in case of nested fields. The
* field which triggered the event is still accessible through ev.target.
*
* @private
* @param {CustomEvent} ev
*/
_onNavigationMove(ev) {
ev.detail.target = this;
}
}
/**
* An object representing fields to be fetched by the model even though not
* present in the view.
* This object contains "field name" as key and an object as value.
* That value object must contain the key "type"
* @see FieldBinaryImage for an example.
*/
AbstractField.fieldDependencies = {};
/**
* If this flag is given a string, the related BasicModel will be used to
* initialize specialData the field might need. This data will be available
* through this.record.specialData[this.name].
*
* @see BasicModel._fetchSpecialData
*/
AbstractField.specialData = false;
/**
* to override to indicate which field types are supported by the component
*
* @type Array<string>
*/
AbstractField.supportedFieldTypes = [];
/**
* To override to give a user friendly name to the component.
*
* @type string
*/
AbstractField.description = "";
/**
* Currently only used in list view.
* If this flag is set to true, the list column name will be empty.
*/
AbstractField.noLabel = false;
/**
* Currently only used in list view.
* If set, this value will be displayed as column name.
*/
AbstractField.label = "";
return AbstractField;
});
|