summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/fields
diff options
context:
space:
mode:
authorGéry Debongnie <ged@odoo.com>2017-03-05 10:05:56 +0100
committerAaron Bohy <aab@odoo.com>2017-04-11 19:44:38 +0200
commit905e01921f3c3ef43ace6fc537d1d8c0d280002c (patch)
tree265c43ab21c5401a5d9f0f3d64d86f1ac417451c /addons/web/static/src/js/fields
parent54d4c73af4ad05c7640d28c1892247c9538b8017 (diff)
[REF] web, *: redesign all JS views
This commit introduce a full redesign of all JS views. We started basically from scratch. The goal was to unify all the various views under a common framework, to make them testable, to make then usable in different conditions (in studio, or in the frontend), and to make our lives easier. Some important points are: - we introduced new coding guidelines (camelCase, 80 chars width, ...) - we have a brand new testing framework (still QUnit based) - kanban view moved to the web addon - calendar view (formerly web_calender) moved to web as well - the tree view was removed - all new code should be documented We hope that this code is the start of a new era for the Odoo web client, we want to have a high quality codebase, well documented, well tested, well designed. Work done by the framework team: mostly aab, ged, chm, dmo, qsm
Diffstat (limited to 'addons/web/static/src/js/fields')
-rw-r--r--addons/web/static/src/js/fields/abstract_field.js328
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js1300
-rw-r--r--addons/web/static/src/js/fields/field_registry.js66
-rw-r--r--addons/web/static/src/js/fields/field_utils.js240
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js1493
-rw-r--r--addons/web/static/src/js/fields/upgrade_fields.js177
6 files changed, 3604 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 00000000000..fbf27f07eee
--- /dev/null
+++ b/addons/web/static/src/js/fields/abstract_field.js
@@ -0,0 +1,328 @@
+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 pyeval = require('web.pyeval');
+var Widget = require('web.Widget');
+
+var AbstractField = Widget.extend({
+ className: 'o_field_widget',
+ events: {
+ 'keydown': '_onKeydown',
+ },
+ /**
+ * if this flag is set to true, the rest of the web client will assume that
+ * it is not editable. For example, the list view in editable mode will
+ * skip the widget when pressing tab
+ */
+ readonly: false,
+
+ /**
+ * 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 true, the widget will replace a cell in an editable list view
+ */
+ replace_element: false,
+ /**
+ * to override to indicate which field types are supported by the widget
+ */
+ supportedFieldTypes: [],
+
+ /**
+ * 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'
+ * @param {string} [options.required=false]
+ * @param {string} [options.idForLabel]
+ */
+ init: function (parent, name, record, options) {
+ this._super(parent);
+ options = options || {};
+
+ // 'name' is the field name displayed by this widget
+ this.name = name;
+
+ // 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 'attrs' property contains the attributes of the xml 'field' tag
+ this.attrs = record.fieldAttrs[name] || {};
+
+ // 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 = pyeval.py_eval(this.attrs.options || '{}', this.recordData);
+
+ // 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;
+
+ // the 'required' flag is basically only needed to determine if the widget
+ // is in a valid state (not valid if empty and required)
+ this.required = options.required || false;
+
+ // the 'idForLabel' is the (html) id that should be set to the relevent
+ // dom entity. If done correctly, clicking on the corresponding label
+ // (in form view) will focus and select the value.
+ this.idForLabel = options.idForLabel;
+ },
+ /**
+ * 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 {Deferred}
+ */
+ start: function() {
+ var self = this;
+ return this._super.apply(this, arguments).then(function() {
+ self._render();
+ if (self.required) {
+ self.$el.addClass('o_form_required');
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Right now, this function is only used in editable list view when the user
+ * select a cell. The corresponding widget will be activated, which in general
+ * means that the input text will be focused and selected
+ */
+ activate: function () {
+ },
+ /**
+ * 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 its value is valid and if there is a value when
+ * it is required. This is checked before saving a record, by the form view.
+ *
+ * @returns {boolean}
+ */
+ isValid: function () {
+ return this._isValid && !(this.required && !this.isSet());
+ },
+ /**
+ * 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 {Deferred} A Deferred, which resolves when the widget rendering
+ * is complete
+ */
+ reset: function (record, event) {
+ this._reset(record, event);
+ return this._render();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * convert the value from the field to a string representation
+ *
+ * @private
+ * @param {any} value (from the field type)
+ * @returns {string}
+ */
+ _formatValue: function (value) {
+ var options = _.extend({}, this.nodeOptions, { data: this.recordData });
+ return field_utils.format_field(value, this.field, options);
+ },
+ /**
+ * convert a string representation to a valid value, depending on the field
+ * type.
+ *
+ * @private
+ * @param {string} value
+ * @returns {any}
+ */
+ _parseValue: function (value) {
+ return field_utils.parse_field(value, this.field);
+ },
+ /**
+ * 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 {Deferred}
+ */
+ _render: function () {
+ 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 {Deferred}
+ */
+ _renderEdit: function () {
+ },
+ /**
+ * Render the widget in readonly mode. The actual implementation is left to
+ * the concrete widget.
+ *
+ * @private
+ * @returns {Deferred}
+ */
+ _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.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
+ */
+ _setValue: function (value) {
+ try {
+ value = this._parseValue(value);
+ this._isValid = true;
+ } catch(e) {
+ this._isValid = false;
+ }
+ var changes = {};
+ changes[this.name] = value;
+ this.trigger_up('field_changed', {
+ dataPointID: this.dataPointID,
+ changes: changes,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * might be controversial: intercept the tab key, to allow the editable list
+ * view to control where the focus is.
+ *
+ * @private
+ * @param {KeyEvent} event
+ */
+ _onKeydown: function (event) {
+ if (event.which === $.ui.keyCode.TAB) {
+ this.trigger_up('move_next');
+ event.preventDefault();
+ }
+ },
+});
+
+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 00000000000..d07bcb5dd95
--- /dev/null
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -0,0 +1,1300 @@
+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 ajax = require('web.ajax');
+var config = require('web.config');
+var core = require('web.core');
+var crash_manager = require('web.crash_manager');
+var datepicker = require('web.datepicker');
+var dom = require('web.dom');
+var field_utils = require('web.field_utils');
+var framework = require('web.framework');
+var session = require('web.session');
+var utils = require('web.utils');
+
+var qweb = core.qweb;
+var _t = core._t;
+
+
+var InputField = AbstractField.extend({
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'input': '_onInput',
+ }),
+ init: function () {
+ this._super.apply(this, arguments);
+ if (this.mode === 'edit') {
+ this.tagName = 'input';
+ }
+ },
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ activate: function () {
+ this.$el.focus();
+ setTimeout(this.$el.select.bind(this.$el), 0);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _renderEdit: function () {
+ this.$el.addClass('o_form_input');
+ this.$el.attr('type', 'text');
+ if (this.attrs.placeholder) {
+ this.$el.attr('placeholder', this.attrs.placeholder);
+ }
+ this.$el.attr('id', this.idForLabel);
+ // save cursor position to restore it after updating value
+ var selectionStart = this.el.selectionStart;
+ var selectionEnd = this.el.selectionEnd;
+ this.$el.val(this._formatValue(this.value));
+ this.el.selectionStart = selectionStart;
+ this.el.selectionEnd = selectionEnd;
+ },
+ _renderReadonly: function () {
+ this.$el.empty().html(this._formatValue(this.value));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Implement keyboard movements. Mostly useful for its environment, such
+ * as a list view.
+ *
+ * @override
+ * @param {any} event
+ */
+ _onKeydown: function (event) {
+ var input = this.$el[0];
+ var is_not_selecting;
+ switch (event.which) {
+ case $.ui.keyCode.DOWN:
+ this.trigger_up('move_down');
+ break;
+ case $.ui.keyCode.UP:
+ this.trigger_up('move_up');
+ break;
+ case $.ui.keyCode.LEFT:
+ is_not_selecting = input.selectionEnd === input.selectionStart;
+ if (is_not_selecting && input.selectionStart === 0) {
+ this.trigger_up('move_left');
+ }
+ break;
+ case $.ui.keyCode.RIGHT:
+ is_not_selecting = input.selectionEnd === input.selectionStart;
+ if (is_not_selecting && input.selectionEnd === input.value.length) {
+ this.trigger_up('move_right');
+ }
+ break;
+ case $.ui.keyCode.ENTER:
+ this.trigger_up('move_next_line');
+ break;
+ }
+ this._super.apply(this, arguments);
+ },
+ /**
+ * We notify the outside world for each change in this input value. It does
+ * not necessarily mean that onchanges will be triggered instantly.
+ */
+ _onInput: function () {
+ this._setValue(this.$el.val());
+ },
+});
+
+var FieldChar = InputField.extend({
+ supportedFieldTypes: ['char', 'text'],
+ tagName: 'span',
+});
+
+var FieldDate = InputField.extend({
+ className: "o_form_field_date",
+ tagName: "span",
+ replace_element: true,
+ supportedFieldTypes: ['date', 'datetime'],
+
+ start: function () {
+ var self = this;
+ var def;
+ if (this.mode === 'edit') {
+ this.datewidget = this._makeDatePicker();
+ this.datewidget.on('datetime_changed', this, function () {
+ if (this.datewidget.get_value() !== this._parseValue(this.value)) {
+ this._setValue(this.datewidget.get_value());
+ }
+ });
+ def = this.datewidget.appendTo('<div>').done(function () {
+ self.datewidget.$el.addClass(self.$el.attr('class'));
+ self.datewidget.$input.addClass('o_form_input');
+ self.datewidget.$input.attr('id', self.idForLabel);
+ self.replaceElement(self.datewidget.$el);
+ });
+ }
+ return $.when(def, this._super.apply(this, arguments));
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ activate: function () {
+ var $input = this.datewidget.$input;
+ $input.focus();
+ setTimeout($input.select.bind($input), 0);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _makeDatePicker: function () {
+ return new datepicker.DateWidget(this);
+ },
+ _renderEdit: function () {
+ this.datewidget.set_value(this.value);
+ },
+});
+
+var FieldDateTime = FieldDate.extend({
+ supportedFieldTypes: ['datetime'],
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _makeDatePicker: function () {
+ return new datepicker.DateTimeWidget(this);
+ },
+});
+
+var FieldMonetary = InputField.extend({
+ className: 'o_list_number',
+ supportedFieldTypes: ['float', 'monetary'],
+ // FieldMonetary overrides _formatValue to ensure that the format_monetary
+ // method is used. This allows this widget to be used with other field
+ // types, such as float.
+ _formatValue: function (value) {
+ var options = _.extend({}, this.nodeOptions, { data: this.recordData });
+ return field_utils.format_monetary(value, this.field, options);
+ },
+ isSet: function () {
+ return this.value !== false;
+ }
+});
+
+var FieldBoolean = AbstractField.extend({
+ className: 'o_field_boolean',
+ events: _.extend({}, AbstractField.prototype.events, {
+ change: '_onChange',
+ keydown: '_onKeyDown',
+ }),
+ replace_element: true,
+ supportedFieldTypes: ['boolean'],
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ activate: function () {
+ this.$input.focus();
+ setTimeout(this.$input.select.bind(this.$input), 0);
+ },
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _renderReadonly: function () {
+ var $checkbox = this._formatValue(this.value);
+ this.$input = $checkbox.find('input');
+ this.$el.empty().append($checkbox);
+ },
+ _renderEdit: function () {
+ this._renderReadonly();
+ this.$input.prop('disabled', false);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ _onChange: function () {
+ this._setValue(this.$input[0].checked);
+ },
+ /**
+ * @param {KeyEvent} event
+ */
+ _onKeydown: function (event) {
+ switch (event.which) {
+ case $.ui.keyCode.DOWN:
+ this.trigger_up('move_down');
+ break;
+ case $.ui.keyCode.UP:
+ this.trigger_up('move_up');
+ break;
+ case $.ui.keyCode.LEFT:
+ this.trigger_up('move_left');
+ break;
+ case $.ui.keyCode.RIGHT:
+ this.trigger_up('move_right');
+ break;
+ case $.ui.keyCode.TAB:
+ this.trigger_up('move_next');
+ event.preventDefault();
+ break;
+ case $.ui.keyCode.ENTER:
+ this.$input.prop('checked', !this.value);
+ this._setValue(!this.value);
+ break;
+ }
+ },
+});
+
+var FieldInteger = InputField.extend({
+ supportedFieldTypes: ['integer'],
+ isSet: function () {
+ return this.value !== false;
+ },
+});
+
+var FieldFloat = InputField.extend({
+ supportedFieldTypes: ['float'],
+ _renderReadonly: function () {
+ var precision;
+ var value = this.value;
+ if (this.attrs.digits) {
+ precision = this.node.attrs.digits;
+ } else {
+ precision = this.field.digits;
+ }
+ if (precision && precision.length === 2) {
+ value = utils.round_decimals(value, precision[1]);
+ }
+ var $span = $('<span>').addClass('o_form_field o_form_field_number').text(this._formatValue(value));
+ this.$el.html($span);
+ },
+ isSet: function () {
+ return this.value !== '';
+ },
+});
+
+var FieldFloatTime = FieldFloat.extend({
+ supportedFieldTypes: ['float'],
+ _formatValue: function (value) {
+ var pattern = '%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);
+ },
+ _parseValue: function (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 * field_utils.parse_float(value, this.field);
+ }
+ var hours = field_utils.parse_integer(float_time_pair[0]);
+ var minutes = field_utils.parse_integer(float_time_pair[1]);
+ return factor * (hours + (minutes / 60));
+ },
+});
+
+var FieldText = AbstractField.extend({
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'input': function () {
+ this._setValue(this.$textarea.val());
+ },
+ }),
+ supportedFieldTypes: ['text'],
+ start: function () {
+ this.$el.addClass('o_list_text o_form_textarea');
+ this.$el.attr('id', this.idForLabel);
+
+ if (this.mode === 'edit') {
+ // in edit mode, the widget contains a textarea. We append it in
+ // start() instead of _renderEdit() to keep the same textarea even
+ // if several _renderEdit are done. This allows to keep the cursor
+ // position and to autoresize only once
+ this.$textarea = $('<textarea>').appendTo(this.$el);
+ if (this.attrs.placeholder) {
+ this.$textarea.attr('placeholder', this.attrs.placeholder);
+ }
+ dom.autoresize(this.$textarea, {parent: this});
+ }
+
+ return this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ activate: function () {
+ var $textarea = this.$('textarea');
+ $textarea.focus();
+ setTimeout($textarea.select.bind($textarea), 0);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _renderEdit: function () {
+ this.$textarea.val(this._formatValue(this.value));
+ },
+ _renderReadonly: function () {
+ this.$el.empty().text(this._formatValue(this.value));
+ },
+});
+
+var FieldHtml = InputField.extend({
+ supportedFieldTypes: ['html'],
+ text_to_html: function (text) {
+ var value = text || "";
+ if (value.match(/^\s*$/)) {
+ value = '<p><br/></p>';
+ } else {
+ value = "<p>"+value.split(/<br\/?>/).join("<br/></p><p>")+"</p>";
+ value = value.replace(/<p><\/p>/g, '').replace('<p><p>', '<p>').replace('<p><p ', '<p ').replace('</p></p>', '</p>');
+ }
+ return value;
+ },
+ _render: function () {
+ this.$el.html(this.text_to_html(this.value));
+ },
+});
+
+/**
+ * displays a handle and allows the resequencing
+ * This widget works for integer fields
+ */
+var HandleWidget = AbstractField.extend({
+ tagName: 'span',
+ className: 'o_row_handle fa fa-arrows ui-sortable-handle',
+ description: "",
+ readonly: true,
+ replace_element: true,
+ supportedFieldTypes: ['integer'],
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * content is only "set" with pure css...
+ * @override
+ */
+ _render: function () {
+ this.$el.empty();
+ },
+});
+
+var EmailWidget = InputField.extend({
+ prefix: 'mailto',
+ supportedFieldTypes: ['char'],
+ init: function () {
+ this._super.apply(this, arguments);
+ this.tagName = this.mode === 'readonly' ? 'a' : 'input';
+ },
+ _renderReadonly: function () {
+ this.$el.text(this.value)
+ .addClass('o_form_uri o_text_overflow')
+ .attr('href', this.prefix + ':' + this.value);
+ }
+});
+
+var FieldPhone = EmailWidget.extend({
+ prefix: 'tel',
+ init: function () {
+ this._super.apply(this, arguments);
+ if (this.mode === 'readonly' && !this._can_call()) {
+ this.tagName = 'span';
+ }
+ },
+ _renderReadonly: function () {
+ this._super();
+ if(this._can_call()) {
+ var text = this.$el.text();
+ this.$el.html(text.substr(0, text.length/2) + "&shy;" + text.substr(text.length/2)); // To prevent Skype app to find the phone number
+ } else {
+ this.$el.removeClass('o_form_uri');
+ }
+ },
+ _can_call: function () {
+ // Phone fields are clickable in readonly on small screens ~= on phones
+ // This can be overriden by call-capable modules to display a clickable
+ // link in different situations, like always regardless of screen size,
+ // or only allow national calls for example.
+ return config.device.size_class <= config.device.SIZES.XS;
+ }
+});
+
+var UrlWidget = InputField.extend({
+ supportedFieldTypes: ['char'],
+ init: function () {
+ this._super.apply(this, arguments);
+ this.tagName = this.mode === 'readonly' ? 'a' : 'input';
+ },
+ _renderReadonly: function () {
+ this.$el.text(this.value)
+ .addClass('o_form_uri o_text_overflow')
+ .attr('href', this.value);
+ }
+});
+
+var AbstractFieldBinary = AbstractField.extend({
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'change .o_form_input_file': 'on_file_change',
+ 'click .o_select_file_button': function () {
+ this.$('.o_form_input_file').click();
+ },
+ 'click .o_clear_file_button': 'on_clear',
+ }),
+ init: function (parent, name, record) {
+ this._super.apply(this, arguments);
+ this.fields = record.fields;
+ this.useFileAPI = !!window.FileReader;
+ this.max_upload_size = 25 * 1024 * 1024; // 25Mo
+ 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;
+ }
+ var filereader = new FileReader();
+ filereader.readAsDataURL(file);
+ filereader.onloadend = function (upload) {
+ var data = upload.target.result;
+ data = data.split(',')[1];
+ self.on_file_uploaded(file.size, file.name, file.type, data);
+ };
+ } else {
+ this.$('form.o_form_binary_form input[name=session_id]').val(this.session.session_id);
+ 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(_t("File Upload"), _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) {
+ this.trigger_up('update_field', { name: filename, value: value });
+ }
+ },
+ on_clear: function () {
+ this.set_filename('');
+ this._setValue(false);
+ this._render();
+ },
+});
+
+var FieldBinaryImage = AbstractFieldBinary.extend({
+ 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'],
+ _render: function () {
+ var self = this;
+ var attrs = this.attrs;
+ var url = this.placeholder;
+ if (this.value) {
+ if (!utils.is_bin_size(this.value)) {
+ url = 'data:image/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: (this.recordData.__last_update || '').replace(/[^0-9]/g, ''),
+ });
+ }
+ }
+ var $img = $('<img>').attr('src', url);
+ $img.css({
+ width: this.nodeOptions.size ? this.nodeOptions.size[0] : attrs.img_width || attrs.width,
+ height: this.nodeOptions.size ? this.nodeOptions.size[1] : attrs.img_height || attrs.height,
+ });
+ this.$('> img').remove();
+ this.$el.prepend($img);
+ $img.on('error', function () {
+ self.on_clear();
+ $img.attr('src', self.placeholder);
+ self.do_warn(_t("Image"), _t("Could not display the selected image."));
+ });
+ },
+ isSet: function () {
+ return true;
+ },
+});
+
+var FieldBinaryFile = AbstractFieldBinary.extend({
+ template: 'FieldBinaryFile',
+ events: _.extend({}, AbstractFieldBinary.prototype.events, {
+ 'click': function (event) {
+ if (this.mode === 'readonly' && this.value) {
+ this.on_save_as(event);
+ }
+ },
+ 'click .o_form_input': function () { // eq[0]
+ this.$('.o_form_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.filename_value) {
+ this.$el.append(" " + this.filename_value);
+ }
+ }
+ },
+ _renderEdit: function () {
+ if(this.value) {
+ this.$el.children().removeClass('o_hidden');
+ this.$('.o_select_file_button').first().addClass('o_hidden');
+ this.$('.o_form_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(_t("Save As..."), _t("The field is empty, there's nothing to save !"));
+ ev.stopPropagation();
+ } else {
+ framework.blockUI();
+ var c = crash_manager;
+ var filename_fieldname = this.attrs.filename;
+ this.session.get_file({
+ 'url': '/web/content',
+ 'data': {
+ 'model': this.model,
+ 'id': this.res_id,
+ 'field': this.name,
+ 'filename_field': filename_fieldname,
+ 'filename': this.recordData[filename_fieldname] || null,
+ 'download': true,
+ 'data': utils.is_bin_size(this.value) ? null : this.value,
+ },
+ 'complete': framework.unblockUI,
+ 'error': c.rpc_error.bind(c)
+ });
+ ev.stopPropagation();
+ }
+ },
+});
+
+var PriorityWidget = AbstractField.extend({
+ // the current implementation of this widget makes it
+ // only usable for fields of type selection
+ className: "o_priority",
+ events: {
+ 'mouseover > a': function (e) {
+ clearTimeout(this.hover_timer);
+ this.$('.o_priority_star').removeClass('fa-star-o').addClass('fa-star');
+ $(e.currentTarget).nextAll().removeClass('fa-star').addClass('fa-star-o');
+ },
+ 'mouseout > a': function () {
+ clearTimeout(this.hover_timer);
+
+ var self = this;
+ this.hover_timer = setTimeout(function () {
+ self._render();
+ }, 200);
+ },
+ 'click > a': function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ var new_value = String($(e.currentTarget).data('value'));
+ if(new_value === this.value) {
+ new_value = this.empty_value;
+ }
+ this._setValue(new_value);
+ },
+ },
+ supportedFieldTypes: ['integer'],
+ render_star: function (tag, is_full, value, tip) {
+ return $(tag)
+ .attr('title', tip)
+ .attr('data-value', value)
+ .addClass('o_priority_star fa')
+ .toggleClass('fa-star', is_full)
+ .toggleClass('fa-star-o', !is_full);
+ },
+ _render: function () {
+ var self = this;
+ var value = parseInt(this.value, 10);
+ this.$el.empty();
+ this.empty_value = this.field.selection[0][0];
+ _.each(this.field.selection.slice(1), function (choice) {
+ self.$el.append(self.render_star('<a href="#">', value >= parseInt(choice[0]), choice[0], choice[1]));
+ });
+ },
+});
+
+var AttachmentImage = AbstractField.extend({
+ template: 'AttachmentImage',
+});
+
+var StateSelectionWidget = AbstractField.extend({
+ template: 'FormSelection',
+ events: {
+ 'click a': function (e) {
+ e.preventDefault();
+ },
+ 'click li': 'set_selection'
+ },
+ prepare_dropdown_values: 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;
+ },
+ _render: function () {
+ var self = this;
+ var states = this.prepare_dropdown_values();
+ // Adapt "FormSelection"
+ var current_state = _.find(states, function (state) {
+ return state.name === self.value;
+ });
+ this.$('.o_status')
+ .removeClass('o_status_red o_status_green')
+ .addClass(current_state.state_class);
+
+ // Render "FormSelection.Items" and move it into "FormSelection"
+ var $items = $(qweb.render('FormSelection.items', {
+ states: _.without(states, current_state)
+ }));
+ var $dropdown = this.$('.dropdown-menu');
+ $dropdown.children().remove(); // remove old items
+ $items.appendTo($dropdown);
+ },
+ set_selection: function (ev) {
+ var li = $(ev.target).closest('li');
+ if (li.length) {
+ var value = String(li.data('value'));
+ this._setValue(value);
+ if (this.mode === 'edit') {
+ this._render();
+ }
+ }
+ },
+});
+
+var LabelSelection = AbstractField.extend({
+ _render: function () {
+ this.classes = this.nodeOptions && this.nodeOptions.classes || {};
+ var lbl_class = this.classes[this.value] || 'primary';
+ this.$el.addClass('label label-' + lbl_class).text(this._formatValue(this.value));
+ },
+});
+
+var FieldBooleanButton = AbstractField.extend({
+ className: 'o_stat_info',
+ supportedFieldTypes: ['boolean'],
+ _render: function () {
+ this.$el.empty();
+ var text, hover;
+ switch (this.nodeOptions.terminology) {
+ case "active":
+ text = this.value ? _t("Active") : _t("Inactive");
+ hover = this.value ? _t("Deactivate") : _t("Activate");
+ break;
+ case "archive":
+ text = this.value ? _t("Active") : _t("Archived");
+ hover = this.value ? _t("Archive") : _t("Restore");
+ break;
+ case "close":
+ text = this.value ? _t("Active") : _t("Closed");
+ hover = this.value ? _t("Close") : _t("Open");
+ break;
+ default:
+ var opt_terms = this.nodeOptions.terminology || {};
+ if (typeof opt_terms === 'string') {
+ opt_terms = {}; //unsupported terminology
+ }
+ text = this.value ? _t(opt_terms.string_true) || _t("On")
+ : _t(opt_terms.string_false) || _t("Off");
+ hover = this.value ? _t(opt_terms.hover_true) || _t("Switch Off")
+ : _t(opt_terms.hover_false) || _t("Switch On");
+ }
+ var val_color = this.value ? 'text-success' : 'text-danger';
+ var hover_color = this.value ? 'text-danger' : 'text-success';
+ var $val = $('<span>').addClass('o_stat_text o_not_hover ' + val_color).text(text);
+ var $hover = $('<span>').addClass('o_stat_text o_hover ' + hover_color).text(hover);
+ this.$el.append($val).append($hover);
+ },
+ isSet: function () {
+ return true;
+ },
+});
+
+var FieldID = InputField.extend({
+ supportedFieldTypes: ['integer'],
+ init: function () {
+ this._super.apply(this, arguments);
+ this.mode = 'readonly';
+ },
+});
+
+var StatInfo = AbstractField.extend({
+ supportedFieldTypes: ['integer', 'float'],
+ _render: function () {
+ var options = {
+ value: this.value || 0,
+ };
+ if (! this.nodeOptions.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');
+ },
+ isSet: function () {
+ return true;
+ },
+});
+
+var FieldPercentPie = AbstractField.extend({
+ template: 'FieldPercentPie',
+ supportedFieldTypes: ['integer'],
+ start: function () {
+ this.$left_mask = this.$('.o_mask').first();
+ this.$right_mask = this.$('.o_mask').last();
+ this.$pie_value = this.$('.o_pie_value');
+ return this._super();
+ },
+ _render: function () {
+ var value = this.value || 0;
+ var degValue = 360*value/100;
+
+ this.$right_mask.toggleClass('o_full', degValue >= 180);
+
+ var leftDeg = 'rotate(' + ((degValue < 180)? 180 : degValue) + 'deg)';
+ var rightDeg = 'rotate(' + ((degValue < 180)? degValue : 0) + 'deg)';
+ this.$left_mask.css({transform: leftDeg, msTransform: leftDeg, mozTransform: leftDeg, webkitTransform: leftDeg});
+ this.$right_mask.css({transform: rightDeg, msTransform: rightDeg, mozTransform: rightDeg, webkitTransform: rightDeg});
+
+ this.$pie_value.html(Math.round(value) + '%');
+ },
+ isSet: function () {
+ return true;
+ },
+});
+
+/**
+ * FieldProgressBar
+ * parameters
+ * - 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({
+ 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];
+ }
+ this.max_value = this.recordData[this.nodeOptions.max_value] || 100;
+ this.readonly = this.nodeOptions.readonly || !this.nodeOptions.editable;
+ this.edit_max_value = this.nodeOptions.edit_max_value || false;
+ this.title = _t(this.attrs.title || this.nodeOptions.title) || '';
+ this.edit_on_click = !this.nodeOptions.edit_max_value || false;
+
+ this.write_mode = false;
+ },
+ _render: function () {
+ var self = this;
+ this._render_value();
+
+ if(!this.readonly) {
+ if(this.edit_on_click) {
+ this.$el.on('click', '.o_progress', function (e) {
+ var $target = $(e.currentTarget);
+ self.value = Math.floor((e.pageX - $target.offset().left) / $target.outerWidth() * self.max_value);
+ self._render_value();
+ self.on_update(self.value);
+ });
+ } else {
+ this.$el.on('click', function () {
+ if (!self.write_mode) {
+ var $input = $('<input>', {type: 'text', class: 'o_progressbar_value'});
+ $input.on('blur', _.bind(self.on_change_input, self));
+ self.$('.o_progressbar_value').replaceWith($input);
+ self.write_mode = true;
+ self._render_value();
+ }
+ });
+ }
+ }
+ return this._super();
+ },
+ on_update: function (value) {
+ if(!isNaN(value)) {
+ if (this.edit_max_value) {
+ try {
+ this.max_value = this._parseValue(value);
+ this._isValid = true;
+ } catch(e) {
+ this._isValid = false;
+ }
+ var changes = {};
+ changes[this.nodeOptions.max_value] = this.max_value;
+ this.trigger_up('field_changed', {
+ dataPointID: this.dataPointID,
+ changes: changes,
+ });
+ } else {
+ this._setValue(value);
+ }
+ }
+ },
+ on_change_input: function (e) {
+ var $input = $(e.target);
+ if(e.type === 'change' && !$input.is(':focus')) {
+ return;
+ }
+ if(isNaN($input.val())) {
+ this.do_warn(_t("Wrong value entered!"), _t("Only Integer Value should be valid."));
+ } else {
+ if(e.type === 'input') {
+ this._render_value($input.val());
+ if(parseFloat($input.val()) === 0) {
+ $input.select();
+ }
+ } else {
+ if(this.edit_max_value) {
+ this.max_value = $(e.target).val();
+ } else {
+ this.value = $(e.target).val() || 0;
+ }
+ var $div = $('<div>', {class: 'o_progressbar_value'});
+ this.$('.o_progressbar_value').replaceWith($div);
+ this.write_mode = false;
+
+ this._render_value();
+ this.on_update(this.edit_max_value ? this.max_value : this.value);
+ }
+ }
+ },
+ _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);
+ this.$('.o_progressbar_complete').css('width', widthComplete + '%');
+
+ if(!this.write_mode) {
+ if(max_value !== 100) {
+ this.$('.o_progressbar_value').html(utils.human_number(value) + " / " + utils.human_number(max_value));
+ } else {
+ this.$('.o_progressbar_value').html(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();
+ }
+ },
+ 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({
+ template: "toggle_button",
+ events: {
+ 'click': 'set_toggle_button'
+ },
+ supportedFieldTypes: ['boolean'],
+ _render: function () {
+ var class_name = this.value ? 'o_toggle_button_success' : 'text-muted';
+ this.$('i').attr('class', ('fa fa-circle ' + class_name));
+ },
+ set_toggle_button: function () {
+ var toggle_value = !this.value;
+ this._setValue(toggle_value);
+ if (this.mode === 'edit') {
+ this._render();
+ }
+ },
+ isSet: function () {
+ return true;
+ },
+});
+
+var JournalDashboardGraph = AbstractField.extend({
+ className: "o_dashboard_graph",
+ init: function () {
+ this._super.apply(this, arguments);
+ this.graph_type = this.attrs.graph_type;
+ this.data = JSON.parse(this.value);
+ },
+ start: function () {
+ nv.utils.windowResize(this._onResize.bind(this));
+ return this._super.apply(this, arguments);
+ },
+ destroy: function () {
+ nv.utils.offWindowResize(this._onResize);
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _customizeChart: function () {
+ if (this.graph_type === 'bar') {
+ // Add classes related to time on each bar of the bar chart
+ var bar_classes = _.map(this.data[0].values, function (v) {return v.type; });
+
+ _.each(this.$('.nv-bar'), function (v, k){
+ // classList doesn't work with phantomJS & addClass doesn't work with a SVG element
+ $(v).attr('class', $(v).attr('class') + ' ' + bar_classes[k]);
+ });
+ }
+ },
+ /**
+ * @private
+ */
+ _render: function () {
+ // note: the rendering of this widget is aynchronous as nvd3 does a
+ // setTimeout(0) before executing the callback given to addGraph
+ var self = this;
+ this.chart = null;
+ nv.addGraph(function () {
+ self.$svg = self.$el.append('<svg>');
+ switch(self.graph_type) {
+ case "line":
+ self.$svg.addClass('o_graph_linechart');
+
+ self.chart = nv.models.lineChart();
+ self.chart.forceY([0]);
+ self.chart.options({
+ x: function (d, u) { return u; },
+ margin: {'left': 0, 'right': 0, 'top': 0, 'bottom': 0},
+ showYAxis: false,
+ showLegend: false,
+ });
+ self.chart.xAxis.tickFormat(function (d) {
+ var label = '';
+ _.each(self.data, function (v){
+ if (v.values[d] && v.values[d].x){
+ label = v.values[d].x;
+ }
+ });
+ return label;
+ });
+ self.chart.yAxis.tickFormat(d3.format(',.2f'));
+
+ break;
+
+ case "bar":
+ self.$svg.addClass('o_graph_barchart');
+
+ self.chart = nv.models.discreteBarChart()
+ .x(function (d) { return d.label; })
+ .y(function (d) { return d.value; })
+ .showValues(false)
+ .showYAxis(false)
+ .margin({'left': 0, 'right': 0, 'top': 0, 'bottom': 40});
+
+ self.chart.xAxis.axisLabel(self.data[0].title);
+ self.chart.yAxis.tickFormat(d3.format(',.2f'));
+
+ break;
+ }
+ d3.select(self.$('svg')[0])
+ .datum(self.data)
+ .transition().duration(1200)
+ .call(self.chart);
+
+ self._customizeChart();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onResize: function () {
+ if (this.chart) {
+ this.chart.update();
+ this._customizeChart();
+ }
+ },
+
+});
+
+/**
+ * This widget is intended to be used on Text fields. It will provide Ace Editor
+ * for editing XML and Python.
+ */
+var AceEditor = AbstractField.extend({
+ template: "AceEditor",
+ willStart: function () {
+ if (!window.ace && !this.loadJS_def) {
+ this.loadJS_def = ajax.loadJS('/web/static/lib/ace/ace.odoo-custom.js').then(function () {
+ return $.when(ajax.loadJS('/web/static/lib/ace/mode-python.js'),
+ ajax.loadJS('/web/static/lib/ace/mode-xml.js')
+ );
+ });
+ }
+ return $.when(this._super(), this.loadJS_def);
+ },
+ start: function () {
+ this._startAce(this.$('.ace-view-editor')[0]);
+ return this._super();
+ },
+ destroy: function () {
+ if (this.aceEditor) {
+ this.aceEditor.destroy();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _renderEdit: function () {
+ var value = this._formatValue(this.value);
+ if (value !== this.lastValue) {
+ this.aceSession.setValue(this._formatValue(this.value));
+ }
+ },
+ _renderReadonly: function () {
+ this.aceSession.setValue(this._formatValue(this.value));
+ },
+ _setValue: function (value) {
+ this.lastValue = value;
+ this._super(value);
+ },
+ _startAce: function (node) {
+ var self = this;
+ 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,
+ });
+ this.aceEditor.on("change", function () {
+ if (self.aceSession.getUndoManager().hasUndo()) {
+ self._setValue(self.aceSession.getValue());
+ }
+ });
+ },
+});
+
+return {
+ EmailWidget: EmailWidget,
+ FieldBinaryFile: FieldBinaryFile,
+ FieldBinaryImage: FieldBinaryImage,
+ FieldBoolean: FieldBoolean,
+ FieldBooleanButton: FieldBooleanButton,
+ FieldChar: FieldChar,
+ FieldDate: FieldDate,
+ FieldDateTime: FieldDateTime,
+ FieldFloat: FieldFloat,
+ FieldFloatTime: FieldFloatTime,
+ FieldHtml: FieldHtml,
+ FieldID: FieldID,
+ FieldInteger: FieldInteger,
+ FieldMonetary: FieldMonetary,
+ FieldPercentPie: FieldPercentPie,
+ FieldPhone: FieldPhone,
+ FieldProgressBar: FieldProgressBar,
+ FieldText: FieldText,
+ FieldToggleBoolean: FieldToggleBoolean,
+ HandleWidget: HandleWidget,
+ InputField: InputField,
+ AttachmentImage: AttachmentImage,
+ LabelSelection: LabelSelection,
+ StateSelectionWidget: StateSelectionWidget,
+ PriorityWidget: PriorityWidget,
+ StatInfo: StatInfo,
+ UrlWidget: UrlWidget,
+ JournalDashboardGraph: JournalDashboardGraph,
+ AceEditor: AceEditor,
+};
+
+});
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 00000000000..0bfddd03098
--- /dev/null
+++ b/addons/web/static/src/js/fields/field_registry.js
@@ -0,0 +1,66 @@
+odoo.define('web.field_registry', function (require) {
+"use strict";
+
+var Registry = require('web.Registry');
+
+return new Registry();
+});
+
+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');
+
+
+// Basic fields
+registry
+ .add('abstract', AbstractField)
+ .add('input', basic_fields.InputField)
+ .add('integer', basic_fields.FieldInteger)
+ .add('html', basic_fields.FieldHtml)
+ .add('boolean', basic_fields.FieldBoolean)
+ .add('date', basic_fields.FieldDate)
+ .add('datetime', basic_fields.FieldDateTime)
+ .add('text', basic_fields.FieldText)
+ .add('float', basic_fields.FieldFloat)
+ .add('char', basic_fields.FieldChar)
+ .add('handle', basic_fields.HandleWidget)
+ .add('email', basic_fields.EmailWidget)
+ .add('phone', basic_fields.FieldPhone)
+ .add('url', basic_fields.UrlWidget)
+ .add('image', basic_fields.FieldBinaryImage)
+ .add('binary', basic_fields.FieldBinaryFile)
+ .add('monetary', basic_fields.FieldMonetary)
+ .add('priority', basic_fields.PriorityWidget)
+ .add('attachment_image', basic_fields.AttachmentImage)
+ .add('label_selection', basic_fields.LabelSelection)
+ .add('state_selection', basic_fields.StateSelectionWidget)
+ .add('boolean_button', basic_fields.FieldBooleanButton)
+ .add('id', basic_fields.FieldID)
+ .add('statinfo', basic_fields.StatInfo)
+ .add('percentpie', basic_fields.FieldPercentPie)
+ .add('float_time', basic_fields.FieldFloatTime)
+ .add('progressbar', basic_fields.FieldProgressBar)
+ .add('toggle_button', basic_fields.FieldToggleBoolean)
+ .add('dashboard_graph', basic_fields.JournalDashboardGraph)
+ .add('ace', basic_fields.AceEditor);
+
+// Relational fields
+registry
+ .add('selection', relational_fields.FieldSelection)
+ .add('radio', relational_fields.FieldRadio)
+ .add('many2one', relational_fields.FieldMany2One)
+ .add('list.many2one', relational_fields.ListFieldMany2One)
+ .add('kanban.many2one', relational_fields.KanbanFieldMany2One)
+ .add('many2many', relational_fields.FieldMany2Many)
+ .add('many2many_tags', relational_fields.FieldMany2ManyTags)
+ .add('many2many_checkboxes', relational_fields.FieldMany2ManyCheckBoxes)
+ .add('form.many2many_tags', relational_fields.FormFieldMany2ManyTags)
+ .add('one2many', relational_fields.FieldOne2Many)
+ .add('statusbar', relational_fields.FieldStatus)
+ .add('one2many_list', relational_fields.FieldOne2Many);
+
+}); \ No newline at end of file
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 00000000000..3b06af9efc4
--- /dev/null
+++ b/addons/web/static/src/js/fields/field_utils.js
@@ -0,0 +1,240 @@
+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".
+ */
+
+var core = require('web.core');
+var session = require('web.session');
+var time = require('web.time');
+var utils = require('web.utils');
+
+function format_boolean(value) {
+ var $input = $('<input type="checkbox">')
+ .prop('checked', value)
+ .prop('disabled', true);
+ return $('<div>')
+ .addClass('o_checkbox')
+ .append($input)
+ .append($('<span>'));
+}
+
+function format_char(value) {
+ return typeof value === 'string' ? value : '';
+}
+
+function format_date(value) {
+ var l10n = core._t.database.parameters;
+ var date_format = time.strftime_to_moment_format(l10n.date_format);
+ return value && moment(time.str_to_date(value)).format(date_format);
+}
+
+function format_datetime(value) {
+ var l10n = core._t.database.parameters;
+ var date_format = time.strftime_to_moment_format(l10n.date_format);
+ var time_format = time.strftime_to_moment_format(l10n.time_format);
+ var datetime_format = date_format + ' ' + time_format;
+ return value && moment(time.str_to_datetime(value)).format(datetime_format);
+}
+
+// Format a float, according to the local settings
+// Params:
+// * value: a number describing the raw value of the float
+// * field [optional]: a description of a field, that may contains extra
+// information, such as a given precision
+function format_float(value, field) {
+ var l10n = core._t.database.parameters;
+ var precision = (field && field.digits) ? field.digits[1] : 2;
+ var formatted = _.str.sprintf('%.' + precision + 'f', value || 0).split('.');
+ formatted[0] = utils.insert_thousand_seps(formatted[0]);
+ return formatted.join(l10n.decimal_point);
+}
+
+function format_float_time(value) {
+ var pattern = '%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);
+}
+
+
+function format_id(value) {
+ return value ? value.toString() : false;
+}
+
+function format_integer(value) {
+ 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 "";
+ }
+ return utils.insert_thousand_seps(_.str.sprintf('%d', value));
+}
+
+function format_many2one(value) {
+ return value && (_.isArray(value) ? value[1] : value.data.display_name) || '';
+}
+
+function format_many2many(value) {
+ var names = _.map(value.data, function(p) {
+ return p.data.display_name;
+ });
+ return names.join(', ');
+}
+
+function format_monetary(value, field, options) {
+ options = options || {};
+ 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;
+ }
+ var currency = session.get_currency(currency_id);
+
+ var digits_precision = (currency && currency.digits) || [69,2];
+ var precision = digits_precision[1];
+ var formatted = _.str.sprintf('%.' + precision + 'f', value || 0).split('.');
+ formatted[0] = utils.insert_thousand_seps(formatted[0]);
+ var l10n = core._t.database.parameters;
+ var formatted_value = formatted.join(l10n.decimal_point);
+
+ if (!currency) {
+ return formatted_value;
+ }
+ if (currency.position === "after") {
+ return formatted_value += '&nbsp;' + currency.symbol;
+ } else {
+ return currency.symbol + '&nbsp;' + formatted_value;
+ }
+}
+
+function format_field(value, field, options) {
+ var formatter = result['format_' + field.type];
+ return formatter(value, field, options);
+}
+
+function format_selection(value, field) {
+ if (!value) {
+ return '';
+ }
+ var val = _.find(field.selection, function(option) {
+ return option[0] === value;
+ });
+ return val[1];
+}
+
+function parse_field(value, field) {
+ var parser = result['parse_' + field.type];
+ return parser(value, field);
+}
+
+
+function parse_float(value) {
+ value = value.replace(new RegExp(core._t.database.parameters.thousands_sep, "g"), '');
+ value = value.replace(core._t.database.parameters.decimal_point, '.');
+ var parsed = Number(value);
+ if (isNaN(parsed)) {
+ throw new Error(_.str.sprintf(core._t("'%s' is not a correct float"), value));
+ }
+ return parsed;
+}
+
+function parse_float_time(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 * parse_float(value);
+ var hours = parse_integer(float_time_pair[0]);
+ var minutes = parse_integer(float_time_pair[1]);
+ return factor * (hours + (minutes / 60));
+}
+
+function parse_integer(value) {
+ value = value.replace(new RegExp(core._t.database.parameters.thousands_sep, "g"), '');
+ var parsed = Number(value);
+ // do not accept not numbers or float values
+ if (isNaN(parsed) || parsed % 1) {
+ throw new Error(_.str.sprintf(core._t("'%s' is not a correct integer"), value));
+ }
+ return parsed;
+}
+
+function parse_monetary(formatted_value) {
+ var l10n = core._t.database.parameters;
+ var value = formatted_value.replace(l10n.thousands_sep, '')
+ .replace(l10n.decimal_point, '.')
+ .match(/([0-9]+(\.[0-9]*)?)/)[1];
+ return parseFloat(value);
+}
+
+function identity(value) {
+ return value;
+}
+
+var result = {
+ format_binary: identity, // todo
+ format_boolean: format_boolean,
+ format_char: format_char,
+ format_date: format_date,
+ format_datetime: format_datetime,
+ format_float: format_float,
+ format_float_time: format_float_time,
+ format_html: identity, // todo
+ format_id: format_id,
+ format_integer: format_integer,
+ format_many2many: format_many2many,
+ format_many2one: format_many2one,
+ format_monetary: format_monetary,
+ format_one2many: identity, // todo
+ format_reference: identity, // todo
+ format_selection: format_selection,
+ format_text: format_char,
+
+ format_field: format_field,
+
+ parse_binary: identity, // todo
+ parse_boolean: identity, // todo
+ parse_char: identity, // todo
+ parse_date: identity, // todo
+ parse_datetime: identity, // todo
+ parse_float: parse_float,
+ parse_float_time: parse_float_time,
+ parse_html: identity, // todo
+ parse_id: identity,
+ parse_integer: parse_integer,
+ parse_many2many: identity, // todo
+ parse_many2one: identity,
+ parse_monetary: parse_monetary,
+ parse_one2many: identity,
+ parse_reference: identity, // todo
+ parse_selection: identity, // todo
+ parse_text: identity, // todo
+
+ parse_field: parse_field,
+
+};
+
+return result;
+
+});
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 00000000000..4282d78faf7
--- /dev/null
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -0,0 +1,1493 @@
+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 concurrency = require('web.concurrency');
+var config = require('web.config');
+var ControlPanel = require('web.ControlPanel');
+var dialogs = require('web.view_dialogs');
+var core = require('web.core');
+var data = require('web.data');
+var Dialog = require('web.Dialog');
+var field_utils = require('web.field_utils');
+var KanbanRenderer = require('web.KanbanRenderer');
+var ListRenderer = require('web.ListRenderer');
+var mixins = require('web.mixins');
+var Pager = require('web.Pager');
+var pyeval = require('web.pyeval');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+/**
+ * Abstract widget to handle relational fields
+ */
+var AbstractRelationalField = AbstractField.extend({
+ init: function (parent, name, record) {
+ this._super.apply(this, arguments);
+ // 'domain' and 'context' that should be used by the field widget. It
+ // can come from the attrs, and is mostly useful for rpcs
+ this.domain = this.attrs.domain || this.field.domain || [];
+ this.context = this.attrs.context || this.field.context || {};
+
+ // todo: remove this
+ this.record = record;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _reset: function (record) {
+ this.record = record;
+ this._super.apply(this, arguments);
+ },
+});
+
+//------------------------------------------------------------------------------
+// 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("Create a %s"), this.name),
+ size: 'medium',
+ buttons: [{
+ text: _t('Create'),
+ classes: 'btn-primary',
+ click: function () {
+ if (this.$("input").val() !== ''){
+ this.trigger_up('quick_create', { value: this.$('input').val() });
+ this.close();
+ } else {
+ this.$("input").focus();
+ }
+ },
+ }, {
+ text: _t('Create and edit'),
+ classes: 'btn-primary',
+ close: true,
+ click: function () {
+ this.trigger_up('search_create_popup', {
+ view_type: 'form',
+ value: this.$('input').val(),
+ });
+ },
+ }, {
+ text: _t('Cancel'),
+ close: true,
+ }],
+ });
+ },
+ start: function () {
+ this.$("p").text(_.str.sprintf(_t("You are creating a new %s, are you sure it does not exist yet?"), this.name));
+ this.$("input").val(this.value);
+ },
+});
+
+var FieldMany2One = AbstractRelationalField.extend(mixins.ServicesMixin, {
+ template: 'FieldMany2One',
+ custom_events: {
+ 'quick_create': '_onQuickCreate',
+ 'search_create_popup': '_onSearchCreatePopup',
+ },
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click .o_form_input': '_onInputClick',
+ 'focusout .o_form_input': '_onInputFocusout',
+ 'keyup .o_form_input': '_onInputKeyup',
+ 'click .o_external_button': '_onExternalButtonClick',
+ 'click': '_onClick',
+ }),
+
+ init: function () {
+ this._super.apply(this, arguments);
+ this.limit = 7;
+ this.orderer = new concurrency.DropMisordered();
+ this.can_create = 'can_create' in this.attrs ? this.attrs.can_create : true;
+ this.can_write = 'can_write' in this.attrs ? this.attrs.can_write : true;
+ this.nodeOptions = _.defaults(this.nodeOptions, {
+ quick_create: true,
+ });
+ this.m2o_value = field_utils.format_many2one(this.value);
+ },
+ start: function () {
+ // what is 'this.floating' for? add a comment!
+ this.floating = false;
+
+ this.$input = this.$('.o_form_input');
+ this.$external_button = this.$('.o_external_button');
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ activate: function () {
+ this.$input.focus();
+ setTimeout(this.$input.select.bind(this.$input), 0);
+ },
+ reinitialize: function (value) {
+ this.floating = false;
+ this._setValue(value);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _bindAutoComplete: function () {
+ var self = this;
+ this.$input.autocomplete({
+ source: function (req, resp) {
+ self._search(req.term).then(function (result) {
+ resp(result);
+ });
+ },
+ select: function (event, ui) {
+ 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
+ },
+ autoFocus: true,
+ html: true,
+ minLength: 0,
+ delay: 200,
+ });
+ this.$input.autocomplete("option", "position", { my : "left top", at: "left bottom" });
+ this.autocomplete_bound = true;
+ },
+ /**
+ * @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
+ */
+ _focus: function () {
+ if (this.mode === "edit") {
+ this.$input.focus();
+ }
+ },
+ /**
+ * @private
+ * @returns {Array}
+ */
+ _getSearchBlacklist: function () {
+ return [];
+ },
+ /**
+ * @private
+ * @param {string} name
+ */
+ _quickCreate: function (name) {
+ var self = this;
+ var slow_create = this._searchCreatePopup.bind(this, "form", false, this._createContext(name));
+ if (this.nodeOptions.quick_create) {
+ this.trigger_up('name_create', {
+ model: this.field.relation,
+ name: name,
+ context: data.build_context(this.record, this.context),
+ on_success: function (result) {
+ if (self.mode === "edit") {
+ self.reinitialize({id: result[0], display_name: result[1]});
+ }
+ },
+ on_fail: slow_create,
+ });
+ } else {
+ slow_create();
+ }
+ },
+ /**
+ * @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 patner, 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 noone tries to uses this mechanism to do
+ // something else.
+ if (this.field.__always_reload) {
+ value = value.split('\n')[0];
+ }
+ this.$input.val(value);
+ if (!this.autocomplete_bound) {
+ this._bindAutoComplete();
+ }
+ this._updateExternalButton();
+ },
+ /**
+ * @private
+ */
+ _renderReadonly: function () {
+ var value = _.escape((this.m2o_value || "").trim()).split("\n").join("<br/>");
+ this.$el.html(value);
+ if (!this.nodeOptions.no_open) {
+ this.$el.attr('href', '#');
+ this.$el.addClass('o_form_uri');
+ }
+ },
+ /**
+ * @private
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ this.m2o_value = field_utils.format_many2one(this.value);
+ },
+ /**
+ * @private
+ * @param {string} search_val
+ * @returns {Deferred}
+ */
+ _search: function (search_val) {
+ var self = this;
+ var def = $.Deferred();
+ this.orderer.add(def);
+
+ var exclusion_domain = [];
+ var blacklisted_ids = this._getSearchBlacklist();
+ if (blacklisted_ids.length > 0) {
+ exclusion_domain.push(['id', 'not in', blacklisted_ids]);
+ }
+ var domain = new data.CompoundDomain(data.build_domain(this.record, this.domain), exclusion_domain);
+ this.trigger_up('name_search', {
+ model: this.field.relation,
+ search_val: search_val,
+ domain: pyeval.eval('domain', domain),
+ operator: 'ilike',
+ limit: this.limit + 1,
+ on_success: function (result) {
+ // possible selections for the m2o
+ var values = _.map(result, function (x) {
+ x[1] = x[1].split("\n")[0];
+ return {
+ label: _.str.escapeHTML(x[1].trim()) || data.noDisplayContent,
+ value: x[1],
+ name: x[1],
+ id: x[0],
+ };
+ });
+
+ // search more... if more results than limit
+ if (values.length > self.limit) {
+ values = values.slice(0, self.limit);
+ values.push({
+ label: _t("Search More..."),
+ action: function () {
+ self.trigger_up('name_search', {
+ model: self.field.relation,
+ search_val: search_val,
+ domain: pyeval.eval('domain', domain),
+ operator: 'ilike',
+ limit: 160,
+ on_success: self._searchCreatePopup.bind(self, "search"),
+ });
+ },
+ classname: 'o_m2o_dropdown_option',
+ });
+ }
+ var create_enabled = self.can_create && !self.nodeOptions.no_create;
+ // quick create
+ var raw_result = _.map(result, function (x) { return x[1]; });
+ if (create_enabled && !self.nodeOptions.no_quick_create &&
+ search_val.length > 0 && !_.contains(raw_result, search_val)) {
+ values.push({
+ label: _.str.sprintf(_t('Create "<strong>%s</strong>"'),
+ $('<span />').text(search_val).html()),
+ action: self._quickCreate.bind(self, search_val),
+ classname: 'o_m2o_dropdown_option'
+ });
+ }
+ // create and edit ...
+ if (create_enabled && !self.nodeOptions.no_create_edit) {
+ values.push({
+ label: _t("Create and Edit..."),
+ action: self._searchCreatePopup.bind(self, "form", false, self._createContext(search_val)),
+ classname: 'o_m2o_dropdown_option',
+ });
+ } else if (values.length === 0) {
+ values.push({
+ label: _t("No results to show..."),
+ });
+ }
+
+ def.resolve(values);
+ },
+ });
+
+ return def;
+ },
+ /**
+ * all search/create popup handling
+ *
+ * @private
+ * @param {any} view
+ * @param {any} ids
+ * @param {any} context
+ */
+ _searchCreatePopup: function (view, ids, context) {
+ var self = this;
+ new dialogs.SelectCreateDialog(this, _.extend({}, this.nodeOptions, {
+ res_model: this.field.relation,
+ domain: data.build_domain(this.record, this.domain),
+ context: new data.CompoundContext(data.build_context(this.record, this.context), context || {}),
+ title: (view === 'search' ? _t("Search: ") : _t("Create: ")) + this.string,
+ initial_ids: ids ? _.map(ids, function (x) { return x[0]; }) : undefined,
+ initial_view: view,
+ disable_multiple_selection: true,
+ on_selected: function (element_ids) {
+ self.reinitialize({id: element_ids[0]});
+ self._focus();
+ }
+ })).open();
+ },
+ /**
+ * @private
+ */
+ _updateExternalButton: function () {
+ var has_external_button = !this.nodeOptions.no_open && !this.floating && this.isSet();
+ this.$external_button.toggle(has_external_button);
+ this.$el.toggleClass('o_with_button', has_external_button);
+ },
+
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onClick: function (event) {
+ var self = this;
+ if (this.mode === 'readonly' && !this.nodeOptions.no_open) {
+ event.preventDefault();
+ event.stopPropagation();
+ var context = data.build_context(this.record, this.context);
+ var args = [[this.value.res_id], context.eval()];
+ this.performModelRPC(this.field.relation, 'get_formview_action', args)
+ .then(function (action) {
+ self.trigger_up('do_action', {action: action});
+ });
+ }
+ },
+ /**
+ * @private
+ */
+ _onExternalButtonClick: function () {
+ if (!this.value) {
+ this.focus();
+ return;
+ }
+ var self = this;
+ var context = data.build_context(this.record, this.context);
+ var model = this.field.relation;
+ var method = 'get_formview_id';
+ var args = [[this.value.res_id], context.eval()];
+ this.performModelRPC(model, method, args).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 () {
+ self.trigger_up('reload', {db_id: self.value.id});
+ },
+ }).open();
+ });
+ },
+ /**
+ * @private
+ */
+ _onInputClick: function () {
+ if (this.$input.autocomplete("widget").is(":visible")) {
+ this.$input.autocomplete("close");
+ } else {
+ this.$input.autocomplete("search", "");
+ }
+ },
+ /**
+ * @private
+ */
+ _onInputFocusout: function () {
+ if (this.can_create && this.floating) {
+ new M2ODialog(this, this.string, this.$input.val()).open();
+ }
+ },
+ /**
+ * @private
+ */
+ _onInputKeyup: function () {
+ if (this.$input.val() === "") {
+ this.reinitialize(false);
+ } else if (this.m2o_value !== this.$input.val()) {
+ this.floating = true;
+ this._updateExternalButton();
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onSearchCreatePopup: function (event) {
+ var data = event.data;
+ this._searchCreatePopup(data.view_type, false, this._createContext(data.value));
+ },
+});
+
+var ListFieldMany2One = FieldMany2One.extend({
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _renderReadonly: function () {
+ this.$el.text(this.m2o_value);
+ },
+});
+
+var KanbanFieldMany2One = AbstractRelationalField.extend({
+ tagName: 'span',
+ init: function () {
+ this._super.apply(this, arguments);
+ this.m2o_value = field_utils.format_many2one(this.value);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _render: function () {
+ this.$el.text(this.m2o_value);
+ },
+});
+
+//------------------------------------------------------------------------------
+// X2Many widgets
+//------------------------------------------------------------------------------
+
+var FieldX2Many = AbstractRelationalField.extend({
+ tagName: 'div',
+ custom_events: _.extend({}, AbstractRelationalField.prototype.custom_events, {
+ field_changed: '_onFieldChanged',
+ kanban_record_delete: '_onDeleteRecord',
+ list_record_delete: '_onDeleteRecord',
+ add_record: '_onAddRecord',
+ open_record: '_onOpenRecord',
+ toggle_column_order: '_onToggleColumnOrder',
+ }),
+
+ /**
+ * @override
+ */
+ init: function (parent, name, record, options) {
+ this._super.apply(this, arguments);
+ this.operations = [];
+ this.isReadonly = this.mode === 'readonly';
+ this.view = this.field.views.tree || this.field.views.kanban;
+ this.activeActions = {};
+ var arch = this.view && this.view.arch;
+ if (arch) {
+ this.activeActions.create = arch.attrs.create ?
+ JSON.parse(arch.attrs.create) :
+ true;
+ this.editable = arch.attrs.editable;
+ }
+ this.widgets_registry = options && options.widgets_registry;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ return this._renderControlPanel().then(this._super.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * This function is meant to be overriden by the One2ManyField.
+ *
+ * @private
+ * @returns {Boolean} true if the 'Add an item' row should be available in
+ * the list renderer, false otherwise
+ */
+ _hasCreateLine: function () {
+ return !this.isReadonly;
+ },
+ /**
+ * Instanciates or updates the adequate renderer.
+ *
+ * @override
+ * @private
+ * @returns {Deferred}
+ */
+ _render: function () {
+ if (!this.view) {
+ return this._super();
+ }
+ if (this.renderer) {
+ this.renderer.updateState(this.value);
+ this.pager.updateState({ size: this.value.count });
+ return $.when();
+ }
+ var arch = this.view.arch;
+ if (arch.tag === 'tree') {
+ this.renderer = new ListRenderer(this, this.value, {
+ arch: arch,
+ mode: this.mode,
+ addCreateLine: this._hasCreateLine(),
+ addTrashIcon: !this.isReadonly,
+ });
+ }
+ if (arch.tag === 'kanban') {
+ var record_options = {
+ editable: false,
+ deletable: false,
+ model: this.value.model, // required by includes of KanbanRecord
+ read_only_mode: this.isReadonly,
+ };
+ this.renderer = new KanbanRenderer(this, this.value, {
+ arch: arch,
+ record_options: record_options
+ });
+ }
+ return this.renderer ? this.renderer.appendTo(this.$el) : this._super();
+ },
+ /**
+ * Instanciates a control panel with the appropriate buttons and a pager.
+ * Prepends the control panel's $el to this widget's $el.
+ *
+ * @private
+ * @returns {Deferred}
+ */
+ _renderControlPanel: function () {
+ if (!this.view) {
+ return $.when();
+ }
+ var self = this;
+ var defs = [];
+ this.control_panel = new ControlPanel(this, "X2ManyControlPanel");
+ this.pager = new Pager(this, this.value.count, this.value.offset + 1, this.value.limit, {
+ single_page_hidden: true,
+ withAccessKey: false,
+ });
+ this.pager.on('pager_changed', this, function (new_state) {
+ this.trigger_up('load', {
+ id: this.value.id,
+ limit: new_state.limit,
+ offset: new_state.current_min - 1,
+ on_success: function (value) {
+ self.value = value;
+ self._render();
+ },
+ });
+ });
+ this._renderButtons();
+ defs.push(this.pager.appendTo($('<div>'))); // start the pager
+ defs.push(this.control_panel.prependTo(this.$el));
+ return $.when.apply($, defs).then(function () {
+ self.control_panel.update({
+ cp_content: {
+ $buttons: self.$buttons,
+ $pager: self.pager.$el,
+ }
+ });
+ });
+ },
+ /**
+ * Renders the buttons and sets this.$buttons.
+ *
+ * @private
+ */
+ _renderButtons: function () {
+ if (!this.isReadonly && this.view.arch.tag === 'kanban') {
+ var options = { create_text: this.nodeOptions.create_text };
+ this.$buttons = $(qweb.render('KanbanView.buttons', options));
+ this.$buttons.on('click', 'button.o-kanban-button-new', this._onAddRecord.bind(this));
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the user clicks on the 'Add an item' 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}
+ */
+ _onDeleteRecord: function (event) {
+ event.stopPropagation();
+ this._setValue({
+ operation: 'REMOVE',
+ id: event.data.id
+ });
+ },
+ /**
+ * Updates the given record with the changes.
+ *
+ * @private
+ * @param {OdooEvent}
+ */
+ _onFieldChanged: function (event) {
+ if (event.target === this) {
+ return;
+ }
+ event.stopPropagation();
+ // changes occured in an editable list
+ var changes = event.data.changes;
+ if (Object.keys(changes).length) {
+ this._setValue({
+ operation: 'UPDATE',
+ id: event.data.dataPointID,
+ data: changes
+ });
+ }
+ },
+ /**
+ * Intercepts the 'open_record' event to edit its data and lets it bubble up
+ * to the form view.
+ *
+ * @private
+ */
+ _onOpenRecord: function (event) {
+ _.extend(event.data, {
+ context: data.build_context(this.record, this.context),
+ domain: data.build_context(this.record, this.domain),
+ form_view: this.field.views && this.field.views.form,
+ readonly: this.mode === 'readonly',
+ string: this.string,
+ });
+ },
+ /**
+ * Adds field name information to the event, so that the view upstream is
+ * aware of which widgets it has to redraw.
+ *
+ * @private
+ * @param {OdooEvent}
+ */
+ _onToggleColumnOrder: function (event) {
+ event.data.field = this.name;
+ },
+});
+
+var FieldOne2Many = FieldX2Many.extend({
+ className: 'o_form_field_one2many',
+ supportedFieldTypes: ['one2many'],
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+ /**
+ * @override
+ * @param {Object} record
+ * @param {OdooEvent} [event] an event that triggered the reset action
+ * @returns {Deferred}
+ */
+ reset: function (record, event) {
+ var self = this;
+ if (event && event.target === this && self.view.arch.tag === 'tree' && this.editable) {
+ var command = event.data.changes[this.name];
+ if (command.operation === 'UPDATE') {
+ var fieldsChanged = _.keys(command.data);
+ this.renderer.confirmChange(record.data[this.name], command.id, fieldsChanged);
+ return $.when();
+ }
+ }
+ return this._super.apply(this, arguments).then(function () {
+ if (event && event.target === self && self.view.arch.tag === 'tree') {
+ if (event.data.changes[self.name].operation === 'CREATE') {
+ // todo: get last record if editable = bottom
+ var newID = self.value.data[0].id;
+ self.renderer.editRecord(newID);
+ }
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Overrides enable the 'Add an item' feature if the 'create' action is
+ * available.
+ *
+ * @override
+ * @private
+ */
+ _hasCreateLine: function () {
+ return this._super.apply(this, arguments) && this.activeActions.create;
+ },
+ /**
+ * Overrides to only render the buttons if the 'create' action is available.
+ *
+ * @override
+ * @private
+ */
+ _renderButtons: function () {
+ if (this.activeActions.create) {
+ this._super.apply(this, arguments);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Opens a FormViewDialog to allow creating a new record for a one2many.
+ *
+ * @override
+ * @private
+ * @param {OdooEvent|MouseEvent} event 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 (event) {
+ // we don't want interference with the components upstream.
+ event.stopPropagation();
+
+ if (this.editable) {
+ this._setValue({
+ operation: 'CREATE',
+ position: this.editable,
+ });
+ } else {
+ this.trigger_up('add_one2many_record', {
+ domain: data.build_domain(this.record, this.domain),
+ context: data.build_context(this.record, this.context),
+ field: this.field,
+ on_save: function (record) {
+ this._setValue({
+ operation: 'ADD',
+ id: record.id
+ });
+ }.bind(this),
+ });
+ }
+ },
+ /**
+ * 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.
+ *
+ * @override
+ * @private
+ * @param {OdooEvent}
+ */
+ _onOpenRecord: function (event) {
+ var self = this;
+ this._super.apply(this, arguments);
+ _.extend(event.data, {
+ on_save: function (record) {
+ // update in a form view opened from the o2m (kanban or non editable list)
+ var relRecord = _.find(self.value.data, function (d) {
+ return d.res_id === record.res_id;
+ });
+ self._setValue({
+ operation: 'UPDATE',
+ id: relRecord.id,
+ data: record.data,
+ });
+ },
+ });
+ },
+});
+
+var FieldMany2Many = FieldX2Many.extend({
+ className: 'o_form_field_many2many',
+ supportedFieldTypes: ['many2many'],
+
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.nodeOptions = _.defaults(this.nodeOptions, {
+ create_text: _t('Add'),
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Opens a SelectCreateDialog.
+ *
+ * @override
+ * @private
+ * @param {OdooEvent}
+ */
+ _onAddRecord: function () {
+ var self = this;
+ var domain = data.build_domain(this.record, this.domain);
+ new dialogs.SelectCreateDialog(this, {
+ res_model: this.field.relation,
+ domain: new data.CompoundDomain(domain, ["!", ["id", "in", this.value.res_ids]]),
+ context: data.build_context(this.record, this.context),
+ title: _t("Add: ") + this.string,
+ no_create: this.nodeOptions.no_create || !this.activeActions.create,
+ on_selected: function (res_ids) {
+ var new_ids = _.difference(res_ids, self.value.res_ids);
+ if (new_ids.length) {
+ var values = _.map(new_ids, function (id) {
+ return {id: id};
+ });
+ self._setValue({
+ operation: 'ADD_M2M',
+ ids: values
+ });
+ }
+ }
+ }).open();
+ },
+ /**
+ * Overrides the handler to reload the field when the subrecord is saved.
+ *
+ * @override
+ * @private
+ * @param {OdooEvent}
+ */
+ _onOpenRecord: function (event) {
+ this._super.apply(this, arguments);
+ _.extend(event.data, {
+ on_saved: this.trigger_up.bind(this, 'reload', {db_id: event.data.id}),
+ });
+ },
+});
+
+var FieldMany2ManyTags = AbstractRelationalField.extend({
+ tag_template: "FieldMany2ManyTag",
+ className: "o_form_field o_form_field_many2manytags",
+ replace_element: true,
+ supportedFieldTypes: ['many2many'],
+ custom_events: {
+ field_changed: '_onFieldChanged',
+ move_next: '_onMoveNext',
+ },
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click .o_delete': '_onDeleteTag',
+ 'keydown .o_form_field_many2one input': '_onKeyDown',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ activate: function () {
+ this.many2one.focus();
+ },
+ isSet: function () {
+ return !!this.value && this.value.count;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {any} data
+ */
+ _addTag: function (data) {
+ if (!_.contains(this.value.res_ids, data.id)) {
+ this._setValue({
+ operation: 'ADD_M2M',
+ ids: data
+ });
+ }
+ },
+ /**
+ * @private
+ * @param {any} id
+ */
+ _removeTag: function (id) {
+ var record = _.findWhere(this.value.data, {res_id: id});
+ this._setValue({
+ operation: 'REMOVE',
+ id: record.id
+ });
+ },
+ /**
+ * @private
+ */
+ _renderEdit: function () {
+ var self = this;
+ this._renderTags();
+ this.many2one = new FieldMany2One(this, this.name, this.record, {
+ idForLabel: this.idForLabel,
+ mode: 'edit',
+ });
+ // 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.nodeOptions.no_open = true;
+ this.many2one._getSearchBlacklist = function () {
+ return self.value.res_ids;
+ };
+ this.many2one.appendTo(this.$el);
+ },
+ /**
+ * @private
+ */
+ _renderReadonly: function () {
+ this._renderTags();
+ },
+ /**
+ * @private
+ */
+ _renderTags: function () {
+ var elements = this.value ? _.pluck(this.value.data, 'data') : [];
+ this.$el.html(qweb.render(this.tag_template, {
+ elements: elements,
+ readonly: this.mode === "readonly",
+ }));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onDeleteTag: function (event) {
+ this._removeTag($(event.target).parent().data('id'));
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onFieldChanged: function (event) {
+ if (event.target === this) {
+ return;
+ }
+ event.stopPropagation();
+ var new_value = event.data.changes[this.name];
+ if (new_value) {
+ this._addTag(new_value);
+ this.many2one.reinitialize(false);
+ }
+ },
+ /**
+ * @private
+ * @param {KeyboardEvent} event
+ */
+ _onKeyDown: function (event) {
+ if($(event.target).val() === "" && event.which === $.ui.keyCode.BACKSPACE) {
+ var $badges = this.$('.badge');
+ if($badges.length) {
+ this._removeTag($badges.last().data('id'));
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onMoveNext: function (event) {
+ if (event.target === this) {
+ return;
+ }
+ // Intercept event triggered up by the many2one, to prevent triggering it twice
+ event.stopPropagation();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onQuickCreate: function (event) {
+ this._quickCreate(event.data.value);
+ },
+});
+
+var FormFieldMany2ManyTags = FieldMany2ManyTags.extend({
+ events: _.extend({}, FieldMany2ManyTags.prototype.events, {
+ 'click .badge': '_onOpenColorPicker',
+ 'mousedown .o_colorpicker span': '_onUpdateColor',
+ 'focusout .o_colorpicker': '_onCloseColorPicker',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onCloseColorPicker: function (){
+ this.$color_picker.remove();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onOpenColorPicker: function (event) {
+ var tag_id = $(event.currentTarget).data('id');
+ var tag = _.findWhere(this.value.data, { res_id: tag_id });
+ if (tag && 'color' in tag.data) { // if there is a color field on the related model
+ this.$color_picker = $(qweb.render('FieldMany2ManyTag.colorpicker', {
+ 'widget': this,
+ 'tag_id': tag_id,
+ }));
+
+ $(event.currentTarget).append(this.$color_picker);
+ this.$color_picker.dropdown('toggle');
+ this.$color_picker.attr("tabindex", 1).focus();
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onUpdateColor: function (event) {
+ event.preventDefault();
+ var self = this;
+ var color = $(event.currentTarget).data('color');
+ var id = $(event.currentTarget).data('id');
+ var tag = self.$("span.badge[data-id='" + id + "']");
+ var current_color = tag.data('color');
+
+ if (color === current_color) { return; }
+
+ this.trigger_up('field_changed', {
+ dataPointID: _.findWhere(this.value.data, {res_id: id}).id,
+ changes: {
+ color: color,
+ },
+ force_save: true,
+ });
+ },
+});
+
+var FieldMany2ManyCheckBoxes = AbstractRelationalField.extend({
+ template: 'FieldMany2ManyCheckBoxes',
+ events: _.extend({}, AbstractRelationalField.prototype.events, {
+ change: '_onChange',
+ }),
+ supportedFieldTypes: ['many2many'],
+ init: function () {
+ this._super.apply(this, arguments);
+ this.m2mValues = this.field.__many2many_information;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _render: function () {
+ var self = this;
+ this._super.apply(this, arguments);
+ _.each(this.value, function (id) {
+ self.$('input[data-record-id="' + id + '"]').prop('checked', true);
+ });
+ },
+ /**
+ * @private
+ */
+ _renderReadonly: function () {
+ this.$("input").prop("disabled", true);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onChange: function () {
+ var ids = _.map(this.$('input:checked'), function (input) {
+ return $(input).data("record-id");
+ });
+ this._setValue({
+ operation: 'REPLACE_WITH',
+ ids: ids,
+ });
+ },
+});
+
+//------------------------------------------------------------------------------
+// Widgets handling both basic and relational fields (selection and Many2one)
+//------------------------------------------------------------------------------
+
+var FieldStatus = AbstractField.extend({
+ template: "FieldStatus",
+ className: 'o_statusbar_status',
+ events: {
+ 'click .o_arrow_button': '_onClickStage',
+ },
+ supportedFieldTypes: ['selection', 'many2one'],
+ init: function () {
+ this._super.apply(this, arguments);
+ this._setState();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ this._setState();
+ },
+ /**
+ * @private
+ */
+ _setState: function () {
+ if (this.field.type === 'many2one') {
+ this.status_information = this.field.__status_information;
+ var status = _.findWhere(this.status_information, {id: this.value.res_id});
+ if (status) {
+ status.selected = true;
+ }
+ } else {
+ var self = this;
+ this.status_information = _.map(this.field.selection, function (val) {
+ return { id: val[0], display_name: val[1], selected: val[0] === self.value };
+ });
+ }
+ },
+ /**
+ * @private
+ */
+ _render: function () {
+ var visible = this.attrs.statusbar_visible;
+ var selection_folded = [];
+ var selection_unfolded = [];
+ _.each(this.status_information, function (info) {
+ if (info.selected && (info.fold || visible && visible.indexOf(info.display_name) === -1)) {
+ selection_folded.push(info);
+ } else {
+ selection_unfolded.push(info);
+ }
+ });
+ var mobile = config.device.size_class <= config.device.SIZES.XS;
+ // grep: FieldStatus.content.mobile, FieldStatus.content.desktop
+ var content = qweb.render("FieldStatus.content." + (mobile ? 'mobile' : 'desktop'), {
+ selection_folded: selection_folded.reverse(),
+ selection_unfolded: selection_unfolded.reverse(),
+ clickable: !!this.attrs.clickable,
+ });
+ this.$el.html(content);
+ this.$('.disabled').prop('disabled', true);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Note that this function is debounced...
+ *
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onClickStage: _.debounce(function (event) {
+ var $stage = $(event.currentTarget);
+ var val;
+ if (this.field.type === "many2one") {
+ val = parseInt($stage.data("value"), 10);
+ } else {
+ val = $stage.data("value");
+ }
+ if (val) {
+ this._setValue(val);
+ }
+ }, 300, true),
+});
+
+/**
+ * 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({
+ template: 'FieldSelection',
+ supportedFieldTypes: ['selection', 'many2one'],
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'input': '_onInput',
+ }),
+ replace_element: true,
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.values = [];
+ if (this.field.type === 'many2one') {
+ this.values = this.field.__selection_information;
+ } else {
+ this.values = _.reject(this.field.selection, function (v) {
+ return v[0] === false && v[1] === '';
+ });
+ }
+ this.values = [[false, this.attrs.placeholder || '']].concat(this.values);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Format the value to a valid string
+ *
+ * @override
+ * @private
+ * @param {any} value
+ * @returns {string}
+ */
+ _formatValue: function (value) {
+ if (this.field.type === 'many2one') {
+ var options = _.extend({}, this.nodeOptions);
+ return field_utils.format_many2one(value, this.field, this.recordData, options);
+ } else {
+ return this._super(value);
+ }
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderEdit: function () {
+ this.$el.empty();
+ for (var i = 0 ; i < this.values.length ; i++) {
+ this.$el.append($('<option/>', {
+ value: JSON.stringify(this.values[i][0]),
+ html: this.values[i][1]
+ }));
+ }
+ this.$el.val(JSON.stringify(this._parseValue(this.value)));
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderReadonly: function () {
+ this.$el.empty().html(this._formatValue(this.value));
+ },
+ /**
+ * @override
+ * @private
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ if (this.field.type === 'many2one') {
+ this.value = this.value.data.id;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * The small slight difficulty is that we have to set the value differently
+ * depending on the field type.
+ * @private
+ */
+ _onInput: 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({
+ template: 'FieldRadio',
+ supportedFieldTypes: ['selection', 'many2one'],
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click input': '_onInputClick',
+ }),
+ /**
+ * @constructs FieldRadio
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ if (this.field.type === 'selection') {
+ this.values = this.field.selection || [];
+ } else if (this.field.type === 'many2one') {
+ this.values = _.map(this.field.__status_information, function (val) {
+ return [val.id, val.display_name];
+ });
+ }
+ this.unique_id = _.uniqueId("radio");
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @returns {boolean} always true
+ */
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @override
+ */
+ _renderEdit: function () {
+ this.$("input").prop("checked", false);
+ var key;
+ if (this.field.type === 'many2one') {
+ key = this._parseValue(this.value);
+ } else {
+ key = this.value;
+ }
+ this.$('input[data-value="' + key + '"]').prop('checked', true);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onInputClick: function (event) {
+ var res_id = $(event.target).data('value');
+ 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);
+ }
+ },
+});
+
+return {
+ FieldMany2One: FieldMany2One,
+ KanbanFieldMany2One: KanbanFieldMany2One,
+ ListFieldMany2One: ListFieldMany2One,
+
+ FieldOne2Many: FieldOne2Many,
+
+ FieldMany2Many: FieldMany2Many,
+ FieldMany2ManyCheckBoxes: FieldMany2ManyCheckBoxes,
+ FieldMany2ManyTags: FieldMany2ManyTags,
+ FormFieldMany2ManyTags: FormFieldMany2ManyTags,
+
+ FieldRadio: FieldRadio,
+ FieldSelection: FieldSelection,
+ FieldStatus: FieldStatus,
+};
+
+});
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 00000000000..8d72cecf391
--- /dev/null
+++ b/addons/web/static/src/js/fields/upgrade_fields.js
@@ -0,0 +1,177 @@
+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 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 {Deferred}
+ */
+ _confirmUpgrade: function () {
+ var args = [[["share", "=", false]]];
+ return this.performModelRPC('res.users', 'search_count', args).then(function (data) {
+ framework.redirect("https://www.odoo.com/odoo-enterprise/upgrade?num_users=" + data);
+ });
+ },
+ /**
+ * This function is meant to be overriden to insert the 'Enterprise' label
+ * JQuery node at the right place.
+ *
+ * @abstract
+ * @private
+ * @param {JQuery} the 'Enterprise' label to insert
+ */
+ _insertEnterpriseLabel: function ($enterprise_label) {},
+ /**
+ * Opens the Upgrade dialog.
+ *
+ * @private
+ */
+ _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,
+ },
+ ];
+
+ 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': "label label-primary oe_inline"
+ }));
+ },
+ /**
+ * This function is meant to be overriden to reset the $el to its initial
+ * state.
+ *
+ * @abstract
+ * @private
+ */
+ _resetValue: function () {},
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {MouseEvent}
+ */
+ _onInputClicked: function (event) {
+ if ($(event.currentTarget).prop("checked")) {
+ this._openDialog().on('closed', this, this._resetValue.bind(this));
+ }
+ },
+
+};
+
+var UpgradeBoolean = FieldBoolean.extend(AbstractFieldUpgrade, {
+ events: _.extend({}, FieldBoolean.prototype.events, {
+ 'click input': '_onInputClicked',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _insertEnterpriseLabel: function ($enterprise_label) {
+ this.$el.append('&nbsp;').append($enterprise_label);
+ },
+ /**
+ * @override
+ */
+ _resetValue: function () {
+ this.$input.prop("checked", false).change();
+ },
+});
+
+var UpgradeRadio = FieldRadio.extend(AbstractFieldUpgrade, {
+ events: _.extend({}, FieldRadio.prototype.events, {
+ 'click input:last': '_onInputClicked',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _insertEnterpriseLabel: function ($enterprise_label) {
+ this.$('label').last().append('&nbsp;').append($enterprise_label);
+ },
+ /**
+ * @override
+ */
+ _resetValue: function () {
+ this.$('input').first().prop("checked", true).click();
+ },
+});
+
+
+field_registry
+ .add('upgrade_boolean', UpgradeBoolean)
+ .add('upgrade_radio', UpgradeRadio);
+
+});