summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/fields
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/src/js/fields')
-rw-r--r--addons/web/static/src/js/fields/abstract_field.js621
-rw-r--r--addons/web/static/src/js/fields/abstract_field_owl.js648
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js3757
-rw-r--r--addons/web/static/src/js/fields/basic_fields_owl.js132
-rw-r--r--addons/web/static/src/js/fields/field_registry.js101
-rw-r--r--addons/web/static/src/js/fields/field_registry_owl.js26
-rw-r--r--addons/web/static/src/js/fields/field_utils.js762
-rw-r--r--addons/web/static/src/js/fields/field_wrapper.js157
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js3460
-rw-r--r--addons/web/static/src/js/fields/signature.js173
-rw-r--r--addons/web/static/src/js/fields/special_fields.js262
-rw-r--r--addons/web/static/src/js/fields/upgrade_fields.js199
12 files changed, 10298 insertions, 0 deletions
diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js
new file mode 100644
index 00000000..16e928ea
--- /dev/null
+++ b/addons/web/static/src/js/fields/abstract_field.js
@@ -0,0 +1,621 @@
+odoo.define('web.AbstractField', function (require) {
+"use strict";
+
+/**
+ * This is the basic field widget used by all the views to render a field in a view.
+ * These field widgets are mostly common to all views, in particular form and list
+ * views.
+ *
+ * The responsabilities of a field widget are mainly:
+ * - render a visual representation of the current value of a field
+ * - that representation is either in 'readonly' or in 'edit' mode
+ * - notify the rest of the system when the field has been changed by
+ * the user (in edit mode)
+ *
+ * Notes
+ * - the widget is not supposed to be able to switch between modes. If another
+ * mode is required, the view will take care of instantiating another widget.
+ * - notify the system when its value has changed and its mode is changed to 'readonly'
+ * - notify the system when some action has to be taken, such as opening a record
+ * - the Field widget should not, ever, under any circumstance, be aware of
+ * its parent. The way it communicates changes with the rest of the system is by
+ * triggering events (with trigger_up). These events bubble up and are interpreted
+ * by the most appropriate parent.
+ *
+ * Also, in some cases, it may not be practical to have the same widget for all
+ * views. In that situation, you can have a 'view specific widget'. Just register
+ * the widget in the registry prefixed by the view type and a dot. So, for example,
+ * a form specific many2one widget should be registered as 'form.many2one'.
+ *
+ * @module web.AbstractField
+ */
+
+var field_utils = require('web.field_utils');
+var Widget = require('web.Widget');
+
+var AbstractField = Widget.extend({
+ events: {
+ 'keydown': '_onKeydown',
+ },
+ custom_events: {
+ navigation_move: '_onNavigationMove',
+ },
+
+ /**
+ * An object representing fields to be fetched by the model eventhough not present in the view
+ * This object contains "field name" as key and an object as value.
+ * That value object must contain the key "type"
+ * see FieldBinaryImage for an example.
+ */
+ fieldDependencies: {},
+
+ /**
+ * If this flag is set to true, the field widget will be reset on every
+ * change which is made in the view (if the view supports it). This is
+ * currently a form view feature.
+ */
+ resetOnAnyFieldChange: false,
+ /**
+ * If this flag is given a string, the related BasicModel will be used to
+ * initialize specialData the field might need. This data will be available
+ * through this.record.specialData[this.name].
+ *
+ * @see BasicModel._fetchSpecialData
+ */
+ specialData: false,
+ /**
+ * to override to indicate which field types are supported by the widget
+ *
+ * @type Array<String>
+ */
+ supportedFieldTypes: [],
+
+ /**
+ * To override to give a user friendly name to the widget.
+ *
+ * @type <string>
+ */
+ description: "",
+ /**
+ * Currently only used in list view.
+ * If this flag is set to true, the list column name will be empty.
+ */
+ noLabel: false,
+ /**
+ * Currently only used in list view.
+ * If set, this value will be displayed as column name.
+ */
+ label: '',
+ /**
+ * Abstract field class
+ *
+ * @constructor
+ * @param {Widget} parent
+ * @param {string} name The field name defined in the model
+ * @param {Object} record A record object (result of the get method of
+ * a basic model)
+ * @param {Object} [options]
+ * @param {string} [options.mode=readonly] should be 'readonly' or 'edit'
+ */
+ init: function (parent, name, record, options) {
+ this._super(parent);
+ options = options || {};
+
+ // 'name' is the field name displayed by this widget
+ this.name = name;
+
+ // the datapoint fetched from the model
+ this.record = record;
+
+ // the 'field' property is a description of all the various field properties,
+ // such as the type, the comodel (relation), ...
+ this.field = record.fields[name];
+
+ // the 'viewType' is the type of the view in which the field widget is
+ // instantiated. For standalone widgets, a 'default' viewType is set.
+ this.viewType = options.viewType || 'default';
+
+ // the 'attrs' property contains the attributes of the xml 'field' tag,
+ // the inner views...
+ var fieldsInfo = record.fieldsInfo[this.viewType];
+ this.attrs = options.attrs || (fieldsInfo && fieldsInfo[name]) || {};
+
+ // the 'additionalContext' property contains the attributes to pass through the context.
+ this.additionalContext = options.additionalContext || {};
+
+ // this property tracks the current (parsed if needed) value of the field.
+ // Note that we don't use an event system anymore, using this.get('value')
+ // is no longer valid.
+ this.value = record.data[name];
+
+ // recordData tracks the values for the other fields for the same record.
+ // note that it is expected to be mostly a readonly property, you cannot
+ // use this to try to change other fields value, this is not how it is
+ // supposed to work. Also, do not use this.recordData[this.name] to get
+ // the current value, this could be out of sync after a _setValue.
+ this.recordData = record.data;
+
+ // the 'string' property is a human readable (and translated) description
+ // of the field. Mostly useful to be displayed in various places in the
+ // UI, such as tooltips or create dialogs.
+ this.string = this.attrs.string || this.field.string || this.name;
+
+ // Widget can often be configured in the 'options' attribute in the
+ // xml 'field' tag. These options are saved (and evaled) in nodeOptions
+ this.nodeOptions = this.attrs.options || {};
+
+ // dataPointID is the id corresponding to the current record in the model.
+ // Its intended use is to be able to tag any messages going upstream,
+ // so the view knows which records was changed for example.
+ this.dataPointID = record.id;
+
+ // this is the res_id for the record in database. Obviously, it is
+ // readonly. Also, when the user is creating a new record, there is
+ // no res_id. When the record will be created, the field widget will
+ // be destroyed (when the form view switches to readonly mode) and a new
+ // widget with a res_id in mode readonly will be created.
+ this.res_id = record.res_id;
+
+ // useful mostly to trigger rpcs on the correct model
+ this.model = record.model;
+
+ // a widget can be in two modes: 'edit' or 'readonly'. This mode should
+ // never be changed, if a view changes its mode, it will destroy and
+ // recreate a new field widget.
+ this.mode = options.mode || "readonly";
+
+ // this flag tracks if the widget is in a valid state, meaning that the
+ // current value represented in the DOM is a value that can be parsed
+ // and saved. For example, a float field can only use a number and not
+ // a string.
+ this._isValid = true;
+
+ // this is the last value that was set by the user, unparsed. This is
+ // used to avoid setting the value twice in a row with the exact value.
+ this.lastSetValue = undefined;
+
+ // formatType is used to determine which format (and parse) functions
+ // to call to format the field's value to insert into the DOM (typically
+ // put into a span or an input), and to parse the value from the input
+ // to send it to the server. These functions are chosen according to
+ // the 'widget' attrs if is is given, and if it is a valid key, with a
+ // fallback on the field type, ensuring that the value is formatted and
+ // displayed according to the chosen widget, if any.
+ this.formatType = this.attrs.widget in field_utils.format ?
+ this.attrs.widget :
+ this.field.type;
+ // formatOptions (resp. parseOptions) is a dict of options passed to
+ // calls to the format (resp. parse) function.
+ this.formatOptions = {};
+ this.parseOptions = {};
+
+ // if we add decorations, we need to reevaluate the field whenever any
+ // value from the record is changed
+ if (this.attrs.decorations) {
+ this.resetOnAnyFieldChange = true;
+ }
+ },
+ /**
+ * When a field widget is appended to the DOM, its start method is called,
+ * and will automatically call render. Most widgets should not override this.
+ *
+ * @returns {Promise}
+ */
+ start: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self.$el.attr('name', self.name);
+ self.$el.addClass('o_field_widget');
+ return self._render();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Activates the field widget. By default, activation means focusing and
+ * selecting (if possible) the associated focusable element. The selecting
+ * part can be disabled. In that case, note that the focused input/textarea
+ * will have the cursor at the very end.
+ *
+ * @param {Object} [options]
+ * @param {boolean} [options.noselect=false] if false and the input
+ * is of type text or textarea, the content will also be selected
+ * @param {Event} [options.event] the event which fired this activation
+ * @returns {boolean} true if the widget was activated, false if the
+ * focusable element was not found or invisible
+ */
+ activate: function (options) {
+ if (this.isFocusable()) {
+ var $focusable = this.getFocusableElement();
+ $focusable.focus();
+ if ($focusable.is('input[type="text"], textarea')) {
+ $focusable[0].selectionStart = $focusable[0].selectionEnd = $focusable[0].value.length;
+ if (options && !options.noselect) {
+ $focusable.select();
+ }
+ }
+ return true;
+ }
+ return false;
+ },
+ /**
+ * This function should be implemented by widgets that are not able to
+ * notify their environment when their value changes (maybe because their
+ * are not aware of the changes) or that may have a value in a temporary
+ * state (maybe because some action should be performed to validate it
+ * before notifying it). This is typically called before trying to save the
+ * widget's value, so it should call _setValue() to notify the environment
+ * if the value changed but was not notified.
+ *
+ * @abstract
+ * @returns {Promise|undefined}
+ */
+ commitChanges: function () {},
+ /**
+ * Returns the main field's DOM element (jQuery form) which can be focused
+ * by the browser.
+ *
+ * @returns {jQuery} main focusable element inside the widget
+ */
+ getFocusableElement: function () {
+ return $();
+ },
+ /**
+ * Returns whether or not the field is empty and can thus be hidden. This
+ * method is typically called when the widget is in readonly, to hide it
+ * (and its label) if it is empty.
+ *
+ * @returns {boolean}
+ */
+ isEmpty: function () {
+ return !this.isSet();
+ },
+ /**
+ * Returns true iff the widget has a visible element that can take the focus
+ *
+ * @returns {boolean}
+ */
+ isFocusable: function () {
+ var $focusable = this.getFocusableElement();
+ return $focusable.length && $focusable.is(':visible');
+ },
+ /**
+ * this method is used to determine if the field value is set to a meaningful
+ * value. This is useful to determine if a field should be displayed as empty
+ *
+ * @returns {boolean}
+ */
+ isSet: function () {
+ return !!this.value;
+ },
+ /**
+ * A field widget is valid if it was checked as valid the last time its
+ * value was changed by the user. This is checked before saving a record, by
+ * the view.
+ *
+ * Note: this is the responsibility of the view to check that required
+ * fields have a set value.
+ *
+ * @returns {boolean} true/false if the widget is valid
+ */
+ isValid: function () {
+ return this._isValid;
+ },
+ /**
+ * this method is supposed to be called from the outside of field widgets.
+ * The typical use case is when an onchange has changed the widget value.
+ * It will reset the widget to the values that could have changed, then will
+ * rerender the widget.
+ *
+ * @param {any} record
+ * @param {OdooEvent} [event] an event that triggered the reset action. It
+ * is optional, and may be used by a widget to share information from the
+ * moment a field change event is triggered to the moment a reset
+ * operation is applied.
+ * @returns {Promise} A promise, which resolves when the widget rendering
+ * is complete
+ */
+ reset: function (record, event) {
+ this._reset(record, event);
+ return this._render() || Promise.resolve();
+ },
+ /**
+ * Remove the invalid class on a field
+ */
+ removeInvalidClass: function () {
+ this.$el.removeClass('o_field_invalid');
+ this.$el.removeAttr('aria-invalid');
+ },
+ /**
+ * Sets the given id on the focusable element of the field and as 'for'
+ * attribute of potential internal labels.
+ *
+ * @param {string} id
+ */
+ setIDForLabel: function (id) {
+ this.getFocusableElement().attr('id', id);
+ },
+ /**
+ * add the invalid class on a field
+ */
+ setInvalidClass: function () {
+ this.$el.addClass('o_field_invalid');
+ this.$el.attr('aria-invalid', 'true');
+ },
+ /**
+ * Update the modifiers with the newest value.
+ * Now this.attrs.modifiersValue can be used consistantly even with
+ * conditional modifiers inside field widgets, and without needing new
+ * events or synchronization between the widgets, renderer and controller
+ *
+ * @param {Object | null} modifiers the updated modifiers
+ * @override
+ */
+ updateModifiersValue: function(modifiers) {
+ this.attrs.modifiersValue = modifiers || {};
+ },
+
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Apply field decorations (only if field-specific decorations have been
+ * defined in an attribute).
+ *
+ * @private
+ */
+ _applyDecorations: function () {
+ var self = this;
+ this.attrs.decorations.forEach(function (dec) {
+ var isToggled = py.PY_isTrue(
+ py.evaluate(dec.expression, self.record.evalContext)
+ );
+ const className = self._getClassFromDecoration(dec.name);
+ self.$el.toggleClass(className, isToggled);
+ });
+ },
+ /**
+ * Converts the value from the field to a string representation.
+ *
+ * @private
+ * @param {any} value (from the field type)
+ * @param {string} [formatType=this.formatType] the formatter to use
+ * @returns {string}
+ */
+ _formatValue: function (value, formatType) {
+ var options = _.extend({}, this.nodeOptions, { data: this.recordData }, this.formatOptions);
+ return field_utils.format[formatType || this.formatType](value, this.field, options);
+ },
+ /**
+ * Returns the className corresponding to a given decoration. A
+ * decoration is of the form 'decoration-%s'. By default, replaces
+ * 'decoration' by 'text'.
+ *
+ * @private
+ * @param {string} decoration must be of the form 'decoration-%s'
+ * @returns {string}
+ */
+ _getClassFromDecoration: function (decoration) {
+ return `text-${decoration.split('-')[1]}`;
+ },
+ /**
+ * Compares the given value with the last value that has been set.
+ * Note that we compare unparsed values. Handles the special case where no
+ * value has been set yet, and the given value is the empty string.
+ *
+ * @private
+ * @param {any} value
+ * @returns {boolean} true iff values are the same
+ */
+ _isLastSetValue: function (value) {
+ return this.lastSetValue === value || (this.value === false && value === '');
+ },
+ /**
+ * This method check if a value is the same as the current value of the
+ * field. For example, a fieldDate widget might want to use the moment
+ * specific value isSame instead of ===.
+ *
+ * This method is used by the _setValue method.
+ *
+ * @private
+ * @param {any} value
+ * @returns {boolean}
+ */
+ _isSameValue: function (value) {
+ return this.value === value;
+ },
+ /**
+ * Converts a string representation to a valid value.
+ *
+ * @private
+ * @param {string} value
+ * @returns {any}
+ */
+ _parseValue: function (value) {
+ return field_utils.parse[this.formatType](value, this.field, this.parseOptions);
+ },
+ /**
+ * main rendering function. Override this if your widget has the same render
+ * for each mode. Note that this function is supposed to be idempotent:
+ * the result of calling 'render' twice is the same as calling it once.
+ * Also, the user experience will be better if your rendering function is
+ * synchronous.
+ *
+ * @private
+ * @returns {Promise|undefined}
+ */
+ _render: function () {
+ if (this.attrs.decorations) {
+ this._applyDecorations();
+ }
+ if (this.mode === 'edit') {
+ return this._renderEdit();
+ } else if (this.mode === 'readonly') {
+ return this._renderReadonly();
+ }
+ },
+ /**
+ * Render the widget in edit mode. The actual implementation is left to the
+ * concrete widget.
+ *
+ * @private
+ * @returns {Promise|undefined}
+ */
+ _renderEdit: function () {
+ },
+ /**
+ * Render the widget in readonly mode. The actual implementation is left to
+ * the concrete widget.
+ *
+ * @private
+ * @returns {Promise|undefined}
+ */
+ _renderReadonly: function () {
+ },
+ /**
+ * pure version of reset, can be overridden, called before render()
+ *
+ * @private
+ * @param {any} record
+ * @param {OdooEvent} event the event that triggered the change
+ */
+ _reset: function (record, event) {
+ this.lastSetValue = undefined;
+ this.record = record;
+ this.value = record.data[this.name];
+ this.recordData = record.data;
+ },
+ /**
+ * this method is called by the widget, to change its value and to notify
+ * the outside world of its new state. This method also validates the new
+ * value. Note that this method does not rerender the widget, it should be
+ * handled by the widget itself, if necessary.
+ *
+ * @private
+ * @param {any} value
+ * @param {Object} [options]
+ * @param {boolean} [options.doNotSetDirty=false] if true, the basic model
+ * will not consider that this field is dirty, even though it was changed.
+ * Please do not use this flag unless you really need it. Our only use
+ * case is currently the pad widget, which does a _setValue in the
+ * renderEdit method.
+ * @param {boolean} [options.notifyChange=true] if false, the basic model
+ * will not notify and not trigger the onchange, even though it was changed.
+ * @param {boolean} [options.forceChange=false] if true, the change event will be
+ * triggered even if the new value is the same as the old one
+ * @returns {Promise}
+ */
+ _setValue: function (value, options) {
+ // we try to avoid doing useless work, if the value given has not changed.
+ if (this._isLastSetValue(value)) {
+ return Promise.resolve();
+ }
+ this.lastSetValue = value;
+ try {
+ value = this._parseValue(value);
+ this._isValid = true;
+ } catch (e) {
+ this._isValid = false;
+ this.trigger_up('set_dirty', {dataPointID: this.dataPointID});
+ return Promise.reject({message: "Value set is not valid"});
+ }
+ if (!(options && options.forceChange) && this._isSameValue(value)) {
+ return Promise.resolve();
+ }
+ var self = this;
+ return new Promise(function (resolve, reject) {
+ var changes = {};
+ changes[self.name] = value;
+ self.trigger_up('field_changed', {
+ dataPointID: self.dataPointID,
+ changes: changes,
+ viewType: self.viewType,
+ doNotSetDirty: options && options.doNotSetDirty,
+ notifyChange: !options || options.notifyChange !== false,
+ allowWarning: options && options.allowWarning,
+ onSuccess: resolve,
+ onFailure: reject,
+ });
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Intercepts navigation keyboard events to prevent their default behavior
+ * and notifies the view so that it can handle it its own way.
+ *
+ * Note: the navigation keyboard events are stopped so that potential parent
+ * abstract field does not trigger the navigation_move event a second time.
+ * However, this might be controversial, we might wanna let the event
+ * continue its propagation and flag it to say that navigation has already
+ * been handled (TODO ?).
+ *
+ * @private
+ * @param {KeyEvent} ev
+ */
+ _onKeydown: function (ev) {
+ switch (ev.which) {
+ case $.ui.keyCode.TAB:
+ var event = this.trigger_up('navigation_move', {
+ direction: ev.shiftKey ? 'previous' : 'next',
+ });
+ if (event.is_stopped()) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ }
+ break;
+ case $.ui.keyCode.ENTER:
+ // We preventDefault the ENTER key because of two coexisting behaviours:
+ // - In HTML5, pressing ENTER on a <button> triggers two events: a 'keydown' AND a 'click'
+ // - When creating and opening a dialog, the focus is automatically given to the primary button
+ // The end result caused some issues where a modal opened by an ENTER keypress (e.g. saving
+ // changes in multiple edition) confirmed the modal without any intentionnal user input.
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.trigger_up('navigation_move', {direction: 'next_line'});
+ break;
+ case $.ui.keyCode.ESCAPE:
+ this.trigger_up('navigation_move', {direction: 'cancel', originalEvent: ev});
+ break;
+ case $.ui.keyCode.UP:
+ ev.stopPropagation();
+ this.trigger_up('navigation_move', {direction: 'up'});
+ break;
+ case $.ui.keyCode.RIGHT:
+ ev.stopPropagation();
+ this.trigger_up('navigation_move', {direction: 'right'});
+ break;
+ case $.ui.keyCode.DOWN:
+ ev.stopPropagation();
+ this.trigger_up('navigation_move', {direction: 'down'});
+ break;
+ case $.ui.keyCode.LEFT:
+ ev.stopPropagation();
+ this.trigger_up('navigation_move', {direction: 'left'});
+ break;
+ }
+ },
+ /**
+ * Updates the target data value with the current AbstractField instance.
+ * This allows to consider the parent field in case of nested fields. The
+ * field which triggered the event is still accessible through ev.target.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNavigationMove: function (ev) {
+ ev.data.target = this;
+ },
+});
+
+return AbstractField;
+
+});
diff --git a/addons/web/static/src/js/fields/abstract_field_owl.js b/addons/web/static/src/js/fields/abstract_field_owl.js
new file mode 100644
index 00000000..43e3d468
--- /dev/null
+++ b/addons/web/static/src/js/fields/abstract_field_owl.js
@@ -0,0 +1,648 @@
+odoo.define('web.AbstractFieldOwl', function (require) {
+ "use strict";
+
+ const field_utils = require('web.field_utils');
+ const { useListener } = require('web.custom_hooks');
+
+ const { onMounted, onPatched } = owl.hooks;
+
+ /**
+ * This file defines the Owl version of the AbstractField. Specific fields
+ * written in Owl should override this component.
+ *
+ * =========================================================================
+ *
+ * /!\ This api works almost exactly like the legacy one but
+ * /!\ it still could change! There are already a few methods that will be
+ * /!\ removed like setIdForLabel, setInvalidClass, etc..
+ *
+ * =========================================================================
+ *
+ * This is the basic field component used by all the views to render a field in a view.
+ * These field components are mostly common to all views, in particular form and list
+ * views.
+ *
+ * The responsabilities of a field component are mainly:
+ * - render a visual representation of the current value of a field
+ * - that representation is either in 'readonly' or in 'edit' mode
+ * - notify the rest of the system when the field has been changed by
+ * the user (in edit mode)
+ *
+ * Notes
+ * - the component is not supposed to be able to switch between modes. If another
+ * mode is required, the view will take care of instantiating another component.
+ * - notify the system when its value has changed and its mode is changed to 'readonly'
+ * - notify the system when some action has to be taken, such as opening a record
+ * - the Field component should not, ever, under any circumstance, be aware of
+ * its parent. The way it communicates changes with the rest of the system is by
+ * triggering events. These events bubble up and are interpreted
+ * by the most appropriate parent.
+ *
+ * Also, in some cases, it may not be practical to have the same component for all
+ * views. In that situation, you can have a 'view specific component'. Just register
+ * the component in the registry prefixed by the view type and a dot. So, for example,
+ * a form specific many2one component should be registered as 'form.many2one'.
+ *
+ * @module web.AbstractFieldOwl
+ */
+ class AbstractField extends owl.Component {
+ /**
+ * Abstract field class
+ *
+ * @constructor
+ * @param {Component} parent
+ * @param {Object} props
+ * @param {string} props.fieldName The field name defined in the model
+ * @param {Object} props.record A record object (result of the get method
+ * of a basic model)
+ * @param {Object} [props.options]
+ * @param {string} [props.options.mode=readonly] should be 'readonly' or 'edit'
+ * @param {string} [props.options.viewType=default]
+ */
+ constructor() {
+ super(...arguments);
+
+ this._isValid = true;
+ // this is the last value that was set by the user, unparsed. This is
+ // used to avoid setting the value twice in a row with the exact value.
+ this._lastSetValue = undefined;
+
+ useListener('keydown', this._onKeydown);
+ useListener('navigation-move', this._onNavigationMove);
+ onMounted(() => this._applyDecorations());
+ onPatched(() => this._applyDecorations());
+ }
+ /**
+ * Hack: studio tries to find the field with a selector base on its
+ * name, before it is mounted into the DOM. Ideally, this should be
+ * done in the onMounted hook, but in this case we are too late, and
+ * Studio finds nothing. As a consequence, the field can't be edited
+ * by clicking on its label (or on the row formed by the pair label-field).
+ *
+ * TODO: move this to mounted at some point?
+ *
+ * @override
+ */
+ __patch() {
+ const res = super.__patch(...arguments);
+ this.el.setAttribute('name', this.name);
+ this.el.classList.add('o_field_widget');
+ return res;
+ }
+ /**
+ * @async
+ * @param {Object} [nextProps]
+ * @returns {Promise}
+ */
+ async willUpdateProps(nextProps) {
+ this._lastSetValue = undefined;
+ return super.willUpdateProps(nextProps);
+ }
+
+ //----------------------------------------------------------------------
+ // Getters
+ //----------------------------------------------------------------------
+
+ /**
+ * This contains the attributes to pass through the context.
+ *
+ * @returns {Object}
+ */
+ get additionalContext() {
+ return this.options.additionalContext || {};
+ }
+ /**
+ * This contains the attributes of the xml 'field' tag, the inner views...
+ *
+ * @returns {Object}
+ */
+ get attrs() {
+ const fieldsInfo = this.record.fieldsInfo[this.viewType];
+ return this.options.attrs || (fieldsInfo && fieldsInfo[this.name]) || {};
+ }
+ /**
+ * Id corresponding to the current record in the model.
+ * Its intended use is to be able to tag any messages going upstream,
+ * so the view knows which records was changed for example.
+ *
+ * @returns {string}
+ */
+ get dataPointId() {
+ return this.record.id;
+ }
+ /**
+ * This is a description of all the various field properties,
+ * such as the type, the comodel (relation), ...
+ *
+ * @returns {string}
+ */
+ get field() {
+ return this.record.fields[this.name];
+ }
+ /**
+ * Returns the main field's DOM element which can be focused by the browser.
+ *
+ * @returns {HTMLElement|null} main focusable element inside the component
+ */
+ get focusableElement() {
+ return null;
+ }
+ /**
+ * Returns the additional options pass to the format function.
+ * Override this getter to add options.
+ *
+ * @returns {Object}
+ */
+ get formatOptions() {
+ return {};
+ }
+ /**
+ * Used to determine which format (and parse) functions
+ * to call to format the field's value to insert into the DOM (typically
+ * put into a span or an input), and to parse the value from the input
+ * to send it to the server. These functions are chosen according to
+ * the 'widget' attrs if is is given, and if it is a valid key, with a
+ * fallback on the field type, ensuring that the value is formatted and
+ * displayed according to the chosen widget, if any.
+ *
+ * @returns {string}
+ */
+ get formatType() {
+ return this.attrs.widget in field_utils.format ?
+ this.attrs.widget : this.field.type;
+ }
+ /**
+ * Returns whether or not the field is empty and can thus be hidden. This
+ * method is typically called when the component is in readonly, to hide it
+ * (and its label) if it is empty.
+ *
+ * @returns {boolean}
+ */
+ get isEmpty() {
+ return !this.isSet;
+ }
+ /**
+ * Returns true if the component has a visible element that can take the focus
+ *
+ * @returns {boolean}
+ */
+ get isFocusable() {
+ const focusable = this.focusableElement;
+ // check if element is visible
+ return focusable && !!(focusable.offsetWidth ||
+ focusable.offsetHeight || focusable.getClientRects().length);
+ }
+ /**
+ * Determines if the field value is set to a meaningful
+ * value. This is useful to determine if a field should be displayed as empty
+ *
+ * @returns {boolean}
+ */
+ get isSet() {
+ return !!this.value;
+ }
+ /**
+ * Tracks if the component is in a valid state, meaning that the current
+ * value represented in the DOM is a value that can be parsed and saved.
+ * For example, a float field can only use a number and not a string.
+ *
+ * @returns {boolean}
+ */
+ get isValid() {
+ return this._isValid;
+ }
+ /**
+ * Fields can be in two modes: 'edit' or 'readonly'.
+ *
+ * @returns {string}
+ */
+ get mode() {
+ return this.options.mode || "readonly";
+ }
+ /**
+ * Useful mostly to trigger rpcs on the correct model.
+ *
+ * @returns {string}
+ */
+ get model() {
+ return this.record.model;
+ }
+ /**
+ * The field name displayed by this component.
+ *
+ * @returns {string}
+ */
+ get name() {
+ return this.props.fieldName;
+ }
+ /**
+ * Component can often be configured in the 'options' attribute in the
+ * xml 'field' tag. These options are saved (and evaled) in nodeOptions.
+ *
+ * @returns {Object}
+ */
+ get nodeOptions() {
+ return this.attrs.options || {};
+ }
+ /**
+ * @returns {Object}
+ */
+ get options() {
+ return this.props.options || {};
+ }
+ /**
+ * Returns the additional options passed to the parse function.
+ * Override this getter to add options.
+ *
+ * @returns {Object}
+ */
+ get parseOptions() {
+ return {};
+ }
+ /**
+ * The datapoint fetched from the model.
+ *
+ * @returns {Object}
+ */
+ get record() {
+ return this.props.record;
+ }
+ /**
+ * Tracks the values for the other fields for the same record.
+ * note that it is expected to be mostly a readonly property, you cannot
+ * use this to try to change other fields value, this is not how it is
+ * supposed to work. Also, do not use this.recordData[this.name] to get
+ * the current value, this could be out of sync after a _setValue.
+ *
+ * @returns {Object}
+ */
+ get recordData() {
+ return this.record.data;
+ }
+ /**
+ * If this flag is set to true, the field component will be reset on
+ * every change which is made in the view (if the view supports it).
+ * This is currently a form view feature.
+ *
+ * /!\ This getter could be removed when basic views (form, list, kanban)
+ * are converted.
+ *
+ * @returns {boolean}
+ */
+ get resetOnAnyFieldChange() {
+ return !!this.attrs.decorations;
+ }
+ /**
+ * The res_id of the record in database.
+ * When the user is creating a new record, there is no res_id.
+ * When the record will be created, the field component will
+ * be destroyed (when the form view switches to readonly mode) and a
+ * new component with a res_id in mode readonly will be created.
+ *
+ * @returns {Number}
+ */
+ get resId() {
+ return this.record.res_id;
+ }
+ /**
+ * Human readable (and translated) description of the field.
+ * Mostly useful to be displayed in various places in the
+ * UI, such as tooltips or create dialogs.
+ *
+ * @returns {string}
+ */
+ get string() {
+ return this.attrs.string || this.field.string || this.name;
+ }
+ /**
+ * Tracks the current (parsed if needed) value of the field.
+ *
+ * @returns {any}
+ */
+ get value() {
+ return this.record.data[this.name];
+ }
+ /**
+ * The type of the view in which the field component is instantiated.
+ * For standalone components, a 'default' viewType is set.
+ *
+ * @returns {string}
+ */
+ get viewType() {
+ return this.options.viewType || 'default';
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Activates the field component. By default, activation means focusing and
+ * selecting (if possible) the associated focusable element. The selecting
+ * part can be disabled. In that case, note that the focused input/textarea
+ * will have the cursor at the very end.
+ *
+ * @param {Object} [options]
+ * @param {boolean} [options.noselect=false] if false and the input
+ * is of type text or textarea, the content will also be selected
+ * @param {Event} [options.event] the event which fired this activation
+ * @returns {boolean} true if the component was activated, false if the
+ * focusable element was not found or invisible
+ */
+ activate(options) {
+ if (this.isFocusable) {
+ const focusable = this.focusableElement;
+ focusable.focus();
+ if (focusable.matches('input[type="text"], textarea')) {
+ focusable.selectionStart = focusable.selectionEnd = focusable.value.length;
+ if (options && !options.noselect) {
+ focusable.select();
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+ /**
+ * This function should be implemented by components that are not able to
+ * notify their environment when their value changes (maybe because their
+ * are not aware of the changes) or that may have a value in a temporary
+ * state (maybe because some action should be performed to validate it
+ * before notifying it). This is typically called before trying to save the
+ * component's value, so it should call _setValue() to notify the environment
+ * if the value changed but was not notified.
+ *
+ * @abstract
+ * @returns {Promise|undefined}
+ */
+ commitChanges() {}
+ /**
+ * Remove the invalid class on a field
+ *
+ * This function should be removed when BasicRenderer will be rewritten in owl
+ */
+ removeInvalidClass() {
+ this.el.classList.remove('o_field_invalid');
+ this.el.removeAttribute('aria-invalid');
+ }
+ /**
+ * Sets the given id on the focusable element of the field and as 'for'
+ * attribute of potential internal labels.
+ *
+ * This function should be removed when BasicRenderer will be rewritten in owl
+ *
+ * @param {string} id
+ */
+ setIdForLabel(id) {
+ if (this.focusableElement) {
+ this.focusableElement.setAttribute('id', id);
+ }
+ }
+ /**
+ * add the invalid class on a field
+ *
+ * This function should be removed when BasicRenderer will be rewritten in owl
+ */
+ setInvalidClass() {
+ this.el.classList.add('o_field_invalid');
+ this.el.setAttribute('aria-invalid', 'true');
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * Apply field decorations (only if field-specific decorations have been
+ * defined in an attribute).
+ *
+ * This function should be removed when BasicRenderer will be rewritten in owl
+ *
+ * @private
+ */
+ _applyDecorations() {
+ for (const dec of this.attrs.decorations || []) {
+ const isToggled = py.PY_isTrue(
+ py.evaluate(dec.expression, this.record.evalContext)
+ );
+ const className = this._getClassFromDecoration(dec.name);
+ this.el.classList.toggle(className, isToggled);
+ }
+ }
+ /**
+ * Converts the value from the field to a string representation.
+ *
+ * @private
+ * @param {any} value (from the field type)
+ * @returns {string}
+ */
+ _formatValue(value) {
+ const options = Object.assign({}, this.nodeOptions,
+ { data: this.recordData }, this.formatOptions);
+ return field_utils.format[this.formatType](value, this.field, options);
+ }
+ /**
+ * Returns the className corresponding to a given decoration. A
+ * decoration is of the form 'decoration-%s'. By default, replaces
+ * 'decoration' by 'text'.
+ *
+ * @private
+ * @param {string} decoration must be of the form 'decoration-%s'
+ * @returns {string}
+ */
+ _getClassFromDecoration(decoration) {
+ return `text-${decoration.split('-')[1]}`;
+ }
+ /**
+ * Compares the given value with the last value that has been set.
+ * Note that we compare unparsed values. Handles the special case where no
+ * value has been set yet, and the given value is the empty string.
+ *
+ * @private
+ * @param {any} value
+ * @returns {boolean} true iff values are the same
+ */
+ _isLastSetValue(value) {
+ return this._lastSetValue === value || (this.value === false && value === '');
+ }
+ /**
+ * This method check if a value is the same as the current value of the
+ * field. For example, a fieldDate component might want to use the moment
+ * specific value isSame instead of ===.
+ *
+ * This method is used by the _setValue method.
+ *
+ * @private
+ * @param {any} value
+ * @returns {boolean}
+ */
+ _isSameValue(value) {
+ return this.value === value;
+ }
+ /**
+ * Converts a string representation to a valid value.
+ *
+ * @private
+ * @param {string} value
+ * @returns {any}
+ */
+ _parseValue(value) {
+ return field_utils.parse[this.formatType](value, this.field, this.parseOptions);
+ }
+ /**
+ * This method is called by the component, to change its value and to notify
+ * the outside world of its new state. This method also validates the new
+ * value. Note that this method does not rerender the component, it should be
+ * handled by the component itself, if necessary.
+ *
+ * @private
+ * @param {any} value
+ * @param {Object} [options]
+ * @param {boolean} [options.doNotSetDirty=false] if true, the basic model
+ * will not consider that this field is dirty, even though it was changed.
+ * Please do not use this flag unless you really need it. Our only use
+ * case is currently the pad component, which does a _setValue in the
+ * renderEdit method.
+ * @param {boolean} [options.notifyChange=true] if false, the basic model
+ * will not notify and not trigger the onchange, even though it was changed.
+ * @param {boolean} [options.forceChange=false] if true, the change event will be
+ * triggered even if the new value is the same as the old one
+ * @returns {Promise}
+ */
+ _setValue(value, options) {
+ // we try to avoid doing useless work, if the value given has not changed.
+ if (this._isLastSetValue(value)) {
+ return Promise.resolve();
+ }
+ this._lastSetValue = value;
+ try {
+ value = this._parseValue(value);
+ this._isValid = true;
+ } catch (e) {
+ this._isValid = false;
+ this.trigger('set-dirty', {dataPointID: this.dataPointId});
+ return Promise.reject({message: "Value set is not valid"});
+ }
+ if (!(options && options.forceChange) && this._isSameValue(value)) {
+ return Promise.resolve();
+ }
+ return new Promise((resolve, reject) => {
+ const changes = {};
+ changes[this.name] = value;
+ this.trigger('field-changed', {
+ dataPointID: this.dataPointId,
+ changes: changes,
+ viewType: this.viewType,
+ doNotSetDirty: options && options.doNotSetDirty,
+ notifyChange: !options || options.notifyChange !== false,
+ allowWarning: options && options.allowWarning,
+ onSuccess: resolve,
+ onFailure: reject,
+ });
+ });
+ }
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * Intercepts navigation keyboard events to prevent their default behavior
+ * and notifies the view so that it can handle it its own way.
+ *
+ * @private
+ * @param {KeyEvent} ev
+ */
+ _onKeydown(ev) {
+ switch (ev.which) {
+ case $.ui.keyCode.TAB:
+ this.trigger('navigation-move', {
+ direction: ev.shiftKey ? 'previous' : 'next',
+ originalEvent: ev,
+ });
+ break;
+ case $.ui.keyCode.ENTER:
+ // We preventDefault the ENTER key because of two coexisting behaviours:
+ // - In HTML5, pressing ENTER on a <button> triggers two events: a 'keydown' AND a 'click'
+ // - When creating and opening a dialog, the focus is automatically given to the primary button
+ // The end result caused some issues where a modal opened by an ENTER keypress (e.g. saving
+ // changes in multiple edition) confirmed the modal without any intentionnal user input.
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.trigger('navigation-move', {direction: 'next_line'});
+ break;
+ case $.ui.keyCode.ESCAPE:
+ this.trigger('navigation-move', {direction: 'cancel', originalEvent: ev});
+ break;
+ case $.ui.keyCode.UP:
+ ev.stopPropagation();
+ this.trigger('navigation-move', {direction: 'up'});
+ break;
+ case $.ui.keyCode.RIGHT:
+ ev.stopPropagation();
+ this.trigger('navigation-move', {direction: 'right'});
+ break;
+ case $.ui.keyCode.DOWN:
+ ev.stopPropagation();
+ this.trigger('navigation-move', {direction: 'down'});
+ break;
+ case $.ui.keyCode.LEFT:
+ ev.stopPropagation();
+ this.trigger('navigation-move', {direction: 'left'});
+ break;
+ }
+ }
+ /**
+ * Updates the target data value with the current AbstractField instance.
+ * This allows to consider the parent field in case of nested fields. The
+ * field which triggered the event is still accessible through ev.target.
+ *
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onNavigationMove(ev) {
+ ev.detail.target = this;
+ }
+ }
+
+ /**
+ * An object representing fields to be fetched by the model even though not
+ * present in the view.
+ * This object contains "field name" as key and an object as value.
+ * That value object must contain the key "type"
+ * @see FieldBinaryImage for an example.
+ */
+ AbstractField.fieldDependencies = {};
+ /**
+ * If this flag is given a string, the related BasicModel will be used to
+ * initialize specialData the field might need. This data will be available
+ * through this.record.specialData[this.name].
+ *
+ * @see BasicModel._fetchSpecialData
+ */
+ AbstractField.specialData = false;
+ /**
+ * to override to indicate which field types are supported by the component
+ *
+ * @type Array<string>
+ */
+ AbstractField.supportedFieldTypes = [];
+ /**
+ * To override to give a user friendly name to the component.
+ *
+ * @type string
+ */
+ AbstractField.description = "";
+ /**
+ * Currently only used in list view.
+ * If this flag is set to true, the list column name will be empty.
+ */
+ AbstractField.noLabel = false;
+ /**
+ * Currently only used in list view.
+ * If set, this value will be displayed as column name.
+ */
+ AbstractField.label = "";
+
+ return AbstractField;
+});
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,
+};
+
+});
diff --git a/addons/web/static/src/js/fields/basic_fields_owl.js b/addons/web/static/src/js/fields/basic_fields_owl.js
new file mode 100644
index 00000000..507f883a
--- /dev/null
+++ b/addons/web/static/src/js/fields/basic_fields_owl.js
@@ -0,0 +1,132 @@
+odoo.define('web.basic_fields_owl', function (require) {
+ "use strict";
+
+ const AbstractField = require('web.AbstractFieldOwl');
+ const CustomCheckbox = require('web.CustomCheckbox');
+ const { _lt } = require('web.translation');
+
+
+ /**
+ * FieldBadge displays the field's value inside a bootstrap pill badge.
+ * The supported field's types are 'char', 'selection' and 'many2one'.
+ *
+ * By default, the background color of the badge is a light gray, but it can
+ * be customized by setting a 'decoration-xxx' attribute on the field.
+ * For instance,
+ * <field name="some_field" widget="badge" decoration-danger="state == 'cancel'"/>
+ * renders a badge with a red background on records matching the condition.
+ */
+ class FieldBadge extends AbstractField {
+ _getClassFromDecoration(decoration) {
+ return `bg-${decoration.split('-')[1]}-light`;
+ }
+ }
+ FieldBadge.description = _lt("Badge");
+ FieldBadge.supportedFieldTypes = ['selection', 'many2one', 'char'];
+ FieldBadge.template = 'web.FieldBadge';
+
+
+ class FieldBoolean extends AbstractField {
+ patched() {
+ super.patched();
+ if (this.props.event && this.props.event.target === this) {
+ this.activate();
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ * @returns {HTMLElement|null} the focusable checkbox input
+ */
+ get focusableElement() {
+ return this.mode === 'readonly' ? null : this.el.querySelector('input');
+ }
+ /**
+ * A boolean field is always set since false is a valid value.
+ *
+ * @override
+ */
+ get isSet() {
+ return true;
+ }
+ /**
+ * Toggle the checkbox if it is activated due to a click on itself.
+ *
+ * @override
+ * @param {Object} [options]
+ * @param {Event} [options.event] the event which fired this activation
+ * @returns {boolean} true if the component was activated, false if the
+ * focusable element was not found or invisible
+ */
+ activate(options) {
+ const activated = super.activate(options);
+ // The event might have been fired on the non field version of
+ // this field, we can still test the presence of its custom class.
+ if (activated && options && options.event && options.event.target
+ .closest('.custom-control.custom-checkbox')) {
+ this._setValue(!this.value); // Toggle the checkbox
+ }
+ return activated;
+ }
+ /**
+ * Associates the 'for' attribute of the internal label.
+ *
+ * @override
+ */
+ setIdForLabel(id) {
+ super.setIdForLabel(id);
+ this.el.querySelector('label').setAttribute('for', id);
+ }
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * Properly update the value when the checkbox is (un)ticked to trigger
+ * possible onchanges.
+ *
+ * @private
+ */
+ _onChange(ev) {
+ this._setValue(ev.target.checked);
+ }
+ /**
+ * Implement keyboard movements. Mostly useful for its environment, such
+ * as a list view.
+ *
+ * @override
+ * @private
+ * @param {KeyEvent} ev
+ */
+ _onKeydown(ev) {
+ switch (ev.which) {
+ case $.ui.keyCode.ENTER:
+ // prevent subsequent 'click' event (see _onKeydown of AbstractField)
+ ev.preventDefault();
+ this._setValue(!this.value);
+ return;
+ case $.ui.keyCode.UP:
+ case $.ui.keyCode.RIGHT:
+ case $.ui.keyCode.DOWN:
+ case $.ui.keyCode.LEFT:
+ ev.preventDefault();
+ }
+ super._onKeydown(ev);
+ }
+ }
+ FieldBoolean.components = { CustomCheckbox };
+ FieldBoolean.description = _lt("Checkbox");
+ FieldBoolean.supportedFieldTypes = ['boolean'];
+ FieldBoolean.template = 'web.FieldBoolean';
+
+
+ return {
+ FieldBadge,
+ FieldBoolean,
+ };
+});
diff --git a/addons/web/static/src/js/fields/field_registry.js b/addons/web/static/src/js/fields/field_registry.js
new file mode 100644
index 00000000..bb407cda
--- /dev/null
+++ b/addons/web/static/src/js/fields/field_registry.js
@@ -0,0 +1,101 @@
+odoo.define('web.field_registry', function (require) {
+ "use strict";
+
+ const Registry = require('web.Registry');
+
+ return new Registry(
+ null,
+ (value) => !(value.prototype instanceof owl.Component)
+ );
+});
+
+odoo.define('web._field_registry', function (require) {
+"use strict";
+
+var AbstractField = require('web.AbstractField');
+var basic_fields = require('web.basic_fields');
+var relational_fields = require('web.relational_fields');
+var registry = require('web.field_registry');
+var special_fields = require('web.special_fields');
+
+
+// Basic fields
+registry
+ .add('abstract', AbstractField)
+ .add('input', basic_fields.InputField)
+ .add('integer', basic_fields.FieldInteger)
+ .add('boolean', basic_fields.FieldBoolean)
+ .add('date', basic_fields.FieldDate)
+ .add('datetime', basic_fields.FieldDateTime)
+ .add('daterange', basic_fields.FieldDateRange)
+ .add('remaining_days', basic_fields.RemainingDays)
+ .add('domain', basic_fields.FieldDomain)
+ .add('text', basic_fields.FieldText)
+ .add('list.text', basic_fields.ListFieldText)
+ .add('html', basic_fields.FieldText)
+ .add('float', basic_fields.FieldFloat)
+ .add('char', basic_fields.FieldChar)
+ .add('link_button', basic_fields.LinkButton)
+ .add('handle', basic_fields.HandleWidget)
+ .add('email', basic_fields.FieldEmail)
+ .add('phone', basic_fields.FieldPhone)
+ .add('url', basic_fields.UrlWidget)
+ .add('CopyClipboardText', basic_fields.TextCopyClipboard)
+ .add('CopyClipboardChar', basic_fields.CharCopyClipboard)
+ .add('image', basic_fields.FieldBinaryImage)
+ .add('image_url', basic_fields.CharImageUrl)
+ .add('kanban.image', basic_fields.KanbanFieldBinaryImage)
+ .add('kanban.image_url', basic_fields.KanbanCharImageUrl)
+ .add('binary', basic_fields.FieldBinaryFile)
+ .add('pdf_viewer', basic_fields.FieldPdfViewer)
+ .add('monetary', basic_fields.FieldMonetary)
+ .add('percentage', basic_fields.FieldPercentage)
+ .add('priority', basic_fields.PriorityWidget)
+ .add('attachment_image', basic_fields.AttachmentImage)
+ .add('label_selection', basic_fields.LabelSelection)
+ .add('kanban_label_selection', basic_fields.LabelSelection) // deprecated, use label_selection
+ .add('state_selection', basic_fields.StateSelectionWidget)
+ .add('kanban_state_selection', basic_fields.StateSelectionWidget) // deprecated, use state_selection
+ .add('boolean_favorite', basic_fields.FavoriteWidget)
+ .add('boolean_toggle', basic_fields.BooleanToggle)
+ .add('statinfo', basic_fields.StatInfo)
+ .add('percentpie', basic_fields.FieldPercentPie)
+ .add('float_time', basic_fields.FieldFloatTime)
+ .add('float_factor', basic_fields.FieldFloatFactor)
+ .add('float_toggle', basic_fields.FieldFloatToggle)
+ .add('progressbar', basic_fields.FieldProgressBar)
+ .add('toggle_button', basic_fields.FieldToggleBoolean)
+ .add('dashboard_graph', basic_fields.JournalDashboardGraph)
+ .add('ace', basic_fields.AceEditor)
+ .add('color', basic_fields.FieldColor)
+ .add('many2one_reference', basic_fields.FieldInteger)
+ .add('color_picker', basic_fields.FieldColorPicker);
+
+// Relational fields
+registry
+ .add('selection', relational_fields.FieldSelection)
+ .add('radio', relational_fields.FieldRadio)
+ .add('selection_badge', relational_fields.FieldSelectionBadge)
+ .add('many2one', relational_fields.FieldMany2One)
+ .add('many2one_barcode', relational_fields.Many2oneBarcode)
+ .add('list.many2one', relational_fields.ListFieldMany2One)
+ .add('kanban.many2one', relational_fields.KanbanFieldMany2One)
+ .add('many2one_avatar', relational_fields.Many2OneAvatar)
+ .add('many2many', relational_fields.FieldMany2Many)
+ .add('many2many_binary', relational_fields.FieldMany2ManyBinaryMultiFiles)
+ .add('many2many_tags', relational_fields.FieldMany2ManyTags)
+ .add('many2many_tags_avatar', relational_fields.FieldMany2ManyTagsAvatar)
+ .add('form.many2many_tags', relational_fields.FormFieldMany2ManyTags)
+ .add('kanban.many2many_tags', relational_fields.KanbanFieldMany2ManyTags)
+ .add('many2many_checkboxes', relational_fields.FieldMany2ManyCheckBoxes)
+ .add('one2many', relational_fields.FieldOne2Many)
+ .add('statusbar', relational_fields.FieldStatus)
+ .add('reference', relational_fields.FieldReference)
+ .add('font', relational_fields.FieldSelectionFont);
+
+// Special fields
+registry
+ .add('timezone_mismatch', special_fields.FieldTimezoneMismatch)
+ .add('report_layout', special_fields.FieldReportLayout)
+ .add('iframe_wrapper', special_fields.IframeWrapper)
+});
diff --git a/addons/web/static/src/js/fields/field_registry_owl.js b/addons/web/static/src/js/fields/field_registry_owl.js
new file mode 100644
index 00000000..635db621
--- /dev/null
+++ b/addons/web/static/src/js/fields/field_registry_owl.js
@@ -0,0 +1,26 @@
+odoo.define('web.field_registry_owl', function (require) {
+ "use strict";
+
+ const Registry = require('web.Registry');
+
+ return new Registry(
+ null,
+ (value) => value.prototype instanceof owl.Component
+ );
+});
+
+odoo.define('web._field_registry_owl', function (require) {
+ "use strict";
+
+ /**
+ * This module registers field components (specifications of the AbstractField Component)
+ */
+
+ const basicFields = require('web.basic_fields_owl');
+ const registry = require('web.field_registry_owl');
+
+ // Basic fields
+ registry
+ .add('badge', basicFields.FieldBadge)
+ .add('boolean', basicFields.FieldBoolean);
+});
diff --git a/addons/web/static/src/js/fields/field_utils.js b/addons/web/static/src/js/fields/field_utils.js
new file mode 100644
index 00000000..beba1b07
--- /dev/null
+++ b/addons/web/static/src/js/fields/field_utils.js
@@ -0,0 +1,762 @@
+odoo.define('web.field_utils', function (require) {
+"use strict";
+
+/**
+ * Field Utils
+ *
+ * This file contains two types of functions: formatting functions and parsing
+ * functions.
+ *
+ * Each field type has to display in string form at some point, but it should be
+ * stored in memory with the actual value. For example, a float value of 0.5 is
+ * represented as the string "0.5" but is kept in memory as a float. A date
+ * (or datetime) value is always stored as a Moment.js object, but displayed as
+ * a string. This file contains all sort of functions necessary to perform the
+ * conversions.
+ */
+
+var core = require('web.core');
+var dom = require('web.dom');
+var session = require('web.session');
+var time = require('web.time');
+var utils = require('web.utils');
+
+var _t = core._t;
+
+//------------------------------------------------------------------------------
+// Formatting
+//------------------------------------------------------------------------------
+
+/**
+ * Convert binary to bin_size
+ *
+ * @param {string} [value] base64 representation of the binary (might be already a bin_size!)
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options (note: this parameter is ignored)
+ *
+ * @returns {string} bin_size (which is human-readable)
+ */
+function formatBinary(value, field, options) {
+ if (!value) {
+ return '';
+ }
+ return utils.binaryToBinsize(value);
+}
+
+/**
+ * @todo Really? it returns a jQuery element... We should try to avoid this and
+ * let DOM utility functions handle this directly. And replace this with a
+ * function that returns a string so we can get rid of the forceString.
+ *
+ * @param {boolean} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.forceString=false] if true, returns a string
+* representation of the boolean rather than a jQueryElement
+ * @returns {jQuery|string}
+ */
+function formatBoolean(value, field, options) {
+ if (options && options.forceString) {
+ return value ? _t('True') : _t('False');
+ }
+ return dom.renderCheckbox({
+ prop: {
+ checked: value,
+ disabled: true,
+ },
+ });
+}
+
+/**
+ * Returns a string representing a char. If the value is false, then we return
+ * an empty string.
+ *
+ * @param {string|false} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.escape=false] if true, escapes the formatted value
+ * @param {boolean} [options.isPassword=false] if true, returns '********'
+ * instead of the formatted value
+ * @returns {string}
+ */
+function formatChar(value, field, options) {
+ value = typeof value === 'string' ? value : '';
+ if (options && options.isPassword) {
+ return _.str.repeat('*', value ? value.length : 0);
+ }
+ if (options && options.escape) {
+ value = _.escape(value);
+ }
+ return value;
+}
+
+/**
+ * Returns a string representing a date. If the value is false, then we return
+ * an empty string. Note that this is dependant on the localization settings
+ *
+ * @param {Moment|false} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.timezone=true] use the user timezone when formating the
+ * date
+ * @returns {string}
+ */
+function formatDate(value, field, options) {
+ if (value === false || isNaN(value)) {
+ return "";
+ }
+ if (field && field.type === 'datetime') {
+ if (!options || !('timezone' in options) || options.timezone) {
+ value = value.clone().add(session.getTZOffset(value), 'minutes');
+ }
+ }
+ var date_format = time.getLangDateFormat();
+ return value.format(date_format);
+}
+
+/**
+ * Returns a string representing a datetime. If the value is false, then we
+ * return an empty string. Note that this is dependant on the localization
+ * settings
+ *
+ * @params {Moment|false}
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.timezone=true] use the user timezone when formating the
+ * date
+ * @returns {string}
+ */
+function formatDateTime(value, field, options) {
+ if (value === false) {
+ return "";
+ }
+ if (!options || !('timezone' in options) || options.timezone) {
+ value = value.clone().add(session.getTZOffset(value), 'minutes');
+ }
+ return value.format(time.getLangDatetimeFormat());
+}
+
+/**
+ * Returns a string representing a float. The result takes into account the
+ * user settings (to display the correct decimal separator).
+ *
+ * @param {float|false} value the value that should be formatted
+ * @param {Object} [field] a description of the field (returned by fields_get
+ * for example). It may contain a description of the number of digits that
+ * should be used.
+ * @param {Object} [options] additional options to override the values in the
+ * python description of the field.
+ * @param {integer[]} [options.digits] the number of digits that should be used,
+ * instead of the default digits precision in the field.
+ * @param {function} [options.humanReadable] if returns true,
+ * formatFloat acts like utils.human_number
+ * @returns {string}
+ */
+function formatFloat(value, field, options) {
+ options = options || {};
+ if (value === false) {
+ return "";
+ }
+ if (options.humanReadable && options.humanReadable(value)) {
+ return utils.human_number(value, options.decimals, options.minDigits, options.formatterCallback);
+ }
+ var l10n = core._t.database.parameters;
+ var precision;
+ if (options.digits) {
+ precision = options.digits[1];
+ } else if (field && field.digits) {
+ precision = field.digits[1];
+ } else {
+ precision = 2;
+ }
+ var formatted = _.str.sprintf('%.' + precision + 'f', value || 0).split('.');
+ formatted[0] = utils.insert_thousand_seps(formatted[0]);
+ return formatted.join(l10n.decimal_point);
+}
+
+
+/**
+ * Returns a string representing a float value, from a float converted with a
+ * factor.
+ *
+ * @param {number} value
+ * @param {number} [options.factor]
+ * Conversion factor, default value is 1.0
+ * @returns {string}
+ */
+function formatFloatFactor(value, field, options) {
+ var factor = options.factor || 1;
+ return formatFloat(value * factor, field, options);
+}
+
+/**
+ * Returns a string representing a time value, from a float. The idea is that
+ * we sometimes want to display something like 1:45 instead of 1.75, or 0:15
+ * instead of 0.25.
+ *
+ * @param {float} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options]
+ * @param {boolean} [options.noLeadingZeroHour] if true, format like 1:30
+ * otherwise, format like 01:30
+ * @returns {string}
+ */
+function formatFloatTime(value, field, options) {
+ options = options || {};
+ var pattern = options.noLeadingZeroHour ? '%1d:%02d' : '%02d:%02d';
+ if (value < 0) {
+ value = Math.abs(value);
+ pattern = '-' + pattern;
+ }
+ var hour = Math.floor(value);
+ var min = Math.round((value % 1) * 60);
+ if (min === 60){
+ min = 0;
+ hour = hour + 1;
+ }
+ return _.str.sprintf(pattern, hour, min);
+}
+
+/**
+ * Returns a string representing an integer. If the value is false, then we
+ * return an empty string.
+ *
+ * @param {integer|false} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.isPassword=false] if true, returns '********'
+ * @param {function} [options.humanReadable] if returns true,
+ * formatFloat acts like utils.human_number
+ * @returns {string}
+ */
+function formatInteger(value, field, options) {
+ options = options || {};
+ if (options.isPassword) {
+ return _.str.repeat('*', String(value).length);
+ }
+ if (!value && value !== 0) {
+ // previously, it returned 'false'. I don't know why. But for the Pivot
+ // view, I want to display the concept of 'no value' with an empty
+ // string.
+ return "";
+ }
+ if (options.humanReadable && options.humanReadable(value)) {
+ return utils.human_number(value, options.decimals, options.minDigits, options.formatterCallback);
+ }
+ return utils.insert_thousand_seps(_.str.sprintf('%d', value));
+}
+
+/**
+ * Returns a string representing an many2one. If the value is false, then we
+ * return an empty string. Note that it accepts two types of input parameters:
+ * an array, in that case we assume that the many2one value is of the form
+ * [id, nameget], and we return the nameget, or it can be an object, and in that
+ * case, we assume that it is a record datapoint from a BasicModel.
+ *
+ * @param {Array|Object|false} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.escape=false] if true, escapes the formatted value
+ * @returns {string}
+ */
+function formatMany2one(value, field, options) {
+ if (!value) {
+ value = '';
+ } else if (_.isArray(value)) {
+ // value is a pair [id, nameget]
+ value = value[1];
+ } else {
+ // value is a datapoint, so we read its display_name field, which
+ // may in turn be a datapoint (if the name field is a many2one)
+ while (value.data) {
+ value = value.data.display_name || '';
+ }
+ }
+ if (options && options.escape) {
+ value = _.escape(value);
+ }
+ return value;
+}
+
+/**
+ * Returns a string indicating the number of records in the relation.
+ *
+ * @param {Object} value a valid element from a BasicModel, that represents a
+ * list of values
+ * @returns {string}
+ */
+function formatX2Many(value) {
+ if (value.data.length === 0) {
+ return _t('No records');
+ } else if (value.data.length === 1) {
+ return _t('1 record');
+ } else {
+ return value.data.length + _t(' records');
+ }
+}
+
+/**
+ * Returns a string representing a monetary value. The result takes into account
+ * the user settings (to display the correct decimal separator, currency, ...).
+ *
+ * @param {float|false} value the value that should be formatted
+ * @param {Object} [field]
+ * a description of the field (returned by fields_get for example). It
+ * may contain a description of the number of digits that should be used.
+ * @param {Object} [options]
+ * additional options to override the values in the python description of
+ * the field.
+ * @param {Object} [options.currency] the description of the currency to use
+ * @param {integer} [options.currency_id]
+ * the id of the 'res.currency' to use (ignored if options.currency)
+ * @param {string} [options.currency_field]
+ * the name of the field whose value is the currency id
+ * (ignore if options.currency or options.currency_id)
+ * Note: if not given it will default to the field currency_field value
+ * or to 'currency_id'.
+ * @param {Object} [options.data]
+ * a mapping of field name to field value, required with
+ * options.currency_field
+ * @param {integer[]} [options.digits]
+ * the number of digits that should be used, instead of the default
+ * digits precision in the field. Note: if the currency defines a
+ * precision, the currency's one is used.
+ * @param {boolean} [options.forceString=false]
+ * if false, returns a string encoding the html formatted value (with
+ * whitespace encoded as '&nbsp;')
+ * @returns {string}
+ */
+function formatMonetary(value, field, options) {
+ if (value === false) {
+ return "";
+ }
+ options = Object.assign({ forceString: false }, options);
+
+ var currency = options.currency;
+ if (!currency) {
+ var currency_id = options.currency_id;
+ if (!currency_id && options.data) {
+ var currency_field = options.currency_field || field.currency_field || 'currency_id';
+ currency_id = options.data[currency_field] && options.data[currency_field].res_id;
+ }
+ currency = session.get_currency(currency_id);
+ }
+
+ var digits = (currency && currency.digits) || options.digits;
+ if (options.field_digits === true) {
+ digits = field.digits || digits;
+ }
+ var formatted_value = formatFloat(value, field,
+ _.extend({}, options , {digits: digits})
+ );
+
+ if (!currency || options.noSymbol) {
+ return formatted_value;
+ }
+ const ws = options.forceString ? ' ' : '&nbsp;';
+ if (currency.position === "after") {
+ return formatted_value + ws + currency.symbol;
+ } else {
+ return currency.symbol + ws + formatted_value;
+ }
+}
+/**
+ * Returns a string representing the given value (multiplied by 100)
+ * concatenated with '%'.
+ *
+ * @param {number | false} value
+ * @param {Object} [field]
+ * @param {Object} [options]
+ * @param {function} [options.humanReadable] if returns true, parsing is avoided
+ * @returns {string}
+ */
+function formatPercentage(value, field, options) {
+ options = options || {};
+ let result = formatFloat(value * 100, field, options) || '0';
+ if (!options.humanReadable || !options.humanReadable(value * 100)) {
+ result = parseFloat(result).toString().replace('.', _t.database.parameters.decimal_point);
+ }
+ return result + (options.noSymbol ? '' : '%');
+}
+/**
+ * Returns a string representing the value of the selection.
+ *
+ * @param {string|false} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.escape=false] if true, escapes the formatted value
+ */
+function formatSelection(value, field, options) {
+ var val = _.find(field.selection, function (option) {
+ return option[0] === value;
+ });
+ if (!val) {
+ return '';
+ }
+ value = val[1];
+ if (options && options.escape) {
+ value = _.escape(value);
+ }
+ return value;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// Parse
+////////////////////////////////////////////////////////////////////////////////
+
+/**
+ * Smart date inputs are shortcuts to write dates quicker.
+ * These shortcuts should respect the format ^[+-]\d+[dmwy]?$
+ *
+ * e.g.
+ * "+1d" or "+1" will return now + 1 day
+ * "-2w" will return now - 2 weeks
+ * "+3m" will return now + 3 months
+ * "-4y" will return now + 4 years
+ *
+ * @param {string} value
+ * @returns {Moment|false} Moment date object
+ */
+function parseSmartDateInput(value) {
+ const units = {
+ d: 'days',
+ m: 'months',
+ w: 'weeks',
+ y: 'years',
+ };
+ const re = new RegExp(`^([+-])(\\d+)([${Object.keys(units).join('')}]?)$`);
+ const match = re.exec(value);
+ if (match) {
+ let date = moment();
+ const offset = parseInt(match[2], 10);
+ const unit = units[match[3] || 'd'];
+ if (match[1] === '+') {
+ date.add(offset, unit);
+ } else {
+ date.subtract(offset, unit);
+ }
+ return date;
+ }
+ return false;
+}
+
+/**
+ * Create an Date object
+ * The method toJSON return the formated value to send value server side
+ *
+ * @param {string} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.isUTC] the formatted date is utc
+ * @param {boolean} [options.timezone=false] format the date after apply the timezone
+ * offset
+ * @returns {Moment|false} Moment date object
+ */
+function parseDate(value, field, options) {
+ if (!value) {
+ return false;
+ }
+ var datePattern = time.getLangDateFormat();
+ var datePatternWoZero = time.getLangDateFormatWoZero();
+ var date;
+ const smartDate = parseSmartDateInput(value);
+ if (smartDate) {
+ date = smartDate;
+ } else {
+ if (options && options.isUTC) {
+ value = value.padStart(10, "0"); // server may send "932-10-10" for "0932-10-10" on some OS
+ date = moment.utc(value);
+ } else {
+ date = moment.utc(value, [datePattern, datePatternWoZero, moment.ISO_8601]);
+ }
+ }
+ if (date.isValid()) {
+ if (date.year() === 0) {
+ date.year(moment.utc().year());
+ }
+ if (date.year() >= 1000){
+ date.toJSON = function () {
+ return this.clone().locale('en').format('YYYY-MM-DD');
+ };
+ return date;
+ }
+ }
+ throw new Error(_.str.sprintf(core._t("'%s' is not a correct date"), value));
+}
+
+/**
+ * Create an Date object
+ * The method toJSON return the formated value to send value server side
+ *
+ * @param {string} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.isUTC] the formatted date is utc
+ * @param {boolean} [options.timezone=false] format the date after apply the timezone
+ * offset
+ * @returns {Moment|false} Moment date object
+ */
+function parseDateTime(value, field, options) {
+ if (!value) {
+ return false;
+ }
+ const datePattern = time.getLangDateFormat();
+ const timePattern = time.getLangTimeFormat();
+ const datePatternWoZero = time.getLangDateFormatWoZero();
+ const timePatternWoZero = time.getLangTimeFormatWoZero();
+ var pattern1 = datePattern + ' ' + timePattern;
+ var pattern2 = datePatternWoZero + ' ' + timePatternWoZero;
+ var datetime;
+ const smartDate = parseSmartDateInput(value);
+ if (smartDate) {
+ datetime = smartDate;
+ } else {
+ if (options && options.isUTC) {
+ value = value.padStart(19, "0"); // server may send "932-10-10" for "0932-10-10" on some OS
+ // phatomjs crash if we don't use this format
+ datetime = moment.utc(value.replace(' ', 'T') + 'Z');
+ } else {
+ datetime = moment.utc(value, [pattern1, pattern2, moment.ISO_8601]);
+ if (options && options.timezone) {
+ datetime.add(-session.getTZOffset(datetime), 'minutes');
+ }
+ }
+ }
+ if (datetime.isValid()) {
+ if (datetime.year() === 0) {
+ datetime.year(moment.utc().year());
+ }
+ if (datetime.year() >= 1000) {
+ datetime.toJSON = function () {
+ return this.clone().locale('en').format('YYYY-MM-DD HH:mm:ss');
+ };
+ return datetime;
+ }
+ }
+ throw new Error(_.str.sprintf(core._t("'%s' is not a correct datetime"), value));
+}
+
+/**
+ * Parse a String containing number in language formating
+ *
+ * @param {string} value
+ * The string to be parsed with the setting of thousands and
+ * decimal separator
+ * @returns {float|NaN} the number value contained in the string representation
+ */
+function parseNumber(value) {
+ if (core._t.database.parameters.thousands_sep) {
+ var escapedSep = _.str.escapeRegExp(core._t.database.parameters.thousands_sep);
+ value = value.replace(new RegExp(escapedSep, 'g'), '');
+ }
+ if (core._t.database.parameters.decimal_point) {
+ value = value.replace(core._t.database.parameters.decimal_point, '.');
+ }
+ return Number(value);
+}
+
+/**
+ * Parse a String containing float in language formating
+ *
+ * @param {string} value
+ * The string to be parsed with the setting of thousands and
+ * decimal separator
+ * @returns {float}
+ * @throws {Error} if no float is found respecting the language configuration
+ */
+function parseFloat(value) {
+ var parsed = parseNumber(value);
+ if (isNaN(parsed)) {
+ throw new Error(_.str.sprintf(core._t("'%s' is not a correct float"), value));
+ }
+ return parsed;
+}
+
+/**
+ * Parse a String containing currency symbol and returns amount
+ *
+ * @param {string} value
+ * The string to be parsed
+ * We assume that a monetary is always a pair (symbol, amount) separated
+ * by a non breaking space. A simple float can also be accepted as value
+ * @param {Object} [field]
+ * a description of the field (returned by fields_get for example).
+ * @param {Object} [options] additional options.
+ * @param {Object} [options.currency] - the description of the currency to use
+ * @param {integer} [options.currency_id]
+ * the id of the 'res.currency' to use (ignored if options.currency)
+ * @param {string} [options.currency_field]
+ * the name of the field whose value is the currency id
+ * (ignore if options.currency or options.currency_id)
+ * Note: if not given it will default to the field currency_field value
+ * or to 'currency_id'.
+ * @param {Object} [options.data]
+ * a mapping of field name to field value, required with
+ * options.currency_field
+ *
+ * @returns {float} the float value contained in the string representation
+ * @throws {Error} if no float is found or if parameter does not respect monetary condition
+ */
+function parseMonetary(value, field, options) {
+ var values = value.split('&nbsp;');
+ if (values.length === 1) {
+ return parseFloat(value);
+ }
+ else if (values.length !== 2) {
+ throw new Error(_.str.sprintf(core._t("'%s' is not a correct monetary field"), value));
+ }
+ options = options || {};
+ var currency = options.currency;
+ if (!currency) {
+ var currency_id = options.currency_id;
+ if (!currency_id && options.data) {
+ var currency_field = options.currency_field || field.currency_field || 'currency_id';
+ currency_id = options.data[currency_field] && options.data[currency_field].res_id;
+ }
+ currency = session.get_currency(currency_id);
+ }
+ return parseFloat(values[0] === currency.symbol ? values[1] : values[0]);
+}
+
+/**
+ * Parse a String containing float and unconvert it with a conversion factor
+ *
+ * @param {number} [options.factor]
+ * Conversion factor, default value is 1.0
+ */
+function parseFloatFactor(value, field, options) {
+ var parsed = parseFloat(value);
+ var factor = options.factor || 1.0;
+ return parsed / factor;
+}
+
+function parseFloatTime(value) {
+ var factor = 1;
+ if (value[0] === '-') {
+ value = value.slice(1);
+ factor = -1;
+ }
+ var float_time_pair = value.split(":");
+ if (float_time_pair.length !== 2)
+ return factor * parseFloat(value);
+ var hours = parseInteger(float_time_pair[0]);
+ var minutes = parseInteger(float_time_pair[1]);
+ return factor * (hours + (minutes / 60));
+}
+
+/**
+ * Parse a String containing float and unconvert it with a conversion factor
+ * of 100. The percentage can be a regular xx.xx float or a xx%.
+ *
+ * @param {string} value
+ * The string to be parsed
+ * @returns {float}
+ * @throws {Error} if the value couldn't be converted to float
+ */
+function parsePercentage(value) {
+ return parseFloat(value) / 100;
+}
+
+/**
+ * Parse a String containing integer with language formating
+ *
+ * @param {string} value
+ * The string to be parsed with the setting of thousands and
+ * decimal separator
+ * @returns {integer}
+ * @throws {Error} if no integer is found respecting the language configuration
+ */
+function parseInteger(value) {
+ var parsed = parseNumber(value);
+ // do not accept not numbers or float values
+ if (isNaN(parsed) || parsed % 1 || parsed < -2147483648 || parsed > 2147483647) {
+ throw new Error(_.str.sprintf(core._t("'%s' is not a correct integer"), value));
+ }
+ return parsed;
+}
+
+/**
+ * Creates an object with id and display_name.
+ *
+ * @param {Array|number|string|Object} value
+ * The given value can be :
+ * - an array with id as first element and display_name as second element
+ * - a number or a string representing the id (the display_name will be
+ * returned as undefined)
+ * - an object, simply returned untouched
+ * @returns {Object} (contains the id and display_name)
+ * Note: if the given value is not an array, a string or a
+ * number, the value is returned untouched.
+ */
+function parseMany2one(value) {
+ if (_.isArray(value)) {
+ return {
+ id: value[0],
+ display_name: value[1],
+ };
+ }
+ if (_.isNumber(value) || _.isString(value)) {
+ return {
+ id: parseInt(value, 10),
+ };
+ }
+ return value;
+}
+
+return {
+ format: {
+ binary: formatBinary,
+ boolean: formatBoolean,
+ char: formatChar,
+ date: formatDate,
+ datetime: formatDateTime,
+ float: formatFloat,
+ float_factor: formatFloatFactor,
+ float_time: formatFloatTime,
+ html: _.identity, // todo
+ integer: formatInteger,
+ many2many: formatX2Many,
+ many2one: formatMany2one,
+ many2one_reference: formatInteger,
+ monetary: formatMonetary,
+ one2many: formatX2Many,
+ percentage: formatPercentage,
+ reference: formatMany2one,
+ selection: formatSelection,
+ text: formatChar,
+ },
+ parse: {
+ binary: _.identity,
+ boolean: _.identity, // todo
+ char: _.identity, // todo
+ date: parseDate, // todo
+ datetime: parseDateTime, // todo
+ float: parseFloat,
+ float_factor: parseFloatFactor,
+ float_time: parseFloatTime,
+ html: _.identity, // todo
+ integer: parseInteger,
+ many2many: _.identity, // todo
+ many2one: parseMany2one,
+ many2one_reference: parseInteger,
+ monetary: parseMonetary,
+ one2many: _.identity,
+ percentage: parsePercentage,
+ reference: parseMany2one,
+ selection: _.identity, // todo
+ text: _.identity, // todo
+ },
+};
+
+});
diff --git a/addons/web/static/src/js/fields/field_wrapper.js b/addons/web/static/src/js/fields/field_wrapper.js
new file mode 100644
index 00000000..ad32d046
--- /dev/null
+++ b/addons/web/static/src/js/fields/field_wrapper.js
@@ -0,0 +1,157 @@
+odoo.define('web.FieldWrapper', function (require) {
+ "use strict";
+
+ const { ComponentWrapper } = require('web.OwlCompatibility');
+ const field_utils = require('web.field_utils');
+
+ /**
+ * This file defines the FieldWrapper component, an extension of ComponentWrapper,
+ * needed to instanciate Owl fields inside legacy widgets. This component
+ * will be no longer necessary as soon as all legacy widgets using fields will
+ * be rewritten in Owl.
+ */
+ class FieldWrapper extends ComponentWrapper {
+ constructor() {
+ super(...arguments);
+
+ this._data = {};
+
+ const options = this.props.options || {};
+ const record = this.props.record;
+ this._data.name = this.props.fieldName;
+ this._data.record = record;
+ this._data.field = record.fields[this._data.name];
+ this._data.viewType = options.viewType || 'default';
+ const fieldsInfo = record.fieldsInfo[this._data.viewType];
+ this._data.attrs = options.attrs || (fieldsInfo && fieldsInfo[this._data.name]) || {};
+ this._data.additionalContext = options.additionalContext || {};
+ this._data.value = record.data[this._data.name];
+ this._data.recordData = record.data;
+ this._data.string = this._data.attrs.string || this._data.field.string || this._data.name;
+ this._data.nodeOptions = this._data.attrs.options || {};
+ this._data.dataPointID = record.id;
+ this._data.res_id = record.res_id;
+ this._data.model = record.model;
+ this._data.mode = options.mode || "readonly";
+ this._data.formatType = this._data.attrs.widget in field_utils.format ?
+ this._data.attrs.widget :
+ this._data.field.type;
+ this._data.formatOptions = {};
+ this._data.parseOptions = {};
+ if (this._data.attrs.decorations) {
+ this._data.resetOnAnyFieldChange = true;
+ }
+
+ for (let key in this._data) {
+ Object.defineProperty(this, key, {
+ get: () => {
+ if (this.el) {
+ if (key === 'dataPointID') {
+ return this.componentRef.comp.dataPointId;
+ } else if (key === 'res_id') {
+ return this.componentRef.comp.resId;
+ }
+ }
+ return (this.el ? this.componentRef.comp : this._data)[key];
+ },
+ });
+ }
+ }
+
+ /**
+ * Renderers set the '__node' attribute on fields they instantiate. It
+ * is used for instance to evaluate modifiers on multi-edition. In this
+ * case, the controller reads this property on the target of the event.
+ * However, with Owl field Components, it is set on the FieldWrapper,
+ * not the real field Component, which triggers the 'field-changed'
+ * event. This function writes the attribute on that field Component.
+ */
+ mounted() {
+ super.mounted(...arguments);
+ this.componentRef.comp.__node = this.__node;
+ }
+
+ //----------------------------------------------------------------------
+ // Getters
+ //----------------------------------------------------------------------
+
+ get $el() {
+ return $(this.el);
+ }
+ get fieldDependencies() {
+ return this.Component.fieldDependencies;
+ }
+ get specialData() {
+ return this.Component.specialData;
+ }
+ get supportedFieldTypes() {
+ return this.Component.supportedFieldTypes;
+ }
+ get description() {
+ return this.Component.description;
+ }
+ get noLabel() {
+ return this.Component.noLabel;
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ activate() {
+ return this.componentRef.comp.activate(...arguments);
+ }
+ commitChanges() {
+ return this.componentRef.comp.commitChanges(...arguments);
+ }
+ getFocusableElement() {
+ return $(this.componentRef.comp.focusableElement);
+ }
+ isEmpty() {
+ return this.componentRef.comp.isEmpty;
+ }
+ isFocusable() {
+ return this.componentRef.comp.isFocusable;
+ }
+ isSet() {
+ if (this.componentRef.comp) {
+ return this.componentRef.comp.isSet;
+ }
+ // because of the willStart, the real field component may not be
+ // instantiated yet when the renderer first asks if it is set
+ // (only the wrapper is instantiated), so we instantiate one
+ // with the same props, get its 'isSet' status, and destroy it.
+ const c = new this.Component(null, this.props);
+ const isSet = c.isSet;
+ c.destroy();
+ return isSet;
+ }
+ isValid() {
+ return this.componentRef.comp.isValid;
+ }
+ removeInvalidClass() {
+ return this.componentRef.comp.removeInvalidClass(...arguments);
+ }
+ reset(record, event) {
+ return this.update({record, event});
+ }
+ setIDForLabel() {
+ return this.componentRef.comp.setIdForLabel(...arguments);
+ }
+ setInvalidClass() {
+ return this.componentRef.comp.setInvalidClass(...arguments);
+ }
+ updateModifiersValue(modifiers) {
+ if (this.props.options.attrs) {
+ this.props.options.attrs.modifiersValue = modifiers || {};
+ } else {
+ const viewType = this.props.options.viewType || 'default';
+ const fieldsInfo = this.props.record.fieldsInfo[viewType];
+ fieldsInfo[this.props.fieldName].modifiersValue = modifiers || {};
+ }
+ this.componentRef.comp.props = this.props;
+ }
+ }
+
+ return FieldWrapper;
+});
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
new file mode 100644
index 00000000..481ba16f
--- /dev/null
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -0,0 +1,3460 @@
+odoo.define('web.relational_fields', function (require) {
+"use strict";
+
+/**
+ * Relational Fields
+ *
+ * In this file, we have a collection of various relational field widgets.
+ * Relational field widgets are more difficult to use/manipulate, because the
+ * relations add a level of complexity: a value is not a basic type, it can be
+ * a collection of other records.
+ *
+ * Also, the way relational fields are edited is more complex. We can change
+ * the corresponding record(s), or alter some of their fields.
+ */
+
+var AbstractField = require('web.AbstractField');
+var basicFields = require('web.basic_fields');
+var concurrency = require('web.concurrency');
+const ControlPanelX2Many = require('web.ControlPanelX2Many');
+var core = require('web.core');
+var data = require('web.data');
+var Dialog = require('web.Dialog');
+var dialogs = require('web.view_dialogs');
+var dom = require('web.dom');
+const Domain = require('web.Domain');
+var KanbanRecord = require('web.KanbanRecord');
+var KanbanRenderer = require('web.KanbanRenderer');
+var ListRenderer = require('web.ListRenderer');
+const { ComponentWrapper, WidgetAdapterMixin } = require('web.OwlCompatibility');
+const { sprintf } = require("web.utils");
+
+const { escape } = owl.utils;
+var _t = core._t;
+var _lt = core._lt;
+var qweb = core.qweb;
+
+//------------------------------------------------------------------------------
+// Many2one widgets
+//------------------------------------------------------------------------------
+
+var M2ODialog = Dialog.extend({
+ template: "M2ODialog",
+ init: function (parent, name, value) {
+ this.name = name;
+ this.value = value;
+ this._super(parent, {
+ title: _.str.sprintf(_t("New %s"), this.name),
+ size: 'medium',
+ buttons: [{
+ text: _t('Create'),
+ classes: 'btn-primary',
+ close: true,
+ click: function () {
+ this.trigger_up('quick_create', { value: this.value });
+ },
+ }, {
+ text: _t('Create and edit'),
+ classes: 'btn-primary',
+ close: true,
+ click: function () {
+ this.trigger_up('search_create_popup', {
+ view_type: 'form',
+ value: this.value,
+ });
+ },
+ }, {
+ text: _t('Cancel'),
+ close: true,
+ }],
+ });
+ },
+ /**
+ * @override
+ * @param {boolean} isSet
+ */
+ close: function (isSet) {
+ this.isSet = isSet;
+ this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (!this.isSet) {
+ this.trigger_up('closed_unset');
+ }
+ this._super.apply(this, arguments);
+ },
+});
+
+var FieldMany2One = AbstractField.extend({
+ description: _lt("Many2one"),
+ supportedFieldTypes: ['many2one'],
+ template: 'FieldMany2One',
+ custom_events: _.extend({}, AbstractField.prototype.custom_events, {
+ 'closed_unset': '_onDialogClosedUnset',
+ 'field_changed': '_onFieldChanged',
+ 'quick_create': '_onQuickCreate',
+ 'search_create_popup': '_onSearchCreatePopup',
+ }),
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click input': '_onInputClick',
+ 'focusout input': '_onInputFocusout',
+ 'keyup input': '_onInputKeyup',
+ 'click .o_external_button': '_onExternalButtonClick',
+ 'click': '_onClick',
+ }),
+ AUTOCOMPLETE_DELAY: 200,
+ SEARCH_MORE_LIMIT: 320,
+
+ /**
+ * @override
+ * @param {boolean} [options.noOpen=false] if true, there is no external
+ * button to open the related record in a dialog
+ * @param {boolean} [options.noCreate=false] if true, the many2one does not
+ * allow to create records
+ */
+ init: function (parent, name, record, options) {
+ options = options || {};
+ this._super.apply(this, arguments);
+ this.limit = 7;
+ this.orderer = new concurrency.DropMisordered();
+
+ // should normally be set, except in standalone M20
+ const canCreate = 'can_create' in this.attrs ? JSON.parse(this.attrs.can_create) : true;
+ this.can_create = canCreate && !this.nodeOptions.no_create && !options.noCreate;
+ this.can_write = 'can_write' in this.attrs ? JSON.parse(this.attrs.can_write) : true;
+
+ this.nodeOptions = _.defaults(this.nodeOptions, {
+ quick_create: true,
+ });
+ this.noOpen = 'noOpen' in options ? options.noOpen : this.nodeOptions.no_open;
+ this.m2o_value = this._formatValue(this.value);
+ // 'recordParams' is a dict of params used when calling functions
+ // 'getDomain' and 'getContext' on this.record
+ this.recordParams = {fieldName: this.name, viewType: this.viewType};
+ // 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;
+
+ // List of autocomplete sources
+ this._autocompleteSources = [];
+ // Add default search method for M20 (name_search)
+ this._addAutocompleteSource(this._search, {placeholder: _t('Loading...'), order: 1});
+
+ // use a DropPrevious to properly handle related record quick creations,
+ // and store a createDef to be able to notify the environment that there
+ // is pending quick create operation
+ this.dp = new concurrency.DropPrevious();
+ this.createDef = undefined;
+ },
+ start: function () {
+ // booleean indicating that the content of the input isn't synchronized
+ // with the current m2o value (for instance, the user is currently
+ // typing something in the input, and hasn't selected a value yet).
+ this.floating = false;
+
+ this.$input = this.$('input');
+ this.$external_button = this.$('.o_external_button');
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (this._onScroll) {
+ window.removeEventListener('scroll', this._onScroll, true);
+ }
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override to make the caller wait for potential ongoing record creation.
+ * This ensures that the correct many2one value is set when the main record
+ * is saved.
+ *
+ * @override
+ * @returns {Promise} resolved as soon as there is no longer record being
+ * (quick) created
+ */
+ commitChanges: function () {
+ return Promise.resolve(this.createDef);
+ },
+ /**
+ * @override
+ * @returns {jQuery}
+ */
+ getFocusableElement: function () {
+ return this.mode === 'edit' && this.$input || this.$el;
+ },
+ /**
+ * TODO
+ */
+ reinitialize: function (value) {
+ this.isDirty = false;
+ this.floating = false;
+ return this._setValue(value);
+ },
+ /**
+ * 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) {
+ return Promise.resolve();
+ } else {
+ return this._render();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a source to the autocomplete results
+ *
+ * @param {function} method : A function that returns a list of results. If async source, the function should return a promise
+ * @param {Object} params : Parameters containing placeholder/validation/order
+ * @private
+ */
+ _addAutocompleteSource: function (method, params) {
+ this._autocompleteSources.push({
+ method: method,
+ placeholder: (params.placeholder ? _t(params.placeholder) : _t('Loading...')) + '<i class="fa fa-spinner fa-spin pull-right"></i>' ,
+ validation: params.validation,
+ loading: false,
+ order: params.order || 999
+ });
+
+ this._autocompleteSources = _.sortBy(this._autocompleteSources, 'order');
+ },
+ /**
+ * @private
+ */
+ _bindAutoComplete: function () {
+ var self = this;
+ // avoid ignoring autocomplete="off" by obfuscating placeholder, see #30439
+ if ($.browser.chrome && this.$input.attr('placeholder')) {
+ this.$input.attr('placeholder', function (index, val) {
+ return val.split('').join('\ufeff');
+ });
+ }
+ this.$input.autocomplete({
+ source: function (req, resp) {
+ _.each(self._autocompleteSources, function (source) {
+ // Resets the results for this source
+ source.results = [];
+
+ // Check if this source should be used for the searched term
+ const search = req.term.trim();
+ if (!source.validation || source.validation.call(self, search)) {
+ source.loading = true;
+
+ // Wrap the returned value of the source.method with a promise
+ // So event if the returned value is not async, it will work
+ Promise.resolve(source.method.call(self, search)).then(function (results) {
+ source.results = results;
+ source.loading = false;
+ resp(self._concatenateAutocompleteResults());
+ });
+ }
+ });
+ },
+ select: function (event, ui) {
+ // we do not want the select event to trigger any additional
+ // effect, such as navigating to another field.
+ event.stopImmediatePropagation();
+ event.preventDefault();
+
+ var item = ui.item;
+ self.floating = false;
+ if (item.id) {
+ self.reinitialize({id: item.id, display_name: item.name});
+ } else if (item.action) {
+ item.action();
+ }
+ return false;
+ },
+ focus: function (event) {
+ event.preventDefault(); // don't automatically select values on focus
+ },
+ open: function (event) {
+ self._onScroll = function (ev) {
+ if (ev.target !== self.$input.get(0) && self.$input.hasClass('ui-autocomplete-input')) {
+ self.$input.autocomplete('close');
+ }
+ };
+ window.addEventListener('scroll', self._onScroll, true);
+ },
+ close: function (event) {
+ // it is necessary to prevent ESC key from propagating to field
+ // root, to prevent unwanted discard operations.
+ if (event.which === $.ui.keyCode.ESCAPE) {
+ event.stopPropagation();
+ }
+ if (self._onScroll) {
+ window.removeEventListener('scroll', self._onScroll, true);
+ }
+ },
+ autoFocus: true,
+ html: true,
+ minLength: 0,
+ delay: this.AUTOCOMPLETE_DELAY,
+ });
+ this.$input.autocomplete("option", "position", { my : "left top", at: "left bottom" });
+ this.autocomplete_bound = true;
+ },
+ /**
+ * Concatenate async results for autocomplete.
+ *
+ * @returns {Array}
+ * @private
+ */
+ _concatenateAutocompleteResults: function () {
+ var results = [];
+ _.each(this._autocompleteSources, function (source) {
+ if (source.results && source.results.length) {
+ results = results.concat(source.results);
+ } else if (source.loading) {
+ results.push({
+ label: source.placeholder
+ });
+ }
+ });
+ return results;
+ },
+ /**
+ * @private
+ * @param {string} [name]
+ * @returns {Object}
+ */
+ _createContext: function (name) {
+ var tmp = {};
+ var field = this.nodeOptions.create_name_field;
+ if (field === undefined) {
+ field = "name";
+ }
+ if (field !== false && name && this.nodeOptions.quick_create !== false) {
+ tmp["default_" + field] = name;
+ }
+ return tmp;
+ },
+ /**
+ * @private
+ * @returns {Array}
+ */
+ _getSearchBlacklist: function () {
+ return [];
+ },
+ /**
+ * Returns the display_name from a string which contains it but was altered
+ * as a result of the show_address option using a horrible hack.
+ *
+ * @private
+ * @param {string} value
+ * @returns {string} display_name without show_address mess
+ */
+ _getDisplayName: function (value) {
+ return value.split('\n')[0];
+ },
+ /**
+ * Prepares and returns options for SelectCreateDialog
+ *
+ * @private
+ */
+ _getSearchCreatePopupOptions: function(view, ids, context, dynamicFilters) {
+ var self = this;
+ return {
+ res_model: this.field.relation,
+ domain: this.record.getDomain({fieldName: this.name}),
+ context: _.extend({}, this.record.getContext(this.recordParams), context || {}),
+ _createContext: this._createContext.bind(this),
+ dynamicFilters: dynamicFilters || [],
+ title: (view === 'search' ? _t("Search: ") : _t("Create: ")) + this.string,
+ initial_ids: ids,
+ initial_view: view,
+ disable_multiple_selection: true,
+ no_create: !self.can_create,
+ kanban_view_ref: this.attrs.kanban_view_ref,
+ on_selected: function (records) {
+ self.reinitialize(records[0]);
+ },
+ on_closed: function () {
+ self.activate();
+ },
+ };
+ },
+ /**
+ * @private
+ * @param {Object} values
+ * @param {string} search_val
+ * @param {Object} domain
+ * @param {Object} context
+ * @returns {Object}
+ */
+ _manageSearchMore: function (values, search_val, domain, context) {
+ var self = this;
+ values = values.slice(0, this.limit);
+ values.push({
+ label: _t("Search More..."),
+ action: function () {
+ var prom;
+ if (search_val !== '') {
+ prom = self._rpc({
+ model: self.field.relation,
+ method: 'name_search',
+ kwargs: {
+ name: search_val,
+ args: domain,
+ operator: "ilike",
+ limit: self.SEARCH_MORE_LIMIT,
+ context: context,
+ },
+ });
+ }
+ Promise.resolve(prom).then(function (results) {
+ var dynamicFilters;
+ if (results) {
+ var ids = _.map(results, function (x) {
+ return x[0];
+ });
+ dynamicFilters = [{
+ description: _.str.sprintf(_t('Quick search: %s'), search_val),
+ domain: [['id', 'in', ids]],
+ }];
+ }
+ self._searchCreatePopup("search", false, {}, dynamicFilters);
+ });
+ },
+ classname: 'o_m2o_dropdown_option',
+ });
+ return values;
+ },
+ /**
+ * 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;
+ },
+ /**
+ * @private
+ * @param {string} name
+ * @returns {Promise} resolved after the name_create or when the slowcreate
+ * modal is closed.
+ */
+ _quickCreate: function (name) {
+ var self = this;
+ var createDone;
+
+ var def = new Promise(function (resolve, reject) {
+ self.createDef = new Promise(function (innerResolve) {
+ // called when the record has been quick created, or when the dialog has
+ // been closed (in the case of a 'slow' create), meaning that the job is
+ // done
+ createDone = function () {
+ innerResolve();
+ resolve();
+ self.createDef = undefined;
+ };
+ });
+
+ // called if the quick create is disabled on this many2one, or if the
+ // quick creation failed (probably because there are mandatory fields on
+ // the model)
+ var slowCreate = function () {
+ var dialog = self._searchCreatePopup("form", false, self._createContext(name));
+ dialog.on('closed', self, createDone);
+ };
+ if (self.nodeOptions.quick_create) {
+ const prom = self.reinitialize({id: false, display_name: name});
+ prom.guardedCatch(reason => {
+ reason.event.preventDefault();
+ slowCreate();
+ });
+ self.dp.add(prom).then(createDone).guardedCatch(reject);
+ } else {
+ slowCreate();
+ }
+ });
+
+ return def;
+ },
+ /**
+ * @private
+ */
+ _renderEdit: function () {
+ var value = this.m2o_value;
+
+ // this is a stupid hack necessary to support the always_reload flag.
+ // the field value has been reread by the basic model. We use it to
+ // display the full address of a partner, separated by \n. This is
+ // really a bad way to do it. Now, we need to remove the extra lines
+ // and hope for the best that no one tries to uses this mechanism to do
+ // something else.
+ if (this.nodeOptions.always_reload) {
+ value = this._getDisplayName(value);
+ }
+ this.$input.val(value);
+ if (!this.autocomplete_bound) {
+ this._bindAutoComplete();
+ }
+ this._updateExternalButton();
+ },
+ /**
+ * @private
+ */
+ _renderReadonly: function () {
+ var escapedValue = _.escape((this.m2o_value || "").trim());
+ var value = escapedValue.split('\n').map(function (line) {
+ return '<span>' + line + '</span>';
+ }).join('<br/>');
+ this.$el.html(value);
+ if (!this.noOpen && this.value) {
+ this.$el.attr('href', _.str.sprintf('#id=%s&model=%s', this.value.res_id, this.field.relation));
+ this.$el.addClass('o_form_uri');
+ }
+ },
+ /**
+ * @private
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ this.floating = false;
+ this.m2o_value = this._formatValue(this.value);
+ },
+ /**
+ * Executes a 'name_search' and returns a list of formatted objects meant to
+ * be displayed in the autocomplete widget dropdown. These items are either:
+ * - a formatted version of a 'name_search' result
+ * - an option meant to display additional information or perform an action
+ *
+ * @private
+ * @param {string} [searchValue=""]
+ * @returns {Promise<{
+ * label: string,
+ * id?: number,
+ * name?: string,
+ * value?: string,
+ * classname?: string,
+ * action?: () => Promise<any>,
+ * }[]>}
+ */
+ _search: async function (searchValue = "") {
+ const value = searchValue.trim();
+ const domain = this.record.getDomain(this.recordParams);
+ const context = Object.assign(
+ this.record.getContext(this.recordParams),
+ this.additionalContext
+ );
+
+ // Exclude black-listed ids from the domain
+ const blackListedIds = this._getSearchBlacklist();
+ if (blackListedIds.length) {
+ domain.push(['id', 'not in', blackListedIds]);
+ }
+
+ const nameSearch = this._rpc({
+ model: this.field.relation,
+ method: "name_search",
+ kwargs: {
+ name: value,
+ args: domain,
+ operator: "ilike",
+ limit: this.limit + 1,
+ context,
+ }
+ });
+ const results = await this.orderer.add(nameSearch);
+
+ // Format results to fit the options dropdown
+ let values = results.map((result) => {
+ const [id, fullName] = result;
+ const displayName = this._getDisplayName(fullName).trim();
+ result[1] = displayName;
+ return {
+ id,
+ label: escape(displayName) || data.noDisplayContent,
+ value: displayName,
+ name: displayName,
+ };
+ });
+
+ // Add "Search more..." option if results count is higher than the limit
+ if (this.limit < values.length) {
+ values = this._manageSearchMore(values, value, domain, context);
+ }
+ if (!this.can_create) {
+ return values;
+ }
+
+ // Additional options...
+ const canQuickCreate = !this.nodeOptions.no_quick_create;
+ const canCreateEdit = !this.nodeOptions.no_create_edit;
+ if (value.length) {
+ // "Quick create" option
+ const nameExists = results.some((result) => result[1] === value);
+ if (canQuickCreate && !nameExists) {
+ values.push({
+ label: sprintf(
+ _t(`Create "<strong>%s</strong>"`),
+ escape(value)
+ ),
+ action: () => this._quickCreate(value),
+ classname: 'o_m2o_dropdown_option'
+ });
+ }
+ // "Create and Edit" option
+ if (canCreateEdit) {
+ const valueContext = this._createContext(value);
+ values.push({
+ label: _t("Create and Edit..."),
+ action: () => {
+ // Input value is cleared and the form popup opens
+ this.el.querySelector(':scope input').value = "";
+ return this._searchCreatePopup('form', false, valueContext);
+ },
+ classname: 'o_m2o_dropdown_option',
+ });
+ }
+ // "No results" option
+ if (!values.length) {
+ values.push({
+ label: _t("No results to show..."),
+ });
+ }
+ } else if (!this.value && (canQuickCreate || canCreateEdit)) {
+ // "Start typing" option
+ values.push({
+ label: _t("Start typing..."),
+ classname: 'o_m2o_start_typing',
+ });
+ }
+
+ return values;
+ },
+ /**
+ * all search/create popup handling
+ *
+ * TODO: ids argument is no longer used, remove it in master (as well as
+ * initial_ids param of the dialog)
+ *
+ * @private
+ * @param {any} view
+ * @param {any} ids
+ * @param {any} context
+ * @param {Object[]} [dynamicFilters=[]] filters to add to the search view
+ * in the dialog (each filter has keys 'description' and 'domain')
+ */
+ _searchCreatePopup: function (view, ids, context, dynamicFilters) {
+ var options = this._getSearchCreatePopupOptions(view, ids, context, dynamicFilters);
+ return new dialogs.SelectCreateDialog(this, _.extend({}, this.nodeOptions, options)).open();
+ },
+ /**
+ * @private
+ */
+ _updateExternalButton: function () {
+ var has_external_button = !this.noOpen && !this.floating && this.isSet();
+ this.$external_button.toggle(has_external_button);
+ this.$el.toggleClass('o_with_button', has_external_button); // Should not be required anymore but kept for compatibility
+ },
+
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onClick: function (event) {
+ var self = this;
+ if (this.mode === 'readonly' && !this.noOpen) {
+ event.preventDefault();
+ event.stopPropagation();
+ this._rpc({
+ model: this.field.relation,
+ method: 'get_formview_action',
+ args: [[this.value.res_id]],
+ context: this.record.getContext(this.recordParams),
+ })
+ .then(function (action) {
+ self.trigger_up('do_action', {action: action});
+ });
+ }
+ },
+
+ /**
+ * Reset the input as dialog has been closed without m2o creation.
+ *
+ * @private
+ */
+ _onDialogClosedUnset: function () {
+ this.isDirty = false;
+ this.floating = false;
+ this._render();
+ },
+ /**
+ * @private
+ */
+ _onExternalButtonClick: function () {
+ if (!this.value) {
+ this.activate();
+ return;
+ }
+ var self = this;
+ var context = this.record.getContext(this.recordParams);
+ this._rpc({
+ model: this.field.relation,
+ method: 'get_formview_id',
+ args: [[this.value.res_id]],
+ context: context,
+ })
+ .then(function (view_id) {
+ new dialogs.FormViewDialog(self, {
+ res_model: self.field.relation,
+ res_id: self.value.res_id,
+ context: context,
+ title: _t("Open: ") + self.string,
+ view_id: view_id,
+ readonly: !self.can_write,
+ on_saved: function (record, changed) {
+ if (changed) {
+ const _setValue = self._setValue.bind(self, self.value.data, {
+ forceChange: true,
+ });
+ self.trigger_up('reload', {
+ db_id: self.value.id,
+ onSuccess: _setValue,
+ onFailure: _setValue,
+ });
+ }
+ },
+ }).open();
+ });
+ },
+ /**
+ * @private
+ */
+ _onInputClick: function () {
+ if (this.$input.autocomplete("widget").is(":visible")) {
+ this.$input.autocomplete("close");
+ } else if (this.floating) {
+ this.$input.autocomplete("search"); // search with the input's content
+ } else {
+ this.$input.autocomplete("search", ''); // search with the empty string
+ }
+ },
+ /**
+ * @private
+ */
+ _onInputFocusout: function () {
+ if (this.can_create && this.floating) {
+ new M2ODialog(this, this.string, this.$input.val()).open();
+ }
+ },
+ /**
+ * @private
+ *
+ * @param {OdooEvent} ev
+ */
+ _onInputKeyup: function (ev) {
+ if (ev.which === $.ui.keyCode.ENTER || ev.which === $.ui.keyCode.TAB) {
+ // If we pressed enter or tab, we want to prevent _onInputFocusout from
+ // executing since it would open a M2O dialog to request
+ // confirmation that the many2one is not properly set.
+ // It's a case that is already handled by the autocomplete lib.
+ return;
+ }
+ this.isDirty = true;
+ if (this.$input.val() === "") {
+ this.reinitialize(false);
+ } else if (this._getDisplayName(this.m2o_value) !== this.$input.val()) {
+ this.floating = true;
+ this._updateExternalButton();
+ }
+ },
+ /**
+ * @override
+ * @private
+ */
+ _onKeydown: function () {
+ this.floating = false;
+ this._super.apply(this, arguments);
+ },
+ /**
+ * Stops the left/right navigation move event if the cursor is not at the
+ * start/end of the input element. Stops any navigation move event if the
+ * user is selecting text.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNavigationMove: function (ev) {
+ // TODO Maybe this should be done in a mixin or, better, the m2o field
+ // should be an InputField (but this requires some refactoring).
+ basicFields.InputField.prototype._onNavigationMove.apply(this, arguments);
+ if (this.mode === 'edit' && $(this.$input.autocomplete('widget')).is(':visible')) {
+ ev.stopPropagation();
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onQuickCreate: function (event) {
+ this._quickCreate(event.data.value);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onSearchCreatePopup: function (event) {
+ var data = event.data;
+ this._searchCreatePopup(data.view_type, false, this._createContext(data.value));
+ },
+});
+
+var Many2oneBarcode = FieldMany2One.extend({
+ // We don't require this widget to be displayed in studio sidebar in
+ // non-debug mode hence just extended it from its original widget, so that
+ // description comes from parent and hasOwnProperty based condition fails
+});
+
+var ListFieldMany2One = FieldMany2One.extend({
+ events: _.extend({}, FieldMany2One.prototype.events, {
+ 'focusin input': '_onInputFocusin',
+ }),
+
+ /**
+ * Should never be allowed to be opened while in readonly mode in a list
+ *
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ // when we empty the input, we delay the setValue to prevent from
+ // triggering the 'fieldChanged' event twice when the user wants set
+ // another m2o value ; the following attribute is used to determine when
+ // we skipped the setValue, s.t. we can perform it later on if the user
+ // didn't select another value
+ this.mustSetValue = false;
+ this.m2oDialogFocused = false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * If in readonly, will never be considered as an active widget.
+ *
+ * @override
+ */
+ activate: function () {
+ if (this.mode === 'readonly') {
+ return false;
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ reinitialize: function () {
+ this.mustSetValue = false;
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _renderReadonly: function () {
+ this.$el.text(this.m2o_value);
+ },
+ /**
+ * @override
+ * @private
+ */
+ _searchCreatePopup: function () {
+ this.m2oDialogFocused = true;
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onInputFocusin: function () {
+ this.m2oDialogFocused = false;
+ },
+ /**
+ * In case the focus is lost from a mousedown, we want to prevent the click occuring on the
+ * following mouseup since it might trigger some unwanted list functions.
+ * If it's not the case, we want to remove the added handler on the next mousedown.
+ * @see list_editable_renderer._onWindowClicked()
+ *
+ * Also, in list views, we don't want to try to trigger a fieldChange when the field
+ * is being emptied. Instead, it will be triggered as the user leaves the field
+ * while it is empty.
+ *
+ * @override
+ * @private
+ */
+ _onInputFocusout: function () {
+ if (this.can_create && this.floating) {
+ // In case the focus out is due to a mousedown, we want to prevent the next click
+ var attachedEvents = ['click', 'mousedown'];
+ var stopNextClick = (function (ev) {
+ ev.stopPropagation();
+ attachedEvents.forEach(function (eventName) {
+ window.removeEventListener(eventName, stopNextClick, true);
+ });
+ }).bind(this);
+ attachedEvents.forEach(function (eventName) {
+ window.addEventListener(eventName, stopNextClick, true);
+ });
+ }
+ this._super.apply(this, arguments);
+ if (!this.m2oDialogFocused && this.$input.val() === "" && this.mustSetValue) {
+ this.reinitialize(false);
+ }
+ },
+ /**
+ * Prevents the triggering of an immediate _onFieldChanged when emptying the field.
+ *
+ * @override
+ * @private
+ */
+ _onInputKeyup: function () {
+ if (this.$input.val() !== "") {
+ this._super.apply(this, arguments);
+ } else {
+ this.mustSetValue = true;
+ }
+ },
+});
+
+var KanbanFieldMany2One = AbstractField.extend({
+ tagName: 'span',
+ init: function () {
+ this._super.apply(this, arguments);
+ this.m2o_value = this._formatValue(this.value);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _render: function () {
+ this.$el.text(this.m2o_value);
+ },
+});
+
+/**
+ * Widget Many2OneAvatar is only supported on many2one fields pointing to a
+ * model which inherits from 'image.mixin'. In readonly, it displays the
+ * record's image next to the display_name. In edit, it behaves exactly like a
+ * regular many2one widget.
+ */
+const Many2OneAvatar = FieldMany2One.extend({
+ _template: 'web.Many2OneAvatar',
+
+ init() {
+ this._super.apply(this, arguments);
+ if (this.mode === 'readonly') {
+ this.template = null;
+ this.tagName = 'div';
+ this.className = 'o_field_many2one_avatar';
+ // disable the redirection to the related record on click, in readonly
+ this.noOpen = true;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _renderReadonly() {
+ this.$el.empty();
+ if (this.value) {
+ this.$el.html(qweb.render(this._template, {
+ url: `/web/image/${this.field.relation}/${this.value.res_id}/image_128`,
+ value: this.m2o_value,
+ }));
+ }
+ },
+});
+
+//------------------------------------------------------------------------------
+// X2Many widgets
+//------------------------------------------------------------------------------
+
+var FieldX2Many = AbstractField.extend(WidgetAdapterMixin, {
+ tagName: 'div',
+ custom_events: _.extend({}, AbstractField.prototype.custom_events, {
+ add_record: '_onAddRecord',
+ discard_changes: '_onDiscardChanges',
+ edit_line: '_onEditLine',
+ field_changed: '_onFieldChanged',
+ open_record: '_onOpenRecord',
+ kanban_record_delete: '_onRemoveRecord',
+ list_record_remove: '_onRemoveRecord',
+ resequence_records: '_onResequenceRecords',
+ save_line: '_onSaveLine',
+ toggle_column_order: '_onToggleColumnOrder',
+ activate_next_widget: '_onActiveNextWidget',
+ navigation_move: '_onNavigationMove',
+ save_optional_fields: '_onSaveOrLoadOptionalFields',
+ load_optional_fields: '_onSaveOrLoadOptionalFields',
+ pager_changed: '_onPagerChanged',
+ }),
+
+ // We need to trigger the reset on every changes to be aware of the parent changes
+ // and then evaluate the 'column_invisible' modifier in case a evaluated value
+ // changed.
+ resetOnAnyFieldChange: true,
+
+ /**
+ * useSubview is used in form view to load view of the related model of the x2many field
+ */
+ useSubview: true,
+
+ /**
+ * @override
+ */
+ init: function (parent, name, record, options) {
+ this._super.apply(this, arguments);
+ this.nodeOptions = _.defaults(this.nodeOptions, {
+ create_text: _t('Add'),
+ });
+ this.operations = [];
+ this.isReadonly = this.mode === 'readonly';
+ this.view = this.attrs.views[this.attrs.mode];
+ this.isMany2Many = this.field.type === 'many2many' || this.attrs.widget === 'many2many';
+ this.activeActions = {};
+ this.recordParams = {fieldName: this.name, viewType: this.viewType};
+ // The limit is fixed so it cannot be changed by adding/removing lines in
+ // the widget. It will only change through a hard reload or when manually
+ // changing the pager (see _onPagerChanged).
+ this.pagingState = {
+ currentMinimum: this.value.offset + 1,
+ limit: this.value.limit,
+ size: this.value.count,
+ validate: () => {
+ // TODO: we should have some common method in the basic renderer...
+ return this.view.arch.tag === 'tree' ?
+ this.renderer.unselectRow() :
+ Promise.resolve();
+ },
+ withAccessKey: false,
+ };
+ var arch = this.view && this.view.arch;
+ if (arch) {
+ this.activeActions.create = arch.attrs.create ?
+ !!JSON.parse(arch.attrs.create) :
+ true;
+ this.activeActions.delete = arch.attrs.delete ?
+ !!JSON.parse(arch.attrs.delete) :
+ true;
+ this.editable = arch.attrs.editable;
+ }
+ this._computeAvailableActions(record);
+ if (this.attrs.columnInvisibleFields) {
+ this._processColumnInvisibleFields();
+ }
+ },
+ /**
+ * @override
+ */
+ start: async function () {
+ const _super = this._super.bind(this);
+ if (this.view) {
+ this._renderButtons();
+ this._controlPanelWrapper = new ComponentWrapper(this, ControlPanelX2Many, {
+ cp_content: { $buttons: this.$buttons },
+ pager: this.pagingState,
+ });
+ await this._controlPanelWrapper.mount(this.el, { position: 'first-child' });
+ }
+ return _super(...arguments);
+ },
+ destroy: function () {
+ WidgetAdapterMixin.destroy.call(this);
+ this._super();
+ },
+ /**
+ * For the list renderer to properly work, it must know if it is in the DOM,
+ * and be notified when it is attached to the DOM.
+ */
+ on_attach_callback: function () {
+ this.isInDOM = true;
+ WidgetAdapterMixin.on_attach_callback.call(this);
+ if (this.renderer) {
+ this.renderer.on_attach_callback();
+ }
+ },
+ /**
+ * For the list renderer to properly work, it must know if it is in the DOM.
+ */
+ on_detach_callback: function () {
+ this.isInDOM = false;
+ WidgetAdapterMixin.on_detach_callback.call(this);
+ if (this.renderer) {
+ this.renderer.on_detach_callback();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * A x2m field can only be saved if it finished the edition of all its rows.
+ * On parent view saving, we have to ask the x2m fields to commit their
+ * changes, that is confirming the save of the in-edition row or asking the
+ * user if he wants to discard it if necessary.
+ *
+ * @override
+ * @returns {Promise}
+ */
+ commitChanges: function () {
+ var self = this;
+ var inEditionRecordID =
+ this.renderer &&
+ this.renderer.viewType === "list" &&
+ this.renderer.getEditableRecordID();
+ if (inEditionRecordID) {
+ return this.renderer.commitChanges(inEditionRecordID).then(function () {
+ return self._saveLine(inEditionRecordID);
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ isSet: function () {
+ return true;
+ },
+ /**
+ * @override
+ * @param {Object} record
+ * @param {OdooEvent} [ev] an event that triggered the reset action
+ * @param {Boolean} [fieldChanged] if true, the widget field has changed
+ * @returns {Promise}
+ */
+ reset: function (record, ev, fieldChanged) {
+ // re-evaluate available actions
+ const oldCanCreate = this.canCreate;
+ const oldCanDelete = this.canDelete;
+ const oldCanLink = this.canLink;
+ const oldCanUnlink = this.canUnlink;
+ this._computeAvailableActions(record);
+ const actionsChanged =
+ this.canCreate !== oldCanCreate ||
+ this.canDelete !== oldCanDelete ||
+ this.canLink !== oldCanLink ||
+ this.canUnlink !== oldCanUnlink;
+
+ // If 'fieldChanged' is false, it means that the reset was triggered by
+ // the 'resetOnAnyFieldChange' mechanism. If it is the case, if neither
+ // the modifiers (so the visible columns) nor the available actions
+ // changed, the reset is skipped.
+ if (!fieldChanged && !actionsChanged) {
+ var newEval = this._evalColumnInvisibleFields();
+ if (_.isEqual(this.currentColInvisibleFields, newEval)) {
+ this._reset(record, ev); // update the internal state, but do not re-render
+ return Promise.resolve();
+ }
+ } else if (ev && ev.target === this && ev.data.changes && this.view.arch.tag === 'tree') {
+ var command = ev.data.changes[this.name];
+ // Here, we only consider 'UPDATE' commands with data, which occur
+ // with editable list view. In order to keep the current line in
+ // edition, we call confirmUpdate which will try to reset the widgets
+ // of the line being edited, and rerender the rest of the list.
+ // 'UPDATE' commands with no data can be ignored: they occur in
+ // one2manys when the record is updated from a dialog and in this
+ // case, we can re-render the whole subview.
+ if (command && command.operation === 'UPDATE' && command.data) {
+ var state = record.data[this.name];
+ var fieldNames = state.getFieldNames({ viewType: 'list' });
+ this._reset(record, ev);
+ return this.renderer.confirmUpdate(state, command.id, fieldNames, ev.initialEvent);
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ /**
+ * @override
+ * @returns {jQuery}
+ */
+ getFocusableElement: function () {
+ return (this.mode === 'edit' && this.$input) || this.$el;
+ },
+
+ /**
+ * @override
+ * @param {Object|undefined} [options={}]
+ * @param {boolean} [options.noAutomaticCreate=false]
+ */
+ activate: function (options) {
+ if (!this.activeActions.create || this.isReadonly || !this.$el.is(":visible")) {
+ return false;
+ }
+ if (this.view.type === 'kanban') {
+ this.$buttons.find(".o-kanban-button-new").focus();
+ }
+ if (this.view.arch.tag === 'tree') {
+ if (options && options.noAutomaticCreate) {
+ this.renderer.$('.o_field_x2many_list_row_add a:first').focus();
+ } else {
+ this.renderer.$('.o_field_x2many_list_row_add a:first').click();
+ }
+ }
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} record
+ */
+ _computeAvailableActions: function (record) {
+ const evalContext = record.evalContext;
+ this.canCreate = 'create' in this.nodeOptions ?
+ new Domain(this.nodeOptions.create, evalContext).compute(evalContext) :
+ true;
+ this.canDelete = 'delete' in this.nodeOptions ?
+ new Domain(this.nodeOptions.delete, evalContext).compute(evalContext) :
+ true;
+ this.canLink = 'link' in this.nodeOptions ?
+ new Domain(this.nodeOptions.link, evalContext).compute(evalContext) :
+ true;
+ this.canUnlink = 'unlink' in this.nodeOptions ?
+ new Domain(this.nodeOptions.unlink, evalContext).compute(evalContext) :
+ true;
+ },
+ /**
+ * Evaluates the 'column_invisible' modifier for the parent record.
+ *
+ * @return {Object} Object containing fieldName as key and the evaluated
+ * column_invisible modifier
+ */
+ _evalColumnInvisibleFields: function () {
+ var self = this;
+ return _.mapObject(this.columnInvisibleFields, function (domains) {
+ return self.record.evalModifiers({
+ column_invisible: domains,
+ }).column_invisible;
+ });
+ },
+ /**
+ * Returns qweb context to render buttons.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getButtonsRenderingContext() {
+ return {
+ btnClass: 'btn-secondary',
+ create_text: this.nodeOptions.create_text,
+ };
+ },
+ /**
+ * Computes the default renderer to use depending on the view type.
+ * We create this as a method so we can override it if we want to use
+ * another renderer instead (eg. section_and_note_one2many).
+ *
+ * @private
+ * @returns {Object} The renderer to use
+ */
+ _getRenderer: function () {
+ if (this.view.arch.tag === 'tree') {
+ return ListRenderer;
+ }
+ if (this.view.arch.tag === 'kanban') {
+ return KanbanRenderer;
+ }
+ },
+ /**
+ * @private
+ * @returns {boolean} true iff the list should contain a 'create' line.
+ */
+ _hasCreateLine: function () {
+ return !this.isReadonly && (
+ (!this.isMany2Many && this.activeActions.create && this.canCreate) ||
+ (this.isMany2Many && this.canLink)
+ );
+ },
+ /**
+ * @private
+ * @returns {boolean} true iff the list should add a trash icon on each row.
+ */
+ _hasTrashIcon: function () {
+ return !this.isReadonly && (
+ (!this.isMany2Many && this.activeActions.delete && this.canDelete) ||
+ (this.isMany2Many && this.canUnlink)
+ );
+ },
+ /**
+ * Instanciates or updates the adequate renderer.
+ *
+ * @override
+ * @private
+ * @returns {Promise|undefined}
+ */
+ _render: function () {
+ var self = this;
+ if (!this.view) {
+ return this._super();
+ }
+
+ if (this.renderer) {
+ this.currentColInvisibleFields = this._evalColumnInvisibleFields();
+ return this.renderer.updateState(this.value, {
+ addCreateLine: this._hasCreateLine(),
+ addTrashIcon: this._hasTrashIcon(),
+ columnInvisibleFields: this.currentColInvisibleFields,
+ keepWidths: true,
+ }).then(() => {
+ return this._updateControlPanel({ size: this.value.count });
+ });
+ }
+ var arch = this.view.arch;
+ var viewType;
+ var rendererParams = {
+ arch: arch,
+ };
+
+ if (arch.tag === 'tree') {
+ viewType = 'list';
+ this.currentColInvisibleFields = this._evalColumnInvisibleFields();
+ _.extend(rendererParams, {
+ editable: this.mode === 'edit' && arch.attrs.editable,
+ addCreateLine: this._hasCreateLine(),
+ addTrashIcon: this._hasTrashIcon(),
+ isMany2Many: this.isMany2Many,
+ columnInvisibleFields: this.currentColInvisibleFields,
+ });
+ }
+
+ if (arch.tag === 'kanban') {
+ viewType = 'kanban';
+ var record_options = {
+ editable: false,
+ deletable: false,
+ read_only_mode: this.isReadonly,
+ };
+ _.extend(rendererParams, {
+ record_options: record_options,
+ readOnlyMode: this.isReadonly,
+ });
+ }
+
+ _.extend(rendererParams, {
+ viewType: viewType,
+ });
+ var Renderer = this._getRenderer();
+ this.renderer = new Renderer(this, this.value, rendererParams);
+
+ this.$el.addClass('o_field_x2many o_field_x2many_' + viewType);
+ if (this.renderer) {
+ return this.renderer.appendTo(document.createDocumentFragment()).then(function () {
+ dom.append(self.$el, self.renderer.$el, {
+ in_DOM: self.isInDOM,
+ callbacks: [{widget: self.renderer}],
+ });
+ });
+ } else {
+ return this._super();
+ }
+ },
+ /**
+ * Renders the buttons and sets this.$buttons.
+ *
+ * @private
+ */
+ _renderButtons: function () {
+ if (!this.isReadonly && this.view.arch.tag === 'kanban') {
+ const renderingContext = this._getButtonsRenderingContext();
+ this.$buttons = $(qweb.render('KanbanView.buttons', renderingContext));
+ this.$buttons.on('click', 'button.o-kanban-button-new', this._onAddRecord.bind(this));
+ }
+ },
+ /**
+ * Saves the line associated to the given recordID. If the line is valid,
+ * it only has to be switched to readonly mode as all the line changes have
+ * already been notified to the model so that they can be saved in db if the
+ * parent view is actually saved. If the line is not valid, the line is to
+ * be discarded if the user agrees (this behavior is not a list editable
+ * one but a x2m one as it is made to replace the "discard" button which
+ * exists for list editable views).
+ *
+ * @private
+ * @param {string} recordID
+ * @returns {Promise} resolved if the line was properly saved or discarded.
+ * rejected if the line could not be saved and the user
+ * did not agree to discard.
+ */
+ _saveLine: function (recordID) {
+ var self = this;
+ return new Promise(function (resolve, reject) {
+ var fieldNames = self.renderer.canBeSaved(recordID);
+ if (fieldNames.length) {
+ self.trigger_up('discard_changes', {
+ recordID: recordID,
+ onSuccess: resolve,
+ onFailure: reject,
+ });
+ } else {
+ self.renderer.setRowMode(recordID, 'readonly').then(resolve);
+ }
+ }).then(async function () {
+ self._updateControlPanel({ size: self.value.count });
+ var newEval = self._evalColumnInvisibleFields();
+ if (!_.isEqual(self.currentColInvisibleFields, newEval)) {
+ self.currentColInvisibleFields = newEval;
+ self.renderer.updateState(self.value, {
+ columnInvisibleFields: self.currentColInvisibleFields,
+ });
+ }
+ });
+ },
+ /**
+ * Re-renders buttons and updates the control panel. This method is called
+ * when the widget is reset, as the available buttons might have changed.
+ * The only mutable element in X2Many fields will be the pager.
+ *
+ * @private
+ */
+ _updateControlPanel: function (pagingState) {
+ if (this._controlPanelWrapper) {
+ this._renderButtons();
+ const pagerProps = Object.assign(this.pagingState, pagingState, {
+ // sometimes, we temporarily want to increase the pager limit
+ // (for instance, when we add a new record on a page that already
+ // contains the maximum number of records)
+ limit: Math.max(this.value.limit, this.value.data.length),
+ });
+ const newProps = {
+ cp_content: { $buttons: this.$buttons },
+ pager: pagerProps,
+ };
+ return this._controlPanelWrapper.update(newProps);
+ }
+ },
+ /**
+ * Parses the 'columnInvisibleFields' attribute to search for the domains
+ * containing the key 'parent'. If there are such domains, the string
+ * 'parent.field' is replaced with 'field' in order to be evaluated
+ * with the right field name in the parent context.
+ *
+ * @private
+ */
+ _processColumnInvisibleFields: function () {
+ var columnInvisibleFields = {};
+ _.each(this.attrs.columnInvisibleFields, function (domains, fieldName) {
+ if (_.isArray(domains)) {
+ columnInvisibleFields[fieldName] = _.map(domains, function (domain) {
+ // We check if the domain is an array to avoid processing
+ // the '|' and '&' cases
+ if (_.isArray(domain)) {
+ return [domain[0].split('.')[1]].concat(domain.slice(1));
+ }
+ return domain;
+ });
+ }
+ });
+ this.columnInvisibleFields = columnInvisibleFields;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the user clicks on the 'Add a line' link (list case) or the
+ * 'Add' button (kanban case).
+ *
+ * @abstract
+ * @private
+ */
+ _onAddRecord: function () {
+ // to implement
+ },
+ /**
+ * Removes the given record from the relation.
+ * Stops the propagation of the event to prevent it from being handled again
+ * by the parent controller.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onRemoveRecord: function (ev) {
+ ev.stopPropagation();
+ var operation = this.isMany2Many ? 'FORGET' : 'DELETE';
+ this._setValue({
+ operation: operation,
+ ids: [ev.data.id],
+ });
+ },
+ /**
+ * When the discard_change event go through this field, we can just decorate
+ * the data with the name of the field. The origin field ignore this
+ * information (it is a subfield in a o2m), and the controller will need to
+ * know which field needs to be handled.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onDiscardChanges: function (ev) {
+ if (ev.target !== this) {
+ ev.stopPropagation();
+ this.trigger_up('discard_changes', _.extend({}, ev.data, {fieldName: this.name}));
+ }
+ },
+ /**
+ * Called when the renderer asks to edit a line, in that case simply tells
+ * him back to toggle the mode of this row.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onEditLine: function (ev) {
+ ev.stopPropagation();
+ this.trigger_up('edited_list', { id: this.value.id });
+ this.renderer.setRowMode(ev.data.recordId, 'edit')
+ .then(ev.data.onSuccess);
+ },
+ /**
+ * Updates the given record with the changes.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onFieldChanged: function (ev) {
+ if (ev.target === this) {
+ ev.initialEvent = this.lastInitialEvent;
+ return;
+ }
+ ev.stopPropagation();
+ // changes occured in an editable list
+ var changes = ev.data.changes;
+ // save the initial event triggering the field_changed, as it will be
+ // necessary when the field triggering this event will be reset (to
+ // prevent it from re-rendering itself, formatting its value, loosing
+ // the focus... while still being edited)
+ this.lastInitialEvent = undefined;
+ if (Object.keys(changes).length) {
+ this.lastInitialEvent = ev;
+ this._setValue({
+ operation: 'UPDATE',
+ id: ev.data.dataPointID,
+ data: changes,
+ }).then(function () {
+ if (ev.data.onSuccess) {
+ ev.data.onSuccess();
+ }
+ }).guardedCatch(function (reason) {
+ if (ev.data.onFailure) {
+ ev.data.onFailure(reason);
+ }
+ });
+ }
+ },
+ /**
+ * Override to handle the navigation inside editable list controls
+ *
+ * @override
+ * @private
+ */
+ _onNavigationMove: function (ev) {
+ if (this.view.arch.tag === 'tree') {
+ var $curControl = this.renderer.$('.o_field_x2many_list_row_add a:focus');
+ if ($curControl.length) {
+ var $nextControl;
+ if (ev.data.direction === 'right') {
+ $nextControl = $curControl.next('a');
+ } else if (ev.data.direction === 'left') {
+ $nextControl = $curControl.prev('a');
+ }
+ if ($nextControl && $nextControl.length) {
+ ev.stopPropagation();
+ $nextControl.focus();
+ return;
+ }
+ }
+ }
+ this._super.apply(this, arguments);
+ },
+ /**
+ * Called when the user clicks on a relational record.
+ *
+ * @abstract
+ * @private
+ */
+ _onOpenRecord: function () {
+ // to implement
+ },
+ /**
+ * We re-render the pager immediately with the new event values to allow
+ * it to request another pager change while another one is still ongoing.
+ * @see field_manager_mixin for concurrency handling.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onPagerChanged: function (ev) {
+ ev.stopPropagation();
+ const { currentMinimum, limit } = ev.data;
+ this._updateControlPanel({ currentMinimum, limit });
+ this.trigger_up('load', {
+ id: this.value.id,
+ limit,
+ offset: currentMinimum - 1,
+ on_success: value => {
+ this.value = value;
+ this.pagingState.limit = value.limit;
+ this.pagingState.size = value.count;
+ this._render();
+ },
+ });
+ },
+ /**
+ * Called when the renderer ask to save a line (the user tries to leave it)
+ * -> Nothing is to "save" here, the model was already notified of the line
+ * changes; if the row could be saved, we make the row readonly. Otherwise,
+ * we trigger a new event for the view to tell it to discard the changes
+ * made to that row.
+ * Note that we do that in the controller mutex to ensure that the check on
+ * the row (whether or not it can be saved) is done once all potential
+ * onchange RPCs are done (those RPCs being executed in the same mutex).
+ * This particular handling is done in this handler, instead of in the
+ * _saveLine function directly, because _saveLine is also called from
+ * the controller (via commitChanges), and in this case, it is already
+ * executed in the mutex.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {string} ev.recordID
+ * @param {function} ev.onSuccess success callback (see '_saveLine')
+ * @param {function} ev.onFailure fail callback (see '_saveLine')
+ */
+ _onSaveLine: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+ this.renderer.commitChanges(ev.data.recordID).then(function () {
+ self.trigger_up('mutexify', {
+ action: function () {
+ return self._saveLine(ev.data.recordID)
+ .then(ev.data.onSuccess)
+ .guardedCatch(ev.data.onFailure);
+ },
+ });
+ });
+ },
+ /**
+ * Add necessary key parts for the basic controller to compute the local
+ * storage key. The event will be properly handled by the basic controller.
+ *
+ * @param {OdooEvent} ev
+ * @private
+ */
+ _onSaveOrLoadOptionalFields: function (ev) {
+ ev.data.keyParts.relationalField = this.name;
+ ev.data.keyParts.subViewId = this.view.view_id;
+ ev.data.keyParts.subViewType = this.view.type;
+ },
+ /**
+ * Forces a resequencing of the records.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {string[]} ev.data.recordIds
+ * @param {integer} ev.data.offset
+ * @param {string} ev.data.handleField
+ */
+ _onResequenceRecords: function (ev) {
+ ev.stopPropagation();
+ var self = this;
+ if (this.view.arch.tag === 'tree') {
+ this.trigger_up('edited_list', { id: this.value.id });
+ }
+ var handleField = ev.data.handleField;
+ var offset = ev.data.offset;
+ var recordIds = ev.data.recordIds.slice();
+ // trigger an update of all records but the last one with option
+ // 'notifyChanges' set to false, and once all those changes have been
+ // validated by the model, trigger the change on the last record
+ // (without the option, s.t. the potential onchange on parent record
+ // is triggered)
+ var recordId = recordIds.pop();
+ var proms = recordIds.map(function (recordId, index) {
+ var data = {};
+ data[handleField] = offset + index;
+ return self._setValue({
+ operation: 'UPDATE',
+ id: recordId,
+ data: data,
+ }, {
+ notifyChange: false,
+ });
+ });
+ Promise.all(proms).then(function () {
+ function always() {
+ if (self.view.arch.tag === 'tree') {
+ self.trigger_up('toggle_column_order', {
+ id: self.value.id,
+ name: handleField,
+ });
+ }
+ }
+ var data = {};
+ data[handleField] = offset + recordIds.length;
+ self._setValue({
+ operation: 'UPDATE',
+ id: recordId,
+ data: data,
+ }).then(always).guardedCatch(always);
+ });
+ },
+ /**
+ * Adds field name information to the event, so that the view upstream is
+ * aware of which widgets it has to redraw.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onToggleColumnOrder: function (ev) {
+ ev.data.field = this.name;
+ },
+ /*
+ * Move to next widget.
+ *
+ * @private
+ */
+ _onActiveNextWidget: function (e) {
+ e.stopPropagation();
+ this.renderer.unselectRow();
+ this.trigger_up('navigation_move', {
+ direction: e.data.direction || 'next',
+ });
+ },
+});
+
+var One2ManyKanbanRecord = KanbanRecord.extend({
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Apply same logic as in the ListRenderer: buttons with type="object"
+ * are disabled for no saved yet records, as calling the python method
+ * with no id would make no sense.
+ *
+ * To avoid to expose this logic inside all Kanban views, we define
+ * a specific KanbanRecord Class for the One2many case.
+ *
+ * This could be refactored to prevent from duplicating this logic in
+ * list and kanban views.
+ *
+ * @private
+ */
+ _postProcessObjectButtons: function () {
+ var self = this;
+ // if the res_id is defined, it's already correctly handled by the Kanban record global event click
+ if (!this.state.res_id) {
+ this.$('.oe_kanban_action[data-type=object]').each(function (index, button) {
+ var $button = $(button);
+ if ($button.attr('warn')) {
+ $button.on('click', function (e) {
+ e.stopPropagation();
+ self.do_warn(false, _t('Please click on the "save" button first'));
+ });
+ } else {
+ $button.attr('disabled', 'disabled');
+ }
+ });
+ }
+ },
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self._postProcessObjectButtons();
+ });
+ },
+});
+
+var One2ManyKanbanRenderer = KanbanRenderer.extend({
+ config: _.extend({}, KanbanRenderer.prototype.config, {
+ KanbanRecord: One2ManyKanbanRecord,
+ }),
+});
+
+var FieldOne2Many = FieldX2Many.extend({
+ description: _lt("One2many"),
+ className: 'o_field_one2many',
+ supportedFieldTypes: ['one2many'],
+
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ // boolean used to prevent concurrent record creation
+ this.creatingRecord = false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+ /**
+ * @override
+ * @param {Object} record
+ * @param {OdooEvent} [ev] an event that triggered the reset action
+ * @returns {Promise}
+ */
+ reset: function (record, ev) {
+ var self = this;
+ return this._super.apply(this, arguments).then(() => {
+ if (ev && ev.target === self && ev.data.changes && self.view.arch.tag === 'tree') {
+ if (ev.data.changes[self.name] && ev.data.changes[self.name].operation === 'CREATE') {
+ var index = 0;
+ if (self.editable !== 'top') {
+ index = self.value.data.length - 1;
+ }
+ var newID = self.value.data[index].id;
+ self.renderer.editRecord(newID);
+ }
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _getButtonsRenderingContext() {
+ const renderingContext = this._super(...arguments);
+ renderingContext.noCreate = !this.canCreate;
+ return renderingContext;
+ },
+ /**
+ * @override
+ * @private
+ */
+ _getRenderer: function () {
+ if (this.view.arch.tag === 'kanban') {
+ return One2ManyKanbanRenderer;
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Overrides to only render the buttons if the 'create' action is available.
+ *
+ * @override
+ * @private
+ */
+ _renderButtons: function () {
+ if (this.activeActions.create) {
+ return this._super(...arguments);
+ }
+ },
+ /**
+ * @private
+ * @param {Object} params
+ * @param {Object} [params.context] We allow additional context, this is
+ * used for example to define default values when adding new lines to
+ * a one2many with control/create tags.
+ */
+ _openFormDialog: function (params) {
+ var context = this.record.getContext(_.extend({},
+ this.recordParams,
+ { additionalContext: params.context }
+ ));
+ this.trigger_up('open_one2many_record', _.extend(params, {
+ domain: this.record.getDomain(this.recordParams),
+ context: context,
+ field: this.field,
+ fields_view: this.attrs.views && this.attrs.views.form,
+ parentID: this.value.id,
+ viewInfo: this.view,
+ deletable: this.activeActions.delete && params.deletable && this.canDelete,
+ }));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Opens a FormViewDialog to allow creating a new record for a one2many.
+ *
+ * @override
+ * @private
+ * @param {OdooEvent|MouseEvent} ev this event comes either from the 'Add
+ * record' link in the list editable renderer, or from the 'Create' button
+ * in the kanban view
+ * @param {Array} ev.data.context additional context for the added records,
+ * if several contexts are provided, multiple records will be added
+ * (form dialog will only use the context at index 0 if provided)
+ * @param {boolean} ev.data.forceEditable this is used to bypass the dialog opening
+ * in case you want to add record(s) to a list
+ * @param {function} ev.data.onSuccess called when the records are correctly created
+ * (not supported by form dialog)
+ * @param {boolean} ev.data.allowWarning defines if the records can be added
+ * to the list even if warnings are triggered (e.g: stock warning for product availability)
+ */
+ _onAddRecord: function (ev) {
+ var self = this;
+ var data = ev.data || {};
+
+ // we don't want interference with the components upstream.
+ ev.stopPropagation();
+
+ if (this.editable || data.forceEditable) {
+ if (!this.activeActions.create) {
+ if (data.onFail) {
+ data.onFail();
+ }
+ } else if (!this.creatingRecord) {
+ this.creatingRecord = true;
+ this.trigger_up('edited_list', { id: this.value.id });
+ this._setValue({
+ operation: 'CREATE',
+ position: this.editable || data.forceEditable,
+ context: data.context,
+ }, {
+ allowWarning: data.allowWarning
+ }).then(function () {
+ self.creatingRecord = false;
+ }).then(function (){
+ if (data.onSuccess){
+ data.onSuccess();
+ }
+ }).guardedCatch(function() {
+ self.creatingRecord = false;
+ })
+ ;
+ }
+ } else {
+ this._openFormDialog({
+ context: data.context && data.context[0],
+ on_saved: function (record) {
+ self._setValue({ operation: 'ADD', id: record.id });
+ },
+ });
+ }
+ },
+ /**
+ * Overrides the handler to set a specific 'on_save' callback as the o2m
+ * sub-records aren't saved directly when the user clicks on 'Save' in the
+ * dialog. Instead, the relational record is changed in the local data, and
+ * this change is saved in DB when the user clicks on 'Save' in the main
+ * form view.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onOpenRecord: function (ev) {
+ // we don't want interference with the components upstream.
+ var self = this;
+ ev.stopPropagation();
+
+ var id = ev.data.id;
+ var onSaved = function (record) {
+ if (_.some(self.value.data, {id: record.id})) {
+ // the record already exists in the relation, so trigger an
+ // empty 'UPDATE' operation when the user clicks on 'Save' in
+ // the dialog, to notify the main record that a subrecord of
+ // this relational field has changed (those changes will be
+ // already stored on that subrecord, thanks to the 'Save').
+ self._setValue({ operation: 'UPDATE', id: record.id });
+ } else {
+ // the record isn't in the relation yet, so add it ; this can
+ // happen if the user clicks on 'Save & New' in the dialog (the
+ // opened record will be updated, and other records will be
+ // created)
+ self._setValue({ operation: 'ADD', id: record.id });
+ }
+ };
+ this._openFormDialog({
+ id: id,
+ on_saved: onSaved,
+ on_remove: function () {
+ self._setValue({operation: 'DELETE', ids: [id]});
+ },
+ deletable: this.activeActions.delete && this.view.arch.tag !== 'tree' && this.canDelete,
+ readonly: this.mode === 'readonly',
+ });
+ },
+});
+
+var FieldMany2Many = FieldX2Many.extend({
+ description: _lt("Many2many"),
+ className: 'o_field_many2many',
+ supportedFieldTypes: ['many2many'],
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+ /**
+ * Opens a SelectCreateDialog
+ */
+ onAddRecordOpenDialog: function () {
+ var self = this;
+ var domain = this.record.getDomain({fieldName: this.name});
+
+ new dialogs.SelectCreateDialog(this, {
+ res_model: this.field.relation,
+ domain: domain.concat(["!", ["id", "in", this.value.res_ids]]),
+ context: this.record.getContext(this.recordParams),
+ title: _t("Add: ") + this.string,
+ no_create: this.nodeOptions.no_create || !this.activeActions.create || !this.canCreate,
+ fields_view: this.attrs.views.form,
+ kanban_view_ref: this.attrs.kanban_view_ref,
+ on_selected: function (records) {
+ var resIDs = _.pluck(records, 'id');
+ var newIDs = _.difference(resIDs, self.value.res_ids);
+ if (newIDs.length) {
+ var values = _.map(newIDs, function (id) {
+ return {id: id};
+ });
+ self._setValue({
+ operation: 'ADD_M2M',
+ ids: values,
+ });
+ }
+ }
+ }).open();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _getButtonsRenderingContext() {
+ const renderingContext = this._super(...arguments);
+ renderingContext.noCreate = !this.canLink;
+ return renderingContext;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Opens a SelectCreateDialog.
+ *
+ * @override
+ * @private
+ * @param {OdooEvent|MouseEvent} ev this event comes either from the 'Add
+ * record' link in the list editable renderer, or from the 'Create' button
+ * in the kanban view
+ */
+ _onAddRecord: function (ev) {
+ ev.stopPropagation();
+ this.onAddRecordOpenDialog();
+ },
+
+ /**
+ * Intercepts the 'open_record' event to edit its data and lets it bubble up
+ * to the form view.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onOpenRecord: function (ev) {
+ var self = this;
+ _.extend(ev.data, {
+ context: this.record.getContext(this.recordParams),
+ domain: this.record.getDomain(this.recordParams),
+ fields_view: this.attrs.views && this.attrs.views.form,
+ on_saved: function () {
+ self._setValue({operation: 'TRIGGER_ONCHANGE'}, {forceChange: true})
+ .then(function () {
+ self.trigger_up('reload', {db_id: ev.data.id});
+ });
+ },
+ on_remove: function () {
+ self._setValue({operation: 'FORGET', ids: [ev.data.id]});
+ },
+ readonly: this.mode === 'readonly',
+ deletable: this.activeActions.delete && this.view.arch.tag !== 'tree' && this.canDelete,
+ string: this.string,
+ });
+ },
+});
+
+/**
+ * Widget to upload or delete one or more files at the same time.
+ */
+var FieldMany2ManyBinaryMultiFiles = AbstractField.extend({
+ template: "FieldBinaryFileUploader",
+ template_files: "FieldBinaryFileUploader.files",
+ supportedFieldTypes: ['many2many'],
+ fieldsToFetch: {
+ name: {type: 'char'},
+ mimetype: {type: 'char'},
+ },
+ events: {
+ 'click .o_attach': '_onAttach',
+ 'click .o_attachment_delete': '_onDelete',
+ 'change .o_input_file': '_onFileChanged',
+ },
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ if (this.field.type !== 'many2many' || this.field.relation !== 'ir.attachment') {
+ var msg = _t("The type of the field '%s' must be a many2many field with a relation to 'ir.attachment' model.");
+ throw _.str.sprintf(msg, this.field.string);
+ }
+
+ this.uploadedFiles = {};
+ this.uploadingFiles = [];
+ this.fileupload_id = _.uniqueId('oe_fileupload_temp');
+ this.accepted_file_extensions = (this.nodeOptions && this.nodeOptions.accepted_file_extensions) || this.accepted_file_extensions || '*';
+ $(window).on(this.fileupload_id, this._onFileLoaded.bind(this));
+
+ this.metadata = {};
+ },
+
+ destroy: function () {
+ this._super();
+ $(window).off(this.fileupload_id);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Compute the URL of an attachment.
+ *
+ * @private
+ * @param {Object} attachment
+ * @returns {string} URL of the attachment
+ */
+ _getFileUrl: function (attachment) {
+ return '/web/content/' + attachment.id + '?download=true';
+ },
+ /**
+ * Process the field data to add some information (url, etc.).
+ *
+ * @private
+ */
+ _generatedMetadata: function () {
+ var self = this;
+ _.each(this.value.data, function (record) {
+ // tagging `allowUnlink` ascertains if the attachment was user
+ // uploaded or was an existing or system generated attachment
+ self.metadata[record.id] = {
+ allowUnlink: self.uploadedFiles[record.data.id] || false,
+ url: self._getFileUrl(record.data),
+ };
+ });
+ },
+ /**
+ * @private
+ * @override
+ */
+ _render: function () {
+ // render the attachments ; as the attachments will changes after each
+ // _setValue, we put the rendering here to ensure they will be updated
+ this._generatedMetadata();
+ this.$('.oe_placeholder_files, .o_attachments')
+ .replaceWith($(qweb.render(this.template_files, {
+ widget: this,
+ })));
+ this.$('.oe_fileupload').show();
+
+ // display image thumbnail
+ this.$('.o_image[data-mimetype^="image"]').each(function () {
+ var $img = $(this);
+ if (/gif|jpe|jpg|png/.test($img.data('mimetype')) && $img.data('src')) {
+ $img.css('background-image', "url('" + $img.data('src') + "')");
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAttach: function () {
+ // This widget uses a hidden form to upload files. Clicking on 'Attach'
+ // will simulate a click on the related input.
+ this.$('.o_input_file').click();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onDelete: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ var fileID = $(ev.currentTarget).data('id');
+ var record = _.findWhere(this.value.data, {res_id: fileID});
+ if (record) {
+ this._setValue({
+ operation: 'FORGET',
+ ids: [record.id],
+ });
+ var metadata = this.metadata[record.id];
+ if (!metadata || metadata.allowUnlink) {
+ this._rpc({
+ model: 'ir.attachment',
+ method: 'unlink',
+ args: [record.res_id],
+ });
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onFileChanged: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+
+ var files = ev.target.files;
+ var attachment_ids = this.value.res_ids;
+
+ // Don't create an attachment if the upload window is cancelled.
+ if(files.length === 0)
+ return;
+
+ _.each(files, function (file) {
+ var record = _.find(self.value.data, function (attachment) {
+ return attachment.data.name === file.name;
+ });
+ if (record) {
+ var metadata = self.metadata[record.id];
+ if (!metadata || metadata.allowUnlink) {
+ // there is a existing attachment with the same name so we
+ // replace it
+ attachment_ids = _.without(attachment_ids, record.res_id);
+ self._rpc({
+ model: 'ir.attachment',
+ method: 'unlink',
+ args: [record.res_id],
+ });
+ }
+ }
+ self.uploadingFiles.push(file);
+ });
+
+ this._setValue({
+ operation: 'REPLACE_WITH',
+ ids: attachment_ids,
+ });
+
+ this.$('form.o_form_binary_form').submit();
+ this.$('.oe_fileupload').hide();
+ ev.target.value = "";
+ },
+ /**
+ * @private
+ */
+ _onFileLoaded: function () {
+ var self = this;
+ // the first argument isn't a file but the jQuery.Event
+ var files = Array.prototype.slice.call(arguments, 1);
+ // files has been uploaded, clear uploading
+ this.uploadingFiles = [];
+
+ var attachment_ids = this.value.res_ids;
+ _.each(files, function (file) {
+ if (file.error) {
+ self.do_warn(_t('Uploading Error'), file.error);
+ } else {
+ attachment_ids.push(file.id);
+ self.uploadedFiles[file.id] = true;
+ }
+ });
+
+ this._setValue({
+ operation: 'REPLACE_WITH',
+ ids: attachment_ids,
+ });
+ },
+});
+
+var FieldMany2ManyTags = AbstractField.extend({
+ description: _lt("Tags"),
+ tag_template: "FieldMany2ManyTag",
+ className: "o_field_many2manytags",
+ supportedFieldTypes: ['many2many'],
+ custom_events: _.extend({}, AbstractField.prototype.custom_events, {
+ field_changed: '_onFieldChanged',
+ }),
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click .o_delete': '_onDeleteTag',
+ }),
+ fieldsToFetch: {
+ display_name: {type: 'char'},
+ },
+ limit: 1000,
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ if (this.mode === 'edit') {
+ this.className += ' o_input';
+ }
+
+ this.colorField = this.nodeOptions.color_field;
+ this.hasDropdown = false;
+
+ this._computeAvailableActions(this.record);
+ // have listen to react to other fields changes to re-evaluate 'create' option
+ this.resetOnAnyFieldChange = this.resetOnAnyFieldChange || 'create' in this.nodeOptions;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ activate: function () {
+ return this.many2one ? this.many2one.activate() : false;
+ },
+ /**
+ * @override
+ * @returns {jQuery}
+ */
+ getFocusableElement: function () {
+ return this.many2one ? this.many2one.getFocusableElement() : $();
+ },
+ /**
+ * @override
+ * @returns {boolean}
+ */
+ isSet: function () {
+ return !!this.value && this.value.count;
+ },
+ /**
+ * Reset the focus on this field if it was the origin of the onchange call.
+ *
+ * @override
+ */
+ reset: function (record, event) {
+ var self = this;
+ this._computeAvailableActions(record);
+ return this._super.apply(this, arguments).then(function () {
+ if (event && event.target === self) {
+ self.activate();
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {any} data
+ * @returns {Promise}
+ */
+ _addTag: function (data) {
+ if (!_.contains(this.value.res_ids, data.id)) {
+ return this._setValue({
+ operation: 'ADD_M2M',
+ ids: data
+ });
+ }
+ return Promise.resolve();
+ },
+ /**
+ * @private
+ * @param {Object} record
+ */
+ _computeAvailableActions: function (record) {
+ const evalContext = record.evalContext;
+ this.canCreate = 'create' in this.nodeOptions ?
+ new Domain(this.nodeOptions.create, evalContext).compute(evalContext) :
+ true;
+ },
+ /**
+ * Get the QWeb rendering context used by the tag template; this computation
+ * is placed in a separate function for other tags to override it.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getRenderTagsContext: function () {
+ var elements = this.value ? _.pluck(this.value.data, 'data') : [];
+ return {
+ colorField: this.colorField,
+ elements: elements,
+ hasDropdown: this.hasDropdown,
+ readonly: this.mode === "readonly",
+ };
+ },
+ /**
+ * @private
+ * @param {any} id
+ */
+ _removeTag: function (id) {
+ var record = _.findWhere(this.value.data, {res_id: id});
+ this._setValue({
+ operation: 'FORGET',
+ ids: [record.id],
+ });
+ },
+ /**
+ * @private
+ */
+ _renderEdit: function () {
+ var self = this;
+ this._renderTags();
+ if (this.many2one) {
+ this.many2one.destroy();
+ }
+ this.many2one = new FieldMany2One(this, this.name, this.record, {
+ mode: 'edit',
+ noOpen: true,
+ noCreate: !this.canCreate,
+ viewType: this.viewType,
+ attrs: this.attrs,
+ });
+ // to prevent the M2O to take the value of the M2M
+ this.many2one.value = false;
+ // to prevent the M2O to take the relational values of the M2M
+ this.many2one.m2o_value = '';
+
+ this.many2one._getSearchBlacklist = function () {
+ return self.value.res_ids;
+ };
+ var _getSearchCreatePopupOptions = this.many2one._getSearchCreatePopupOptions;
+ this.many2one._getSearchCreatePopupOptions = function (view, ids, context, dynamicFilters) {
+ var options = _getSearchCreatePopupOptions.apply(this, arguments);
+ var domain = this.record.getDomain({fieldName: this.name});
+ var m2mRecords = [];
+ return _.extend({}, options, {
+ domain: domain.concat(["!", ["id", "in", self.value.res_ids]]),
+ disable_multiple_selection: false,
+ on_selected: function (records) {
+ m2mRecords.push(...records);
+ },
+ on_closed: function () {
+ self.many2one.reinitialize(m2mRecords);
+ },
+ });
+ };
+ return this.many2one.appendTo(this.$el);
+ },
+ /**
+ * @private
+ */
+ _renderReadonly: function () {
+ this._renderTags();
+ },
+ /**
+ * @private
+ */
+ _renderTags: function () {
+ this.$el.html(qweb.render(this.tag_template, this._getRenderTagsContext()));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onDeleteTag: function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ this._removeTag($(event.target).parent().data('id'));
+ },
+ /**
+ * Controls the changes made in the internal m2o field.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onFieldChanged: function (ev) {
+ if (ev.target !== this.many2one) {
+ return;
+ }
+ ev.stopPropagation();
+ var newValue = ev.data.changes[this.name];
+ if (newValue) {
+ this._addTag(newValue)
+ .then(ev.data.onSuccess || function () {})
+ .guardedCatch(ev.data.onFailure || function () {});
+ this.many2one.reinitialize(false);
+ }
+ },
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown: function (ev) {
+ if (ev.which === $.ui.keyCode.BACKSPACE && this.$('input').val() === "") {
+ var $badges = this.$('.badge');
+ if ($badges.length) {
+ this._removeTag($badges.last().data('id'));
+ return;
+ }
+ }
+ this._super.apply(this, arguments);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onQuickCreate: function (event) {
+ this._quickCreate(event.data.value);
+ },
+});
+
+var FieldMany2ManyTagsAvatar = FieldMany2ManyTags.extend({
+ tag_template: 'FieldMany2ManyTagAvatar',
+ className: 'o_field_many2manytags avatar',
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _getRenderTagsContext: function () {
+ var result = this._super.apply(this, arguments);
+ result.avatarModel = this.nodeOptions.avatarModel || this.field.relation;
+ result.avatarField = this.nodeOptions.avatarField || 'image_128';
+ return result;
+ },
+});
+
+var FormFieldMany2ManyTags = FieldMany2ManyTags.extend({
+ events: _.extend({}, FieldMany2ManyTags.prototype.events, {
+ 'click .dropdown-toggle': '_onOpenColorPicker',
+ 'mousedown .o_colorpicker a': '_onUpdateColor',
+ 'mousedown .o_colorpicker .o_hide_in_kanban': '_onUpdateColor',
+ }),
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ this.hasDropdown = !!this.colorField;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onOpenColorPicker: function (ev) {
+ ev.preventDefault();
+ if (this.nodeOptions.no_edit_color) {
+ ev.stopPropagation();
+ return;
+ }
+ var tagID = $(ev.currentTarget).parent().data('id');
+ var tagColor = $(ev.currentTarget).parent().data('color');
+ var tag = _.findWhere(this.value.data, { res_id: tagID });
+ if (tag && this.colorField in tag.data) { // if there is a color field on the related model
+ this.$color_picker = $(qweb.render('FieldMany2ManyTag.colorpicker', {
+ 'widget': this,
+ 'tag_id': tagID,
+ }));
+
+ $(ev.currentTarget).after(this.$color_picker);
+ this.$color_picker.dropdown();
+ this.$color_picker.attr("tabindex", 1).focus();
+ if (!tagColor) {
+ this.$('.custom-checkbox input').prop('checked', true);
+ }
+ }
+ },
+ /**
+ * Update color based on target of ev
+ * either by clicking on a color item or
+ * by toggling the 'Hide in Kanban' checkbox.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onUpdateColor: function (ev) {
+ ev.preventDefault();
+ var $target = $(ev.currentTarget);
+ var color = $target.data('color');
+ var id = $target.data('id');
+ var $tag = this.$(".badge[data-id='" + id + "']");
+ var currentColor = $tag.data('color');
+ var changes = {};
+
+ if ($target.is('.o_hide_in_kanban')) {
+ var $checkbox = $('.o_hide_in_kanban .custom-checkbox input');
+ $checkbox.prop('checked', !$checkbox.prop('checked')); // toggle checkbox
+ this.prevColors = this.prevColors ? this.prevColors : {};
+ if ($checkbox.is(':checked')) {
+ this.prevColors[id] = currentColor;
+ } else {
+ color = this.prevColors[id] ? this.prevColors[id] : 1;
+ }
+ } else if ($target.is('[class^="o_tag_color"]')) { // $target.is('o_tag_color_')
+ if (color === currentColor) { return; }
+ }
+
+ changes[this.colorField] = color;
+
+ this.trigger_up('field_changed', {
+ dataPointID: _.findWhere(this.value.data, {res_id: id}).id,
+ changes: changes,
+ force_save: true,
+ });
+ },
+});
+
+var KanbanFieldMany2ManyTags = FieldMany2ManyTags.extend({
+ // Remove event handlers on this widget to ensure that the kanban 'global
+ // click' opens the clicked record, even if the click is done on a tag
+ // This is necessary because of the weird 'global click' logic in
+ // KanbanRecord, which should definitely be cleaned.
+ // Anyway, those handlers are only necessary in Form and List views, so we
+ // can removed them here.
+ events: AbstractField.prototype.events,
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ var self = this;
+
+ if (this.$el) {
+ this.$el.empty().addClass('o_field_many2manytags o_kanban_tags');
+ }
+
+ _.each(this.value.data, function (m2m) {
+ if (self.colorField in m2m.data && !m2m.data[self.colorField]) {
+ // When a color field is specified and that color is the default
+ // one, the kanban tag is not rendered.
+ return;
+ }
+
+ $('<span>', {
+ class: 'o_tag o_tag_color_' + (m2m.data[self.colorField] || 0),
+ text: m2m.data.display_name,
+ })
+ .prepend('<span>')
+ .appendTo(self.$el);
+ });
+ },
+});
+
+var FieldMany2ManyCheckBoxes = AbstractField.extend({
+ description: _lt("Checkboxes"),
+ template: 'FieldMany2ManyCheckBoxes',
+ events: _.extend({}, AbstractField.prototype.events, {
+ change: '_onChange',
+ }),
+ specialData: "_fetchSpecialRelation",
+ supportedFieldTypes: ['many2many'],
+ // set an arbitrary high limit to ensure that all data returned by the server
+ // are processed by the BasicModel (otherwise it would be 40)
+ limit: 100000,
+ init: function () {
+ this._super.apply(this, arguments);
+ this.m2mValues = this.record.specialData[this.name];
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _renderCheckboxes: function () {
+ var self = this;
+ this.m2mValues = this.record.specialData[this.name];
+ this.$el.html(qweb.render(this.template, {widget: this}));
+ _.each(this.value.res_ids, function (id) {
+ self.$('input[data-record-id="' + id + '"]').prop('checked', true);
+ });
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderEdit: function () {
+ this._renderCheckboxes();
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderReadonly: function () {
+ this._renderCheckboxes();
+ this.$("input").prop("disabled", true);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onChange: function () {
+ // Get the list of selected ids
+ var ids = _.map(this.$('input:checked'), function (input) {
+ return $(input).data("record-id");
+ });
+ // The number of displayed checkboxes is limited to 100 (name_search
+ // limit, server-side), to prevent extreme cases where thousands of
+ // records are fetched/displayed. If not all values are displayed, it may
+ // happen that some values that are in the relation aren't available in the
+ // widget. In this case, when the user (un)selects a value, we don't
+ // want to remove those non displayed values from the relation. For that
+ // reason, we manually add those values to the list of ids.
+ const displayedIds = this.m2mValues.map(v => v[0]);
+ const idsInRelation = this.value.res_ids;
+ ids = ids.concat(idsInRelation.filter(a => !displayedIds.includes(a)));
+ this._setValue({
+ operation: 'REPLACE_WITH',
+ ids: ids,
+ });
+ },
+});
+
+//------------------------------------------------------------------------------
+// Widgets handling both basic and relational fields (selection and Many2one)
+//------------------------------------------------------------------------------
+
+var FieldStatus = AbstractField.extend({
+ className: 'o_statusbar_status',
+ events: {
+ 'click button:not(.dropdown-toggle)': '_onClickStage',
+ },
+ specialData: "_fetchSpecialStatus",
+ supportedFieldTypes: ['selection', 'many2one'],
+ /**
+ * @override init from AbstractField
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._setState();
+ this._onClickStage = _.debounce(this._onClickStage, 300, true); // TODO maybe not useful anymore ?
+
+ // Retro-compatibility: clickable used to be defined in the field attrs
+ // instead of options.
+ // If not set, the statusbar is not clickable.
+ try {
+ this.isClickable = !!JSON.parse(this.attrs.clickable);
+ } catch (_) {
+ this.isClickable = !!this.nodeOptions.clickable;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns false to force the statusbar to be always visible (even the field
+ * it not set).
+ *
+ * @override
+ * @returns {boolean} always false
+ */
+ isEmpty: function () {
+ return false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override _reset from AbstractField
+ * @private
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ this._setState();
+ },
+ /**
+ * Prepares the rendering data from the field and record data.
+ * @private
+ */
+ _setState: function () {
+ var self = this;
+ if (this.field.type === 'many2one') {
+ this.status_information = _.map(this.record.specialData[this.name], function (info) {
+ return _.extend({
+ selected: info.id === self.value.res_id,
+ }, info);
+ });
+ } else {
+ var selection = this.field.selection;
+ if (this.attrs.statusbar_visible) {
+ var restriction = this.attrs.statusbar_visible.split(",");
+ selection = _.filter(selection, function (val) {
+ return _.contains(restriction, val[0]) || val[0] === self.value;
+ });
+ }
+ this.status_information = _.map(selection, function (val) {
+ return { id: val[0], display_name: val[1], selected: val[0] === self.value, fold: false };
+ });
+ }
+ },
+ /**
+ * @override _render from AbstractField
+ * @private
+ */
+ _render: function () {
+ var selections = _.partition(this.status_information, function (info) {
+ return (info.selected || !info.fold);
+ });
+ this.$el.html(qweb.render("FieldStatus.content", {
+ selection_unfolded: selections[0],
+ selection_folded: selections[1],
+ clickable: this.isClickable,
+ }));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when on status stage is clicked -> sets the field value.
+ *
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onClickStage: function (e) {
+ this._setValue($(e.currentTarget).data("value"));
+ },
+});
+
+/**
+ * The FieldSelection widget is a simple select tag with a dropdown menu to
+ * allow the selection of a range of values. It is designed to work with fields
+ * of type 'selection' and 'many2one'.
+ */
+var FieldSelection = AbstractField.extend({
+ description: _lt("Selection"),
+ template: 'FieldSelection',
+ specialData: "_fetchSpecialRelation",
+ supportedFieldTypes: ['selection'],
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'change': '_onChange',
+ }),
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._setValues();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @returns {jQuery}
+ */
+ getFocusableElement: function () {
+ return this.$el && this.$el.is('select') ? this.$el : $();
+ },
+ /**
+ * @override
+ */
+ isSet: function () {
+ return this.value !== false;
+ },
+ /**
+ * Listen to modifiers updates to hide/show the falsy value in the dropdown
+ * according to the required modifier.
+ *
+ * @override
+ */
+ updateModifiersValue: function () {
+ this._super.apply(this, arguments);
+ if (!this.attrs.modifiersValue.invisible && this.mode !== 'readonly') {
+ this._setValues();
+ this._renderEdit();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _renderEdit: function () {
+ this.$el.empty();
+ var required = this.attrs.modifiersValue && this.attrs.modifiersValue.required;
+ for (var i = 0 ; i < this.values.length ; i++) {
+ var disabled = required && this.values[i][0] === false;
+
+ this.$el.append($('<option/>', {
+ value: JSON.stringify(this.values[i][0]),
+ text: this.values[i][1],
+ style: disabled ? "display: none" : "",
+ }));
+ }
+ this.$el.val(JSON.stringify(this._getRawValue()));
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderReadonly: function () {
+ this.$el.empty().text(this._formatValue(this.value));
+ this.$el.attr('raw-value', this._getRawValue());
+ },
+ _getRawValue: function() {
+ var raw_value = this.value;
+ if (this.field.type === 'many2one' && raw_value) {
+ raw_value = raw_value.data.id;
+ }
+ return raw_value;
+ },
+ /**
+ * @override
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ this._setValues();
+ },
+ /**
+ * Sets the possible field values. If the field is a many2one, those values
+ * may change during the lifecycle of the widget if the domain change (an
+ * onchange may change the domain).
+ *
+ * @private
+ */
+ _setValues: function () {
+ if (this.field.type === 'many2one') {
+ this.values = this.record.specialData[this.name];
+ this.formatType = 'many2one';
+ } else {
+ this.values = _.reject(this.field.selection, function (v) {
+ return v[0] === false && v[1] === '';
+ });
+ }
+ this.values = [[false, this.attrs.placeholder || '']].concat(this.values);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * The small slight difficulty is that we have to set the value differently
+ * depending on the field type.
+ *
+ * @private
+ */
+ _onChange: function () {
+ var res_id = JSON.parse(this.$el.val());
+ if (this.field.type === 'many2one') {
+ var value = _.find(this.values, function (val) {
+ return val[0] === res_id;
+ });
+ this._setValue({id: res_id, display_name: value[1]});
+ } else {
+ this._setValue(res_id);
+ }
+ },
+});
+
+var FieldRadio = FieldSelection.extend({
+ description: _lt("Radio"),
+ template: null,
+ className: 'o_field_radio',
+ tagName: 'span',
+ specialData: "_fetchSpecialMany2ones",
+ supportedFieldTypes: ['selection', 'many2one'],
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click input': '_onInputClick',
+ }),
+ /**
+ * @constructs FieldRadio
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ if (this.mode === 'edit') {
+ this.tagName = 'div';
+ this.className += this.nodeOptions.horizontal ? ' o_horizontal' : ' o_vertical';
+ }
+ this.unique_id = _.uniqueId("radio");
+ this._setValues();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns the currently-checked radio button, or the first one if no radio
+ * button is checked.
+ *
+ * @override
+ */
+ getFocusableElement: function () {
+ var checked = this.$("[checked='true']");
+ return checked.length ? checked : this.$("[data-index='0']");
+ },
+
+ /**
+ * @override
+ * @returns {boolean} always true
+ */
+ isSet: function () {
+ return true;
+ },
+
+ /**
+ * Associates the 'for' attribute to the radiogroup, instead of the selected
+ * radio button.
+ *
+ * @param {string} id
+ */
+ setIDForLabel: function (id) {
+ this.$el.attr('id', id);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @override
+ */
+ _renderEdit: function () {
+ var self = this;
+ var currentValue;
+ if (this.field.type === 'many2one') {
+ currentValue = this.value && this.value.data.id;
+ } else {
+ currentValue = this.value;
+ }
+ this.$el.empty();
+ this.$el.attr('role', 'radiogroup')
+ .attr('aria-label', this.string);
+ _.each(this.values, function (value, index) {
+ self.$el.append(qweb.render('FieldRadio.button', {
+ checked: value[0] === currentValue,
+ id: self.unique_id + '_' + value[0],
+ index: index,
+ name: self.unique_id,
+ value: value,
+ }));
+ });
+ },
+ /**
+ * @override
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ this._setValues();
+ },
+ /**
+ * Sets the possible field values. If the field is a many2one, those values
+ * may change during the lifecycle of the widget if the domain change (an
+ * onchange may change the domain).
+ *
+ * @private
+ */
+ _setValues: function () {
+ if (this.field.type === 'selection') {
+ this.values = this.field.selection || [];
+ } else if (this.field.type === 'many2one') {
+ this.values = _.map(this.record.specialData[this.name], function (val) {
+ return [val.id, val.display_name];
+ });
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onInputClick: function (event) {
+ var index = $(event.target).data('index');
+ var value = this.values[index];
+ if (this.field.type === 'many2one') {
+ this._setValue({id: value[0], display_name: value[1]});
+ } else {
+ this._setValue(value[0]);
+ }
+ },
+});
+
+
+var FieldSelectionBadge = FieldSelection.extend({
+ description: _lt("Badges"),
+ template: null,
+ className: 'o_field_selection_badge',
+ tagName: 'span',
+ specialData: "_fetchSpecialMany2ones",
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click span.o_selection_badge': '_onBadgeClicked',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @override
+ */
+ _renderEdit: function () {
+ this.currentValue = this.value;
+
+ if (this.field.type === 'many2one') {
+ this.currentValue = this.value && this.value.data.id;
+ }
+ this.$el.empty();
+ this.$el.html(qweb.render('FieldSelectionBadge', {'values': this.values, 'current_value': this.currentValue}));
+ },
+ /**
+ * Sets the possible field values. If the field is a many2one, those values
+ * may change during the life cycle of the widget if the domain change (an
+ * onchange may change the domain).
+ *
+ * @private
+ * @override
+ */
+ _setValues: function () {
+ // Note: We can make abstract widget for common code in radio and selection badge
+ if (this.field.type === 'selection') {
+ this.values = this.field.selection || [];
+ } else if (this.field.type === 'many2one') {
+ this.values = _.map(this.record.specialData[this.name], function (val) {
+ return [val.id, val.display_name];
+ });
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onBadgeClicked: function (event) {
+ var index = $(event.target).data('index');
+ var value = this.values[index];
+ if (value[0] !== this.currentValue) {
+ if (this.field.type === 'many2one') {
+ this._setValue({id: value[0], display_name: value[1]});
+ } else {
+ this._setValue(value[0]);
+ }
+ } else {
+ this._setValue(false);
+ }
+ },
+});
+
+var FieldSelectionFont = FieldSelection.extend({
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Changes CSS for all options according to their value.
+ * Also removes empty labels.
+ *
+ * @private
+ * @override
+ */
+ _renderEdit: function () {
+ this._super.apply(this, arguments);
+
+ this.$('option').each(function (i, option) {
+ if (! option.label) {
+ $(option).remove();
+ }
+ $(option).css('font-family', option.value);
+ });
+ this.$el.css('font-family', this.value);
+ },
+});
+
+/**
+ * The FieldReference is a combination of a select (for the model) and
+ * a FieldMany2one for its value.
+ * Its intern representation is similar to the many2one (a datapoint with a
+ * `name_get` as data).
+ * Note that there is some logic to support char field because of one use in our
+ * codebase, but this use should be removed along with this note.
+ */
+var FieldReference = FieldMany2One.extend({
+ specialData: "_fetchSpecialReference",
+ supportedFieldTypes: ['reference'],
+ template: 'FieldReference',
+ events: _.extend({}, FieldMany2One.prototype.events, {
+ 'change select': '_onSelectionChange',
+ }),
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ // needs to be copied as it is an unmutable object
+ this.field = _.extend({}, this.field);
+
+ this._setState();
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$('select').val(this.field.relation);
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @returns {jQuery}
+ */
+ getFocusableElement: function () {
+ if (this.mode === 'edit' && !this.field.relation) {
+ return this.$('select');
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the encompassing record's display_name
+ *
+ * @override
+ */
+ _formatValue: function () {
+ var value;
+ if (this.field.type === 'char') {
+ value = this.record.specialData[this.name];
+ } else {
+ value = this.value;
+ }
+ return value && value.data && value.data.display_name || '';
+ },
+
+ /**
+ * Add a select in edit mode (for the model).
+ *
+ * @override
+ */
+ _renderEdit: function () {
+ this._super.apply(this, arguments);
+
+ if (this.$('select').val()) {
+ this.$('.o_input_dropdown').show();
+ this.$el.addClass('o_row'); // this class is used to display the two
+ // components (select & input) on the same line
+ } else {
+ // hide the many2one if the selection is empty
+ this.$('.o_input_dropdown').hide();
+ }
+
+ },
+ /**
+ * @override
+ * @private
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ var value = this.$('select').val();
+ this._setState();
+ this.$('select').val(this.value && this.value.model || value);
+ },
+ /**
+ * Set `relation` key in field properties.
+ *
+ * @private
+ * @param {string} model
+ */
+ _setRelation: function (model) {
+ // used to generate the search in many2one
+ this.field.relation = model;
+ },
+ /**
+ * @private
+ */
+ _setState: function () {
+ if (this.field.type === 'char') {
+ // in this case, the value is stored in specialData instead
+ this.value = this.record.specialData[this.name];
+ }
+
+ if (this.value) {
+ this._setRelation(this.value.model);
+ }
+ },
+ /**
+ * @override
+ * @private
+ */
+ _setValue: function (value, options) {
+ value = value || {};
+ // we need to specify the model for the change in basic_model
+ // the value is then now a dict with id, display_name and model
+ value.model = this.$('select').val();
+ return this._super(value, options);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * When the selection (model) changes, the many2one is reset.
+ *
+ * @private
+ */
+ _onSelectionChange: function () {
+ var value = this.$('select').val();
+ this.reinitialize(false);
+ this._setRelation(value);
+ },
+});
+
+return {
+ FieldMany2One: FieldMany2One,
+ Many2oneBarcode: Many2oneBarcode,
+ KanbanFieldMany2One: KanbanFieldMany2One,
+ ListFieldMany2One: ListFieldMany2One,
+ Many2OneAvatar: Many2OneAvatar,
+
+ FieldX2Many: FieldX2Many,
+ FieldOne2Many: FieldOne2Many,
+
+ FieldMany2Many: FieldMany2Many,
+ FieldMany2ManyBinaryMultiFiles: FieldMany2ManyBinaryMultiFiles,
+ FieldMany2ManyCheckBoxes: FieldMany2ManyCheckBoxes,
+ FieldMany2ManyTags: FieldMany2ManyTags,
+ FieldMany2ManyTagsAvatar: FieldMany2ManyTagsAvatar,
+ FormFieldMany2ManyTags: FormFieldMany2ManyTags,
+ KanbanFieldMany2ManyTags: KanbanFieldMany2ManyTags,
+
+ FieldRadio: FieldRadio,
+ FieldSelectionBadge: FieldSelectionBadge,
+ FieldSelection: FieldSelection,
+ FieldStatus: FieldStatus,
+ FieldSelectionFont: FieldSelectionFont,
+
+ FieldReference: FieldReference,
+};
+
+});
diff --git a/addons/web/static/src/js/fields/signature.js b/addons/web/static/src/js/fields/signature.js
new file mode 100644
index 00000000..de70f72c
--- /dev/null
+++ b/addons/web/static/src/js/fields/signature.js
@@ -0,0 +1,173 @@
+odoo.define('web.Signature', function (require) {
+ "use strict";
+
+ var AbstractFieldBinary = require('web.basic_fields').AbstractFieldBinary;
+ var core = require('web.core');
+ var field_utils = require('web.field_utils');
+ var registry = require('web.field_registry');
+ var session = require('web.session');
+ const SignatureDialog = require('web.signature_dialog');
+ var utils = require('web.utils');
+
+
+ var qweb = core.qweb;
+ var _t = core._t;
+ var _lt = core._lt;
+
+var FieldBinarySignature = AbstractFieldBinary.extend({
+ description: _lt("Signature"),
+ fieldDependencies: _.extend({}, AbstractFieldBinary.prototype.fieldDependencies, {
+ __last_update: {type: 'datetime'},
+ }),
+ resetOnAnyFieldChange: true,
+ custom_events: _.extend({}, AbstractFieldBinary.prototype.custom_events, {
+ upload_signature: '_onUploadSignature',
+ }),
+ events: _.extend({}, AbstractFieldBinary.prototype.events, {
+ 'click .o_signature': '_onClickSignature',
+ }),
+ template: null,
+ supportedFieldTypes: ['binary'],
+ file_type_magic_word: {
+ '/': 'jpg',
+ 'R': 'gif',
+ 'i': 'png',
+ 'P': 'svg+xml',
+ },
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * This widget must always have render even if there are no signature.
+ * In edit mode, the real value is return to manage required fields.
+ *
+ * @override
+ */
+ isSet: function () {
+ if (this.mode === 'edit') {
+ return this.value;
+ }
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Renders an empty signature or the saved signature. Both must have the same size.
+ *
+ * @override
+ * @private
+ */
+
+ _render: function () {
+ var self = this;
+ var displaySignatureRatio = 3;
+ var url;
+ var $img;
+ 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 (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 {
+ url = session.url('/web/image', {
+ model: this.model,
+ id: JSON.stringify(this.res_id),
+ field: this.nodeOptions.preview_image || this.name,
+ // unique forces a reload of the image when the record has been updated
+ unique: field_utils.format.datetime(this.recordData.__last_update).replace(/[^0-9]/g, ''),
+ });
+ }
+ $img = $(qweb.render("FieldBinarySignature-img", {widget: this, url: url}));
+ } else {
+ $img = $('<div class="o_signature o_signature_empty"><svg></svg><p>' + _t('SIGNATURE') + '</p></div>');
+ if (width && height) {
+ width = Math.min(width, displaySignatureRatio * height);
+ height = width / displaySignatureRatio;
+ } else if (width) {
+ height = width / displaySignatureRatio;
+ } else if (height) {
+ width = height * displaySignatureRatio;
+ }
+ }
+ 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.$('> div').remove();
+ this.$('> img').remove();
+
+ this.$el.prepend($img);
+
+ $img.on('error', function () {
+ self._clearFile();
+ $img.attr('src', self.placeholder);
+ self.do_warn(false, _t("Could not display the selected image"));
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * If the view is in edit mode, open dialog to sign.
+ *
+ * @private
+ */
+ _onClickSignature: function () {
+ var self = this;
+ if (this.mode === 'edit') {
+
+ var nameAndSignatureOptions = {
+ mode: 'draw',
+ displaySignatureRatio: 3,
+ signatureType: 'signature',
+ noInputName: true,
+ };
+
+ if (this.nodeOptions.full_name) {
+ var signName;
+ if (this.fields[this.nodeOptions.full_name].type === 'many2one') {
+ // If m2o is empty, it will have falsy value in recordData
+ signName = this.recordData[this.nodeOptions.full_name] && this.recordData[this.nodeOptions.full_name].data.display_name;
+ } else {
+ signName = this.recordData[this.nodeOptions.full_name];
+ }
+ nameAndSignatureOptions.defaultName = (signName === '') ? undefined : signName;
+ }
+
+ nameAndSignatureOptions.defaultFont = this.nodeOptions.default_font || '';
+ this.signDialog = new SignatureDialog(self, {nameAndSignatureOptions: nameAndSignatureOptions});
+
+ this.signDialog.open();
+ }
+ },
+
+ /**
+ * Upload the signature image if valid and close the dialog.
+ *
+ * @private
+ */
+ _onUploadSignature: function (ev) {
+ var signatureImage = ev.data.signatureImage;
+ if (signatureImage !== this.signDialog.emptySignature) {
+ var data = signatureImage[1];
+ var type = signatureImage[0].split('/')[1];
+ this.on_file_uploaded(data.length, ev.data.name, type, data);
+ }
+ this.signDialog.close();
+ }
+});
+
+registry.add('signature', FieldBinarySignature);
+
+});
diff --git a/addons/web/static/src/js/fields/special_fields.js b/addons/web/static/src/js/fields/special_fields.js
new file mode 100644
index 00000000..52ef6d51
--- /dev/null
+++ b/addons/web/static/src/js/fields/special_fields.js
@@ -0,0 +1,262 @@
+odoo.define('web.special_fields', function (require) {
+"use strict";
+
+var core = require('web.core');
+var field_utils = require('web.field_utils');
+var relational_fields = require('web.relational_fields');
+var AbstractField = require('web.AbstractField');
+
+var FieldSelection = relational_fields.FieldSelection;
+var _t = core._t;
+var _lt = core._lt;
+
+
+/**
+ * This widget is intended to display a warning near a label of a 'timezone' field
+ * indicating if the browser timezone is identical (or not) to the selected timezone.
+ * This widget depends on a field given with the param 'tz_offset_field', which contains
+ * the time difference between UTC time and local time, in minutes.
+ */
+var FieldTimezoneMismatch = FieldSelection.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ var interval = navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? 60000 : 1000;
+ this._datetime = setInterval(this._renderDateTimeTimezone.bind(this), interval);
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ clearInterval(this._datetime);
+ return this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ this._super.apply(this, arguments);
+ this._renderTimezoneMismatch();
+ },
+ /**
+ * Display the time in the user timezone (reload each second)
+ *
+ * @private
+ */
+ _renderDateTimeTimezone: function () {
+ if (!this.mismatch || !this.$option.html()) {
+ return;
+ }
+ var offset = this.recordData.tz_offset.match(/([+-])([0-9]{2})([0-9]{2})/);
+ offset = (offset[1] === '-' ? -1 : 1) * (parseInt(offset[2])*60 + parseInt(offset[3]));
+ var datetime = field_utils.format.datetime(moment.utc().add(offset, 'minutes'), this.field, {timezone: false});
+ var content = this.$option.html().split(' ')[0];
+ content += ' ('+ datetime + ')';
+ this.$option.html(content);
+ },
+ /**
+ * Display the timezone alert
+ *
+ * Note: timezone alert is a span that is added after $el, and $el is now a
+ * set of two elements
+ *
+ * @private
+ */
+ _renderTimezoneMismatch: function () {
+ // we need to clean the warning to have maximum one alert
+ this.$el.last().filter('.o_tz_warning').remove();
+ this.$el = this.$el.first();
+ var value = this.$el.val();
+ var $span = $('<span class="fa fa-exclamation-triangle o_tz_warning"/>');
+
+ if (this.$option && this.$option.html()) {
+ this.$option.html(this.$option.html().split(' ')[0]);
+ }
+
+ var userOffset = this.recordData.tz_offset;
+ this.mismatch = false;
+ if (userOffset && value !== "" && value !== "false") {
+ var offset = -(new Date().getTimezoneOffset());
+ var browserOffset = (offset < 0) ? "-" : "+";
+ browserOffset += _.str.sprintf("%02d", Math.abs(offset / 60));
+ browserOffset += _.str.sprintf("%02d", Math.abs(offset % 60));
+ this.mismatch = (browserOffset !== userOffset);
+ }
+
+ if (this.mismatch){
+ $span.insertAfter(this.$el);
+ $span.attr('title', _t("Timezone Mismatch : This timezone is different from that of your browser.\nPlease, set the same timezone as your browser's to avoid time discrepancies in your system."));
+ this.$el = this.$el.add($span);
+
+ this.$option = this.$('option').filter(function () {
+ return $(this).attr('value') === value;
+ });
+ this._renderDateTimeTimezone();
+ } else if (value == "false") {
+ $span.insertAfter(this.$el);
+ $span.attr('title', _t("Set a timezone on your user"));
+ this.$el = this.$el.add($span);
+ }
+ },
+ /**
+ * @override
+ * @private
+ * this.$el can have other elements than select
+ * that should not be touched
+ */
+ _renderEdit: function () {
+ // FIXME: hack to handle multiple root elements
+ // in this.$el , which is a bad idea
+ // In master we should make this.$el a wrapper
+ // around multiple subelements
+ var $otherEl = this.$el.not('select');
+ this.$el = this.$el.first();
+
+ this._super.apply(this, arguments);
+
+ $otherEl.insertAfter(this.$el);
+ this.$el = this.$el.add($otherEl);
+ },
+});
+
+var FieldReportLayout = relational_fields.FieldMany2One.extend({
+ // this widget is not generic, so we disable its studio use
+ // supportedFieldTypes: ['many2one', 'selection'],
+ events: _.extend({}, relational_fields.FieldMany2One.prototype.events, {
+ 'click img': '_onImgClicked',
+ }),
+
+ willStart: function () {
+ var self = this;
+ this.previews = {};
+ return this._super()
+ .then(function () {
+ return self._rpc({
+ model: 'report.layout',
+ method: "search_read"
+ }).then(function (values) {
+ self.previews = values;
+ });
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ var self = this;
+ this.$el.empty();
+ var value = _.isObject(this.value) ? this.value.data.id : this.value;
+ _.each(this.previews, function (val) {
+ var $container = $('<div>').addClass('col-3 text-center');
+ var $img = $('<img>')
+ .addClass('img img-fluid img-thumbnail ml16')
+ .toggleClass('btn-info', val.view_id[0] === value)
+ .attr('src', val.image)
+ .data('key', val.view_id[0]);
+ $container.append($img);
+ if (val.pdf) {
+ var $previewLink = $('<a>')
+ .text('Example')
+ .attr('href', val.pdf)
+ .attr('target', '_blank');
+ $container.append($previewLink);
+ }
+ self.$el.append($container);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onImgClicked: function (event) {
+ this._setValue($(event.currentTarget).data('key'));
+ },
+});
+
+
+const IframeWrapper = AbstractField.extend({
+ description: _lt("Wrap raw html within an iframe"),
+
+ // If HTML, don't forget to adjust the sanitize options to avoid stripping most of the metadata
+ supportedFieldTypes: ['text', 'html'],
+
+ template: "web.IframeWrapper",
+
+ _render() {
+
+ const spinner = this.el.querySelector('.o_iframe_wrapper_spinner');
+ const iframe = this.el.querySelector('.o_preview_iframe');
+
+ iframe.style.display = 'none';
+ spinner.style.display = 'block';
+
+ // Promise for tests
+ let resolver;
+ $(iframe).data('ready', new Promise((resolve) => {
+ resolver = resolve;
+ }));
+
+ /**
+ * Certain browser don't trigger onload events of iframe for particular cases.
+ * In our case, chrome and safari could be problematic depending on version and environment.
+ * This rather unorthodox solution replace the onload event handler. (jquery on('load') doesn't fix it)
+ */
+ const onloadReplacement = setInterval(() => {
+ const iframeDoc = iframe.contentDocument;
+ if (iframeDoc && (iframeDoc.readyState === 'complete' || iframeDoc.readyState === 'interactive')) {
+
+ /**
+ * The document.write is not recommended. It is better to manipulate the DOM through $.appendChild and
+ * others. In our case though, we deal with an iframe without src attribute and with metadata to put in
+ * head tag. If we use the usual dom methods, the iframe is automatically created with its document
+ * component containing html > head & body. Therefore, if we want to make it work that way, we would
+ * need to receive each piece at a time to append it to this document (with this.record.data and extra
+ * model fields or with an rpc). It also cause other difficulties getting attribute on the most parent
+ * nodes, parsing to HTML complex elements, etc.
+ * Therefore, document.write makes it much more trivial in our situation.
+ */
+ iframeDoc.open();
+ iframeDoc.write(this.value);
+ iframeDoc.close();
+
+ iframe.style.display = 'block';
+ spinner.style.display = 'none';
+
+ resolver();
+
+ clearInterval(onloadReplacement);
+ }
+ }, 100);
+
+ }
+
+});
+
+
+return {
+ FieldTimezoneMismatch: FieldTimezoneMismatch,
+ FieldReportLayout: FieldReportLayout,
+ IframeWrapper,
+};
+
+});
diff --git a/addons/web/static/src/js/fields/upgrade_fields.js b/addons/web/static/src/js/fields/upgrade_fields.js
new file mode 100644
index 00000000..36af3956
--- /dev/null
+++ b/addons/web/static/src/js/fields/upgrade_fields.js
@@ -0,0 +1,199 @@
+odoo.define('web.upgrade_widgets', function (require) {
+"use strict";
+
+/**
+ * The upgrade widgets are intended to be used in config settings.
+ * When checked, an upgrade popup is showed to the user.
+ */
+
+var AbstractField = require('web.AbstractField');
+var basic_fields = require('web.basic_fields');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var field_registry = require('web.field_registry');
+var framework = require('web.framework');
+var relational_fields = require('web.relational_fields');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+var FieldBoolean = basic_fields.FieldBoolean;
+var FieldRadio = relational_fields.FieldRadio;
+
+
+/**
+ * Mixin that defines the common functions shared between Boolean and Radio
+ * upgrade widgets
+ */
+var AbstractFieldUpgrade = {
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Redirects the user to the odoo-enterprise/uprade page
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _confirmUpgrade: function () {
+ return this._rpc({
+ model: 'res.users',
+ method: 'search_count',
+ args: [[["share", "=", false]]],
+ })
+ .then(function (data) {
+ framework.redirect("https://www.odoo.com/odoo-enterprise/upgrade?num_users=" + data);
+ });
+ },
+ /**
+ * This function is meant to be overridden to insert the 'Enterprise' label
+ * JQuery node at the right place.
+ *
+ * @abstract
+ * @private
+ * @param {jQuery} $enterpriseLabel the 'Enterprise' label to insert
+ */
+ _insertEnterpriseLabel: function ($enterpriseLabel) {},
+ /**
+ * Opens the Upgrade dialog.
+ *
+ * @private
+ * @returns {Dialog} the instance of the opened Dialog
+ */
+ _openDialog: function () {
+ var message = $(QWeb.render('EnterpriseUpgrade'));
+
+ var buttons = [
+ {
+ text: _t("Upgrade now"),
+ classes: 'btn-primary',
+ close: true,
+ click: this._confirmUpgrade.bind(this),
+ },
+ {
+ text: _t("Cancel"),
+ close: true,
+ },
+ ];
+
+ return new Dialog(this, {
+ size: 'medium',
+ buttons: buttons,
+ $content: $('<div>', {
+ html: message,
+ }),
+ title: _t("Odoo Enterprise"),
+ }).open();
+ },
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ this._super.apply(this, arguments);
+ this._insertEnterpriseLabel($("<span>", {
+ text: "Enterprise",
+ 'class': "badge badge-primary oe_inline o_enterprise_label"
+ }));
+ },
+ /**
+ * This function is meant to be overridden to reset the $el to its initial
+ * state.
+ *
+ * @abstract
+ * @private
+ */
+ _resetValue: function () {},
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onInputClicked: function (event) {
+ if ($(event.currentTarget).prop("checked")) {
+ this._openDialog().on('closed', this, this._resetValue.bind(this));
+ }
+ },
+
+};
+
+var UpgradeBoolean = FieldBoolean.extend(AbstractFieldUpgrade, {
+ supportedFieldTypes: [],
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click input': '_onInputClicked',
+ }),
+ /**
+ * Re-renders the widget with the label
+ *
+ * @param {jQuery} $label
+ */
+ renderWithLabel: function ($label) {
+ this.$label = $label;
+ this._render();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _insertEnterpriseLabel: function ($enterpriseLabel) {
+ var $el = this.$label || this.$el;
+ $el.append('&nbsp;').append($enterpriseLabel);
+ },
+ /**
+ * @override
+ * @private
+ */
+ _resetValue: function () {
+ this.$input.prop("checked", false).change();
+ },
+});
+
+var UpgradeRadio = FieldRadio.extend(AbstractFieldUpgrade, {
+ supportedFieldTypes: [],
+ events: _.extend({}, FieldRadio.prototype.events, {
+ 'click input:last': '_onInputClicked',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _insertEnterpriseLabel: function ($enterpriseLabel) {
+ this.$('label').last().append('&nbsp;').append($enterpriseLabel);
+ },
+ /**
+ * @override
+ * @private
+ */
+ _resetValue: function () {
+ this.$('input').first().prop("checked", true).click();
+ },
+});
+
+field_registry
+ .add('upgrade_boolean', UpgradeBoolean)
+ .add('upgrade_radio', UpgradeRadio);
+
+});