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('