diff options
| author | Géry Debongnie <ged@odoo.com> | 2017-03-05 10:05:56 +0100 |
|---|---|---|
| committer | Aaron Bohy <aab@odoo.com> | 2017-04-11 19:44:38 +0200 |
| commit | 905e01921f3c3ef43ace6fc537d1d8c0d280002c (patch) | |
| tree | 265c43ab21c5401a5d9f0f3d64d86f1ac417451c /addons/web/static/src/js/fields | |
| parent | 54d4c73af4ad05c7640d28c1892247c9538b8017 (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.js | 328 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/basic_fields.js | 1300 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/field_registry.js | 66 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/field_utils.js | 240 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/relational_fields.js | 1493 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/upgrade_fields.js | 177 |
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) + "­" + 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 += ' ' + currency.symbol; + } else { + return currency.symbol + ' ' + 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(' ').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(' ').append($enterprise_label); + }, + /** + * @override + */ + _resetValue: function () { + this.$('input').first().prop("checked", true).click(); + }, +}); + + +field_registry + .add('upgrade_boolean', UpgradeBoolean) + .add('upgrade_radio', UpgradeRadio); + +}); |
