diff options
Diffstat (limited to 'addons/web/static/src/js/fields')
| -rw-r--r-- | addons/web/static/src/js/fields/abstract_field.js | 621 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/abstract_field_owl.js | 648 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/basic_fields.js | 3757 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/basic_fields_owl.js | 132 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/field_registry.js | 101 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/field_registry_owl.js | 26 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/field_utils.js | 762 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/field_wrapper.js | 157 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/relational_fields.js | 3460 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/signature.js | 173 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/special_fields.js | 262 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/upgrade_fields.js | 199 |
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 ' ') + * @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 ? ' ' : ' '; + 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(' '); + 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(' ').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(' ').append($enterpriseLabel); + }, + /** + * @override + * @private + */ + _resetValue: function () { + this.$('input').first().prop("checked", true).click(); + }, +}); + +field_registry + .add('upgrade_boolean', UpgradeBoolean) + .add('upgrade_radio', UpgradeRadio); + +}); |
