From 3751379f1e9a4c215fb6eb898b4ccc67659b9ace Mon Sep 17 00:00:00 2001 From: stephanchrst Date: Tue, 10 May 2022 21:51:50 +0700 Subject: initial commit 2 --- addons/web/static/src/js/fields/basic_fields.js | 3757 +++++++++++++++++++++++ 1 file changed, 3757 insertions(+) create mode 100644 addons/web/static/src/js/fields/basic_fields.js (limited to 'addons/web/static/src/js/fields/basic_fields.js') diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js new file mode 100644 index 00000000..d80bb933 --- /dev/null +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -0,0 +1,3757 @@ +odoo.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.zoomodoo"); + +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 $(`${lang}`) + .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 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 element to prepare and save as the $input attribute. + * If no element is given, the is created. + * @returns {jQuery} the prepared this.$input element + */ + _prepareInput: function ($input) { + this.$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 {OdooEvent} 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 {OdooEvent} 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(""); + 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: { + 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, 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.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('
').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 + * + * @private + * @override + * @param {KeyboardEvent} ev + */ + async _onKeydown(ev) { + this._super(...arguments); + if (ev.which === $.ui.keyCode.ENTER) { + await this._setValue(this.$input.val()); + 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 = $('', {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 {OdooEvent} 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 = $('', { 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 {OdooEvent} 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) { + // Odoo 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.attr('height', height); + $img.css('max-height', height + 'px'); + } + 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.zoomOdoo({ + 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.attr('height', height); + $img.css('max-height', height + 'px'); + } + 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 () { + this.do_toggle(!!this.value); + if (this.value) { + this.$el.empty().append($("").addClass('fa fa-download')); + if (this.recordData.id) { + this.$el.css('cursor', 'pointer'); + } else { + this.$el.css('cursor', 'not-allowed'); + } + if (this.filename_value) { + this.$el.append(" " + this.filename_value); + } + } + if (!this.res_id) { + this.$el.css('cursor', 'not-allowed'); + } else { + this.$el.css('cursor', 'pointer'); + } + }, + _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 ? '' : ''; + 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 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($('/', { + 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 ? '' : ' %s'; + 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: + * + * + * @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 = $('', {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 = $('
', {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 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 = $(''); + 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' : '#875a7b'; + 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 {OdooEvent} 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 {OdooEvent} 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 {OdooEvent} 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 {OdooEvent} 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. + * + * 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, +}; + +}); -- cgit v1.2.3