diff options
Diffstat (limited to 'addons/web/static/src/js/fields/basic_fields.js')
| -rw-r--r-- | addons/web/static/src/js/fields/basic_fields.js | 459 |
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, }; }); |
