summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/fields/basic_fields.js
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/src/js/fields/basic_fields.js')
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js3757
1 files changed, 3757 insertions, 0 deletions
diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js
new file mode 100644
index 00000000..d80bb933
--- /dev/null
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -0,0 +1,3757 @@
+odoo.define('web.basic_fields', function (require) {
+"use strict";
+
+/**
+ * This module contains most of the basic (meaning: non relational) field
+ * widgets. Field widgets are supposed to be used in views inheriting from
+ * BasicView, so, they can work with the records obtained from a BasicModel.
+ */
+
+var AbstractField = require('web.AbstractField');
+var config = require('web.config');
+var core = require('web.core');
+var datepicker = require('web.datepicker');
+var deprecatedFields = require('web.basic_fields.deprecated');
+var dom = require('web.dom');
+var Domain = require('web.Domain');
+var DomainSelector = require('web.DomainSelector');
+var DomainSelectorDialog = require('web.DomainSelectorDialog');
+var framework = require('web.framework');
+var py_utils = require('web.py_utils');
+var session = require('web.session');
+var utils = require('web.utils');
+var view_dialogs = require('web.view_dialogs');
+var field_utils = require('web.field_utils');
+var time = require('web.time');
+const {ColorpickerDialog} = require('web.Colorpicker');
+
+let FieldBoolean = deprecatedFields.FieldBoolean;
+
+require("web.zoomodoo");
+
+var qweb = core.qweb;
+var _t = core._t;
+var _lt = core._lt;
+
+var TranslatableFieldMixin = {
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {jQuery}
+ */
+ _renderTranslateButton: function () {
+ if (_t.database.multi_lang && this.field.translate) {
+ var lang = _t.database.parameters.code.split('_')[0].toUpperCase();
+ return $(`<span class="o_field_translate btn btn-link">${lang}</span>`)
+ .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 <input/> 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 <input/> element to prepare and save as the $input attribute.
+ * If no element is given, the <input/> is created.
+ * @returns {jQuery} the prepared this.$input element
+ */
+ _prepareInput: function ($input) {
+ this.$input = $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("<span role='img'/>");
+ 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('<div>').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 = $('<span>', {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 = $('<span>', { 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($("<span/>").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 ? '<span>' : '<a href="#">';
+ 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 <enter> 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($('<img>/', {
+ 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 ? '<a href="#"><i class="fa %s" title="%s" aria-label="%s" role="img"></i></a>' : '<a href="#"><i class="fa %s" role="img" aria-label="%s"></i> %s</a>';
+ 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:
+ * <field [...] options="{'classes': {'value': 'className', ...}}"/>
+ *
+ * @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 = $('<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 = $('<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 = $('<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.
+ *
+ * <a> 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,
+};
+
+});