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, }; });