diff options
Diffstat (limited to 'addons/web/static/src/js/fields/basic_fields.js')
| -rw-r--r-- | addons/web/static/src/js/fields/basic_fields.js | 2117 |
1 files changed, 2117 insertions, 0 deletions
diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js new file mode 100644 index 00000000000..d2649195111 --- /dev/null +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -0,0 +1,2117 @@ +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 Domain = require('web.Domain'); +var DomainSelector = require('web.DomainSelector'); +var DomainSelectorDialog = require('web.DomainSelectorDialog'); +var field_utils = require('web.field_utils'); +var framework = require('web.framework'); +var session = require('web.session'); +var utils = require('web.utils'); +var view_dialogs = require('web.view_dialogs'); + +var qweb = core.qweb; +var _t = core._t; + +var DebouncedField = AbstractField.extend({ + events: _.extend({}, AbstractField.prototype.events, { + 'input': '_onInput', + 'change': '_onChange', + }), + /** + * for field widgets that may have a large number of field changes quickly, + * it could be a good idea to debounce the changes. In that case, this is + * the suggested value. + */ + DEBOUNCE: 1000, + + /** + * Override init to debounce the input changes to make sure they are not + * done too quickly. Note that this is done here and not on the prototype, + * so each inputfield has its own debounced function to work with. + * Also, if the debounce value is set to 0, no debouncing is done, which is + * really useful for the unit tests. + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + + if (this.DEBOUNCE && this.mode === 'edit') { + this._doDebouncedAction = _.debounce(this._doDebouncedAction.bind(this), this.DEBOUNCE); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Should return the current value of the field, in the DOM (for example, + * the content of the input) + * + * @abstract + * @private + * @returns {*} + */ + _getValue: function () { + }, + /** + * Notifies the outside world of the new value (checked from the DOM). + * This method is debounced so that onchanges are not triggered too quickly. + * + * @private + */ + _doDebouncedAction: function () { + this._setValue(this._getValue()); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * We immediately notify the outside world when this field confirms its + * changes. + * + * @private + */ + _onChange: function () { + this._setValue(this._getValue()); + }, + /** + * Called when the user is typing text -> By default this only calls a + * debounced method to notify the outside world of the changes. + * @see _doDebouncedAction + * + * @private + */ + _onInput: function () { + this._doDebouncedAction(); + }, +}); + +var InputField = DebouncedField.extend({ + /** + * The very purpose of this field is to be an input tag in edit mode. + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + if (this.mode === 'edit') { + this.tagName = 'input'; + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Automatically selects the content of the field. + * + * @override + */ + activate: function () { + this.$input.focus(); + setTimeout(this.$input.select.bind(this.$input), 0); + }, + /** + * Do not re-render this field if it was the origin of the onchange call. + * FIXME: make the onchange work on itself without disturbing the user typing + * + * @override + */ + reset: function (record, event) { + this._reset(record, event); + if (event && event.target === this) { + return $.when(); + } else { + return this._render(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @returns {string} the content of the input + */ + _getValue: function () { + return this.$input.val(); + }, + /** + * Formats an input element for edit mode. This is in a separate function so + * extending widgets can use it on their input without having input as tagName. + * + * @private + * @param {jQuery|undefined} $input + * The <input/> element to prepare and save as the $input attribute. + * If no element is given, the <input/> is created. + * @returns {jQuery} the prepared this.$input element + */ + _prepareInput: function ($input) { + this.$input = $input || $("<input/>"); + this.$input.addClass('o_form_input'); + this.$input.attr({ + type: 'text', + id: this.idForLabel, + placeholder: this.attrs.placeholder || "", + }); + // Save cursor position to restore it after updating value + var selectionStart = this.$input[0].selectionStart; + var selectionEnd = this.$input[0].selectionEnd; + this.$input.val(this._formatValue(this.value)); + this.$input[0].selectionStart = selectionStart; + this.$input[0].selectionEnd = selectionEnd; + return this.$input; + }, + /** + * Formats the HTML input tag for edit mode and stores selection status. + * + * @override + * @private + */ + _renderEdit: function () { + // Keep a reference to the input so $el can become something else + // without losing track of the actual input. + this._prepareInput(this.$el); + }, + /** + * Resets the content to the formated value in readonly mode. + * + * @override + * @private + */ + _renderReadonly: function () { + this.$el.html(this._formatValue(this.value)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Implement keyboard movements. Mostly useful for its environment, such + * as a list view. + * + * @override + * @private + * @param {any} event + */ + _onKeydown: function (event) { + var input = this.$input[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); + }, +}); + +var FieldChar = InputField.extend({ + supportedFieldTypes: ['char'], + tagName: 'span', +}); + +var FieldDate = InputField.extend({ + className: "o_form_field_date", + tagName: "span", + replace_element: true, + supportedFieldTypes: ['date'], + + /** + * In edit mode, instantiates a DateWidget datepicker and listen to changes. + * + * @override + */ + start: function () { + var self = this; + var def; + if (this.mode === 'edit') { + this.datewidget = this._makeDatePicker(); + this.datewidget.on('datetime_changed', this, function () { + var value = this._getDateWidgetValue(); + if ((!value && this.value) || (value && !value.isSame(this.value))) { + this._setValue(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)); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * return the datepicker value + * + * @private + */ + _getDateWidgetValue: function () { + return this.datewidget.get_value(); + }, + + /** + * Instantiates a new DateWidget datepicker. + * + * @private + */ + _makeDatePicker: function () { + return new datepicker.DateWidget(this, {defaultDate: this.value}); + }, + + /** + * Set the datepicker to the right value rather than the default one. + * + * @override + * @private + */ + _renderEdit: function () { + this.datewidget.set_value(this.value); + this.$input = this.datewidget.$input; + }, + +}); + +var FieldDateTime = FieldDate.extend({ + supportedFieldTypes: ['datetime'], + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * return the datepicker value + * + * @private + */ + _getDateWidgetValue: function () { + var value = this.datewidget.get_value(); + return value && value.add(-this.getSession().tzOffset, 'minutes'); + }, + + /** + * Instantiates a new DateTimeWidget datepicker rather than DateWidget. + * + * @override + * @private + */ + _makeDatePicker: function () { + var value = this.value && this.value.clone().add(this.getSession().tzOffset, 'minutes'); + return new datepicker.DateTimeWidget(this, {defaultDate: value}); + }, + + /** + * Set the datepicker to the right value rather than the default one. + * + * @override + * @private + */ + _renderEdit: function () { + var value = this.value && this.value.clone().add(this.getSession().tzOffset, 'minutes'); + this.datewidget.set_value(value); + this.$input = this.datewidget.$input; + }, + + /** + * FieldDateTime overrides _formatValue to use the session timezone + * + * @override + * @private + * @param {Moment} value + */ + _formatValue: function (value) { + return field_utils.format.datetime(value, {timezone: true}); + }, +}); + +var FieldMonetary = InputField.extend({ + className: 'o_form_field_monetary o_list_number', + replace_element: true, + supportedFieldTypes: ['float', 'monetary'], + resetOnAnyFieldChange: true, // Have to listen to currency changes + + /** + * Float fields using a monetary widget have an additional currency_field + * parameter which defines the name of the field from which the currency + * should be read. + * + * They are also displayed differently than other inputs in + * edit mode. They are a div containing a span with the currency symbol and + * the actual input. + * + * If no currency field is given or the field does not exist, we fallback + * to the default input behavior instead. + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + + this._setCurrency(); + + if (this.mode === 'edit' && this.currency) { + this.tagName = 'div'; + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * For monetary fields, 0 is a valid value. + * + * @override + */ + isSet: function () { + return this.value === 0 || this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * For monetary fields, the input is inside a div, alongside a span + * containing the currency symbol. + * + * @override + * @private + */ + _renderEdit: function () { + if (!this.currency) { + this._super.apply(this, arguments); + return; + } + + this.$el.empty(); + // Prepare and add the input + this._prepareInput().appendTo(this.$el); + // prepare and add the currency symbol + var $currencySymbol = $('<span>', {text: this.currency.symbol}); + if (this.currency.position === "after") { + this.$el.append($currencySymbol); + } else { + this.$el.prepend($currencySymbol); + } + }, + /** + * Re-gets the currency as its value may have changed. + * @see FieldMonetary.resetOnAnyFieldChange + * + * @override + * @private + */ + _reset: function () { + this._super.apply(this, arguments); + this._setCurrency(); + }, + /** + * Deduces the currency description from the field options and view state. + * The description is then available at this.currency. + * + * @private + */ + _setCurrency: function () { + var currencyField = this.attrs.currency_field || this.field.currency_field || 'currency_id'; + var currencyID = this.record.data[currencyField] && this.record.data[currencyField].res_id; + this.currency = session.get_currency(currencyID); + }, + /** + * FieldMonetary overrides _formatValue to use the format monetary method + * in readonly. + * + * @override + * @private + * @param {float} value + */ + _formatValue: function (value) { + var format = field_utils.format[this.mode === 'edit' ? "float" : "monetary"]; + return format(value, this.field, { + digits: [16, 2], + currency: this.currency, + }); + }, +}); + +var FieldBoolean = AbstractField.extend({ + className: 'o_field_boolean', + events: _.extend({}, AbstractField.prototype.events, { + change: '_onChange', + }), + replace_element: true, + supportedFieldTypes: ['boolean'], + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Automatically selects the field. + * + * @override + */ + activate: function () { + this.$input.focus(); + setTimeout(this.$input.select.bind(this.$input), 0); + }, + + /** + * A boolean field is always set since false is a valid value. + * + * @override + */ + isSet: function () { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * The actual checkbox is designed in css to have full control over its + * appearance, as opposed to letting the browser and the os decide how + * a checkbox should look. The actual input is disabled and hidden. + * + * @override + * @private + */ + _renderReadonly: function () { + var $checkbox = this._formatValue(this.value); + this.$input = $checkbox.find('input'); + this.$el.empty().append($checkbox); + }, + + /** + * The actual checkbox is designed in css to have full control over its + * appearance, as opposed to letting the browser and the os decide how + * a checkbox should look. The actual input is hidden. + * + * @override + * @private + */ + _renderEdit: function () { + this._renderReadonly(); + this.$input.prop('disabled', false); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Properly update the value when the checkbox is (un)ticked to trigger + * possible onchanges. + * + * @private + */ + _onChange: function () { + this._setValue(this.$input[0].checked); + }, + + /** + * Implement keyboard movements. Mostly useful for its environment, such + * as a list view. + * + * @override + * @private + * @param {KeyEvent} event + */ + _onKeydown: function (event) { + this._super.apply(this, arguments); + 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.ENTER: + this.$input.prop('checked', !this.value); + this._setValue(!this.value); + break; + } + }, +}); + +var FieldInteger = InputField.extend({ + supportedFieldTypes: ['integer'], + + /** + * For integer fields, 0 is a valid value. + * + * @override + */ + isSet: function () { + return this.value === 0 || this._super.apply(this, arguments); + }, +}); + +var FieldFloat = InputField.extend({ + supportedFieldTypes: ['float'], + + /** + * Float fields have an additional precision parameter that is read from + * either the field node in the view or the field python definition itself. + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + if (this.attrs.digits) { + this.nodeOptions.digits = JSON.parse(this.attrs.digits); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * For float fields, 0 is a valid value. + * + * @override + */ + isSet: function () { + return this.value === 0 || this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Format value according to precision parameter. + * + * @override + * @private + */ + _renderReadonly: function () { + var value = this._formatValue(this.value); + var $span = $('<span>').addClass('o_form_field o_form_field_number').text(value); + this.$el.html($span); + }, +}); + +var FieldFloatTime = FieldFloat.extend({ + supportedFieldTypes: ['float'], + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Format the float time value into a human-readable time format. + * + * @override + * @private + */ + _formatValue: function (value) { + return field_utils.format.float_time(value); + }, + + /** + * Parse the human-readable formatted time value into a float. + * + * @override + * @private + */ + _parseValue: function (value) { + return field_utils.parse.float_time(value); + }, +}); + +var FieldText = DebouncedField.extend({ + supportedFieldTypes: ['text'], + + /** + * In edit mode, the text 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. + * + * @override + */ + start: function () { + this.$el.addClass('o_list_text o_form_textarea'); + this.$el.attr('id', this.idForLabel); + + if (this.mode === 'edit') { + 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 + //-------------------------------------------------------------------------- + + /** + * Automatically selects the content of the field. + * + * @override + */ + activate: function () { + var $textarea = this.$('textarea'); + $textarea.focus(); + setTimeout($textarea.select.bind($textarea), 0); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @returns {string} the content of the textarea + */ + _getValue: function () { + return this.$textarea.val(); + }, + /** + * Format the value and put it in the textarea. + * + * @override + * @private + */ + _renderEdit: function () { + this.$textarea.val(this._formatValue(this.value)); + }, + + /** + * Format the value and display it. + * + * @override + * @private + */ + _renderReadonly: function () { + this.$el.text(this._formatValue(this.value)); + }, +}); + +/** + * Displays a handle to modify the sequence. + */ +var HandleWidget = AbstractField.extend({ + tagName: 'span', + className: 'o_row_handle fa fa-arrows ui-sortable-handle', + description: "", + readonly: true, + replace_element: true, + supportedFieldTypes: ['integer'], +}); + +var EmailWidget = InputField.extend({ + prefix: 'mailto', + supportedFieldTypes: ['char'], + + /** + * In readonly, emails should be a link, not a span. + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.tagName = this.mode === 'readonly' ? 'a' : 'input'; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * In readonly, emails should be a mailto: link with proper formatting. + * + * @override + * @private + */ + _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', + + /** + * The phone widget is an extension of email, with the distinction that, in + * some cases, we do not want to show a clickable widget in readonly. + * In particular, we only want to make it clickable if the device can call + * this particular number. This is controlled by the _canCall function. + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + if (this.mode === 'readonly' && !this._canCall()) { + this.tagName = 'span'; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * In readonly, we only make the widget clickable if the device can call it. + * Additionally, we obfuscate the phone number to prevent Skype from seeing it. + * + * @override + * @private + */ + _renderReadonly: function () { + this._super(); + if (this._canCall()) { + 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'); + } + }, + + /** + * 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. + * + * @override + * @private + */ + _canCall: function () { + return config.device.size_class <= config.device.SIZES.XS; + } +}); + +var UrlWidget = InputField.extend({ + supportedFieldTypes: ['char'], + + /** + * Urls are links in readonly mode. + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.tagName = this.mode === 'readonly' ? 'a' : 'input'; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * In readonly, the widget needs to be a link with proper href and proper + * support for the design, which is achieved by the added classes. + * + * @override + * @private + */ + _renderReadonly: function () { + 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': '_onMouseOver', + 'mouseout > a': '_onMouseOut', + 'click > a': '_onClick', + }, + readonly: true, + supportedFieldTypes: ['selection'], + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Like boolean fields, this widget always has a value, since the default + * value is already a valid value. + * + * @override + */ + isSet: function () { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Renders a star for each possible value, readonly or edit mode doesn't matter. + * + * @override + * @private + */ + _render: function () { + var self = this; + var index_value = this.value ? _.findIndex(this.field.selection, function (v) { + return v[0] === self.value; + }) : 0; + this.$el.empty(); + this.empty_value = this.field.selection[0][0]; + _.each(this.field.selection.slice(1), function (choice, index) { + self.$el.append(self._renderStar('<a href="#">', index_value >= index+1, index+1, choice[1])); + }); + }, + + /** + * Renders a star representing a particular value for this field. + * + * @param {string} tag html tag to be passed to jquery to hold the star + * @param {boolean} isFull whether the star is a full star or not + * @param {integer} index the index of the star in the series + * @param {string} tip tooltip for this star's meaning + * @private + */ + _renderStar: function (tag, isFull, index, tip) { + return $(tag) + .attr('title', tip) + .attr('data-index', index) + .addClass('o_priority_star fa') + .toggleClass('fa-star', isFull) + .toggleClass('fa-star-o', !isFull); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Update the value of the field based on which star the user clicked on. + * + * @param {MouseEvent} event + * @private + */ + _onClick: function (event) { + event.preventDefault(); + event.stopPropagation(); + var index = $(event.currentTarget).data('index'); + var newValue = this.field.selection[index][0]; + if (newValue === this.value) { + newValue = this.empty_value; + } + this._setValue(newValue); + }, + + /** + * Reset the star display status. + * + * @private + */ + _onMouseOut: function () { + clearTimeout(this.hoverTimer); + var self = this; + this.hoverTimer = setTimeout(function () { + self._render(); + }, 200); + }, + + /** + * Colors the stars to show the user the result when clicking on it. + * + * @param {MouseEvent} event + * @private + */ + _onMouseOver: function (event) { + clearTimeout(this.hoverTimer); + this.$('.o_priority_star').removeClass('fa-star-o').addClass('fa-star'); + $(event.currentTarget).nextAll().removeClass('fa-star').addClass('fa-star-o'); + }, + +}); + +var AttachmentImage = AbstractField.extend({ + template: 'AttachmentImage', +}); + +var StateSelectionWidget = AbstractField.extend({ + template: 'FormSelection', + events: { + 'click a': function (e) { + e.preventDefault(); + }, + 'click li': '_setSelection' + }, + readonly: true, + supportedFieldTypes: ['selection'], + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Prepares the state values to be rendered using the FormSelection.Items template. + * + * @private + */ + _prepareDropdownValues: function () { + var self = this; + var _data = []; + var current_stage_id = self.recordData.stage_id && self.recordData.stage_id[0]; + var stage_data = { + id: current_stage_id, + legend_normal: this.recordData.legend_normal || undefined, + legend_blocked : this.recordData.legend_blocked || undefined, + legend_done: this.recordData.legend_done || undefined, + }; + _.map(this.field.selection || [], function (selection_item) { + var value = { + 'name': selection_item[0], + 'tooltip': selection_item[1], + }; + if (selection_item[0] === 'normal') { + value.state_name = stage_data.legend_normal ? stage_data.legend_normal : selection_item[1]; + } else if (selection_item[0] === 'done') { + value.state_class = 'o_status_green'; + value.state_name = stage_data.legend_done ? stage_data.legend_done : selection_item[1]; + } else { + value.state_class = 'o_status_red'; + value.state_name = stage_data.legend_blocked ? stage_data.legend_blocked : selection_item[1]; + } + _data.push(value); + }); + return _data; + }, + + /** + * This widget uses the FormSelection template but needs to customize it a bit. + * + * @private + * @override + */ + _render: function () { + var self = this; + var states = this._prepareDropdownValues(); + // Adapt "FormSelection" + // Like priority, default on the first possible value if no value is given. + var currentState = _.findWhere(states, {name: self.value}) || states[0]; + this.$('.o_status') + .removeClass('o_status_red o_status_green') + .addClass(currentState.state_class) + .prop('special_click', true); + + // Render "FormSelection.Items" and move it into "FormSelection" + var $items = $(qweb.render('FormSelection.items', { + states: _.without(states, currentState) + })); + var $dropdown = this.$('.dropdown-menu'); + $dropdown.children().remove(); // remove old items + $items.appendTo($dropdown); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Intercepts the click on the FormSelection.Item to set the widget value. + * + * @private + * @param {MouseEvent} ev + */ + _setSelection: 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({ + readonly: true, + supportedFieldTypes: ['selection'], + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This widget renders a simple non-editable label. Color classes can be set + * using the 'classes' key from the options tag, such as: + * <field [...] options="{'classes': {'value': 'className', ...}}"/> + * + * @private + * @override + */ + _render: function () { + this.classes = this.nodeOptions && this.nodeOptions.classes || {}; + var labelClass = this.classes[this.value] || 'primary'; + this.$el.addClass('label label-' + labelClass).text(this._formatValue(this.value)); + }, +}); + +var FieldBooleanButton = AbstractField.extend({ + className: 'o_stat_info', + supportedFieldTypes: ['boolean'], + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * A boolean field is always set since false is a valid value. + * + * @override + */ + isSet: function () { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This widget is supposed to be used inside a stat button and, as such, is + * rendered the same way in edit and readonly mode. + * + * @override + * @private + */ + _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); + }, +}); + +var StatInfo = AbstractField.extend({ + supportedFieldTypes: ['integer', 'float'], + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * StatInfo widgets are always set since they basically only display info. + * + * @override + */ + isSet: function () { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Renders the field value using the StatInfo template. The text part of the + * widget is either the string attribute of this node in the view or the + * label of the field itself if no string attribute is given. + * + * @override + * @private + */ + _render: function () { + var options = { + value: this.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'); + }, +}); + +var FieldPercentPie = AbstractField.extend({ + template: 'FieldPercentPie', + supportedFieldTypes: ['integer'], + + /** + * Register some useful references for later use throughout the widget. + * + * @override + */ + start: function () { + this.$leftMask = this.$('.o_mask').first(); + this.$rightMask = this.$('.o_mask').last(); + this.$pieValue = this.$('.o_pie_value'); + return this._super(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * PercentPie widgets are always set since they basically only display info. + * + * @override + */ + isSet: function () { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This widget template needs javascript to apply the transformation + * associated with the rotation of the pie chart. + * + * @override + * @private + */ + _render: function () { + var value = this.value || 0; + var degValue = 360*value/100; + + this.$rightMask.toggleClass('o_full', degValue >= 180); + + var leftDeg = 'rotate(' + ((degValue < 180)? 180 : degValue) + 'deg)'; + var rightDeg = 'rotate(' + ((degValue < 180)? degValue : 0) + 'deg)'; + this.$leftMask.css({transform: leftDeg, msTransform: leftDeg, mozTransform: leftDeg, webkitTransform: leftDeg}); + this.$rightMask.css({transform: rightDeg, msTransform: rightDeg, mozTransform: rightDeg, webkitTransform: rightDeg}); + + this.$pieValue.html(Math.round(value) + '%'); + }, +}); + +/** + * 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(); + } + }, + +}); + +/** + * The "Domain" field allows the user to construct a technical-prefix domain + * thanks to a tree-like interface and see the selected records in real time. + * In debug mode, an input is also there to be able to enter the prefix char + * domain directly (or to build advanced domains the tree-like interface does + * not allow to). + */ +var FieldDomain = AbstractField.extend({ + /** + * Fetches the number of records which are matched by the domain (if the + * domain is not server-valid, the value is false) and the model the + * field must work with. + */ + specialData: "_fetchSpecialDomain", + + events: _.extend({}, AbstractField.prototype.events, { + "click .o_domain_show_selection_button": "_onShowSelectionButtonClick", + "click .o_form_field_domain_dialog_button": "_onDialogEditButtonClick", + }), + custom_events: { + "domain_changed": "_onDomainSelectorValueChange", + "domain_selected": "_onDomainSelectorDialogValueChange", + }, + /** + * @constructor + * @override init from AbstractField + */ + init: function () { + this._super.apply(this, arguments); + + this.inDialog = !!this.nodeOptions.in_dialog; + this.fsFilters = this.nodeOptions.fs_filters || {}; + + this.className = "o_form_field_domain"; + if (this.mode === "edit") { + this.className += " o_edit_mode"; + } + if (!this.inDialog) { + this.className += " o_inline_mode"; + } + + this._setState(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override isValid from AbstractField.isValid + * Parsing the char value is not enough for this field. It is considered + * valid if the internal domain selector was built correctly and that the + * query to the model to test the domain did not fail. + * + * @returns {boolean} + */ + isValid: function () { + return ( + this._super.apply(this, arguments) + && (!this.domainSelector || this.domainSelector.isValid()) + && this._isValidForModel + ); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @override _render from AbstractField + * @returns {Deferred} + */ + _render: function () { + // If there is no model, only change the non-domain-selector content + if (!this._domainModel) { + this._replaceContent(); + return $.when(); + } + + // Convert char value to array value + var value = this.value || "[]"; + var domain = Domain.prototype.stringToArray(value); + + // Create the domain selector or change the value of the current one... + var def; + if (!this.domainSelector) { + this.domainSelector = new DomainSelector(this, this._domainModel, domain, { + readonly: this.mode === "readonly" || this.inDialog, + filters: this.fsFilters, + debugMode: session.debug, + }); + def = this.domainSelector.prependTo(this.$el); + } else { + def = this.domainSelector.setDomain(domain); + } + // ... then replace the other content (matched records, etc) + return def.then(this._replaceContent.bind(this)); + }, + /** + * Render the field DOM except for the domain selector part. The full field + * DOM is composed of a DIV which contains the domain selector widget, + * followed by other content. This other content is handled by this method. + * + * @private + */ + _replaceContent: function () { + if (this._$content) { + this._$content.remove(); + } + this._$content = $(qweb.render("FieldDomain.content", { + hasModel: !!this._domainModel, + isValid: !!this._isValidForModel, + nbRecords: this.record.specialData[this.name].nbRecords || 0, + inDialogEdit: this.inDialog && this.mode === "edit", + })); + this._$content.appendTo(this.$el); + }, + /** + * @override _reset from AbstractField + * Check if the model the field works with has (to be) changed. + * + * @private + */ + _reset: function () { + this._super.apply(this, arguments); + var oldDomainModel = this._domainModel; + this._setState(); + if (this.domainSelector && this._domainModel !== oldDomainModel) { + // If the model has changed, destroy the current domain selector + this.domainSelector.destroy(); + this.domainSelector = null; + } + }, + /** + * Sets the model the field must work with and whether or not the current + * domain value is valid for this particular model. This is inferred from + * the received special data. + * + * @private + */ + _setState: function () { + var specialData = this.record.specialData[this.name]; + this._domainModel = specialData.model; + this._isValidForModel = (specialData.nbRecords !== false); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the "Show selection" button is clicked + * -> Open a modal to see the matched records + * + * @param {Event} e + */ + _onShowSelectionButtonClick: function (e) { + e.preventDefault(); + new view_dialogs.SelectCreateDialog(this, { + title: _t("Selected records"), + res_model: this._domainModel, + domain: this.value || "[]", + no_create: true, + readonly: true, + disable_multiple_selection: true, + }).open(); + }, + /** + * Called when the "Edit domain" button is clicked (when using the in_dialog + * option) -> Open a DomainSelectorDialog to edit the value + * + * @param {Event} e + */ + _onDialogEditButtonClick: function (e) { + e.preventDefault(); + new DomainSelectorDialog(this, this._domainModel, this.value || "[]", { + readonly: this.mode === "readonly", + filters: this.fsFilters, + debugMode: session.debug, + }).open(); + }, + /** + * Called when the domain selector value is changed (do nothing if it is the + * one which is in a dialog (@see _onDomainSelectorDialogValueChange)) + * -> Adapt the internal value state + * + * @param {OdooEvent} e + */ + _onDomainSelectorValueChange: function (e) { + if (this.inDialog) return; + this._setValue(Domain.prototype.arrayToString(this.domainSelector.getDomain())); + }, + /** + * Called when the in-dialog domain selector value is confirmed + * -> Adapt the internal value state + * + * @param {OdooEvent} e + */ + _onDomainSelectorDialogValueChange: function (e) { + this._setValue(Domain.prototype.arrayToString(e.data.domain)); + }, +}); + +/** + * This widget is intended to be used on Text fields. It will provide Ace Editor + * for editing XML and Python. + */ +var AceEditor = DebouncedField.extend({ + template: "AceEditor", + events: {}, // events are triggered manually for this debounced widget + /** + * @override willStart from AbstractField (Widget) + * Loads the ace library if not already loaded. + * + * @returns {Deferred} + */ + 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.apply(this, arguments), this.loadJS_def); + }, + /** + * @override start from AbstractField (Widget) + * + * @returns {Deferred} + */ + start: function () { + this._startAce(this.$('.ace-view-editor')[0]); + return this._super.apply(this, arguments); + }, + /** + * @override destroy from AbstractField (Widget) + */ + destroy: function () { + if (this.aceEditor) { + this.aceEditor.destroy(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _getValue: function () { + return this.aceSession.getValue(); + }, + /** + * @override _render from AbstractField + * The rendering is the same for edit and readonly mode: changing the ace + * session value. This is only done if the value in the ace editor is not + * already the new one (prevent losing focus / retriggering changes / empty + * the undo stack / ...). + * + * @private + */ + _render: function () { + var newValue = this._formatValue(this.value); + if (this.aceSession.getValue() !== newValue) { + this.aceSession.setValue(newValue); + } + }, + /** + * Starts the ace library on the given DOM element. This initializes the + * ace editor option according to the edit/readonly mode and binds ace + * editor events. + * + * @private + * @param {Node} node - the DOM element the ace library must initialize on + */ + _startAce: function (node) { + this.aceEditor = ace.edit(node); + this.aceEditor.setOptions({ + maxLines: Infinity, + showPrintMargin: false, + }); + if (this.mode === 'readonly') { + this.aceEditor.renderer.setOptions({ + displayIndentGuides: false, + showGutter: false, + }); + this.aceEditor.setOptions({ + highlightActiveLine: false, + highlightGutterLine: false, + readOnly: true, + }); + this.aceEditor.renderer.$cursorLayer.element.style.display = "none"; + } + this.aceEditor.$blockScrolling = true; + this.aceSession = this.aceEditor.getSession(); + this.aceSession.setOptions({ + useWorker: false, + mode: "ace/mode/" + (this.nodeOptions.mode || 'xml'), + tabSize: 2, + useSoftTabs: true, + }); + if (this.mode === "edit") { + this.aceEditor.on("change", this._onInput.bind(this)); + this.aceEditor.on("blur", this._onChange.bind(this)); + } + }, +}); + +return { + DebouncedField: DebouncedField, + EmailWidget: EmailWidget, + FieldBinaryFile: FieldBinaryFile, + FieldBinaryImage: FieldBinaryImage, + FieldBoolean: FieldBoolean, + FieldBooleanButton: FieldBooleanButton, + FieldChar: FieldChar, + FieldDate: FieldDate, + FieldDateTime: FieldDateTime, + FieldDomain: FieldDomain, + FieldFloat: FieldFloat, + FieldFloatTime: FieldFloatTime, + 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, +}; + +}); |
