mirror of
https://gitlab.com/flectra-hq/flectra.git
synced 2026-08-19 01:34:43 -05:00
3777 lines
122 KiB
JavaScript
3777 lines
122 KiB
JavaScript
flectra.define('web.basic_fields', function (require) {
|
|
"use strict";
|
|
|
|
/**
|
|
* This module contains most of the basic (meaning: non relational) field
|
|
* widgets. Field widgets are supposed to be used in views inheriting from
|
|
* BasicView, so, they can work with the records obtained from a BasicModel.
|
|
*/
|
|
|
|
var AbstractField = require('web.AbstractField');
|
|
var config = require('web.config');
|
|
var core = require('web.core');
|
|
var datepicker = require('web.datepicker');
|
|
var deprecatedFields = require('web.basic_fields.deprecated');
|
|
var dom = require('web.dom');
|
|
var Domain = require('web.Domain');
|
|
var DomainSelector = require('web.DomainSelector');
|
|
var DomainSelectorDialog = require('web.DomainSelectorDialog');
|
|
var framework = require('web.framework');
|
|
var py_utils = require('web.py_utils');
|
|
var session = require('web.session');
|
|
var utils = require('web.utils');
|
|
var view_dialogs = require('web.view_dialogs');
|
|
var field_utils = require('web.field_utils');
|
|
var time = require('web.time');
|
|
const {ColorpickerDialog} = require('web.Colorpicker');
|
|
|
|
let FieldBoolean = deprecatedFields.FieldBoolean;
|
|
|
|
require("web.zoomflectra");
|
|
|
|
var qweb = core.qweb;
|
|
var _t = core._t;
|
|
var _lt = core._lt;
|
|
|
|
var TranslatableFieldMixin = {
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @private
|
|
* @returns {jQuery}
|
|
*/
|
|
_renderTranslateButton: function () {
|
|
if (_t.database.multi_lang && this.field.translate) {
|
|
var lang = _t.database.parameters.code.split('_')[0].toUpperCase();
|
|
return $(`<span class="o_field_translate btn btn-link">${lang}</span>`)
|
|
.on('click', this._onTranslate.bind(this));
|
|
}
|
|
return $();
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* open the translation view for the current field
|
|
*
|
|
* @param {MouseEvent} ev
|
|
* @private
|
|
*/
|
|
_onTranslate: function (ev) {
|
|
ev.preventDefault();
|
|
this.trigger_up('translate', {
|
|
fieldName: this.name,
|
|
id: this.dataPointID,
|
|
isComingFromTranslationAlert: false,
|
|
});
|
|
},
|
|
};
|
|
|
|
var DebouncedField = AbstractField.extend({
|
|
/**
|
|
* For field widgets that may have a large number of field changes quickly,
|
|
* it could be a good idea to debounce the changes. In that case, this is
|
|
* the suggested value.
|
|
*/
|
|
DEBOUNCE: 1000000000,
|
|
|
|
/**
|
|
* Override init to debounce the field "_doAction" method (by creating a new
|
|
* one called "_doDebouncedAction"). By default, this method notifies the
|
|
* current value of the field and we do not want that to happen for each
|
|
* keystroke. Note that this is done here and not on the prototype, so that
|
|
* each DebouncedField has its own debounced function to work with. Also, if
|
|
* the debounce value is set to 0, no debouncing is done, which is really
|
|
* useful for the unit tests.
|
|
*
|
|
* @constructor
|
|
* @override
|
|
*/
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
|
|
// _isDirty is used to detect that the user interacted at least
|
|
// once with the widget, so that we can prevent it from triggering a
|
|
// field_changed in commitChanges if the user didn't change anything
|
|
this._isDirty = false;
|
|
if (this.mode === 'edit') {
|
|
if (this.DEBOUNCE) {
|
|
this._doDebouncedAction = _.debounce(this._doAction, this.DEBOUNCE);
|
|
} else {
|
|
this._doDebouncedAction = this._doAction;
|
|
}
|
|
|
|
var self = this;
|
|
var debouncedFunction = this._doDebouncedAction;
|
|
this._doDebouncedAction = function () {
|
|
self._isDirty = true;
|
|
debouncedFunction.apply(self, arguments);
|
|
};
|
|
}
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* This field main action is debounced and might sets the field's value.
|
|
* When the changes are asked to be commited, the debounced action has to
|
|
* be done immediately.
|
|
*
|
|
* @override
|
|
*/
|
|
commitChanges: function () {
|
|
if (this._isDirty && this.mode === 'edit') {
|
|
return this._doAction();
|
|
}
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* By default, notifies the outside world of the new value (checked from the
|
|
* DOM). This method has an automatically-created (@see init) associated
|
|
* debounced version called _doDebouncedAction.
|
|
*
|
|
* @private
|
|
*/
|
|
_doAction: function () {
|
|
// as _doAction may be debounced, it may happen that it is called after
|
|
// the widget has been destroyed, and in this case, we don't want it to
|
|
// do anything (commitChanges ensures that if it has local changes, they
|
|
// are triggered up before the widget is destroyed, if necessary).
|
|
if (!this.isDestroyed()) {
|
|
return this._setValue(this._getValue());
|
|
}
|
|
},
|
|
/**
|
|
* Should return the current value of the field, in the DOM (for example,
|
|
* the content of the input)
|
|
*
|
|
* @abstract
|
|
* @private
|
|
* @returns {*}
|
|
*/
|
|
_getValue: function () {},
|
|
/**
|
|
* Should make an action on lost focus.
|
|
*
|
|
* @abstract
|
|
* @private
|
|
* @returns {*}
|
|
*/
|
|
_onBlur: function () {},
|
|
});
|
|
|
|
var InputField = DebouncedField.extend({
|
|
custom_events: _.extend({}, DebouncedField.prototype.custom_events, {
|
|
field_changed: '_onFieldChanged',
|
|
}),
|
|
events: _.extend({}, DebouncedField.prototype.events, {
|
|
'input': '_onInput',
|
|
'change': '_onChange',
|
|
'blur' : '_onBlur',
|
|
}),
|
|
|
|
/**
|
|
* Prepares the rendering so that it creates an element the user can type
|
|
* text into in edit mode.
|
|
*
|
|
* @override
|
|
*/
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
this.nodeOptions.isPassword = 'password' in this.attrs;
|
|
if (this.mode === 'edit') {
|
|
this.tagName = 'input';
|
|
}
|
|
// We need to know if the widget is dirty (i.e. if the user has changed
|
|
// the value, and those changes haven't been acknowledged yet by the
|
|
// environment), to prevent erasing that new value on a reset (e.g.
|
|
// coming by an onchange on another field)
|
|
this.isDirty = false;
|
|
this.lastChangeEvent = undefined;
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Returns the associated <input/> element.
|
|
*
|
|
* @override
|
|
*/
|
|
getFocusableElement: function () {
|
|
return this.$input || $();
|
|
},
|
|
/**
|
|
* Re-renders the widget if it isn't dirty. The widget is dirty if the user
|
|
* changed the value, and that change hasn't been acknowledged yet by the
|
|
* environment. For example, another field with an onchange has been updated
|
|
* and this field is updated before the onchange returns. Two '_setValue'
|
|
* are done (this is sequential), the first one returns and this widget is
|
|
* reset. However, it has pending changes, so we don't re-render.
|
|
*
|
|
* @override
|
|
*/
|
|
reset: function (record, event) {
|
|
this._reset(record, event);
|
|
if (!event || event === this.lastChangeEvent) {
|
|
this.isDirty = false;
|
|
}
|
|
if (this.isDirty || (event && event.target === this &&
|
|
event.data.changes &&
|
|
event.data.changes[this.name] === this.value)) {
|
|
if (this.attrs.decorations) {
|
|
// if a field is modified, then it could have triggered an onchange
|
|
// which changed some of its decorations. Since we bypass the
|
|
// render function, we need to apply decorations here to make
|
|
// sure they are recomputed.
|
|
this._applyDecorations();
|
|
}
|
|
return Promise.resolve();
|
|
} else {
|
|
return this._render();
|
|
}
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @override
|
|
* @returns {string} the content of the input
|
|
*/
|
|
_getValue: function () {
|
|
return this.$input.val();
|
|
},
|
|
/**
|
|
* Formats an input element for edit mode. This is in a separate function so
|
|
* extending widgets can use it on their input without having input as tagName.
|
|
*
|
|
* @private
|
|
* @param {jQuery|undefined} $input
|
|
* The <input/> element to prepare and save as the $input attribute.
|
|
* If no element is given, the <input/> is created.
|
|
* @returns {jQuery} the prepared this.$input element
|
|
*/
|
|
_prepareInput: function ($input) {
|
|
this.$input = $input || $("<input/>");
|
|
this.$input.addClass('o_input');
|
|
|
|
var inputAttrs = { placeholder: this.attrs.placeholder || "" };
|
|
var inputVal;
|
|
if (this.nodeOptions.isPassword) {
|
|
inputAttrs = _.extend(inputAttrs, { type: 'password', autocomplete: this.attrs.autocomplete || 'new-password' });
|
|
inputVal = this.value || '';
|
|
} else {
|
|
inputAttrs = _.extend(inputAttrs, { type: 'text', autocomplete: this.attrs.autocomplete || 'off'});
|
|
inputVal = this._formatValue(this.value);
|
|
}
|
|
|
|
this.$input.attr(inputAttrs);
|
|
this.$input.val(inputVal);
|
|
|
|
return this.$input;
|
|
},
|
|
/**
|
|
* Formats the HTML input tag for edit mode and stores selection status.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_renderEdit: function () {
|
|
// Keep a reference to the input so $el can become something else
|
|
// without losing track of the actual input.
|
|
this._prepareInput(this.$el);
|
|
},
|
|
/**
|
|
* Resets the content to the formated value in readonly mode.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_renderReadonly: function () {
|
|
this.$el.text(this._formatValue(this.value));
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* We immediately notify the outside world when this field confirms its
|
|
* changes.
|
|
*
|
|
* @private
|
|
*/
|
|
_onChange: function () {
|
|
this._doAction();
|
|
},
|
|
/**
|
|
* Listens to events 'field_changed' to keep track of the last event that
|
|
* has been trigerred. This allows to detect that all changes have been
|
|
* acknowledged by the environment.
|
|
*
|
|
* @param {FlectraEvent} event 'field_changed' event
|
|
*/
|
|
_onFieldChanged: function (event) {
|
|
this.lastChangeEvent = event;
|
|
},
|
|
/**
|
|
* Called when the user is typing text -> By default this only calls a
|
|
* debounced method to notify the outside world of the changes.
|
|
* @see _doDebouncedAction
|
|
*
|
|
* @private
|
|
*/
|
|
_onInput: function () {
|
|
this.isDirty = !this._isLastSetValue(this.$input.val());
|
|
this._doDebouncedAction();
|
|
},
|
|
/**
|
|
* Stops the left/right navigation move event if the cursor is not at the
|
|
* start/end of the input element.
|
|
*
|
|
* @private
|
|
* @param {FlectraEvent} ev
|
|
*/
|
|
_onNavigationMove: function (ev) {
|
|
this._super.apply(this, arguments);
|
|
|
|
// the following code only makes sense in edit mode, with an input
|
|
if (this.mode === 'edit' && ev.data.direction !== 'cancel') {
|
|
var input = this.$input[0];
|
|
var selecting = (input.selectionEnd !== input.selectionStart);
|
|
if ((ev.data.direction === "left" && (selecting || input.selectionStart !== 0))
|
|
|| (ev.data.direction === "right" && (selecting || input.selectionStart !== input.value.length))) {
|
|
ev.stopPropagation();
|
|
}
|
|
if (ev.data.direction ==='next' &&
|
|
this.attrs.modifiersValue &&
|
|
this.attrs.modifiersValue.required &&
|
|
this.viewType !== 'list') {
|
|
if (!this.$input.val()){
|
|
this.setInvalidClass();
|
|
ev.stopPropagation();
|
|
} else {
|
|
this.removeInvalidClass();
|
|
}
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
var NumericField = InputField.extend({
|
|
tagName: 'span',
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
init() {
|
|
this._super.apply(this, arguments);
|
|
this.shouldFormat = Boolean(
|
|
JSON.parse('format' in this.nodeOptions ? this.nodeOptions.format : true)
|
|
);
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* For numeric fields, 0 is a valid value.
|
|
*
|
|
* @override
|
|
*/
|
|
isSet: function () {
|
|
return this.value === 0 || this._super.apply(this, arguments);
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Evaluate a string representing a simple formula,
|
|
* a formula is composed of numbers and arithmetic operations
|
|
* (ex: 4+3*2)
|
|
*
|
|
* Supported arithmetic operations: + - * / ^ ( )
|
|
* Since each number in the formula can be expressed in user locale,
|
|
* we parse each float value inside the formula using the user context
|
|
* This function uses py_eval to safe eval the formula.
|
|
* We assume that this function is used as a calculator so operand ^ (xor)
|
|
* is replaced by operand ** (power) so that users that are used to
|
|
* excel or libreoffice are not confused
|
|
*
|
|
* @private
|
|
* @param expr
|
|
* @return a float representing the result of the evaluated formula
|
|
* @throws error if formula can't be evaluated
|
|
*/
|
|
_evalFormula: function (expr, context) {
|
|
// remove extra space
|
|
var val = expr.replace(new RegExp(/( )/g), '');
|
|
var safeEvalString = '';
|
|
for (let v of val.split(new RegExp(/([-+*/()^])/g))) {
|
|
if (!['+','-','*','/','(',')','^'].includes(v) && v.length) {
|
|
// check if this is a float and take into account user delimiter preference
|
|
v = field_utils.parse.float(v);
|
|
}
|
|
if (v === '^') {
|
|
v = '**';
|
|
}
|
|
safeEvalString += v;
|
|
};
|
|
return py_utils.py_eval(safeEvalString, context);
|
|
},
|
|
|
|
/**
|
|
* Format numerical value (integer or float)
|
|
*
|
|
* Note: We have to overwrite this method to skip the format if we are into
|
|
* edit mode on a input type number.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_formatValue: function (value) {
|
|
if (!this.shouldFormat || (this.mode === 'edit' && this.nodeOptions.type === 'number')) {
|
|
return value;
|
|
}
|
|
return this._super.apply(this, arguments);
|
|
},
|
|
|
|
/**
|
|
* Parse numerical value (integer or float)
|
|
*
|
|
* Note: We have to overwrite this method to skip the format if we are into
|
|
* edit mode on a input type number.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_parseValue: function (value) {
|
|
if (this.mode === 'edit' && this.nodeOptions.type === 'number') {
|
|
return Number(value);
|
|
}
|
|
return this._super.apply(this, arguments);
|
|
},
|
|
|
|
/**
|
|
* Formats an input element for edit mode. This is in a separate function so
|
|
* extending widgets can use it on their input without having input as tagName.
|
|
*
|
|
* Note: We have to overwrite this method to set the input's type to number if
|
|
* option setted into the field.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_prepareInput: function ($input) {
|
|
var result = this._super.apply(this, arguments);
|
|
if (this.nodeOptions.type === 'number') {
|
|
this.$input.attr({type: 'number'});
|
|
}
|
|
if (this.nodeOptions.step) {
|
|
this.$input.attr({step: this.nodeOptions.step});
|
|
}
|
|
return result;
|
|
},
|
|
|
|
/**
|
|
* Evaluate value set by user if starts with =
|
|
*
|
|
* @override
|
|
* @private
|
|
* @param {any} value
|
|
* @param {Object} [options]
|
|
*/
|
|
_setValue: function (value, options) {
|
|
var originalValue = value;
|
|
value = value.trim();
|
|
if (value.startsWith('=')) {
|
|
try {
|
|
// Evaluate the formula
|
|
value = this._evalFormula(value.substr(1));
|
|
// Format back the value in user locale
|
|
value = this._formatValue(value);
|
|
// Set the computed value in the input
|
|
this.$input.val(value);
|
|
} catch (err) {
|
|
// in case of exception, set value as the original value
|
|
// that way the Webclient will show an error as
|
|
// it is expecting a numeric value.
|
|
value = originalValue;
|
|
}
|
|
}
|
|
return this._super(value, options);
|
|
},
|
|
});
|
|
|
|
var FieldChar = InputField.extend(TranslatableFieldMixin, {
|
|
description: _lt("Text"),
|
|
className: 'o_field_char',
|
|
tagName: 'span',
|
|
supportedFieldTypes: ['char'],
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Add translation button
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_renderEdit: function () {
|
|
var def = this._super.apply(this, arguments);
|
|
if (this.field.size && this.field.size > 0) {
|
|
this.$el.attr('maxlength', this.field.size);
|
|
}
|
|
if (this.field.translate) {
|
|
this.$el = this.$el.add(this._renderTranslateButton());
|
|
this.$el.addClass('o_field_translate');
|
|
}
|
|
return def;
|
|
},
|
|
/**
|
|
* Trim the value input by the user.
|
|
*
|
|
* @override
|
|
* @private
|
|
* @param {any} value
|
|
* @param {Object} [options]
|
|
*/
|
|
_setValue: function (value, options) {
|
|
if (this.field.trim) {
|
|
value = value.trim();
|
|
}
|
|
return this._super(value, options);
|
|
},
|
|
});
|
|
|
|
var LinkButton = AbstractField.extend({
|
|
events: _.extend({}, AbstractField.prototype.events, {
|
|
'click': '_onClick'
|
|
}),
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Display button
|
|
* @override
|
|
* @private
|
|
*/
|
|
_render: function () {
|
|
if (this.value) {
|
|
var className = this.attrs.icon || 'fa-globe';
|
|
|
|
this.$el.html("<span role='img'/>");
|
|
this.$el.addClass("fa "+ className);
|
|
this.$el.attr('title', this.value);
|
|
this.$el.attr('aria-label', this.value);
|
|
}
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Open link button
|
|
*
|
|
* @private
|
|
* @param {MouseEvent} event
|
|
*/
|
|
_onClick: function (event) {
|
|
event.stopPropagation();
|
|
window.open(this.value, '_blank');
|
|
},
|
|
});
|
|
|
|
var FieldDateRange = InputField.extend({
|
|
className: 'o_field_date_range',
|
|
tagName: 'span',
|
|
jsLibs: [
|
|
'/web/static/lib/daterangepicker/daterangepicker.js',
|
|
'/web/static/src/js/libs/daterangepicker.js',
|
|
],
|
|
cssLibs: [
|
|
'/web/static/lib/daterangepicker/daterangepicker.css',
|
|
],
|
|
supportedFieldTypes: ['date', 'datetime'],
|
|
/**
|
|
* @override
|
|
*/
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
this.isDateField = this.formatType === 'date';
|
|
this.dateRangePickerOptions = _.defaults(
|
|
{},
|
|
this.nodeOptions.picker_options || {},
|
|
{
|
|
timePicker: !this.isDateField,
|
|
timePicker24Hour: _t.database.parameters.time_format.search('%H') !== -1,
|
|
autoUpdateInput: false,
|
|
timePickerIncrement: 5,
|
|
locale: {
|
|
applyLabel: _t('Apply'),
|
|
cancelLabel: _t('Cancel'),
|
|
format: this.isDateField ? time.getLangDateFormat() : time.getLangDatetimeFormat(),
|
|
},
|
|
}
|
|
);
|
|
this.relatedEndDate = this.nodeOptions.related_end_date;
|
|
this.relatedStartDate = this.nodeOptions.related_start_date;
|
|
},
|
|
/**
|
|
* @override
|
|
*/
|
|
destroy: function () {
|
|
if (this.$pickerContainer) {
|
|
this.$pickerContainer.remove();
|
|
}
|
|
if (this._onScroll) {
|
|
window.removeEventListener('scroll', this._onScroll, true);
|
|
}
|
|
this._super.apply(this, arguments);
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Field widget is valid if value entered can convered to date/dateime value
|
|
* while parsing input value to date/datetime throws error then widget considered
|
|
* invalid
|
|
*
|
|
* @override
|
|
*/
|
|
isValid: function () {
|
|
const value = this.mode === "readonly" ? this.value : this.$input.val();
|
|
try {
|
|
return field_utils.parse[this.formatType](value, this.field, { timezone: true }) || true;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Return the date written in the input, in UTC.
|
|
*
|
|
* @private
|
|
* @returns {Moment|false}
|
|
*/
|
|
_getValue: function () {
|
|
try {
|
|
// user may enter manual value in input and it may not be parsed as date/datetime value
|
|
this.removeInvalidClass();
|
|
return field_utils.parse[this.formatType](this.$input.val(), this.field, { timezone: true });
|
|
} catch (error) {
|
|
this.setInvalidClass();
|
|
return false;
|
|
}
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} ev
|
|
* @param {Object} picker
|
|
*/
|
|
_applyChanges: function (ev, picker) {
|
|
var changes = {};
|
|
var displayStartDate = field_utils.format[this.formatType](picker.startDate, {}, {timezone: false});
|
|
var displayEndDate = field_utils.format[this.formatType](picker.endDate, {}, {timezone: false});
|
|
var changedStartDate = picker.startDate;
|
|
var changedEndDate = picker.endDate;
|
|
if (this.isDateField) {
|
|
// In date mode, the library will give moment object of start and end date having
|
|
// time at 00:00:00. So, Flectra will consider it as UTC. To fix this added browser
|
|
// timezone offset in dates to get a correct selected date.
|
|
changedStartDate = picker.startDate.add(session.getTZOffset(picker.startDate), 'minutes');
|
|
changedEndDate = picker.endDate.startOf('day').add(session.getTZOffset(picker.endDate), 'minutes');
|
|
}
|
|
if (this.relatedEndDate) {
|
|
this.$el.val(displayStartDate);
|
|
changes[this.name] = this._parseValue(changedStartDate);
|
|
changes[this.relatedEndDate] = this._parseValue(changedEndDate);
|
|
}
|
|
if (this.relatedStartDate) {
|
|
this.$el.val(displayEndDate);
|
|
changes[this.name] = this._parseValue(changedEndDate);
|
|
changes[this.relatedStartDate] = this._parseValue(changedStartDate);
|
|
}
|
|
this.trigger_up('field_changed', {
|
|
dataPointID: this.dataPointID,
|
|
viewType: this.viewType,
|
|
changes: changes,
|
|
});
|
|
},
|
|
/**
|
|
* @override
|
|
*/
|
|
_renderEdit: function () {
|
|
this._super.apply(this, arguments);
|
|
var self = this;
|
|
var startDate;
|
|
var endDate;
|
|
if (this.relatedEndDate) {
|
|
startDate = this._formatValue(this.value);
|
|
endDate = this._formatValue(this.recordData[this.relatedEndDate]);
|
|
}
|
|
if (this.relatedStartDate) {
|
|
startDate = this._formatValue(this.recordData[this.relatedStartDate]);
|
|
endDate = this._formatValue(this.value);
|
|
}
|
|
this.dateRangePickerOptions.startDate = startDate || moment();
|
|
this.dateRangePickerOptions.endDate = endDate || moment();
|
|
|
|
this.$el.daterangepicker(this.dateRangePickerOptions);
|
|
this.$el.on('apply.daterangepicker', this._applyChanges.bind(this));
|
|
this.$el.on('show.daterangepicker', this._onDateRangePickerShow.bind(this));
|
|
this.$el.on('hide.daterangepicker', this._onDateRangePickerHide.bind(this));
|
|
this.$el.off('keyup.daterangepicker');
|
|
this.$pickerContainer = this.$el.data('daterangepicker').container;
|
|
|
|
// Prevent from leaving the edition of a row in editable list view
|
|
this.$pickerContainer.on('click', function (ev) {
|
|
ev.stopPropagation();
|
|
if ($(ev.target).hasClass('applyBtn')) {
|
|
self.$el.data('daterangepicker').hide();
|
|
}
|
|
});
|
|
|
|
// Prevent bootstrap from focusing on modal (which breaks hours drop-down in firefox)
|
|
this.$pickerContainer.on('focusin.bs.modal', 'select', function (ev) {
|
|
ev.stopPropagation();
|
|
});
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Unbind the scroll event handler when the daterangepicker is closed.
|
|
*
|
|
* @private
|
|
*/
|
|
_onDateRangePickerHide() {
|
|
if (this._onScroll) {
|
|
window.removeEventListener('scroll', this._onScroll, true);
|
|
}
|
|
},
|
|
/**
|
|
* Bind the scroll event handle when the daterangepicker is open.
|
|
*
|
|
* @private
|
|
*/
|
|
_onDateRangePickerShow() {
|
|
this._onScroll = ev => {
|
|
if (!config.device.isMobile && !this.$pickerContainer.get(0).contains(ev.target)) {
|
|
this.$el.data('daterangepicker').hide();
|
|
}
|
|
};
|
|
window.addEventListener('scroll', this._onScroll, true);
|
|
},
|
|
});
|
|
|
|
var FieldDate = InputField.extend({
|
|
description: _lt("Date"),
|
|
className: "o_field_date",
|
|
tagName: "span",
|
|
supportedFieldTypes: ['date', 'datetime'],
|
|
// we don't need to listen on 'input' nor 'change' events because the
|
|
// datepicker widget is already listening, and will correctly notify changes
|
|
events: AbstractField.prototype.events,
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
// use the session timezone when formatting dates
|
|
this.formatOptions.timezone = true;
|
|
this.datepickerOptions = _.defaults(
|
|
{},
|
|
this.nodeOptions.datepicker || {},
|
|
{defaultDate: this.value}
|
|
);
|
|
},
|
|
/**
|
|
* In edit mode, instantiates a DateWidget datepicker and listen to changes.
|
|
*
|
|
* @override
|
|
*/
|
|
start: function () {
|
|
var self = this;
|
|
var prom;
|
|
if (this.mode === 'edit') {
|
|
this.datewidget = this._makeDatePicker();
|
|
this.datewidget.on('datetime_changed', this, function () {
|
|
var value = this._getValue();
|
|
if ((!value && this.value) || (value && !this._isSameValue(value))) {
|
|
this._setValue(value);
|
|
}
|
|
});
|
|
prom = this.datewidget.appendTo('<div>').then(function () {
|
|
self.datewidget.$el.addClass(self.$el.attr('class'));
|
|
self._prepareInput(self.datewidget.$input);
|
|
self._replaceElement(self.datewidget.$el);
|
|
});
|
|
}
|
|
return Promise.resolve(prom).then(this._super.bind(this));
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Asks the datepicker widget to activate the input, instead of doing it
|
|
* ourself, such that 'input' events triggered by the lib are correctly
|
|
* intercepted, and don't produce unwanted 'field_changed' events.
|
|
*
|
|
* @override
|
|
*/
|
|
activate: function () {
|
|
if (this.isFocusable() && this.datewidget) {
|
|
this.datewidget.$input.select();
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @override
|
|
* @private
|
|
*/
|
|
_doDebouncedAction: function () {
|
|
this.datewidget.changeDatetime();
|
|
},
|
|
|
|
/**
|
|
* return the datepicker value
|
|
*
|
|
* @private
|
|
*/
|
|
_getValue: function () {
|
|
return this.datewidget.getValue();
|
|
},
|
|
/**
|
|
* @override
|
|
* @private
|
|
* @param {Moment|false} value
|
|
* @returns {boolean}
|
|
*/
|
|
_isSameValue: function (value) {
|
|
if (value === false) {
|
|
return this.value === false;
|
|
}
|
|
return value.isSame(this.value, 'day');
|
|
},
|
|
/**
|
|
* Instantiates a new DateWidget datepicker.
|
|
*
|
|
* @private
|
|
*/
|
|
_makeDatePicker: function () {
|
|
return new datepicker.DateWidget(this, this.datepickerOptions);
|
|
},
|
|
|
|
/**
|
|
* Set the datepicker to the right value rather than the default one.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_renderEdit: function () {
|
|
this.datewidget.setValue(this.value);
|
|
this.$input = this.datewidget.$input;
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Confirm the value on hit enter and re-render
|
|
* It will also remove the offset to get the UTC value
|
|
*
|
|
* @private
|
|
* @override
|
|
* @param {KeyboardEvent} ev
|
|
*/
|
|
async _onKeydown(ev) {
|
|
this._super(...arguments);
|
|
if (ev.which === $.ui.keyCode.ENTER) {
|
|
let value = this.$input.val();
|
|
try {
|
|
value = this._parseValue(value);
|
|
if (this.field.type === "datetime") {
|
|
value.add(-this.getSession().getTZOffset(value), "minutes");
|
|
}
|
|
} catch (err) {}
|
|
await this._setValue(value);
|
|
this._render();
|
|
}
|
|
},
|
|
});
|
|
|
|
var FieldDateTime = FieldDate.extend({
|
|
description: _lt("Date & Time"),
|
|
supportedFieldTypes: ['datetime'],
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
if (this.value) {
|
|
var offset = this.getSession().getTZOffset(this.value);
|
|
var displayedValue = this.value.clone().add(offset, 'minutes');
|
|
this.datepickerOptions.defaultDate = displayedValue;
|
|
}
|
|
},
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* return the datepicker value
|
|
*
|
|
* @private
|
|
*/
|
|
_getValue: function () {
|
|
var value = this.datewidget.getValue();
|
|
return value && value.add(-this.getSession().getTZOffset(value), 'minutes');
|
|
},
|
|
/**
|
|
* @override
|
|
* @private
|
|
*/
|
|
_isSameValue: function (value) {
|
|
if (value === false) {
|
|
return this.value === false;
|
|
}
|
|
return value.isSame(this.value);
|
|
},
|
|
/**
|
|
* Instantiates a new DateTimeWidget datepicker rather than DateWidget.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_makeDatePicker: function () {
|
|
return new datepicker.DateTimeWidget(this, this.datepickerOptions);
|
|
},
|
|
/**
|
|
* Set the datepicker to the right value rather than the default one.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_renderEdit: function () {
|
|
var value = this.value && this.value.clone().add(this.getSession().getTZOffset(this.value), 'minutes');
|
|
this.datewidget.setValue(value);
|
|
this.$input = this.datewidget.$input;
|
|
},
|
|
});
|
|
|
|
const RemainingDays = AbstractField.extend({
|
|
description: _lt("Remaining Days"),
|
|
supportedFieldTypes: ['date', 'datetime'],
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Displays the delta (in days) between the value of the field and today. If
|
|
* the delta is larger than 99 days, displays the date as usual (without
|
|
* time).
|
|
*
|
|
* @override
|
|
*/
|
|
_render() {
|
|
if (this.value === false) {
|
|
this.$el.removeClass('text-bf text-danger text-warning');
|
|
return;
|
|
}
|
|
// compare the value (in the user timezone) with now (also in the user
|
|
// timezone), to get a meaningful delta for the user
|
|
const nowUTC = moment().utc();
|
|
const nowUserTZ = nowUTC.clone().add(session.getTZOffset(nowUTC), 'minutes');
|
|
const fieldValue = this.field.type == "datetime" ? this.value.clone().add(session.getTZOffset(this.value), 'minutes') : this.value;
|
|
const diffDays = fieldValue.startOf('day').diff(nowUserTZ.startOf('day'), 'days');
|
|
let text;
|
|
if (Math.abs(diffDays) > 99) {
|
|
text = this._formatValue(this.value, 'date');
|
|
} else if (diffDays === 0) {
|
|
text = _t("Today");
|
|
} else if (diffDays < 0) {
|
|
text = diffDays === -1 ? _t("Yesterday") : _.str.sprintf(_t('%s days ago'), -diffDays);
|
|
} else {
|
|
text = diffDays === 1 ? _t("Tomorrow") : _.str.sprintf(_t('In %s days'), diffDays);
|
|
}
|
|
this.$el.text(text).attr('title', this._formatValue(this.value, 'date'));
|
|
this.$el.toggleClass('text-bf', diffDays <= 0);
|
|
this.$el.toggleClass('text-danger', diffDays < 0);
|
|
this.$el.toggleClass('text-warning', diffDays === 0);
|
|
},
|
|
});
|
|
|
|
var FieldMonetary = NumericField.extend({
|
|
description: _lt("Monetary"),
|
|
className: 'o_field_monetary o_field_number',
|
|
tagName: 'span',
|
|
supportedFieldTypes: ['float', 'monetary'],
|
|
resetOnAnyFieldChange: true, // Have to listen to currency changes
|
|
|
|
/**
|
|
* Float fields using a monetary widget have an additional currency_field
|
|
* parameter which defines the name of the field from which the currency
|
|
* should be read.
|
|
*
|
|
* They are also displayed differently than other inputs in
|
|
* edit mode. They are a div containing a span with the currency symbol and
|
|
* the actual input.
|
|
*
|
|
* If no currency field is given or the field does not exist, we fallback
|
|
* to the default input behavior instead.
|
|
*
|
|
* @override
|
|
*/
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
|
|
this._setCurrency();
|
|
|
|
if (this.mode === 'edit') {
|
|
this.tagName = 'div';
|
|
this.className += ' o_input';
|
|
|
|
// do not display currency symbol in edit
|
|
this.formatOptions.noSymbol = true;
|
|
}
|
|
|
|
this.formatOptions.currency = this.currency;
|
|
this.formatOptions.digits = [16, 2];
|
|
this.formatOptions.field_digits = this.nodeOptions.field_digits;
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* For monetary fields, 0 is a valid value.
|
|
*
|
|
* @override
|
|
*/
|
|
isSet: function () {
|
|
return this.value === 0 || this._super.apply(this, arguments);
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* For monetary fields, the input is inside a div, alongside a span
|
|
* containing the currency symbol.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_renderEdit: function () {
|
|
this.$el.empty();
|
|
|
|
// Prepare and add the input
|
|
var def = this._prepareInput(this.$input).appendTo(this.$el);
|
|
|
|
if (this.currency) {
|
|
// Prepare and add the currency symbol
|
|
var $currencySymbol = $('<span>', {text: this.currency.symbol});
|
|
if (this.currency.position === "after") {
|
|
this.$el.append($currencySymbol);
|
|
} else {
|
|
this.$el.prepend($currencySymbol);
|
|
}
|
|
}
|
|
return def;
|
|
},
|
|
/**
|
|
* @override
|
|
* @private
|
|
*/
|
|
_renderReadonly: function () {
|
|
this.$el.html(this._formatValue(this.value));
|
|
},
|
|
/**
|
|
* Re-gets the currency as its value may have changed.
|
|
* @see FieldMonetary.resetOnAnyFieldChange
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_reset: function () {
|
|
this._super.apply(this, arguments);
|
|
this._setCurrency();
|
|
},
|
|
/**
|
|
* Deduces the currency description from the field options and view state.
|
|
* The description is then available at this.currency.
|
|
*
|
|
* @private
|
|
*/
|
|
_setCurrency: function () {
|
|
var currencyField = this.nodeOptions.currency_field || this.field.currency_field || 'currency_id';
|
|
var currencyID = this.record.data[currencyField] && this.record.data[currencyField].res_id;
|
|
this.currency = session.get_currency(currencyID);
|
|
this.formatOptions.currency = this.currency; // _formatValue() uses formatOptions
|
|
},
|
|
});
|
|
|
|
var FieldInteger = NumericField.extend({
|
|
description: _lt("Integer"),
|
|
className: 'o_field_integer o_field_number',
|
|
supportedFieldTypes: ['integer'],
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Format integer value
|
|
*
|
|
* Note: We have to overwrite this method to allow virtual ids. A virtual id
|
|
* is a character string composed of an integer and has a dash and other
|
|
* information.
|
|
* E.g: in calendar, the recursive event have virtual id linked to a real id
|
|
* virtual event id "23-20170418020000" is linked to the event id 23
|
|
*
|
|
* @override
|
|
* @private
|
|
* @param {integer|string} value
|
|
* @returns {string}
|
|
*/
|
|
_formatValue: function (value) {
|
|
if (typeof value === 'string') {
|
|
if (!/^[0-9]+-/.test(value)) {
|
|
throw new Error('"' + value + '" is not an integer or a virtual id');
|
|
}
|
|
return value;
|
|
}
|
|
return this._super.apply(this, arguments);
|
|
},
|
|
});
|
|
|
|
var FieldFloat = NumericField.extend({
|
|
description: _lt("Decimal"),
|
|
className: 'o_field_float o_field_number',
|
|
supportedFieldTypes: ['float'],
|
|
|
|
/**
|
|
* Float fields have an additional precision parameter that is read from
|
|
* either the field node in the view or the field python definition itself.
|
|
*
|
|
* @override
|
|
*/
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
if (this.attrs.digits) {
|
|
this.nodeOptions.digits = JSON.parse(this.attrs.digits);
|
|
}
|
|
},
|
|
});
|
|
|
|
var FieldFloatTime = FieldFloat.extend({
|
|
description: _lt("Time"),
|
|
// this is not strictly necessary, as for this widget to be used, the 'widget'
|
|
// attrs must be set to 'float_time', so the formatType is automatically
|
|
// 'float_time', but for the sake of clarity, we explicitely define a
|
|
// FieldFloatTime widget with formatType = 'float_time'.
|
|
formatType: 'float_time',
|
|
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
this.formatType = 'float_time';
|
|
}
|
|
});
|
|
|
|
var FieldFloatFactor = FieldFloat.extend({
|
|
supportedFieldTypes: ['float'],
|
|
className: 'o_field_float_factor',
|
|
formatType: 'float_factor',
|
|
|
|
/**
|
|
* @constructor
|
|
*/
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
// default values
|
|
if (!this.nodeOptions.factor){
|
|
this.nodeOptions.factor = 1;
|
|
}
|
|
// use as format and parse options
|
|
this.parseOptions = this.nodeOptions;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* The goal of this widget is to replace the input field by a button containing a
|
|
* range of possible values (given in the options). Each click allows the user to loop
|
|
* in the range. The purpose here is to restrict the field value to a predefined selection.
|
|
* Also, the widget support the factor conversion as the *float_factor* widget (Range values
|
|
* should be the result of the conversion).
|
|
**/
|
|
var FieldFloatToggle = AbstractField.extend({
|
|
supportedFieldTypes: ['float'],
|
|
formatType: 'float_factor',
|
|
className: 'o_field_float_toggle',
|
|
tagName: 'span',
|
|
events: {
|
|
click: '_onClick'
|
|
},
|
|
|
|
/**
|
|
* @constructor
|
|
*/
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
|
|
this.formatType = 'float_factor';
|
|
|
|
if (this.mode === 'edit') {
|
|
this.tagName = 'button';
|
|
}
|
|
|
|
// we don't inherit Float Field
|
|
if (this.attrs.digits) {
|
|
this.nodeOptions.digits = JSON.parse(this.attrs.digits);
|
|
}
|
|
// default values
|
|
if (!this.nodeOptions.factor){
|
|
this.nodeOptions.factor = 1;
|
|
}
|
|
if (!this.nodeOptions.range){
|
|
this.nodeOptions.range = [0.0, 0.5, 1.0];
|
|
}
|
|
|
|
// use as format and parse options
|
|
this.parseOptions = this.nodeOptions;
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get the display value but in real type to use it in calculations
|
|
*
|
|
* @private
|
|
* @returns {float} The current formatted value
|
|
*/
|
|
_getDisplayedValue: function () {
|
|
// this.value is a plain float
|
|
// Matches what is in Database
|
|
var usrFormatValue = this._formatValue(this.value);
|
|
// usrFormatValue is string
|
|
// contains a float represented in a user specific format
|
|
// the float is the fraction by [this.factor] of this.value
|
|
return field_utils.parse['float'](usrFormatValue);
|
|
},
|
|
/**
|
|
* Formats the HTML input tag for edit mode and stores selection status.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_renderEdit: function () {
|
|
// Keep a reference to the input so $el can become something else
|
|
// without losing track of the actual input.
|
|
this.$el.text(this._formatValue(this.value));
|
|
},
|
|
/**
|
|
* Resets the content to the formated value in readonly mode.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_renderReadonly: function () {
|
|
this.$el.text(this._formatValue(this.value));
|
|
},
|
|
/**
|
|
* Get the next value in the range, from the current one. If the current
|
|
* one is not in the range, the next value of the closest one will be chosen.
|
|
*
|
|
* @private
|
|
* @returns {number} The next value in the range
|
|
*/
|
|
_nextValue: function () {
|
|
var range = this.nodeOptions.range;
|
|
var val = utils.closestNumber(this._getDisplayedValue(), range);
|
|
var index = _.indexOf(range, val);
|
|
if (index !== -1) {
|
|
if (index + 1 < range.length) {
|
|
return range[index + 1];
|
|
}
|
|
}
|
|
return range[0];
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Clicking on the button triggers the change of value; the next one of
|
|
* the range will be displayed.
|
|
*
|
|
* @private
|
|
* @param {FlectraEvent} ev
|
|
*/
|
|
_onClick: function(ev) {
|
|
if (this.mode === 'edit') {
|
|
ev.stopPropagation(); // only stop propagation in edit mode
|
|
var next_val = this._nextValue();
|
|
next_val = field_utils.format['float'](next_val);
|
|
this._setValue(next_val); // will be parsed in _setValue
|
|
}
|
|
},
|
|
/**
|
|
* For float toggle fields, 0 is a valid value.
|
|
*
|
|
* @override
|
|
*/
|
|
isSet: function () {
|
|
return this.value === 0 || this._super(...arguments);
|
|
},
|
|
});
|
|
|
|
var FieldPercentage = FieldFloat.extend({
|
|
className: 'o_field_float_percentage o_field_number',
|
|
description: _lt("Percentage"),
|
|
|
|
/**
|
|
* @constructor
|
|
*/
|
|
init() {
|
|
this._super(...arguments);
|
|
if (this.mode === 'edit') {
|
|
this.tagName = 'div';
|
|
this.className += ' o_input';
|
|
|
|
// do not display % in the input in edit
|
|
this.formatOptions.noSymbol = true;
|
|
}
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* For percentage widget, the input is inside a div, alongside a span
|
|
* containing the percentage(%) symbol.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_renderEdit() {
|
|
this.$el.empty();
|
|
// Prepare and add the input
|
|
this._prepareInput(this.$input).appendTo(this.$el);
|
|
const $percentageSymbol = $('<span>', { text: '%' });
|
|
this.$el.append($percentageSymbol);
|
|
},
|
|
});
|
|
|
|
var FieldText = InputField.extend(TranslatableFieldMixin, {
|
|
description: _lt("Multiline Text"),
|
|
className: 'o_field_text',
|
|
supportedFieldTypes: ['text', 'html'],
|
|
tagName: 'span',
|
|
|
|
/**
|
|
* @constructor
|
|
*/
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
|
|
if (this.mode === 'edit') {
|
|
this.tagName = 'textarea';
|
|
}
|
|
this.autoResizeOptions = {parent: this};
|
|
},
|
|
/**
|
|
* As it it done in the start function, the autoresize is done only once.
|
|
*
|
|
* @override
|
|
*/
|
|
start: function () {
|
|
if (this.mode === 'edit') {
|
|
dom.autoresize(this.$el, this.autoResizeOptions);
|
|
if (this.field.translate) {
|
|
this.$el = this.$el.add(this._renderTranslateButton());
|
|
this.$el.addClass('o_field_translate');
|
|
}
|
|
}
|
|
return this._super();
|
|
},
|
|
/**
|
|
* Override to force a resize of the textarea when its value has changed
|
|
*
|
|
* @override
|
|
*/
|
|
reset: function () {
|
|
var self = this;
|
|
return Promise.resolve(this._super.apply(this, arguments)).then(function () {
|
|
if (self.mode === 'edit') {
|
|
self.$input.trigger('change');
|
|
}
|
|
});
|
|
},
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Stops the enter navigation in a text area.
|
|
*
|
|
* @private
|
|
* @param {FlectraEvent} ev
|
|
*/
|
|
_onKeydown: function (ev) {
|
|
if (ev.which === $.ui.keyCode.ENTER) {
|
|
ev.stopPropagation();
|
|
return;
|
|
}
|
|
this._super.apply(this, arguments);
|
|
},
|
|
});
|
|
|
|
var ListFieldText = FieldText.extend({
|
|
/**
|
|
* @override
|
|
*/
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
this.autoResizeOptions.min_height = 0;
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Displays a handle to modify the sequence.
|
|
*/
|
|
var HandleWidget = AbstractField.extend({
|
|
description: _lt("Handle"),
|
|
noLabel: true,
|
|
className: 'o_row_handle fa fa-arrows ui-sortable-handle',
|
|
widthInList: '33px',
|
|
tagName: 'span',
|
|
supportedFieldTypes: ['integer'],
|
|
|
|
/*
|
|
* @override
|
|
*/
|
|
isSet: function () {
|
|
return true;
|
|
},
|
|
});
|
|
|
|
var FieldEmail = InputField.extend({
|
|
description: _lt("Email"),
|
|
className: 'o_field_email',
|
|
events: _.extend({}, InputField.prototype.events, {
|
|
'click': '_onClick',
|
|
}),
|
|
prefix: 'mailto',
|
|
supportedFieldTypes: ['char'],
|
|
|
|
/**
|
|
* In readonly, emails should be a link, not a span.
|
|
*
|
|
* @override
|
|
*/
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
this.tagName = this.mode === 'readonly' ? 'a' : 'input';
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Returns the associated link.
|
|
*
|
|
* @override
|
|
*/
|
|
getFocusableElement: function () {
|
|
return this.mode === 'readonly' ? this.$el : this._super.apply(this, arguments);
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* In readonly, emails should be a mailto: link with proper formatting.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_renderReadonly: function () {
|
|
if (this.value) {
|
|
// Flectra legacy widgets can have multiple nodes inside their $el JQuery object
|
|
// so, select the proper one (other nodes are assumed not to contain proper data)
|
|
this.$el.closest("." + this.className).text(this.value)
|
|
.addClass('o_form_uri o_text_overflow')
|
|
.attr('href', this.prefix + ':' + this.value);
|
|
} else {
|
|
this.$el.text('');
|
|
}
|
|
},
|
|
/**
|
|
* Trim the value input by the user.
|
|
*
|
|
* @override
|
|
* @private
|
|
* @param {any} value
|
|
* @param {Object} [options]
|
|
*/
|
|
_setValue: function (value, options) {
|
|
if (this.field.trim) {
|
|
value = value.trim();
|
|
}
|
|
return this._super(value, options);
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Prevent the URL click from opening the record (when used on a list).
|
|
*
|
|
* @private
|
|
* @param {MouseEvent} ev
|
|
*/
|
|
_onClick: function (ev) {
|
|
ev.stopPropagation();
|
|
},
|
|
});
|
|
|
|
var FieldPhone = FieldEmail.extend({
|
|
description: _lt("Phone"),
|
|
className: 'o_field_phone',
|
|
prefix: 'tel',
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @override
|
|
* @private
|
|
*/
|
|
_renderReadonly: function () {
|
|
this._super();
|
|
|
|
// This class should technically be there in case of a very very long
|
|
// phone number, but it breaks the o_row mechanism, which is more
|
|
// important right now.
|
|
this.$el.removeClass('o_text_overflow');
|
|
},
|
|
});
|
|
|
|
var UrlWidget = InputField.extend({
|
|
description: _lt("URL"),
|
|
className: 'o_field_url',
|
|
events: _.extend({}, InputField.prototype.events, {
|
|
'click': '_onClick',
|
|
}),
|
|
supportedFieldTypes: ['char'],
|
|
|
|
/**
|
|
* Urls are links in readonly mode.
|
|
*
|
|
* @override
|
|
*/
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
this.tagName = this.mode === 'readonly' ? 'a' : 'input';
|
|
this.websitePath = this.nodeOptions.website_path || false;
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Returns the associated link.
|
|
*
|
|
* @override
|
|
*/
|
|
getFocusableElement: function () {
|
|
return this.mode === 'readonly' ? this.$el : this._super.apply(this, arguments);
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* In readonly, the widget needs to be a link with proper href and proper
|
|
* support for the design, which is achieved by the added classes.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_renderReadonly: function () {
|
|
let href = this.value;
|
|
if (this.value && !this.websitePath) {
|
|
const regex = /^(?:[fF]|[hH][tT])[tT][pP][sS]?:\/\//;
|
|
href = !regex.test(this.value) ? `http://${href}` : href;
|
|
}
|
|
this.$el.text(this.attrs.text || this.value)
|
|
.addClass('o_form_uri o_text_overflow')
|
|
.attr('target', '_blank')
|
|
.attr('href', href);
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Prevent the URL click from opening the record (when used on a list).
|
|
*
|
|
* @private
|
|
* @param {MouseEvent} ev
|
|
*/
|
|
_onClick: function (ev) {
|
|
ev.stopPropagation();
|
|
},
|
|
});
|
|
|
|
var CopyClipboard = {
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
destroy: function () {
|
|
this._super.apply(this, arguments);
|
|
if (this.clipboard) {
|
|
this.clipboard.destroy();
|
|
}
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Instatiates the Clipboad lib.
|
|
*/
|
|
_initClipboard: function () {
|
|
var self = this;
|
|
var $clipboardBtn = this.$('.o_clipboard_button');
|
|
$clipboardBtn.tooltip({title: _t('Copied !'), trigger: 'manual', placement: 'right'});
|
|
this.clipboard = new ClipboardJS($clipboardBtn[0], {
|
|
text: function () {
|
|
return self.value.trim();
|
|
},
|
|
// Container added because of Bootstrap modal that give the focus to another element.
|
|
// We need to give to correct focus to ClipboardJS (see in ClipboardJS doc)
|
|
// https://github.com/zenorocha/clipboard.js/issues/155
|
|
container: self.$el[0]
|
|
});
|
|
this.clipboard.on('success', function () {
|
|
_.defer(function () {
|
|
$clipboardBtn.tooltip('show');
|
|
_.delay(function () {
|
|
$clipboardBtn.tooltip('hide');
|
|
}, 800);
|
|
});
|
|
});
|
|
},
|
|
/**
|
|
* @override
|
|
*/
|
|
_renderReadonly: function () {
|
|
this._super.apply(this, arguments);
|
|
if (this.value) {
|
|
this.$el.append($(qweb.render(this.clipboardTemplate)));
|
|
this._initClipboard();
|
|
}
|
|
}
|
|
};
|
|
|
|
var TextCopyClipboard = FieldText.extend(CopyClipboard, {
|
|
description: _lt("Copy to Clipboard"),
|
|
clipboardTemplate: 'CopyClipboardText',
|
|
className: "o_field_copy",
|
|
});
|
|
|
|
var CharCopyClipboard = FieldChar.extend(CopyClipboard, {
|
|
description: _lt("Copy to Clipboard"),
|
|
clipboardTemplate: 'CopyClipboardChar',
|
|
className: 'o_field_copy o_text_overflow',
|
|
});
|
|
|
|
var AbstractFieldBinary = AbstractField.extend({
|
|
events: _.extend({}, AbstractField.prototype.events, {
|
|
'change .o_input_file': 'on_file_change',
|
|
'click .o_select_file_button': function () {
|
|
this.$('.o_input_file').click();
|
|
},
|
|
'click .o_clear_file_button': '_onClearClick',
|
|
}),
|
|
init: function (parent, name, record) {
|
|
this._super.apply(this, arguments);
|
|
this.fields = record.fields;
|
|
this.useFileAPI = !!window.FileReader;
|
|
this.max_upload_size = session.max_file_upload_size || 128 * 1024 * 1024;
|
|
this.accepted_file_extensions = (this.nodeOptions && this.nodeOptions.accepted_file_extensions) || this.accepted_file_extensions || '*';
|
|
if (!this.useFileAPI) {
|
|
var self = this;
|
|
this.fileupload_id = _.uniqueId('o_fileupload');
|
|
$(window).on(this.fileupload_id, function () {
|
|
var args = [].slice.call(arguments).slice(1);
|
|
self.on_file_uploaded.apply(self, args);
|
|
});
|
|
}
|
|
},
|
|
destroy: function () {
|
|
if (this.fileupload_id) {
|
|
$(window).off(this.fileupload_id);
|
|
}
|
|
this._super.apply(this, arguments);
|
|
},
|
|
on_file_change: function (e) {
|
|
var self = this;
|
|
var file_node = e.target;
|
|
if ((this.useFileAPI && file_node.files.length) || (!this.useFileAPI && $(file_node).val() !== '')) {
|
|
if (this.useFileAPI) {
|
|
var file = file_node.files[0];
|
|
if (file.size > this.max_upload_size) {
|
|
var msg = _t("The selected file exceed the maximum file size of %s.");
|
|
this.do_warn(_t("File upload"), _.str.sprintf(msg, utils.human_size(this.max_upload_size)));
|
|
return false;
|
|
}
|
|
utils.getDataURLFromFile(file).then(function (data) {
|
|
data = data.split(',')[1];
|
|
self.on_file_uploaded(file.size, file.name, file.type, data);
|
|
});
|
|
} else {
|
|
this.$('form.o_form_binary_form').submit();
|
|
}
|
|
this.$('.o_form_binary_progress').show();
|
|
this.$('button').hide();
|
|
}
|
|
},
|
|
on_file_uploaded: function (size, name) {
|
|
if (size === false) {
|
|
this.do_warn(false, _t("There was a problem while uploading your file"));
|
|
// TODO: use crashmanager
|
|
console.warn("Error while uploading file : ", name);
|
|
} else {
|
|
this.on_file_uploaded_and_valid.apply(this, arguments);
|
|
}
|
|
this.$('.o_form_binary_progress').hide();
|
|
this.$('button').show();
|
|
},
|
|
on_file_uploaded_and_valid: function (size, name, content_type, file_base64) {
|
|
this.set_filename(name);
|
|
this._setValue(file_base64);
|
|
this._render();
|
|
},
|
|
/**
|
|
* We need to update another field. This method is so deprecated it is not
|
|
* even funny. We need to replace this with the mechanism of field widgets
|
|
* declaring statically that they need to listen to every changes in other
|
|
* fields
|
|
*
|
|
* @deprecated
|
|
*
|
|
* @param {any} value
|
|
*/
|
|
set_filename: function (value) {
|
|
var filename = this.attrs.filename;
|
|
if (filename && filename in this.fields) {
|
|
var changes = {};
|
|
changes[filename] = value;
|
|
this.trigger_up('field_changed', {
|
|
dataPointID: this.dataPointID,
|
|
changes: changes,
|
|
viewType: this.viewType,
|
|
});
|
|
}
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
/**
|
|
* Clear the file input
|
|
*
|
|
* @private
|
|
*/
|
|
_clearFile: function (){
|
|
var self = this;
|
|
this.$('.o_input_file').val('');
|
|
this.set_filename('');
|
|
if (!this.isDestroyed()) {
|
|
this._setValue(false).then(function() {
|
|
self._render();
|
|
});
|
|
}
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
/**
|
|
* On "clear file" button click
|
|
*
|
|
* @param {MouseEvent} ev
|
|
* @private
|
|
*/
|
|
_onClearClick: function (ev) {
|
|
this._clearFile();
|
|
},
|
|
});
|
|
|
|
var FieldBinaryImage = AbstractFieldBinary.extend({
|
|
description: _lt("Image"),
|
|
fieldDependencies: _.extend({}, AbstractFieldBinary.prototype.fieldDependencies, {
|
|
__last_update: {type: 'datetime'},
|
|
}),
|
|
|
|
template: 'FieldBinaryImage',
|
|
placeholder: "/web/static/src/img/placeholder.png",
|
|
events: _.extend({}, AbstractFieldBinary.prototype.events, {
|
|
'click img': function () {
|
|
if (this.mode === "readonly") {
|
|
this.trigger_up('bounce_edit');
|
|
}
|
|
},
|
|
}),
|
|
supportedFieldTypes: ['binary'],
|
|
file_type_magic_word: {
|
|
'/': 'jpg',
|
|
'R': 'gif',
|
|
'i': 'png',
|
|
'P': 'svg+xml',
|
|
},
|
|
accepted_file_extensions: 'image/*',
|
|
/**
|
|
* Returns the image URL from a model.
|
|
*
|
|
* @private
|
|
* @param {string} model model from which to retrieve the image
|
|
* @param {string} res_id id of the record
|
|
* @param {string} field name of the image field
|
|
* @param {string} unique an unique integer for the record, usually __last_update
|
|
* @returns {string} URL of the image
|
|
*/
|
|
_getImageUrl: function (model, res_id, field, unique) {
|
|
return session.url('/web/image', {
|
|
model: model,
|
|
id: JSON.stringify(res_id),
|
|
field: field,
|
|
// unique forces a reload of the image when the record has been updated
|
|
unique: field_utils.format.datetime(unique).replace(/[^0-9]/g, ''),
|
|
});
|
|
},
|
|
_render: function () {
|
|
var self = this;
|
|
var url = this.placeholder;
|
|
if (this.value) {
|
|
if (!utils.is_bin_size(this.value)) {
|
|
// Use magic-word technique for detecting image type
|
|
url = 'data:image/' + (this.file_type_magic_word[this.value[0]] || 'png') + ';base64,' + this.value;
|
|
} else {
|
|
var field = this.nodeOptions.preview_image || this.name;
|
|
var unique = this.recordData.__last_update;
|
|
url = this._getImageUrl(this.model, this.res_id, field, unique);
|
|
}
|
|
}
|
|
var $img = $(qweb.render("FieldBinaryImage-img", {widget: this, url: url}));
|
|
// override css size attributes (could have been defined in css files)
|
|
// if specified on the widget
|
|
var width = this.nodeOptions.size ? this.nodeOptions.size[0] : this.attrs.width;
|
|
var height = this.nodeOptions.size ? this.nodeOptions.size[1] : this.attrs.height;
|
|
if (width) {
|
|
$img.attr('width', width);
|
|
$img.css('max-width', width + 'px');
|
|
if (!height) {
|
|
$img.css('height', 'auto');
|
|
$img.css('max-height', '100%');
|
|
}
|
|
}
|
|
if (height) {
|
|
$img.attr('height', height);
|
|
$img.css('max-height', height + 'px');
|
|
if (!width) {
|
|
$img.css('width', 'auto');
|
|
$img.css('max-width', '100%');
|
|
}
|
|
}
|
|
this.$('> img').remove();
|
|
this.$el.prepend($img);
|
|
|
|
$img.one('error', function () {
|
|
$img.attr('src', self.placeholder);
|
|
self.do_warn(false, _t("Could not display the selected image"));
|
|
});
|
|
|
|
return this._super.apply(this, arguments);
|
|
},
|
|
/**
|
|
* Only enable the zoom on image in read-only mode, and if the option is enabled.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_renderReadonly: function () {
|
|
this._super.apply(this, arguments);
|
|
|
|
if(this.nodeOptions.zoom) {
|
|
var unique = this.recordData.__last_update;
|
|
var url = this._getImageUrl(this.model, this.res_id, 'image_1920', unique);
|
|
var $img;
|
|
var imageField = _.find(Object.keys(this.recordData), function(o) {
|
|
return o.startsWith('image_');
|
|
});
|
|
|
|
if(this.nodeOptions.background)
|
|
{
|
|
if('tag' in this.nodeOptions) {
|
|
this.tagName = this.nodeOptions.tag;
|
|
}
|
|
|
|
if('class' in this.attrs) {
|
|
this.$el.addClass(this.attrs.class);
|
|
}
|
|
|
|
const image_field = this.field.manual ? this.name:'image_128';
|
|
var urlThumb = this._getImageUrl(this.model, this.res_id, image_field, unique);
|
|
|
|
this.$el.empty();
|
|
$img = this.$el;
|
|
$img.css('backgroundImage', 'url(' + urlThumb + ')');
|
|
} else {
|
|
$img = this.$('img');
|
|
}
|
|
var zoomDelay = 0;
|
|
if (this.nodeOptions.zoom_delay) {
|
|
zoomDelay = this.nodeOptions.zoom_delay;
|
|
}
|
|
|
|
if(this.recordData[imageField]) {
|
|
$img.attr('data-zoom', 1);
|
|
$img.attr('data-zoom-image', url);
|
|
|
|
$img.zoomFlectra({
|
|
event: 'mouseenter',
|
|
timer: zoomDelay,
|
|
attach: '.o_content',
|
|
attachToTarget: true,
|
|
onShow: function () {
|
|
var zoomHeight = Math.ceil(this.$zoom.height());
|
|
var zoomWidth = Math.ceil(this.$zoom.width());
|
|
if( zoomHeight < 128 && zoomWidth < 128) {
|
|
this.hide();
|
|
}
|
|
core.bus.on('keydown', this, this.hide);
|
|
core.bus.on('click', this, this.hide);
|
|
},
|
|
beforeAttach: function () {
|
|
this.$flyout.css({ width: '512px', height: '512px' });
|
|
},
|
|
preventClicks: this.nodeOptions.preventClicks,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
var CharImageUrl = AbstractField.extend({
|
|
className: 'o_field_image',
|
|
description: _lt("Image"),
|
|
supportedFieldTypes: ['char'],
|
|
placeholder: "/web/static/src/img/placeholder.png",
|
|
|
|
_renderReadonly: function () {
|
|
var self = this;
|
|
const url = this.value;
|
|
if (url) {
|
|
var $img = $(qweb.render("FieldBinaryImage-img", {widget: this, url: url}));
|
|
// override css size attributes (could have been defined in css files)
|
|
// if specified on the widget
|
|
const width = this.nodeOptions.size ? this.nodeOptions.size[0] : this.attrs.width;
|
|
const height = this.nodeOptions.size ? this.nodeOptions.size[1] : this.attrs.height;
|
|
if (width) {
|
|
$img.attr('width', width);
|
|
$img.css('max-width', width + 'px');
|
|
if (!height) {
|
|
$img.css('height', 'auto');
|
|
$img.css('max-height', '100%');
|
|
}
|
|
}
|
|
if (height) {
|
|
$img.attr('height', height);
|
|
$img.css('max-height', height + 'px');
|
|
if (!width) {
|
|
$img.css('width', 'auto');
|
|
$img.css('max-width', '100%');
|
|
}
|
|
}
|
|
this.$('> img').remove();
|
|
this.$el.prepend($img);
|
|
|
|
$img.one('error', function () {
|
|
$img.attr('src', self.placeholder);
|
|
self.displayNotification({
|
|
type: 'info',
|
|
message: _t("Could not display the specified image url."),
|
|
});
|
|
});
|
|
}
|
|
|
|
return this._super.apply(this, arguments);
|
|
},
|
|
});
|
|
|
|
var KanbanFieldBinaryImage = FieldBinaryImage.extend({
|
|
// In kanban views, there is a weird logic to determine whether or not a
|
|
// click on a card should open the record in a form view. This logic checks
|
|
// if the clicked element has click handlers bound on it, and if so, does
|
|
// not open the record (assuming that the click will be handle by someone
|
|
// else). In the case of this widget, there are clicks handler but they
|
|
// only apply in edit mode, which is never the case in kanban views, so we
|
|
// simply remove them.
|
|
events: {},
|
|
});
|
|
|
|
var KanbanCharImageUrl = CharImageUrl.extend({
|
|
// In kanban views, there is a weird logic to determine whether or not a
|
|
// click on a card should open the record in a form view. This logic checks
|
|
// if the clicked element has click handlers bound on it, and if so, does
|
|
// not open the record (assuming that the click will be handled by someone
|
|
// else). In the case of this widget, there are clicks handler but they
|
|
// only apply in edit mode, which is never the case in kanban views, so we
|
|
// simply remove them.
|
|
events: {},
|
|
});
|
|
|
|
var FieldBinaryFile = AbstractFieldBinary.extend({
|
|
description: _lt("File"),
|
|
template: 'FieldBinaryFile',
|
|
events: _.extend({}, AbstractFieldBinary.prototype.events, {
|
|
'click': function (event) {
|
|
if (this.mode === 'readonly' && this.value && this.recordData.id) {
|
|
this.on_save_as(event);
|
|
}
|
|
},
|
|
'click .o_input': function () { // eq[0]
|
|
this.$('.o_input_file').click();
|
|
},
|
|
}),
|
|
supportedFieldTypes: ['binary'],
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
this.filename_value = this.recordData[this.attrs.filename];
|
|
},
|
|
_renderReadonly: function () {
|
|
var visible = !!(this.value && this.res_id);
|
|
this.$el.empty().css('cursor', 'not-allowed');
|
|
this.do_toggle(visible);
|
|
if (visible) {
|
|
this.$el.css('cursor', 'pointer')
|
|
.text(this.filename_value || '')
|
|
.prepend($('<span class="fa fa-download"/>'), ' ');
|
|
}
|
|
},
|
|
_renderEdit: function () {
|
|
if (this.value) {
|
|
this.$el.children().removeClass('o_hidden');
|
|
this.$('.o_select_file_button').first().addClass('o_hidden');
|
|
this.$('.o_input').eq(0).val(this.filename_value || this.value);
|
|
} else {
|
|
this.$el.children().addClass('o_hidden');
|
|
this.$('.o_select_file_button').first().removeClass('o_hidden');
|
|
}
|
|
},
|
|
set_filename: function (value) {
|
|
this._super.apply(this, arguments);
|
|
this.filename_value = value; // will be used in the re-render
|
|
// the filename being edited but not yet saved, if the user clicks on
|
|
// download, he'll get the file corresponding to the current value
|
|
// stored in db, which isn't the one whose filename is displayed in the
|
|
// input, so we disable the download button
|
|
this.$('.o_save_file_button').prop('disabled', true);
|
|
},
|
|
on_save_as: function (ev) {
|
|
if (!this.value) {
|
|
this.do_warn(false, _t("The field is empty, there's nothing to save."));
|
|
ev.stopPropagation();
|
|
} else if (this.res_id) {
|
|
framework.blockUI();
|
|
var filename_fieldname = this.attrs.filename;
|
|
this.getSession().get_file({
|
|
complete: framework.unblockUI,
|
|
data: {
|
|
'model': this.model,
|
|
'id': this.res_id,
|
|
'field': this.name,
|
|
'filename_field': filename_fieldname,
|
|
'filename': this.recordData[filename_fieldname] || "",
|
|
'download': true,
|
|
'data': utils.is_bin_size(this.value) ? null : this.value,
|
|
},
|
|
error: (error) => this.call('crash_manager', 'rpc_error', error),
|
|
url: '/web/content',
|
|
});
|
|
ev.stopPropagation();
|
|
}
|
|
},
|
|
});
|
|
|
|
var FieldPdfViewer = FieldBinaryFile.extend({
|
|
description: _lt("PDF Viewer"),
|
|
supportedFieldTypes: ['binary'],
|
|
template: 'FieldPdfViewer',
|
|
accepted_file_extensions: 'application/pdf',
|
|
/**
|
|
* @override
|
|
*/
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
this.PDFViewerApplication = false;
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @private
|
|
* @param {DOMElement} iframe
|
|
*/
|
|
_disableButtons: function (iframe) {
|
|
$(iframe).contents().find('button#openFile').hide();
|
|
},
|
|
/**
|
|
* @private
|
|
* @param {string} [fileURI] file URI if specified
|
|
* @returns {string} the pdf viewer URI
|
|
*/
|
|
_getURI: function (fileURI) {
|
|
var page = this.recordData[this.name + '_page'] || 1;
|
|
if (!fileURI) {
|
|
var queryObj = {
|
|
model: this.model,
|
|
field: this.name,
|
|
id: this.res_id,
|
|
};
|
|
var queryString = $.param(queryObj);
|
|
fileURI = '/web/content?' + queryString;
|
|
}
|
|
fileURI = encodeURIComponent(fileURI);
|
|
var viewerURL = '/web/static/lib/pdfjs/web/viewer.html?file=';
|
|
return viewerURL + fileURI + '#page=' + page;
|
|
},
|
|
/**
|
|
* @private
|
|
* @override
|
|
*/
|
|
_render: function () {
|
|
var self = this;
|
|
var $pdfViewer = this.$('.o_form_pdf_controls').children().add(this.$('.o_pdfview_iframe'));
|
|
var $selectUpload = this.$('.o_select_file_button').first();
|
|
var $iFrame = this.$('.o_pdfview_iframe');
|
|
|
|
$iFrame.on('load', function () {
|
|
self.PDFViewerApplication = this.contentWindow.window.PDFViewerApplication;
|
|
self._disableButtons(this);
|
|
});
|
|
if (this.mode === "readonly" && this.value) {
|
|
$iFrame.attr('src', this._getURI());
|
|
} else {
|
|
if (this.value) {
|
|
var binSize = utils.is_bin_size(this.value);
|
|
$pdfViewer.removeClass('o_hidden');
|
|
$selectUpload.addClass('o_hidden');
|
|
if (binSize) {
|
|
$iFrame.attr('src', this._getURI());
|
|
}
|
|
} else {
|
|
$pdfViewer.addClass('o_hidden');
|
|
$selectUpload.removeClass('o_hidden');
|
|
}
|
|
}
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @override
|
|
* @private
|
|
* @param {Event} ev
|
|
*/
|
|
on_file_change: function (ev) {
|
|
this._super.apply(this, arguments);
|
|
var files = ev.target.files;
|
|
if (!files || files.length === 0) {
|
|
return;
|
|
}
|
|
// TOCheck: is there requirement to fallback on FileReader if browser don't support URL
|
|
var fileURI = URL.createObjectURL(files[0]);
|
|
if (this.PDFViewerApplication) {
|
|
this.PDFViewerApplication.open(fileURI, 0);
|
|
} else {
|
|
this.$('.o_pdfview_iframe').attr('src', this._getURI(fileURI));
|
|
}
|
|
},
|
|
/**
|
|
* Remove the behaviour of on_save_as in FieldBinaryFile.
|
|
*
|
|
* @override
|
|
* @private
|
|
* @param {MouseEvent} ev
|
|
*/
|
|
on_save_as: function (ev) {
|
|
ev.stopPropagation();
|
|
},
|
|
|
|
});
|
|
|
|
var PriorityWidget = AbstractField.extend({
|
|
description: _lt("Priority"),
|
|
// the current implementation of this widget makes it
|
|
// only usable for fields of type selection
|
|
className: "o_priority",
|
|
attributes: {
|
|
'role': 'radiogroup',
|
|
},
|
|
events: {
|
|
'mouseover > a': '_onMouseOver',
|
|
'mouseout > a': '_onMouseOut',
|
|
'click > a': '_onClick',
|
|
'keydown > a': '_onKeydown',
|
|
},
|
|
supportedFieldTypes: ['selection'],
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Like boolean fields, this widget always has a value, since the default
|
|
* value is already a valid value.
|
|
*
|
|
* @override
|
|
*/
|
|
isSet: function () {
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Returns the currently-checked star, or the first one if no star is
|
|
* checked.
|
|
*
|
|
* @override
|
|
*/
|
|
getFocusableElement: function () {
|
|
var checked = this.$("[aria-checked='true']");
|
|
return checked.length ? checked : this.$("[data-index='1']");
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Renders a star for each possible value, readonly or edit mode doesn't matter.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_render: function () {
|
|
var self = this;
|
|
var index_value = this.value ? _.findIndex(this.field.selection, function (v) {
|
|
return v[0] === self.value;
|
|
}) : 0;
|
|
this.$el.empty();
|
|
this.empty_value = this.field.selection[0][0];
|
|
this.$el.attr('aria-label', this.string);
|
|
const isReadonly = this.record.evalModifiers(this.attrs.modifiers).readonly;
|
|
_.each(this.field.selection.slice(1), function (choice, index) {
|
|
const tag = isReadonly ? '<span>' : '<a href="#">';
|
|
self.$el.append(self._renderStar(tag, index_value >= index + 1, index + 1, choice[1], index_value));
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Renders a star representing a particular value for this field.
|
|
*
|
|
* @param {string} tag html tag to be passed to jquery to hold the star
|
|
* @param {boolean} isFull whether the star is a full star or not
|
|
* @param {integer} index the index of the star in the series
|
|
* @param {string} tip tooltip for this star's meaning
|
|
* @param {integer} indexValue the index of the last full star or 0
|
|
* @private
|
|
*/
|
|
_renderStar: function (tag, isFull, index, tip, indexValue) {
|
|
var isChecked = indexValue === index;
|
|
var defaultFocus = indexValue === 0 && index === 1;
|
|
return $(tag)
|
|
.attr('role', 'radio')
|
|
.attr('aria-checked', isChecked)
|
|
.attr('title', tip)
|
|
.attr('aria-label', tip)
|
|
.attr('tabindex', isChecked || defaultFocus ? 0 : -1)
|
|
.attr('data-index', index)
|
|
.addClass('o_priority_star fa')
|
|
.toggleClass('fa-star', isFull)
|
|
.toggleClass('fa-star-o', !isFull);
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Update the value of the field based on which star the user clicked on.
|
|
*
|
|
* @param {MouseEvent} event
|
|
* @private
|
|
*/
|
|
_onClick: function (event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
var index = $(event.currentTarget).data('index');
|
|
var newValue = this.field.selection[index][0];
|
|
if (newValue === this.value) {
|
|
newValue = this.empty_value;
|
|
}
|
|
this._setValue(newValue);
|
|
},
|
|
|
|
/**
|
|
* Reset the star display status.
|
|
*
|
|
* @private
|
|
*/
|
|
_onMouseOut: function () {
|
|
clearTimeout(this.hoverTimer);
|
|
var self = this;
|
|
this.hoverTimer = setTimeout(function () {
|
|
self._render();
|
|
}, 200);
|
|
},
|
|
|
|
/**
|
|
* Colors the stars to show the user the result when clicking on it.
|
|
*
|
|
* @param {MouseEvent} event
|
|
* @private
|
|
*/
|
|
_onMouseOver: function (event) {
|
|
clearTimeout(this.hoverTimer);
|
|
this.$('.o_priority_star').removeClass('fa-star-o').addClass('fa-star');
|
|
$(event.currentTarget).nextAll().removeClass('fa-star').addClass('fa-star-o');
|
|
},
|
|
|
|
/**
|
|
* Runs the default behavior when <enter> is pressed over a star
|
|
* (the same as if it was clicked); otherwise forwards event to the widget.
|
|
*
|
|
* @param {KeydownEvent} event
|
|
* @private
|
|
*/
|
|
_onKeydown: function (event) {
|
|
if (event.which === $.ui.keyCode.ENTER) {
|
|
return;
|
|
}
|
|
this._super.apply(this, arguments);
|
|
},
|
|
|
|
_onNavigationMove: function (ev) {
|
|
var $curControl = this.$('a:focus');
|
|
var $nextControl;
|
|
if (ev.data.direction === 'right' || ev.data.direction === 'down') {
|
|
$nextControl = $curControl.next('a');
|
|
} else if (ev.data.direction === 'left' || ev.data.direction === 'up') {
|
|
$nextControl = $curControl.prev('a');
|
|
}
|
|
if ($nextControl && $nextControl.length) {
|
|
ev.stopPropagation();
|
|
$nextControl.focus();
|
|
return;
|
|
}
|
|
this._super.apply(this, arguments);
|
|
},
|
|
});
|
|
|
|
var AttachmentImage = AbstractField.extend({
|
|
className: 'o_attachment_image',
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Reset cover image when widget value change
|
|
*
|
|
* @private
|
|
*/
|
|
_render: function () {
|
|
if (this.value) {
|
|
this.$el.empty().append($('<img>/', {
|
|
src: "/web/image/" + this.value.data.id + "?unique=1",
|
|
title: this.value.data.display_name,
|
|
alt: _t("Image")
|
|
}));
|
|
}
|
|
}
|
|
});
|
|
|
|
var StateSelectionWidget = AbstractField.extend({
|
|
template: 'FormSelection',
|
|
events: {
|
|
'click .dropdown-item': '_setSelection',
|
|
},
|
|
supportedFieldTypes: ['selection'],
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Returns the drop down button.
|
|
*
|
|
* @override
|
|
*/
|
|
getFocusableElement: function () {
|
|
return this.$("a[data-toggle='dropdown']");
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Prepares the state values to be rendered using the FormSelection.Items template.
|
|
*
|
|
* @private
|
|
*/
|
|
_prepareDropdownValues: function () {
|
|
var self = this;
|
|
var _data = [];
|
|
var current_stage_id = self.recordData.stage_id && self.recordData.stage_id[0];
|
|
var stage_data = {
|
|
id: current_stage_id,
|
|
legend_normal: this.recordData.legend_normal || undefined,
|
|
legend_blocked : this.recordData.legend_blocked || undefined,
|
|
legend_done: this.recordData.legend_done || undefined,
|
|
};
|
|
_.map(this.field.selection || [], function (selection_item) {
|
|
var value = {
|
|
'name': selection_item[0],
|
|
'tooltip': selection_item[1],
|
|
};
|
|
if (selection_item[0] === 'normal') {
|
|
value.state_name = stage_data.legend_normal ? stage_data.legend_normal : selection_item[1];
|
|
} else if (selection_item[0] === 'done') {
|
|
value.state_class = 'o_status_green';
|
|
value.state_name = stage_data.legend_done ? stage_data.legend_done : selection_item[1];
|
|
} else {
|
|
value.state_class = 'o_status_red';
|
|
value.state_name = stage_data.legend_blocked ? stage_data.legend_blocked : selection_item[1];
|
|
}
|
|
_data.push(value);
|
|
});
|
|
return _data;
|
|
},
|
|
|
|
/**
|
|
* This widget uses the FormSelection template but needs to customize it a bit.
|
|
*
|
|
* @private
|
|
* @override
|
|
*/
|
|
_render: function () {
|
|
var states = this._prepareDropdownValues();
|
|
// Adapt "FormSelection"
|
|
// Like priority, default on the first possible value if no value is given.
|
|
var currentState = _.findWhere(states, {name: this.value}) || states[0];
|
|
this.$('.o_status')
|
|
.removeClass('o_status_red o_status_green')
|
|
.addClass(currentState.state_class)
|
|
.prop('special_click', true)
|
|
.parent().attr('title', currentState.state_name)
|
|
.attr('aria-label', this.string + ": " + currentState.state_name);
|
|
|
|
// Render "FormSelection.Items" and move it into "FormSelection"
|
|
var $items = $(qweb.render('FormSelection.items', {
|
|
states: _.without(states, currentState)
|
|
}));
|
|
var $dropdown = this.$('.dropdown-menu');
|
|
$dropdown.children().remove(); // remove old items
|
|
$items.appendTo($dropdown);
|
|
|
|
// Disable edition if the field is readonly
|
|
var isReadonly = this.record.evalModifiers(this.attrs.modifiers).readonly;
|
|
this.$('a[data-toggle=dropdown]').toggleClass('disabled', isReadonly || false);
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Intercepts the click on the FormSelection.Item to set the widget value.
|
|
*
|
|
* @private
|
|
* @param {MouseEvent} ev
|
|
*/
|
|
_setSelection: function (ev) {
|
|
ev.preventDefault();
|
|
var $item = $(ev.currentTarget);
|
|
var value = String($item.data('value'));
|
|
this._setValue(value);
|
|
if (this.mode === 'edit') {
|
|
this._render();
|
|
}
|
|
},
|
|
});
|
|
|
|
var FavoriteWidget = AbstractField.extend({
|
|
className: 'o_favorite',
|
|
events: {
|
|
'click': '_setFavorite'
|
|
},
|
|
supportedFieldTypes: ['boolean'],
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* A boolean field is always set since false is a valid value.
|
|
*
|
|
* @override
|
|
*/
|
|
isSet: function () {
|
|
return true;
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Render favorite icon based on state
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_render: function () {
|
|
var tip = this.value ? _t('Remove from Favorites') : _t('Add to Favorites');
|
|
var template = this.attrs.nolabel ? '<a href="#"><i class="fa %s" title="%s" aria-label="%s" role="img"></i></a>' : '<a href="#"><i class="fa %s" role="img" aria-label="%s"></i> %s</a>';
|
|
this.$el.empty().append(_.str.sprintf(template, this.value ? 'fa-star' : 'fa-star-o', tip, tip));
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Toggle favorite state
|
|
*
|
|
* @private
|
|
* @param {MouseEvent} event
|
|
*/
|
|
_setFavorite: function (event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this._setValue(!this.value);
|
|
},
|
|
});
|
|
|
|
var LabelSelection = AbstractField.extend({
|
|
supportedFieldTypes: ['selection'],
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* This widget renders a simple non-editable label. Color classes can be set
|
|
* using the 'classes' key from the options tag, such as:
|
|
* <field [...] options="{'classes': {'value': 'className', ...}}"/>
|
|
*
|
|
* @private
|
|
* @override
|
|
*/
|
|
_render: function () {
|
|
this.classes = this.nodeOptions && this.nodeOptions.classes || {};
|
|
var labelClass = this.classes[this.value] || 'primary';
|
|
this.$el.addClass('badge badge-' + labelClass).text(this._formatValue(this.value));
|
|
},
|
|
});
|
|
|
|
var BooleanToggle = FieldBoolean.extend({
|
|
description: _lt("Toggle"),
|
|
className: FieldBoolean.prototype.className + ' o_boolean_toggle',
|
|
events: {
|
|
'click': '_onClick'
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Toggle active value
|
|
*
|
|
* @private
|
|
* @param {MouseEvent} event
|
|
*/
|
|
_onClick: function (event) {
|
|
event.stopPropagation();
|
|
this._setValue(!this.value);
|
|
},
|
|
});
|
|
|
|
var StatInfo = AbstractField.extend({
|
|
supportedFieldTypes: ['integer', 'float'],
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* StatInfo widgets are always set since they basically only display info.
|
|
*
|
|
* @override
|
|
*/
|
|
isSet: function () {
|
|
return true;
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Renders the field value using the StatInfo template. The text part of the
|
|
* widget is either the string attribute of this node in the view or the
|
|
* label of the field itself if no string attribute is given.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_render: function () {
|
|
var options = {
|
|
value: this._formatValue(this.value || 0),
|
|
};
|
|
if (! this.attrs.nolabel) {
|
|
if (this.nodeOptions.label_field && this.recordData[this.nodeOptions.label_field]) {
|
|
options.text = this.recordData[this.nodeOptions.label_field];
|
|
} else {
|
|
options.text = this.string;
|
|
}
|
|
}
|
|
this.$el.html(qweb.render("StatInfo", options));
|
|
this.$el.addClass('o_stat_info');
|
|
},
|
|
});
|
|
|
|
var FieldPercentPie = AbstractField.extend({
|
|
description: _lt("Percentage Pie"),
|
|
template: 'FieldPercentPie',
|
|
supportedFieldTypes: ['integer', 'float'],
|
|
|
|
/**
|
|
* Register some useful references for later use throughout the widget.
|
|
*
|
|
* @override
|
|
*/
|
|
start: function () {
|
|
this.$leftMask = this.$('.o_mask').first();
|
|
this.$rightMask = this.$('.o_mask').last();
|
|
this.$pieValue = this.$('.o_pie_value');
|
|
return this._super();
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* PercentPie widgets are always set since they basically only display info.
|
|
*
|
|
* @override
|
|
*/
|
|
isSet: function () {
|
|
return true;
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* This widget template needs javascript to apply the transformation
|
|
* associated with the rotation of the pie chart.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_render: function () {
|
|
var value = this.value || 0;
|
|
var degValue = 360*value/100;
|
|
|
|
this.$rightMask.toggleClass('o_full', degValue >= 180);
|
|
|
|
var leftDeg = 'rotate(' + ((degValue < 180)? 180 : degValue) + 'deg)';
|
|
var rightDeg = 'rotate(' + ((degValue < 180)? degValue : 0) + 'deg)';
|
|
this.$leftMask.css({transform: leftDeg, msTransform: leftDeg, mozTransform: leftDeg, webkitTransform: leftDeg});
|
|
this.$rightMask.css({transform: rightDeg, msTransform: rightDeg, mozTransform: rightDeg, webkitTransform: rightDeg});
|
|
|
|
this.$pieValue.text(Math.round(value) + '%');
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Node options:
|
|
*
|
|
* - title: title of the bar, displayed on top of the bar options
|
|
* - editable: boolean if value is editable
|
|
* - current_value: get the current_value from the field that must be present in the view
|
|
* - max_value: get the max_value from the field that must be present in the view
|
|
* - edit_max_value: boolean if the max_value is editable
|
|
* - title: title of the bar, displayed on top of the bar --> not translated, use parameter "title" instead
|
|
*/
|
|
var FieldProgressBar = AbstractField.extend({
|
|
description: _lt("Progress Bar"),
|
|
template: "ProgressBar",
|
|
events: {
|
|
'change input': 'on_change_input',
|
|
'input input': 'on_change_input',
|
|
'keyup input': function (e) {
|
|
if (e.which === $.ui.keyCode.ENTER) {
|
|
this.on_change_input(e);
|
|
}
|
|
},
|
|
},
|
|
supportedFieldTypes: ['integer', 'float'],
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
|
|
// the progressbar needs the values and not the field name, passed in options
|
|
if (this.recordData[this.nodeOptions.current_value]) {
|
|
this.value = this.recordData[this.nodeOptions.current_value];
|
|
}
|
|
|
|
// The few next lines determine if the widget can write on the record or not
|
|
this.editable_readonly = !!this.nodeOptions.editable_readonly;
|
|
// "hard" readonly
|
|
this.readonly = this.nodeOptions.readonly || !this.nodeOptions.editable;
|
|
|
|
this.canWrite = !this.readonly && (
|
|
this.mode === 'edit' ||
|
|
(this.editable_readonly && this.mode === 'readonly') ||
|
|
(this.viewType === 'kanban') // Keep behavior before commit
|
|
);
|
|
|
|
// Boolean to toggle if we edit the numerator (value) or the denominator (max_value)
|
|
this.edit_max_value = !!this.nodeOptions.edit_max_value;
|
|
this.max_value = this.recordData[this.nodeOptions.max_value] || 100;
|
|
|
|
this.title = _t(this.attrs.title || this.nodeOptions.title) || '';
|
|
|
|
// Ability to edit the field through the bar
|
|
// /!\ this feature is disabled
|
|
this.enableBarAsInput = false;
|
|
this.edit_on_click = this.enableBarAsInput && this.mode === 'readonly' && !this.edit_max_value;
|
|
|
|
this.write_mode = false;
|
|
},
|
|
_render: function () {
|
|
var self = this;
|
|
this._render_value();
|
|
|
|
if (this.canWrite) {
|
|
if (this.edit_on_click) {
|
|
this.$el.on('click', '.o_progress', function (e) {
|
|
var $target = $(e.currentTarget);
|
|
var numValue = Math.floor((e.pageX - $target.offset().left) / $target.outerWidth() * self.max_value);
|
|
self.on_update(numValue);
|
|
self._render_value();
|
|
});
|
|
} else {
|
|
this.$el.on('click', function () {
|
|
if (!self.write_mode) {
|
|
var $input = $('<input>', {type: 'text', class: 'o_progressbar_value o_input'});
|
|
$input.on('blur', self.on_change_input.bind(self));
|
|
self.$('.o_progressbar_value').replaceWith($input);
|
|
self.write_mode = true;
|
|
self._render_value();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
return this._super();
|
|
},
|
|
/**
|
|
* Updates the widget with value
|
|
*
|
|
* @param {Number} value
|
|
*/
|
|
on_update: function (value) {
|
|
if (this.edit_max_value) {
|
|
this.max_value = value;
|
|
this._isValid = true;
|
|
var changes = {};
|
|
changes[this.nodeOptions.max_value] = this.max_value;
|
|
this.trigger_up('field_changed', {
|
|
dataPointID: this.dataPointID,
|
|
changes: changes,
|
|
});
|
|
} else {
|
|
// _setValues accepts string and will parse it
|
|
var formattedValue = this._formatValue(value);
|
|
this._setValue(formattedValue);
|
|
}
|
|
},
|
|
on_change_input: function (e) {
|
|
var $input = $(e.target);
|
|
if (e.type === 'change' && !$input.is(':focus')) {
|
|
return;
|
|
}
|
|
|
|
var parsedValue;
|
|
try {
|
|
// Cover all numbers with parseFloat
|
|
parsedValue = field_utils.parse.float($input.val());
|
|
} catch (error) {
|
|
this.do_warn(false, _t("Please enter a numerical value"));
|
|
}
|
|
|
|
if (parsedValue !== undefined) {
|
|
if (e.type === 'input') { // ensure what has just been typed in the input is a number
|
|
// returns NaN if not a number
|
|
this._render_value(parsedValue);
|
|
if (parsedValue === 0) {
|
|
$input.select();
|
|
}
|
|
} else { // Implicit type === 'blur': we commit the value
|
|
if (this.edit_max_value) {
|
|
parsedValue = parsedValue || 100;
|
|
}
|
|
|
|
var $div = $('<div>', {class: 'o_progressbar_value'});
|
|
this.$('.o_progressbar_value').replaceWith($div);
|
|
this.write_mode = false;
|
|
|
|
this.on_update(parsedValue);
|
|
this._render_value();
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* Renders the value
|
|
*
|
|
* @private
|
|
* @param {Number} v
|
|
*/
|
|
_render_value: function (v) {
|
|
var value = this.value;
|
|
var max_value = this.max_value;
|
|
if (!isNaN(v)) {
|
|
if (this.edit_max_value) {
|
|
max_value = v;
|
|
} else {
|
|
value = v;
|
|
}
|
|
}
|
|
value = value || 0;
|
|
max_value = max_value || 0;
|
|
|
|
var widthComplete;
|
|
if (value <= max_value) {
|
|
widthComplete = value/max_value * 100;
|
|
} else {
|
|
widthComplete = 100;
|
|
}
|
|
|
|
this.$('.o_progress').toggleClass('o_progress_overflow', value > max_value)
|
|
.attr('aria-valuemin', '0')
|
|
.attr('aria-valuemax', max_value)
|
|
.attr('aria-valuenow', value);
|
|
this.$('.o_progressbar_complete').css('width', widthComplete + '%');
|
|
|
|
if (!this.write_mode) {
|
|
if (max_value !== 100) {
|
|
this.$('.o_progressbar_value').text(utils.human_number(value) + " / " + utils.human_number(max_value));
|
|
} else {
|
|
this.$('.o_progressbar_value').text(utils.human_number(value) + "%");
|
|
}
|
|
} else if (isNaN(v)) {
|
|
this.$('.o_progressbar_value').val(this.edit_max_value ? max_value : value);
|
|
this.$('.o_progressbar_value').focus().select();
|
|
}
|
|
},
|
|
/**
|
|
* The progress bar has more than one field/value to deal with
|
|
* i.e. max_value
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_reset: function () {
|
|
this._super.apply(this, arguments);
|
|
var new_max_value = this.recordData[this.nodeOptions.max_value];
|
|
this.max_value = new_max_value !== undefined ? new_max_value : this.max_value;
|
|
},
|
|
isSet: function () {
|
|
return true;
|
|
},
|
|
});
|
|
|
|
/**
|
|
* This widget is intended to be used on boolean fields. It toggles a button
|
|
* switching between a green bullet / gray bullet.
|
|
*/
|
|
var FieldToggleBoolean = AbstractField.extend({
|
|
description: _lt("Button"),
|
|
template: "toggle_button",
|
|
events: {
|
|
'click': '_onToggleButton'
|
|
},
|
|
supportedFieldTypes: ['boolean'],
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* A boolean field is always set since false is a valid value.
|
|
*
|
|
* @override
|
|
*/
|
|
isSet: function () {
|
|
return true;
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @override
|
|
* @private
|
|
*/
|
|
_render: function () {
|
|
this.$('i')
|
|
.toggleClass('o_toggle_button_success', !!this.value)
|
|
.toggleClass('text-muted', !this.value);
|
|
var isReadonly = this.record.evalModifiers(this.attrs.modifiers).readonly;
|
|
this.$el.prop('disabled', isReadonly);
|
|
var title = this.value ? this.attrs.options.active : this.attrs.options.inactive;
|
|
this.$el.attr('title', title);
|
|
this.$el.attr('aria-pressed', this.value);
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Toggle the button
|
|
*
|
|
* @private
|
|
* @param {MouseEvent} event
|
|
*/
|
|
_onToggleButton: function (event) {
|
|
event.stopPropagation();
|
|
this._setValue(!this.value);
|
|
},
|
|
});
|
|
|
|
var JournalDashboardGraph = AbstractField.extend({
|
|
className: "o_dashboard_graph",
|
|
jsLibs: [
|
|
'/web/static/lib/Chart/Chart.js',
|
|
],
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
this.graph_type = this.attrs.graph_type;
|
|
this.data = JSON.parse(this.value);
|
|
},
|
|
/**
|
|
* The widget view uses the ChartJS lib to render the graph. This lib
|
|
* requires that the rendering is done directly into the DOM (so that it can
|
|
* correctly compute positions). However, the views are always rendered in
|
|
* fragments, and appended to the DOM once ready (to prevent them from
|
|
* flickering). We here use the on_attach_callback hook, called when the
|
|
* widget is attached to the DOM, to perform the rendering. This ensures
|
|
* that the rendering is always done in the DOM.
|
|
*/
|
|
on_attach_callback: function () {
|
|
this._isInDOM = true;
|
|
this._renderInDOM();
|
|
},
|
|
/**
|
|
* Called when the field is detached from the DOM.
|
|
*/
|
|
on_detach_callback: function () {
|
|
this._isInDOM = false;
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Render the widget only when it is in the DOM.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_render: function () {
|
|
if (this._isInDOM) {
|
|
return this._renderInDOM();
|
|
}
|
|
return Promise.resolve();
|
|
},
|
|
/**
|
|
* Render the widget. This function assumes that it is attached to the DOM.
|
|
*
|
|
* @private
|
|
*/
|
|
_renderInDOM: function () {
|
|
this.$el.empty();
|
|
var config, cssClass;
|
|
if (this.graph_type === 'line') {
|
|
config = this._getLineChartConfig();
|
|
cssClass = 'o_graph_linechart';
|
|
} else if (this.graph_type === 'bar') {
|
|
config = this._getBarChartConfig();
|
|
cssClass = 'o_graph_barchart';
|
|
}
|
|
this.$canvas = $('<canvas/>');
|
|
this.$el.addClass(cssClass);
|
|
this.$el.empty();
|
|
this.$el.append(this.$canvas);
|
|
var context = this.$canvas[0].getContext('2d');
|
|
this.chart = new Chart(context, config);
|
|
},
|
|
_getLineChartConfig: function () {
|
|
var labels = this.data[0].values.map(function (pt) {
|
|
return pt.x;
|
|
});
|
|
var borderColor = this.data[0].is_sample_data ? '#dddddd' : '#009EFB';
|
|
var backgroundColor = this.data[0].is_sample_data ? '#ebebeb' : '#dcd0d9';
|
|
return {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
data: this.data[0].values,
|
|
fill: 'start',
|
|
label: this.data[0].key,
|
|
backgroundColor: backgroundColor,
|
|
borderColor: borderColor,
|
|
borderWidth: 2,
|
|
}]
|
|
},
|
|
options: {
|
|
legend: {display: false},
|
|
scales: {
|
|
yAxes: [{display: false}],
|
|
xAxes: [{display: false}]
|
|
},
|
|
maintainAspectRatio: false,
|
|
elements: {
|
|
line: {
|
|
tension: 0.000001
|
|
}
|
|
},
|
|
tooltips: {
|
|
intersect: false,
|
|
position: 'nearest',
|
|
caretSize: 0,
|
|
},
|
|
},
|
|
};
|
|
},
|
|
_getBarChartConfig: function () {
|
|
var data = [];
|
|
var labels = [];
|
|
var backgroundColor = [];
|
|
|
|
this.data[0].values.forEach(function (pt) {
|
|
data.push(pt.value);
|
|
labels.push(pt.label);
|
|
var color = pt.type === 'past' ? '#ccbdc8' : (pt.type === 'future' ? '#a5d8d7' : '#ebebeb');
|
|
backgroundColor.push(color);
|
|
});
|
|
return {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
data: data,
|
|
fill: 'start',
|
|
label: this.data[0].key,
|
|
backgroundColor: backgroundColor,
|
|
}]
|
|
},
|
|
options: {
|
|
legend: {display: false},
|
|
scales: {
|
|
yAxes: [{display: false}],
|
|
},
|
|
maintainAspectRatio: false,
|
|
tooltips: {
|
|
intersect: false,
|
|
position: 'nearest',
|
|
caretSize: 0,
|
|
},
|
|
elements: {
|
|
line: {
|
|
tension: 0.000001
|
|
}
|
|
},
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
/**
|
|
* The "Domain" field allows the user to construct a technical-prefix domain
|
|
* thanks to a tree-like interface and see the selected records in real time.
|
|
* In debug mode, an input is also there to be able to enter the prefix char
|
|
* domain directly (or to build advanced domains the tree-like interface does
|
|
* not allow to).
|
|
*/
|
|
var FieldDomain = AbstractField.extend({
|
|
/**
|
|
* Fetches the number of records which are matched by the domain (if the
|
|
* domain is not server-valid, the value is false) and the model the
|
|
* field must work with.
|
|
*/
|
|
specialData: "_fetchSpecialDomain",
|
|
|
|
events: _.extend({}, AbstractField.prototype.events, {
|
|
"click .o_domain_show_selection_button": "_onShowSelectionButtonClick",
|
|
"click .o_field_domain_dialog_button": "_onDialogEditButtonClick",
|
|
}),
|
|
custom_events: _.extend({}, AbstractField.prototype.custom_events, {
|
|
domain_changed: "_onDomainSelectorValueChange",
|
|
domain_selected: "_onDomainSelectorDialogValueChange",
|
|
open_record: "_onOpenRecord",
|
|
}),
|
|
/**
|
|
* @constructor
|
|
* @override init from AbstractField
|
|
*/
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
|
|
this.inDialog = !!this.nodeOptions.in_dialog;
|
|
this.fsFilters = this.nodeOptions.fs_filters || {};
|
|
|
|
this.className = "o_field_domain";
|
|
if (this.mode === "edit") {
|
|
this.className += " o_edit_mode";
|
|
}
|
|
if (!this.inDialog) {
|
|
this.className += " o_inline_mode";
|
|
}
|
|
|
|
this._setState();
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* A domain field is always set since the false value is considered to be
|
|
* equal to "[]" (match all records).
|
|
*
|
|
* @override
|
|
*/
|
|
isSet: function () {
|
|
return true;
|
|
},
|
|
/**
|
|
* @override isValid from AbstractField.isValid
|
|
* Parsing the char value is not enough for this field. It is considered
|
|
* valid if the internal domain selector was built correctly and that the
|
|
* query to the model to test the domain did not fail.
|
|
*
|
|
* @returns {boolean}
|
|
*/
|
|
isValid: function () {
|
|
return (
|
|
this._super.apply(this, arguments)
|
|
&& (!this.domainSelector || this.domainSelector.isValid())
|
|
&& this._isValidForModel
|
|
);
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @private
|
|
* @override _render from AbstractField
|
|
* @returns {Promise}
|
|
*/
|
|
_render: function () {
|
|
// If there is no model, only change the non-domain-selector content
|
|
if (!this._domainModel) {
|
|
this._replaceContent();
|
|
return Promise.resolve();
|
|
}
|
|
|
|
// Convert char value to array value
|
|
var value = this.value || "[]";
|
|
|
|
// Create the domain selector or change the value of the current one...
|
|
var def;
|
|
if (!this.domainSelector) {
|
|
this.domainSelector = new DomainSelector(this, this._domainModel, value, {
|
|
readonly: this.mode === "readonly" || this.inDialog,
|
|
filters: this.fsFilters,
|
|
debugMode: config.isDebug(),
|
|
});
|
|
def = this.domainSelector.prependTo(this.$el);
|
|
} else {
|
|
def = this.domainSelector.setDomain(value);
|
|
}
|
|
// ... then replace the other content (matched records, etc)
|
|
return def.then(this._replaceContent.bind(this));
|
|
},
|
|
/**
|
|
* Render the field DOM except for the domain selector part. The full field
|
|
* DOM is composed of a DIV which contains the domain selector widget,
|
|
* followed by other content. This other content is handled by this method.
|
|
*
|
|
* @private
|
|
*/
|
|
_replaceContent: function () {
|
|
if (this._$content) {
|
|
this._$content.remove();
|
|
}
|
|
this._$content = $(qweb.render("FieldDomain.content", {
|
|
hasModel: !!this._domainModel,
|
|
isValid: !!this._isValidForModel,
|
|
nbRecords: this.record.specialData[this.name].nbRecords || 0,
|
|
inDialogEdit: this.inDialog && this.mode === "edit",
|
|
}));
|
|
this._$content.appendTo(this.$el);
|
|
},
|
|
/**
|
|
* @override _reset from AbstractField
|
|
* Check if the model the field works with has (to be) changed.
|
|
*
|
|
* @private
|
|
*/
|
|
_reset: function () {
|
|
this._super.apply(this, arguments);
|
|
var oldDomainModel = this._domainModel;
|
|
this._setState();
|
|
if (this.domainSelector && this._domainModel !== oldDomainModel) {
|
|
// If the model has changed, destroy the current domain selector
|
|
this.domainSelector.destroy();
|
|
this.domainSelector = null;
|
|
}
|
|
},
|
|
/**
|
|
* Sets the model the field must work with and whether or not the current
|
|
* domain value is valid for this particular model. This is inferred from
|
|
* the received special data.
|
|
*
|
|
* @private
|
|
*/
|
|
_setState: function () {
|
|
var specialData = this.record.specialData[this.name];
|
|
this._domainModel = specialData.model;
|
|
this._isValidForModel = (specialData.nbRecords !== false);
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Called when the "Show selection" button is clicked
|
|
* -> Open a modal to see the matched records
|
|
*
|
|
* @param {Event} e
|
|
*/
|
|
_onShowSelectionButtonClick: function (e) {
|
|
e.preventDefault();
|
|
new view_dialogs.SelectCreateDialog(this, {
|
|
title: _t("Selected records"),
|
|
res_model: this._domainModel,
|
|
context: this.record.getContext({fieldName: this.name, viewType: this.viewType}),
|
|
domain: this.value || "[]",
|
|
no_create: true,
|
|
readonly: true,
|
|
disable_multiple_selection: true,
|
|
}).open();
|
|
},
|
|
/**
|
|
* Called when the "Edit domain" button is clicked (when using the in_dialog
|
|
* option) -> Open a DomainSelectorDialog to edit the value
|
|
*
|
|
* @param {Event} e
|
|
*/
|
|
_onDialogEditButtonClick: function (e) {
|
|
e.preventDefault();
|
|
new DomainSelectorDialog(this, this._domainModel, this.value || "[]", {
|
|
readonly: this.mode === "readonly",
|
|
filters: this.fsFilters,
|
|
debugMode: config.isDebug(),
|
|
}).open();
|
|
},
|
|
/**
|
|
* Called when the domain selector value is changed (do nothing if it is the
|
|
* one which is in a dialog (@see _onDomainSelectorDialogValueChange))
|
|
* -> Adapt the internal value state
|
|
*
|
|
* @param {FlectraEvent} e
|
|
*/
|
|
_onDomainSelectorValueChange: function (e) {
|
|
if (this.inDialog) return;
|
|
this._setValue(Domain.prototype.arrayToString(this.domainSelector.getDomain()));
|
|
},
|
|
/**
|
|
* Called when the in-dialog domain selector value is confirmed
|
|
* -> Adapt the internal value state
|
|
*
|
|
* @param {FlectraEvent} e
|
|
*/
|
|
_onDomainSelectorDialogValueChange: function (e) {
|
|
this._setValue(Domain.prototype.arrayToString(e.data.domain));
|
|
},
|
|
/**
|
|
* Stops the propagation of the 'open_record' event, as we don't want the
|
|
* user to be able to open records from the list opened in a dialog.
|
|
*
|
|
* @param {FlectraEvent} event
|
|
*/
|
|
_onOpenRecord: function (event) {
|
|
event.stopPropagation();
|
|
},
|
|
});
|
|
|
|
/**
|
|
* This widget is intended to be used on Text fields. It will provide Ace Editor
|
|
* for editing XML and Python.
|
|
*/
|
|
var AceEditor = DebouncedField.extend({
|
|
template: "AceEditor",
|
|
jsLibs: [
|
|
'/web/static/lib/ace/ace.js',
|
|
[
|
|
'/web/static/lib/ace/mode-python.js',
|
|
'/web/static/lib/ace/mode-xml.js'
|
|
]
|
|
],
|
|
events: {}, // events are triggered manually for this debounced widget
|
|
/**
|
|
* @override start from AbstractField (Widget)
|
|
*
|
|
* @returns {Promise}
|
|
*/
|
|
start: function () {
|
|
this._startAce(this.$('.ace-view-editor')[0]);
|
|
return this._super.apply(this, arguments);
|
|
},
|
|
/**
|
|
* @override destroy from AbstractField (Widget)
|
|
*/
|
|
destroy: function () {
|
|
if (this.aceEditor) {
|
|
this.aceEditor.destroy();
|
|
}
|
|
this._super.apply(this, arguments);
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Format value
|
|
*
|
|
* Note: We have to overwrite this method to always return a string.
|
|
* AceEditor works with string and not boolean value.
|
|
*
|
|
* @override
|
|
* @private
|
|
* @param {boolean|string} value
|
|
* @returns {string}
|
|
*/
|
|
_formatValue: function (value) {
|
|
return this._super.apply(this, arguments) || '';
|
|
},
|
|
|
|
/**
|
|
* @override
|
|
* @private
|
|
*/
|
|
_getValue: function () {
|
|
return this.aceSession.getValue();
|
|
},
|
|
/**
|
|
* @override _render from AbstractField
|
|
* The rendering is the same for edit and readonly mode: changing the ace
|
|
* session value. This is only done if the value in the ace editor is not
|
|
* already the new one (prevent losing focus / retriggering changes / empty
|
|
* the undo stack / ...).
|
|
*
|
|
* @private
|
|
*/
|
|
_render: function () {
|
|
var newValue = this._formatValue(this.value);
|
|
if (this.aceSession.getValue() !== newValue) {
|
|
this.aceSession.setValue(newValue);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Starts the ace library on the given DOM element. This initializes the
|
|
* ace editor option according to the edit/readonly mode and binds ace
|
|
* editor events.
|
|
*
|
|
* @private
|
|
* @param {Node} node - the DOM element the ace library must initialize on
|
|
*/
|
|
_startAce: function (node) {
|
|
this.aceEditor = ace.edit(node);
|
|
this.aceEditor.setOptions({
|
|
maxLines: Infinity,
|
|
showPrintMargin: false,
|
|
});
|
|
if (this.mode === 'readonly') {
|
|
this.aceEditor.renderer.setOptions({
|
|
displayIndentGuides: false,
|
|
showGutter: false,
|
|
});
|
|
this.aceEditor.setOptions({
|
|
highlightActiveLine: false,
|
|
highlightGutterLine: false,
|
|
readOnly: true,
|
|
});
|
|
this.aceEditor.renderer.$cursorLayer.element.style.display = "none";
|
|
}
|
|
this.aceEditor.$blockScrolling = true;
|
|
this.aceSession = this.aceEditor.getSession();
|
|
this.aceSession.setOptions({
|
|
useWorker: false,
|
|
mode: "ace/mode/" + (this.nodeOptions.mode || 'xml'),
|
|
tabSize: 2,
|
|
useSoftTabs: true,
|
|
});
|
|
if (this.mode === "edit") {
|
|
this.aceEditor.on("change", this._doDebouncedAction.bind(this));
|
|
this.aceEditor.on("blur", this._doAction.bind(this));
|
|
}
|
|
},
|
|
});
|
|
|
|
|
|
/**
|
|
* The FieldColor widget give a visual representation of a color
|
|
* Clicking on it bring up an instance of ColorpickerDialog
|
|
*/
|
|
var FieldColor = AbstractField.extend({
|
|
template: 'FieldColor',
|
|
events: _.extend({}, AbstractField.prototype.events, {
|
|
'click .o_field_color': '_onColorClick',
|
|
}),
|
|
custom_events: _.extend({}, AbstractField.prototype.custom_events, {
|
|
'colorpicker:saved': '_onColorpickerSaved',
|
|
}),
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
getFocusableElement: function () {
|
|
return this.$('.o_field_color');
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @override
|
|
* @private
|
|
*/
|
|
_render: function () {
|
|
this.$('.o_field_color').data('value', this.value)
|
|
.css('background-color', this.value)
|
|
.attr('title', this.value);
|
|
return this._super.apply(this, arguments);
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_onColorClick: function () {
|
|
if (this.mode === 'edit') {
|
|
const dialog = new ColorpickerDialog(this, {
|
|
defaultColor: this.value,
|
|
noTransparency: true,
|
|
}).open();
|
|
dialog.on('closed', this, () => {
|
|
// we need to wait for the modal to execute its whole close function.
|
|
Promise.resolve().then(() => {
|
|
this.getFocusableElement().focus();
|
|
});
|
|
});
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {FlectraEvent} ev
|
|
*/
|
|
_onColorpickerSaved: function (ev) {
|
|
this._setValue(ev.data.hex);
|
|
},
|
|
|
|
/**
|
|
* @override
|
|
* @private
|
|
*/
|
|
_onKeydown: function (ev) {
|
|
if (ev.which === $.ui.keyCode.ENTER) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
this._onColorClick(ev);
|
|
} else {
|
|
this._super.apply(this, arguments);
|
|
}
|
|
},
|
|
});
|
|
|
|
var FieldColorPicker = FieldInteger.extend({
|
|
RECORD_COLORS: [
|
|
_t('No color'),
|
|
_t('Red'),
|
|
_t('Orange'),
|
|
_t('Yellow'),
|
|
_t('Light blue'),
|
|
_t('Dark purple'),
|
|
_t('Salmon pink'),
|
|
_t('Medium blue'),
|
|
_t('Dark blue'),
|
|
_t('Fushia'),
|
|
_t('Green'),
|
|
_t('Purple'),
|
|
],
|
|
|
|
/**
|
|
* Prepares the rendering, since we are based on an input but not using it
|
|
* setting tagName after parent init force the widget to not render an input
|
|
*
|
|
* @override
|
|
*/
|
|
init: function () {
|
|
this._super.apply(this, arguments);
|
|
this.tagName = 'div';
|
|
},
|
|
/**
|
|
* Render the widget when it is edited.
|
|
*
|
|
* @override
|
|
*/
|
|
_renderEdit: function () {
|
|
this.$el.html(qweb.render('ColorPicker'));
|
|
this._setupColorPicker();
|
|
this._highlightSelectedColor();
|
|
},
|
|
/**
|
|
* Render the widget when it is NOT edited.
|
|
*
|
|
* @override
|
|
*/
|
|
_renderReadonly: function () {
|
|
var selectedColorName = this.RECORD_COLORS[this.value];
|
|
this.$el.html(qweb.render('ColorPickerReadonly', { active_color: this.value, name_color: selectedColorName }));
|
|
this.$el.on('click', 'a', function(ev){ ev.preventDefault(); });
|
|
},
|
|
/**
|
|
* Render the kanban colors inside first ul element.
|
|
* This is the same template as in KanbanRecord.
|
|
*
|
|
* <a> elements click are bound to _onColorChanged
|
|
*
|
|
*/
|
|
_setupColorPicker: function () {
|
|
var $colorpicker = this.$('ul');
|
|
if (!$colorpicker.length) {
|
|
return;
|
|
}
|
|
$colorpicker.html(qweb.render('KanbanColorPicker', { colors: this.RECORD_COLORS }));
|
|
$colorpicker.on('click', 'a', this._onColorChanged.bind(this));
|
|
},
|
|
/**
|
|
* Returns the widget value.
|
|
* Since NumericField is based on an input, but we don't use it,
|
|
* we override this function to use the internal value of the widget.
|
|
*
|
|
*
|
|
* @override
|
|
* @returns {string}
|
|
*/
|
|
_getValue: function (){
|
|
return this.value;
|
|
},
|
|
/**
|
|
* Listener in edit mode for click on a color.
|
|
* The actual color can be found in the data-color
|
|
* attribute of the target element.
|
|
*
|
|
* We re-render the widget after the update because
|
|
* the selected color has changed and it should
|
|
* be reflected in the ui.
|
|
*
|
|
* @param ev
|
|
*/
|
|
_onColorChanged: function(ev) {
|
|
ev.preventDefault();
|
|
var color = null;
|
|
if(ev.currentTarget && ev.currentTarget.dataset && ev.currentTarget.dataset.color){
|
|
color = ev.currentTarget.dataset.color;
|
|
}
|
|
if(color){
|
|
this.value = color;
|
|
this._onChange();
|
|
this._renderEdit();
|
|
}
|
|
},
|
|
/**
|
|
* Helper to modify the active color's style
|
|
* while in edit mode.
|
|
*
|
|
*/
|
|
_highlightSelectedColor: function(){
|
|
try{
|
|
$(this.$('li')[parseInt(this.value)]).css('border', '2px solid teal');
|
|
} catch(err) {
|
|
|
|
}
|
|
},
|
|
_onNavigationMove() {
|
|
// disable navigation from FieldInput, to prevent a crash
|
|
}
|
|
});
|
|
|
|
return {
|
|
TranslatableFieldMixin: TranslatableFieldMixin,
|
|
DebouncedField: DebouncedField,
|
|
FieldEmail: FieldEmail,
|
|
FieldBinaryFile: FieldBinaryFile,
|
|
FieldPdfViewer: FieldPdfViewer,
|
|
AbstractFieldBinary: AbstractFieldBinary,
|
|
FieldBinaryImage: FieldBinaryImage,
|
|
KanbanFieldBinaryImage: KanbanFieldBinaryImage,
|
|
CharImageUrl: CharImageUrl,
|
|
KanbanCharImageUrl: KanbanCharImageUrl,
|
|
FieldBoolean: FieldBoolean,
|
|
BooleanToggle: BooleanToggle,
|
|
FieldChar: FieldChar,
|
|
LinkButton: LinkButton,
|
|
FieldDate: FieldDate,
|
|
FieldDateTime: FieldDateTime,
|
|
FieldDateRange: FieldDateRange,
|
|
RemainingDays: RemainingDays,
|
|
FieldDomain: FieldDomain,
|
|
FieldFloat: FieldFloat,
|
|
FieldFloatTime: FieldFloatTime,
|
|
FieldFloatFactor: FieldFloatFactor,
|
|
FieldFloatToggle: FieldFloatToggle,
|
|
FieldPercentage: FieldPercentage,
|
|
FieldInteger: FieldInteger,
|
|
FieldMonetary: FieldMonetary,
|
|
FieldPercentPie: FieldPercentPie,
|
|
FieldPhone: FieldPhone,
|
|
FieldProgressBar: FieldProgressBar,
|
|
FieldText: FieldText,
|
|
ListFieldText: ListFieldText,
|
|
FieldToggleBoolean: FieldToggleBoolean,
|
|
HandleWidget: HandleWidget,
|
|
InputField: InputField,
|
|
NumericField: NumericField,
|
|
AttachmentImage: AttachmentImage,
|
|
LabelSelection: LabelSelection,
|
|
StateSelectionWidget: StateSelectionWidget,
|
|
FavoriteWidget: FavoriteWidget,
|
|
PriorityWidget: PriorityWidget,
|
|
StatInfo: StatInfo,
|
|
UrlWidget: UrlWidget,
|
|
TextCopyClipboard: TextCopyClipboard,
|
|
CharCopyClipboard: CharCopyClipboard,
|
|
JournalDashboardGraph: JournalDashboardGraph,
|
|
AceEditor: AceEditor,
|
|
FieldColor: FieldColor,
|
|
FieldColorPicker: FieldColorPicker,
|
|
};
|
|
|
|
});
|