summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/fields/basic_fields.js
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/src/js/fields/basic_fields.js')
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js459
1 files changed, 368 insertions, 91 deletions
diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js
index eeda3d220b6..c1d01022c68 100644
--- a/addons/web/static/src/js/fields/basic_fields.js
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -10,17 +10,21 @@ odoo.define('web.basic_fields', function (require) {
var AbstractField = require('web.AbstractField');
var config = require('web.config');
var core = require('web.core');
-var crash_manager = require('web.crash_manager');
var datepicker = require('web.datepicker');
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');
+var ColorpickerDialog = require('web.ColorpickerDialog');
+
+require("web.zoomodoo");
var qweb = core.qweb;
var _t = core._t;
@@ -36,11 +40,9 @@ var TranslatableFieldMixin = {
* @returns {jQuery}
*/
_renderTranslateButton: function () {
- if (_t.database.multi_lang && this.field.translate && this.res_id) {
- return $('<button>', {
- type: 'button',
- 'class': 'o_field_translate fa fa-globe btn btn-link',
- })
+ 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 $();
@@ -53,10 +55,16 @@ var TranslatableFieldMixin = {
/**
* open the translation view for the current field
*
+ * @param {MouseEvent} ev
* @private
*/
- _onTranslate: function () {
- this.trigger_up('translate', {fieldName: this.name, id: this.dataPointID});
+ _onTranslate: function (ev) {
+ ev.preventDefault();
+ this.trigger_up('translate', {
+ fieldName: this.name,
+ id: this.dataPointID,
+ isComingFromTranslationAlert: false,
+ });
},
};
@@ -207,7 +215,9 @@ var InputField = DebouncedField.extend({
if (!event || event === this.lastChangeEvent) {
this.isDirty = false;
}
- if (this.isDirty || (event && event.target === this && event.data.changes[this.name] === this.value)) {
+ 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
@@ -370,6 +380,41 @@ var NumericField = InputField.extend({
//--------------------------------------------------------------------------
/**
+ * 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
@@ -404,7 +449,36 @@ var NumericField = InputField.extend({
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, {
@@ -428,7 +502,10 @@ var FieldChar = InputField.extend(TranslatableFieldMixin, {
if (this.field.size && this.field.size > 0) {
this.$el.attr('maxlength', this.field.size);
}
- this.$el = this.$el.add(this._renderTranslateButton());
+ if (this.field.translate) {
+ this.$el = this.$el.add(this._renderTranslateButton());
+ this.$el.addClass('o_field_translate');
+ }
return def;
},
/**
@@ -487,11 +564,129 @@ var LinkButton = AbstractField.extend({
},
});
+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: 10,
+ locale: {
+ 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();
+ }
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // 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, Odoo 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.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();
+ }
+ });
+ },
+});
+
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
@@ -663,7 +858,7 @@ var FieldDateTime = FieldDate.extend({
},
});
-var FieldMonetary = InputField.extend({
+var FieldMonetary = NumericField.extend({
description: _lt("Monetary"),
className: 'o_field_monetary o_field_number',
tagName: 'span',
@@ -888,6 +1083,8 @@ var FieldBoolean = AbstractField.extend({
_onKeydown: function (ev) {
switch (ev.which) {
case $.ui.keyCode.ENTER:
+ // prevent subsequent 'click' event (see _onKeydown of AbstractField)
+ ev.preventDefault();
this.$input.prop('checked', !this.value);
this._setValue(!this.value);
return;
@@ -1142,8 +1339,10 @@ var FieldText = InputField.extend(TranslatableFieldMixin, {
start: function () {
if (this.mode === 'edit') {
dom.autoresize(this.$el, this.autoResizeOptions);
-
- this.$el = this.$el.add(this._renderTranslateButton());
+ if (this.field.translate) {
+ this.$el = this.$el.add(this._renderTranslateButton());
+ this.$el.addClass('o_field_translate');
+ }
}
return this._super();
},
@@ -1195,7 +1394,7 @@ var HandleWidget = AbstractField.extend({
description: _lt("Handle"),
noLabel: true,
className: 'o_row_handle fa fa-arrows ui-sortable-handle',
- widthFactor: 0,
+ widthInList: '33px',
tagName: 'span',
supportedFieldTypes: ['integer'],
@@ -1571,6 +1770,25 @@ var FieldBinaryImage = AbstractFieldBinary.extend({
'i': 'png',
'P': 'svg+xml',
},
+ /**
+ * 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;
@@ -1579,13 +1797,9 @@ var FieldBinaryImage = AbstractFieldBinary.extend({
// Use magic-word technique for detecting image type
url = 'data:image/' + (this.file_type_magic_word[this.value[0]] || 'png') + ';base64,' + this.value;
} else {
- url = session.url('/web/image', {
- model: this.model,
- id: JSON.stringify(this.res_id),
- field: this.nodeOptions.preview_image || this.name,
- // unique forces a reload of the image when the record has been updated
- unique: field_utils.format.datetime(this.recordData.__last_update).replace(/[^0-9]/g, ''),
- });
+ 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}));
@@ -1608,7 +1822,77 @@ var FieldBinaryImage = AbstractFieldBinary.extend({
$img.attr('src', self.placeholder);
self.do_warn(_t("Image"), _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);
+ }
+
+ var urlThumb = this._getImageUrl(this.model, this.res_id, 'image_128', unique);
+
+ this.$el.empty();
+ $img = this.$el;
+ $img.css('backgroundImage', 'url(' + urlThumb + ')');
+ } else {
+ $img = this.$('img');
+ }
+
+ if(this.recordData[imageField]) {
+ $img.attr('data-zoom', 1);
+ $img.attr('data-zoom-image', url);
+
+ $img.zoomOdoo({
+ event: 'mouseenter',
+ attach: '.o_content',
+ attachToTarget: true,
+ onShow: function () {
+ if(this.$zoom.height() < 256 && this.$zoom.width() < 256) {
+ this.hide();
+ }
+ },
+ beforeAttach: function () {
+ this.$flyout.css({ width: '512px', height: '512px' });
+ },
+ preventClicks: this.nodeOptions.preventClicks,
+ });
+ }
+ }
+ },
+});
+
+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 FieldBinaryFile = AbstractFieldBinary.extend({
@@ -1673,11 +1957,10 @@ var FieldBinaryFile = AbstractFieldBinary.extend({
ev.stopPropagation();
} else if (this.res_id) {
framework.blockUI();
- var c = crash_manager;
var filename_fieldname = this.attrs.filename;
this.getSession().get_file({
- 'url': '/web/content',
- 'data': {
+ complete: framework.unblockUI,
+ data: {
'model': this.model,
'id': this.res_id,
'field': this.name,
@@ -1686,8 +1969,8 @@ var FieldBinaryFile = AbstractFieldBinary.extend({
'download': true,
'data': utils.is_bin_size(this.value) ? null : this.value,
},
- 'complete': framework.unblockUI,
- 'error': c.rpc_error.bind(c),
+ error: (error) => this.call('crash_manager', 'rpc_error', error),
+ url: '/web/content',
});
ev.stopPropagation();
}
@@ -2113,68 +2396,6 @@ var LabelSelection = AbstractField.extend({
},
});
-var FieldBooleanButton = AbstractField.extend({
- className: 'o_stat_info',
- supportedFieldTypes: ['boolean'],
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * A boolean field is always set since false is a valid value.
- *
- * @override
- */
- isSet: function () {
- return true;
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * This widget is supposed to be used inside a stat button and, as such, is
- * rendered the same way in edit and readonly mode.
- *
- * @override
- * @private
- */
- _render: function () {
- this.$el.empty();
- var text, hover;
- switch (this.nodeOptions.terminology) {
- case "active":
- text = this.value ? _t("Active") : _t("Inactive");
- hover = this.value ? _t("Deactivate") : _t("Activate");
- break;
- case "archive":
- text = this.value ? _t("Active") : _t("Archived");
- hover = this.value ? _t("Archive") : _t("Restore");
- break;
- case "close":
- text = this.value ? _t("Active") : _t("Closed");
- hover = this.value ? _t("Close") : _t("Open");
- break;
- default:
- var opt_terms = this.nodeOptions.terminology || {};
- if (typeof opt_terms === 'string') {
- opt_terms = {}; //unsupported terminology
- }
- text = this.value ? _t(opt_terms.string_true) || _t("On")
- : _t(opt_terms.string_false) || _t("Off");
- hover = this.value ? _t(opt_terms.hover_true) || _t("Switch Off")
- : _t(opt_terms.hover_false) || _t("Switch On");
- }
- var val_color = this.value ? 'text-success' : 'text-danger';
- var hover_color = this.value ? 'text-danger' : 'text-success';
- var $val = $('<span>').addClass('o_stat_text o_not_hover ' + val_color).text(text);
- var $hover = $('<span>').addClass('o_stat_text o_hover ' + hover_color).text(hover);
- this.$el.append($val).append($hover);
- },
-});
-
var BooleanToggle = FieldBoolean.extend({
description: _lt("Toggle"),
className: FieldBoolean.prototype.className + ' o_boolean_toggle',
@@ -2875,6 +3096,7 @@ var FieldDomain = AbstractField.extend({
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,
@@ -3042,6 +3264,59 @@ var AceEditor = DebouncedField.extend({
},
});
+
+/**
+ * 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: {
+ 'click .o_field_color': '_onColorClick',
+ },
+ custom_events: {
+ 'colorpicker:saved': '_onColorpickerSaved',
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ this._super.apply(this, arguments);
+ this.$('.o_field_color').data('value', this.value)
+ .css('background-color', this.value)
+ .attr('title', this.value);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onColorClick: function (ev) {
+ new ColorpickerDialog(this, {
+ defaultColor: this.value,
+ noTransparency: true,
+ }).open();
+ },
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onColorpickerSaved: function (ev) {
+ this._setValue(ev.data.hex);
+ },
+});
+
return {
TranslatableFieldMixin: TranslatableFieldMixin,
DebouncedField: DebouncedField,
@@ -3050,19 +3325,20 @@ return {
FieldPdfViewer: FieldPdfViewer,
AbstractFieldBinary: AbstractFieldBinary,
FieldBinaryImage: FieldBinaryImage,
+ KanbanFieldBinaryImage: KanbanFieldBinaryImage,
FieldBoolean: FieldBoolean,
- FieldBooleanButton: FieldBooleanButton,
BooleanToggle: BooleanToggle,
FieldChar: FieldChar,
LinkButton: LinkButton,
FieldDate: FieldDate,
FieldDateTime: FieldDateTime,
+ FieldDateRange: FieldDateRange,
FieldDomain: FieldDomain,
FieldFloat: FieldFloat,
FieldFloatTime: FieldFloatTime,
FieldFloatFactor: FieldFloatFactor,
FieldFloatToggle: FieldFloatToggle,
- FieldPercentage : FieldPercentage,
+ FieldPercentage: FieldPercentage,
FieldInteger: FieldInteger,
FieldMonetary: FieldMonetary,
FieldPercentPie: FieldPercentPie,
@@ -3085,6 +3361,7 @@ return {
CharCopyClipboard: CharCopyClipboard,
JournalDashboardGraph: JournalDashboardGraph,
AceEditor: AceEditor,
+ FieldColor: FieldColor,
};
});