summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/widgets
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/widgets
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/widgets')
-rw-r--r--addons/web/static/src/js/widgets/attach_document.js139
-rw-r--r--addons/web/static/src/js/widgets/change_password.js75
-rw-r--r--addons/web/static/src/js/widgets/colorpicker.js699
-rw-r--r--addons/web/static/src/js/widgets/data_export.js688
-rw-r--r--addons/web/static/src/js/widgets/date_picker.js358
-rw-r--r--addons/web/static/src/js/widgets/domain_selector.js987
-rw-r--r--addons/web/static/src/js/widgets/domain_selector_dialog.js54
-rw-r--r--addons/web/static/src/js/widgets/iframe_widget.js65
-rw-r--r--addons/web/static/src/js/widgets/model_field_selector.js615
-rw-r--r--addons/web/static/src/js/widgets/name_and_signature.js662
-rw-r--r--addons/web/static/src/js/widgets/notification.js176
-rw-r--r--addons/web/static/src/js/widgets/pie_chart.js102
-rw-r--r--addons/web/static/src/js/widgets/rainbow_man.js71
-rw-r--r--addons/web/static/src/js/widgets/ribbon.js48
-rw-r--r--addons/web/static/src/js/widgets/signature.js97
-rw-r--r--addons/web/static/src/js/widgets/switch_company_menu.js127
-rw-r--r--addons/web/static/src/js/widgets/translation_dialog.js183
17 files changed, 5146 insertions, 0 deletions
diff --git a/addons/web/static/src/js/widgets/attach_document.js b/addons/web/static/src/js/widgets/attach_document.js
new file mode 100644
index 00000000..8ad48882
--- /dev/null
+++ b/addons/web/static/src/js/widgets/attach_document.js
@@ -0,0 +1,139 @@
+odoo.define('web.AttachDocument', function (require) {
+"use static";
+
+var core = require('web.core');
+var framework = require('web.framework');
+var widgetRegistry = require('web.widget_registry');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+
+var AttachDocument = Widget.extend({
+ template: 'AttachDocument',
+ events: {
+ 'click': '_onClickAttachDocument',
+ 'change input.o_input_file': '_onFileChanged',
+ },
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} record
+ * @param {Object} nodeInfo
+ */
+ init: function (parent, record, nodeInfo) {
+ this._super.apply(this, arguments);
+ this.res_id = record.res_id;
+ this.res_model = record.model;
+ this.state = record;
+ this.node = nodeInfo;
+ this.fileuploadID = _.uniqueId('o_fileupload');
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ $(window).on(this.fileuploadID, this._onFileLoaded.bind(this));
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ $(window).off(this.fileuploadID);
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Helper function to display a warning that some fields have an invalid
+ * value. This is used when a save operation cannot be completed.
+ *
+ * @private
+ * @param {string[]} invalidFields - list of field names
+ */
+ _notifyInvalidFields: function (invalidFields) {
+ var fields = this.state.fields;
+ var warnings = invalidFields.map(function (fieldName) {
+ var fieldStr = fields[fieldName].string;
+ return _.str.sprintf('<li>%s</li>', _.escape(fieldStr));
+ });
+ warnings.unshift('<ul>');
+ warnings.push('</ul>');
+ this.do_warn(_t("Invalid fields:"), warnings.join(''));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Opens File Explorer dialog if all fields are valid and record is saved
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onClickAttachDocument: function (ev) {
+ if ($(ev.target).is('input.o_input_file')) {
+ return;
+ }
+ var fieldNames = this.getParent().canBeSaved(this.state.id);
+ if (fieldNames.length) {
+ return this._notifyInvalidFields(fieldNames);
+ }
+ // We want to save record on widget click and then open File Selection Explorer
+ // but due to this security restriction give warning to save record first.
+ // https://stackoverflow.com/questions/29728705/trigger-click-on-input-file-on-asynchronous-ajax-done/29873845#29873845
+ if (!this.res_id) {
+ return this.do_warn(false, _t('Please save before attaching a file'));
+ }
+ this.$('input.o_input_file').trigger('click');
+ },
+ /**
+ * Submits file
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onFileChanged: function (ev) {
+ ev.stopPropagation();
+ this.$('form.o_form_binary_form').trigger('submit');
+ framework.blockUI();
+ },
+ /**
+ * Call action given as node attribute after file submission
+ *
+ * @private
+ */
+ _onFileLoaded: function () {
+ var self = this;
+ // the first argument isn't a file but the jQuery.Event
+ var files = Array.prototype.slice.call(arguments, 1);
+ return new Promise(function (resolve) {
+ if (self.node.attrs.action) {
+ self._rpc({
+ model: self.res_model,
+ method: self.node.attrs.action,
+ args: [self.res_id],
+ kwargs: {
+ attachment_ids: _.map(files, function (file) {
+ return file.id;
+ }),
+ }
+ }).then(function () {
+ resolve();
+ });
+ } else {
+ resolve();
+ }
+ }).then(function () {
+ self.trigger_up('reload');
+ framework.unblockUI();
+ });
+ },
+
+});
+widgetRegistry.add('attach_document', AttachDocument);
+});
diff --git a/addons/web/static/src/js/widgets/change_password.js b/addons/web/static/src/js/widgets/change_password.js
new file mode 100644
index 00000000..e75d8dfd
--- /dev/null
+++ b/addons/web/static/src/js/widgets/change_password.js
@@ -0,0 +1,75 @@
+odoo.define('web.ChangePassword', function (require) {
+"use strict";
+
+/**
+ * This file defines a client action that opens in a dialog (target='new') and
+ * allows the user to change his password.
+ */
+
+var AbstractAction = require('web.AbstractAction');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var web_client = require('web.web_client');
+
+var _t = core._t;
+
+var ChangePassword = AbstractAction.extend({
+ template: "ChangePassword",
+
+ /**
+ * @fixme: weird interaction with the parent for the $buttons handling
+ *
+ * @override
+ * @returns {Promise}
+ */
+ start: function () {
+ var self = this;
+ web_client.set_title(_t("Change Password"));
+ var $button = self.$('.oe_form_button');
+ $button.appendTo(this.getParent().$footer);
+ $button.eq(1).click(function () {
+ self.$el.parents('.modal').modal('hide');
+ });
+ $button.eq(0).click(function () {
+ self._rpc({
+ route: '/web/session/change_password',
+ params: {
+ fields: $('form[name=change_password_form]').serializeArray()
+ }
+ })
+ .then(function (result) {
+ if (result.error) {
+ self._display_error(result);
+ } else {
+ self.do_action('logout');
+ }
+ });
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Displays the error in a dialog
+ *
+ * @private
+ * @param {Object} error
+ * @param {string} error.error
+ * @param {string} error.title
+ */
+ _display_error: function (error) {
+ return new Dialog(this, {
+ size: 'medium',
+ title: error.title,
+ $content: $('<div>').html(error.error)
+ }).open();
+ },
+});
+
+core.action_registry.add("change_password", ChangePassword);
+
+return ChangePassword;
+
+});
diff --git a/addons/web/static/src/js/widgets/colorpicker.js b/addons/web/static/src/js/widgets/colorpicker.js
new file mode 100644
index 00000000..29e8c22a
--- /dev/null
+++ b/addons/web/static/src/js/widgets/colorpicker.js
@@ -0,0 +1,699 @@
+odoo.define('web.Colorpicker', function (require) {
+'use strict';
+
+var core = require('web.core');
+var utils = require('web.utils');
+var Dialog = require('web.Dialog');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+
+var ColorpickerWidget = Widget.extend({
+ xmlDependencies: ['/web/static/src/xml/colorpicker.xml'],
+ template: 'Colorpicker',
+ events: {
+ 'click': '_onClick',
+ 'keypress': '_onKeypress',
+ 'mousedown .o_color_pick_area': '_onMouseDownPicker',
+ 'mousedown .o_color_slider': '_onMouseDownSlider',
+ 'mousedown .o_opacity_slider': '_onMouseDownOpacitySlider',
+ 'change .o_color_picker_inputs': '_onChangeInputs',
+ },
+
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} [options]
+ * @param {string} [options.defaultColor='#FF0000']
+ * @param {string} [options.noTransparency=false]
+ */
+ init: function (parent, options) {
+ this._super(...arguments);
+ options = options || {};
+ this.trigger_up('getRecordInfo', {
+ recordInfo: options,
+ callback: function (recordInfo) {
+ _.defaults(options, recordInfo);
+ },
+ });
+
+ this.pickerFlag = false;
+ this.sliderFlag = false;
+ this.opacitySliderFlag = false;
+ this.colorComponents = {};
+ this.uniqueId = _.uniqueId('colorpicker');
+
+ // Needs to be bound on document to work in all possible cases.
+ const $document = $(document);
+ $document.on(`mousemove.${this.uniqueId}`, _.throttle((ev) => {
+ this._onMouseMovePicker(ev);
+ this._onMouseMoveSlider(ev);
+ this._onMouseMoveOpacitySlider(ev);
+ }, 50));
+ $document.on(`mouseup.${this.uniqueId}`, _.throttle((ev) => {
+ if (this.pickerFlag || this.sliderFlag || this.opacitySliderFlag) {
+ this._colorSelected();
+ }
+ this.pickerFlag = false;
+ this.sliderFlag = false;
+ this.opacitySliderFlag = false;
+ }, 10));
+
+ this.options = _.clone(options);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$colorpickerArea = this.$('.o_color_pick_area');
+ this.$colorpickerPointer = this.$('.o_picker_pointer');
+ this.$colorSlider = this.$('.o_color_slider');
+ this.$colorSliderPointer = this.$('.o_slider_pointer');
+ this.$opacitySlider = this.$('.o_opacity_slider');
+ this.$opacitySliderPointer = this.$('.o_opacity_pointer');
+
+ var defaultColor = this.options.defaultColor || '#FF0000';
+ var rgba = ColorpickerWidget.convertCSSColorToRgba(defaultColor);
+ if (rgba) {
+ this._updateRgba(rgba.red, rgba.green, rgba.blue, rgba.opacity);
+ }
+
+ // Pre-fill the inputs. This is because on safari, the baseline for empty
+ // input is not the baseline of where the text would be, but the bottom
+ // of the input itself. (see https://bugs.webkit.org/show_bug.cgi?id=142968)
+ // This will cause the first _updateUI to alter the layout of the colorpicker
+ // which will change its height. Changing the height of an element inside of
+ // the callback to a ResizeObserver observing it will cause an error
+ // (ResizeObserver loop completed with undelivered notifications) that cannot
+ // be caught, which will open the crash manager. Prefilling the inputs sets
+ // the baseline correctly from the start so the layout doesn't change.
+ Object.entries(this.colorComponents).forEach(([component, value]) => {
+ const input = this.el.querySelector(`.o_${component}_input`);
+ if (input) {
+ input.value = value;
+ }
+ });
+ const resizeObserver = new window.ResizeObserver(() => {
+ this._updateUI();
+ });
+ resizeObserver.observe(this.el);
+
+ this.previewActive = true;
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ $(document).off(`.${this.uniqueId}`);
+ },
+ /**
+ * Sets the currently selected color
+ *
+ * @param {string} color rgb[a]
+ */
+ setSelectedColor: function (color) {
+ var rgba = ColorpickerWidget.convertCSSColorToRgba(color);
+ if (rgba) {
+ this._updateRgba(rgba.red, rgba.green, rgba.blue, rgba.opacity);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Updates input values, color preview, picker and slider pointer positions.
+ *
+ * @private
+ */
+ _updateUI: function () {
+ var self = this;
+
+ // Update inputs
+ _.each(this.colorComponents, function (value, color) {
+ self.$(_.str.sprintf('.o_%s_input', color)).val(value);
+ });
+
+ // Update preview
+ this.$('.o_color_preview').css('background-color', this.colorComponents.cssColor);
+
+ // Update picker area and picker pointer position
+ this.$colorpickerArea.css('background-color', _.str.sprintf('hsl(%s, 100%%, 50%%)', this.colorComponents.hue));
+ var top = (100 - this.colorComponents.lightness) * this.$colorpickerArea.height() / 100;
+ var left = this.colorComponents.saturation * this.$colorpickerArea.width() / 100;
+ this.$colorpickerPointer.css({
+ top: (top - 5) + 'px',
+ left: (left - 5) + 'px',
+ });
+
+ // Update color slider position
+ var height = this.$colorSlider.height();
+ var y = this.colorComponents.hue * height / 360;
+ this.$colorSliderPointer.css('top', Math.round(y - 2));
+
+ if (! this.options.noTransparency) {
+ // Update opacity slider position
+ var heightOpacity = this.$opacitySlider.height();
+ var z = heightOpacity * (1 - this.colorComponents.opacity / 100.0);
+ this.$opacitySliderPointer.css('top', Math.round(z - 2));
+
+ // Add gradient color on opacity slider
+ this.$opacitySlider.css('background', 'linear-gradient(' + this.colorComponents.hex + ' 0%, transparent 100%)');
+ }
+ },
+ /**
+ * Updates colors according to given hex value. Opacity is left unchanged.
+ *
+ * @private
+ * @param {string} hex - hexadecimal code
+ */
+ _updateHex: function (hex) {
+ var rgb = ColorpickerWidget.convertCSSColorToRgba(hex);
+ if (!rgb) {
+ return;
+ }
+ _.extend(this.colorComponents,
+ {hex: hex},
+ rgb,
+ ColorpickerWidget.convertRgbToHsl(rgb.red, rgb.green, rgb.blue)
+ );
+ this._updateCssColor();
+ },
+ /**
+ * Updates colors according to given RGB values.
+ *
+ * @private
+ * @param {integer} r
+ * @param {integer} g
+ * @param {integer} b
+ * @param {integer} [a]
+ */
+ _updateRgba: function (r, g, b, a) {
+ // We update the hexadecimal code by transforming into a css color and
+ // ignoring the opacity (we don't display opacity component in hexa as
+ // not supported on all browsers)
+ var hex = ColorpickerWidget.convertRgbaToCSSColor(r, g, b);
+ if (!hex) {
+ return;
+ }
+ _.extend(this.colorComponents,
+ {red: r, green: g, blue: b},
+ a === undefined ? {} : {opacity: a},
+ {hex: hex},
+ ColorpickerWidget.convertRgbToHsl(r, g, b)
+ );
+ this._updateCssColor();
+ },
+ /**
+ * Updates colors according to given HSL values.
+ *
+ * @private
+ * @param {integer} h
+ * @param {integer} s
+ * @param {integer} l
+ */
+ _updateHsl: function (h, s, l) {
+ var rgb = ColorpickerWidget.convertHslToRgb(h, s, l);
+ if (!rgb) {
+ return;
+ }
+ // We receive an hexa as we ignore the opacity
+ const hex = ColorpickerWidget.convertRgbaToCSSColor(rgb.red, rgb.green, rgb.blue);
+ _.extend(this.colorComponents,
+ {hue: h, saturation: s, lightness: l},
+ rgb,
+ {hex: hex}
+ );
+ this._updateCssColor();
+ },
+ /**
+ * Updates color opacity.
+ *
+ * @private
+ * @param {integer} a
+ */
+ _updateOpacity: function (a) {
+ if (a < 0 || a > 100) {
+ return;
+ }
+ _.extend(this.colorComponents,
+ {opacity: a}
+ );
+ this._updateCssColor();
+ },
+ /**
+ * Trigger an event to annonce that the widget value has changed
+ *
+ * @private
+ */
+ _colorSelected: function () {
+ this.trigger_up('colorpicker_select', this.colorComponents);
+ },
+ /**
+ * Updates css color representation.
+ *
+ * @private
+ */
+ _updateCssColor: function () {
+ const r = this.colorComponents.red;
+ const g = this.colorComponents.green;
+ const b = this.colorComponents.blue;
+ const a = this.colorComponents.opacity;
+ _.extend(this.colorComponents,
+ {cssColor: ColorpickerWidget.convertRgbaToCSSColor(r, g, b, a)}
+ );
+ if (this.previewActive) {
+ this.trigger_up('colorpicker_preview', this.colorComponents);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onKeypress: function (ev) {
+ if (ev.charCode === $.ui.keyCode.ENTER) {
+ if (ev.target.tagName === 'INPUT') {
+ this._onChangeInputs(ev);
+ }
+ ev.preventDefault();
+ this.trigger_up('enter_key_color_colorpicker');
+ }
+ },
+ /**
+ * @param {Event} ev
+ */
+ _onClick: function (ev) {
+ ev.originalEvent.__isColorpickerClick = true;
+ $(ev.target).find('> .o_opacity_pointer, > .o_slider_pointer, > .o_picker_pointer').addBack('.o_opacity_pointer, .o_slider_pointer, .o_picker_pointer').focus();
+ },
+ /**
+ * Updates color when the user starts clicking on the picker.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseDownPicker: function (ev) {
+ this.pickerFlag = true;
+ ev.preventDefault();
+ this._onMouseMovePicker(ev);
+ },
+ /**
+ * Updates saturation and lightness values on mouse drag over picker.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseMovePicker: function (ev) {
+ if (!this.pickerFlag) {
+ return;
+ }
+
+ var offset = this.$colorpickerArea.offset();
+ var top = ev.pageY - offset.top;
+ var left = ev.pageX - offset.left;
+ var saturation = Math.round(100 * left / this.$colorpickerArea.width());
+ var lightness = Math.round(100 * (this.$colorpickerArea.height() - top) / this.$colorpickerArea.height());
+ saturation = utils.confine(saturation, 0, 100);
+ lightness = utils.confine(lightness, 0, 100);
+
+ this._updateHsl(this.colorComponents.hue, saturation, lightness);
+ this._updateUI();
+ },
+ /**
+ * Updates color when user starts clicking on slider.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseDownSlider: function (ev) {
+ this.sliderFlag = true;
+ ev.preventDefault();
+ this._onMouseMoveSlider(ev);
+ },
+ /**
+ * Updates hue value on mouse drag over slider.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseMoveSlider: function (ev) {
+ if (!this.sliderFlag) {
+ return;
+ }
+
+ var y = ev.pageY - this.$colorSlider.offset().top;
+ var hue = Math.round(360 * y / this.$colorSlider.height());
+ hue = utils.confine(hue, 0, 360);
+
+ this._updateHsl(hue, this.colorComponents.saturation, this.colorComponents.lightness);
+ this._updateUI();
+ },
+ /**
+ * Updates opacity when user starts clicking on opacity slider.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseDownOpacitySlider: function (ev) {
+ this.opacitySliderFlag = true;
+ ev.preventDefault();
+ this._onMouseMoveOpacitySlider(ev);
+ },
+ /**
+ * Updates opacity value on mouse drag over opacity slider.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseMoveOpacitySlider: function (ev) {
+ if (!this.opacitySliderFlag || this.options.noTransparency) {
+ return;
+ }
+
+ var y = ev.pageY - this.$opacitySlider.offset().top;
+ var opacity = Math.round(100 * (1 - y / this.$opacitySlider.height()));
+ opacity = utils.confine(opacity, 0, 100);
+
+ this._updateOpacity(opacity);
+ this._updateUI();
+ },
+ /**
+ * Called when input value is changed -> Updates UI: Set picker and slider
+ * position and set colors.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onChangeInputs: function (ev) {
+ switch ($(ev.target).data('colorMethod')) {
+ case 'hex':
+ this._updateHex(this.$('.o_hex_input').val());
+ break;
+ case 'rgb':
+ this._updateRgba(
+ parseInt(this.$('.o_red_input').val()),
+ parseInt(this.$('.o_green_input').val()),
+ parseInt(this.$('.o_blue_input').val())
+ );
+ break;
+ case 'hsl':
+ this._updateHsl(
+ parseInt(this.$('.o_hue_input').val()),
+ parseInt(this.$('.o_saturation_input').val()),
+ parseInt(this.$('.o_lightness_input').val())
+ );
+ break;
+ case 'opacity':
+ this._updateOpacity(parseInt(this.$('.o_opacity_input').val()));
+ break;
+ }
+ this._updateUI();
+ this._colorSelected();
+ },
+});
+
+//--------------------------------------------------------------------------
+// Static
+//--------------------------------------------------------------------------
+
+/**
+ * Converts RGB color components to HSL components.
+ *
+ * @static
+ * @param {integer} r - [0, 255]
+ * @param {integer} g - [0, 255]
+ * @param {integer} b - [0, 255]
+ * @returns {Object|false}
+ * - hue [0, 360[
+ * - saturation [0, 100]
+ * - lightness [0, 100]
+ */
+ColorpickerWidget.convertRgbToHsl = function (r, g, b) {
+ if (typeof (r) !== 'number' || isNaN(r) || r < 0 || r > 255
+ || typeof (g) !== 'number' || isNaN(g) || g < 0 || g > 255
+ || typeof (b) !== 'number' || isNaN(b) || b < 0 || b > 255) {
+ return false;
+ }
+
+ var red = r / 255;
+ var green = g / 255;
+ var blue = b / 255;
+ var maxColor = Math.max(red, green, blue);
+ var minColor = Math.min(red, green, blue);
+ var delta = maxColor - minColor;
+ var hue = 0;
+ var saturation = 0;
+ var lightness = (maxColor + minColor) / 2;
+ if (delta) {
+ if (maxColor === red) {
+ hue = (green - blue) / delta;
+ }
+ if (maxColor === green) {
+ hue = 2 + (blue - red) / delta;
+ }
+ if (maxColor === blue) {
+ hue = 4 + (red - green) / delta;
+ }
+ if (maxColor) {
+ saturation = delta / (1 - Math.abs(2 * lightness - 1));
+ }
+ }
+ hue = 60 * hue | 0;
+ return {
+ hue: hue < 0 ? hue += 360 : hue,
+ saturation: (saturation * 100) | 0,
+ lightness: (lightness * 100) | 0,
+ };
+};
+/**
+ * Converts HSL color components to RGB components.
+ *
+ * @static
+ * @param {integer} h - [0, 360[
+ * @param {integer} s - [0, 100]
+ * @param {integer} l - [0, 100]
+ * @returns {Object|false}
+ * - red [0, 255]
+ * - green [0, 255]
+ * - blue [0, 255]
+ */
+ColorpickerWidget.convertHslToRgb = function (h, s, l) {
+ if (typeof (h) !== 'number' || isNaN(h) || h < 0 || h > 360
+ || typeof (s) !== 'number' || isNaN(s) || s < 0 || s > 100
+ || typeof (l) !== 'number' || isNaN(l) || l < 0 || l > 100) {
+ return false;
+ }
+
+ var huePrime = h / 60;
+ var saturation = s / 100;
+ var lightness = l / 100;
+ var chroma = saturation * (1 - Math.abs(2 * lightness - 1));
+ var secondComponent = chroma * (1 - Math.abs(huePrime % 2 - 1));
+ var lightnessAdjustment = lightness - chroma / 2;
+ var precision = 255;
+ chroma = (chroma + lightnessAdjustment) * precision | 0;
+ secondComponent = (secondComponent + lightnessAdjustment) * precision | 0;
+ lightnessAdjustment = lightnessAdjustment * precision | 0;
+ if (huePrime >= 0 && huePrime < 1) {
+ return {
+ red: chroma,
+ green: secondComponent,
+ blue: lightnessAdjustment,
+ };
+ }
+ if (huePrime >= 1 && huePrime < 2) {
+ return {
+ red: secondComponent,
+ green: chroma,
+ blue: lightnessAdjustment,
+ };
+ }
+ if (huePrime >= 2 && huePrime < 3) {
+ return {
+ red: lightnessAdjustment,
+ green: chroma,
+ blue: secondComponent,
+ };
+ }
+ if (huePrime >= 3 && huePrime < 4) {
+ return {
+ red: lightnessAdjustment,
+ green: secondComponent,
+ blue: chroma,
+ };
+ }
+ if (huePrime >= 4 && huePrime < 5) {
+ return {
+ red: secondComponent,
+ green: lightnessAdjustment,
+ blue: chroma,
+ };
+ }
+ if (huePrime >= 5 && huePrime <= 6) {
+ return {
+ red: chroma,
+ green: lightnessAdjustment,
+ blue: secondComponent,
+ };
+ }
+ return false;
+};
+/**
+ * Converts RGBA color components to a normalized CSS color: if the opacity
+ * is invalid or equal to 100, a hex is returned; otherwise a rgba() css color
+ * is returned.
+ *
+ * Those choice have multiple reason:
+ * - A hex color is more common to c/c from other utilities on the web and is
+ * also shorter than rgb() css colors
+ * - Opacity in hexadecimal notations is not supported on all browsers and is
+ * also less common to use.
+ *
+ * @static
+ * @param {integer} r - [0, 255]
+ * @param {integer} g - [0, 255]
+ * @param {integer} b - [0, 255]
+ * @param {float} a - [0, 100]
+ * @returns {string}
+ */
+ColorpickerWidget.convertRgbaToCSSColor = function (r, g, b, a) {
+ if (typeof (r) !== 'number' || isNaN(r) || r < 0 || r > 255
+ || typeof (g) !== 'number' || isNaN(g) || g < 0 || g > 255
+ || typeof (b) !== 'number' || isNaN(b) || b < 0 || b > 255) {
+ return false;
+ }
+ if (typeof (a) !== 'number' || isNaN(a) || a < 0 || Math.abs(a - 100) < Number.EPSILON) {
+ const rr = r < 16 ? '0' + r.toString(16) : r.toString(16);
+ const gg = g < 16 ? '0' + g.toString(16) : g.toString(16);
+ const bb = b < 16 ? '0' + b.toString(16) : b.toString(16);
+ return (`#${rr}${gg}${bb}`).toUpperCase();
+ }
+ return `rgba(${r}, ${g}, ${b}, ${parseFloat((a / 100.0).toFixed(3))})`;
+};
+/**
+ * Converts a CSS color (rgb(), rgba(), hexadecimal) to RGBA color components.
+ *
+ * Note: we don't support using and displaying hexadecimal color with opacity
+ * but this method allows to receive one and returns the correct opacity value.
+ *
+ * @static
+ * @param {string} cssColor - hexadecimal code or rgb() or rgba()
+ * @returns {Object|false}
+ * - red [0, 255]
+ * - green [0, 255]
+ * - blue [0, 255]
+ * - opacity [0, 100.0]
+ */
+ColorpickerWidget.convertCSSColorToRgba = function (cssColor) {
+ // Check if cssColor is a rgba() or rgb() color
+ const rgba = cssColor.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/);
+ if (rgba) {
+ if (rgba[4] === undefined) {
+ rgba[4] = 1;
+ }
+ return {
+ red: parseInt(rgba[1]),
+ green: parseInt(rgba[2]),
+ blue: parseInt(rgba[3]),
+ opacity: Math.round(parseFloat(rgba[4]) * 100),
+ };
+ }
+
+ // Otherwise, check if cssColor is an hexadecimal code color
+ if (/^#([0-9A-F]{6}|[0-9A-F]{8})$/i.test(cssColor)) {
+ return {
+ red: parseInt(cssColor.substr(1, 2), 16),
+ green: parseInt(cssColor.substr(3, 2), 16),
+ blue: parseInt(cssColor.substr(5, 2), 16),
+ opacity: (cssColor.length === 9 ? (parseInt(cssColor.substr(7, 2), 16) / 255) : 1) * 100,
+ };
+ }
+
+ // TODO maybe implement a support for receiving css color like 'red' or
+ // 'transparent' (which are now considered non-css color by isCSSColor...)
+ // Note: however, if ever implemented be careful of 'white'/'black' which
+ // actually are color names for our color system...
+
+ return false;
+};
+/**
+ * Converts a CSS color (rgb(), rgba(), hexadecimal) to a normalized version
+ * of the same color (@see convertRgbaToCSSColor).
+ *
+ * Normalized color can be safely compared using string comparison.
+ *
+ * @static
+ * @param {string} cssColor - hexadecimal code or rgb() or rgba()
+ * @returns {string} - the normalized css color or the given css color if it
+ * failed to be normalized
+ */
+ColorpickerWidget.normalizeCSSColor = function (cssColor) {
+ const rgba = ColorpickerWidget.convertCSSColorToRgba(cssColor);
+ if (!rgba) {
+ return cssColor;
+ }
+ return ColorpickerWidget.convertRgbaToCSSColor(rgba.red, rgba.green, rgba.blue, rgba.opacity);
+};
+/**
+ * Checks if a given string is a css color.
+ *
+ * @static
+ * @param {string} cssColor
+ * @returns {boolean}
+ */
+ColorpickerWidget.isCSSColor = function (cssColor) {
+ return ColorpickerWidget.convertCSSColorToRgba(cssColor) !== false;
+};
+
+const ColorpickerDialog = Dialog.extend({
+ /**
+ * @override
+ */
+ init: function (parent, options) {
+ this.options = options || {};
+ this._super(parent, _.extend({
+ size: 'small',
+ title: _t('Pick a color'),
+ buttons: [
+ {text: _t('Choose'), classes: 'btn-primary', close: true, click: this._onFinalPick.bind(this)},
+ {text: _t('Discard'), close: true},
+ ],
+ }, this.options));
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ const proms = [this._super(...arguments)];
+ this.colorPicker = new ColorpickerWidget(this, _.extend({
+ colorPreview: true,
+ }, this.options));
+ proms.push(this.colorPicker.appendTo(this.$el));
+ return Promise.all(proms);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onFinalPick: function () {
+ this.trigger_up('colorpicker:saved', this.colorPicker.colorComponents);
+ },
+});
+
+return {
+ ColorpickerDialog: ColorpickerDialog,
+ ColorpickerWidget: ColorpickerWidget,
+};
+});
diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js
new file mode 100644
index 00000000..f6354920
--- /dev/null
+++ b/addons/web/static/src/js/widgets/data_export.js
@@ -0,0 +1,688 @@
+odoo.define('web.DataExport', function (require) {
+"use strict";
+
+var config = require('web.config');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var data = require('web.data');
+var framework = require('web.framework');
+var pyUtils = require('web.py_utils');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+var DataExport = Dialog.extend({
+ template: 'ExportDialog',
+ events: {
+ 'change .o_exported_lists_select': '_onChangeExportList',
+ 'change .o_import_compat input': '_onChangeCompatibleInput',
+ 'click .o_add_field': '_onClickAddField',
+ 'click .o_delete_exported_list': '_onClickDeleteExportListBtn',
+ 'click .o_expand': '_onClickExpand',
+ 'click .o_remove_field': '_onClickRemoveField',
+ 'click .o_save_list .o_save_list_btn': '_onClickSaveListBtn',
+ 'click .o_save_list .o_cancel_list_btn': '_resetTemplateField',
+ 'click .o_export_tree_item': '_onClickTreeItem',
+ 'dblclick .o_export_tree_item:not(.haschild)': '_onDblclickTreeItem',
+ 'keydown .o_export_tree_item': '_onKeydownTreeItem',
+ 'keydown .o_save_list_name': '_onKeydownSaveList',
+ 'input .o_export_search_input': '_onSearchInput',
+ },
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} record
+ * @param {string[]} defaultExportFields
+ */
+ init: function (parent, record, defaultExportFields, groupedBy, activeDomain, idsToExport) {
+ var options = {
+ title: _t("Export Data"),
+ buttons: [
+ {text: _t("Export"), click: this._onExportData, classes: 'btn-primary'},
+ {text: _t("Close"), close: true},
+ ],
+ };
+ this._super(parent, options);
+ this.records = {};
+ this.record = record;
+ this.defaultExportFields = defaultExportFields;
+ this.groupby = groupedBy;
+ this.exports = new data.DataSetSearch(this, 'ir.exports', this.record.getContext());
+ this.rowIndex = 0;
+ this.rowIndexLevel = 0;
+ this.isCompatibleMode = false;
+ this.domain = activeDomain || this.record.domain;
+ this.idsToExport = activeDomain ? false: idsToExport;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ var proms = [this._super.apply(this, arguments)];
+
+ // The default for the ".modal_content" element is "max-height: 100%;"
+ // but we want it to always expand to "height: 100%;" for this modal.
+ // This can be achieved thanks to CSS modification without touching
+ // the ".modal-content" rules... but not with Internet explorer (11).
+ this.$modal.find('.modal-content').css('height', '100%');
+
+ this.$fieldsList = this.$('.o_fields_list');
+
+ proms.push(this._rpc({route: '/web/export/formats'}).then(doSetupExportFormats));
+ proms.push(this._onChangeCompatibleInput().then(function () {
+ _.each(self.defaultExportFields, function (field) {
+ var record = self.records[field];
+ self._addField(record.id, record.string);
+ });
+ }));
+
+ proms.push(this._showExportsList());
+
+ // Bind sortable events after Dialog is open
+ this.opened().then(function () {
+ self.$('.o_fields_list').sortable({
+ axis: 'y',
+ handle: '.o_short_field',
+ forcePlaceholderSize: true,
+ placeholder: 'o-field-placeholder',
+ update: self.proxy('_resetTemplateField'),
+ });
+ });
+ return Promise.all(proms);
+
+ function doSetupExportFormats(formats) {
+ var $fmts = self.$('.o_export_format');
+
+ _.each(formats, function (format) {
+ var $radio = $('<input/>', {type: 'radio', value: format.tag, name: 'o_export_format_name', class: 'form-check-input', id: 'o_radio' + format.label});
+ var $label = $('<label/>', {html: format.label, class: 'form-check-label', for: 'o_radio' + format.label});
+
+ if (format.error) {
+ $radio.prop('disabled', true);
+ $label.html(_.str.sprintf("%s — %s", format.label, format.error));
+ }
+
+ $fmts.append($("<div class='radio form-check form-check-inline pl-4'></div>").append($radio, $label));
+ });
+
+ self.$exportFormatInputs = $fmts.find('input');
+ self.$exportFormatInputs.filter(':enabled').first().prop('checked', true);
+ }
+ },
+
+ /**
+ * Export all data with default values (fields, domain)
+ */
+ export() {
+ let exportedFields = this.defaultExportFields.map(field => ({
+ name: field,
+ label: this.record.fields[field].string,
+ store: this.record.fields[field].store,
+ type: this.record.fields[field].type,
+ }));
+ this._exportData(exportedFields, 'xlsx', false);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add the field in the export list
+ *
+ * @private
+ * @param {string} fieldID
+ * @param {string} label
+ */
+ _addField: function (fieldID, label) {
+ var $fieldList = this.$('.o_fields_list');
+ if (!$fieldList.find(".o_export_field[data-field_id='" + fieldID + "']").length) {
+ $fieldList.append(
+ $('<li>', {'class': 'o_export_field', 'data-field_id': fieldID}).append(
+ $('<span>', {'class': "fa fa-arrows o_short_field mx-1"}),
+ label.trim(),
+ $('<span>', {'class': 'fa fa-trash m-1 pull-right o_remove_field', 'title': _t("Remove field")})
+ )
+ );
+ }
+ },
+
+ /**
+ * Submit the user data and export the file
+ *
+ * @private
+ */
+ _exportData(exportedFields, exportFormat, idsToExport) {
+
+ if (_.isEmpty(exportedFields)) {
+ Dialog.alert(this, _t("Please select fields to export..."));
+ return;
+ }
+ if (this.isCompatibleMode) {
+ exportedFields.unshift({ name: 'id', label: _t('External ID') });
+ }
+
+ framework.blockUI();
+ this.getSession().get_file({
+ url: '/web/export/' + exportFormat,
+ data: {
+ data: JSON.stringify({
+ model: this.record.model,
+ fields: exportedFields,
+ ids: idsToExport,
+ domain: this.domain,
+ groupby: this.groupby,
+ context: pyUtils.eval('contexts', [this.record.getContext()]),
+ import_compat: this.isCompatibleMode,
+ })
+ },
+ complete: framework.unblockUI,
+ error: (error) => this.call('crash_manager', 'rpc_error', error),
+ });
+ },
+ /**
+ * @private
+ * @returns {string[]} exportFields
+ */
+ _getFields: function () {
+ var exportFields = this.$('.o_export_field').map(function () {
+ return $(this).data('field_id');
+ }).get();
+ if (exportFields.length === 0) {
+ Dialog.alert(this, _t("Please select fields to save export list..."));
+ }
+ return exportFields;
+ },
+ /**
+ * Fetch the field info for the relational field. This method will be
+ * invoked when the user expands the relational field from keyboard/mouse.
+ *
+ * @private
+ * @param {Object} record
+ */
+ _onExpandAction: function (record) {
+ var self = this;
+ if (!record.children) {
+ return;
+ }
+
+ var model = record.params.model;
+ var prefix = record.params.prefix;
+ var name = record.params.name;
+ var excludeFields = [];
+ if (record.relation_field) {
+ excludeFields.push(record.relation_field);
+ }
+
+ if (!record.loaded) {
+ this._rpc({
+ route: '/web/export/get_fields',
+ params: {
+ model: model,
+ prefix: prefix,
+ parent_name: name,
+ import_compat: this.isCompatibleMode,
+ parent_field_type: record.field_type,
+ parent_field: record.params.parent_field,
+ exclude: excludeFields,
+ },
+ }).then(function (results) {
+ record.loaded = true;
+ self._onShowData(results, record.id);
+ });
+ } else {
+ this._showContent(record.id);
+ }
+ },
+ /**
+ * After the fetching the fields info for the relational field, this method
+ * will render a list of a field for expanded relational field.
+ *
+ * @private
+ * @param {Object[]} records
+ * @param {string} expansion
+ */
+ _onShowData: function (records, expansion) {
+ var self = this;
+ if (expansion) {
+ this.$('.o_export_tree_item[data-id="' + expansion + '"]')
+ .addClass('show')
+ .find('.o_expand_parent')
+ .toggleClass('fa-chevron-right fa-chevron-down')
+ .next()
+ .after(QWeb.render('Export.TreeItems', {fields: records, debug: config.isDebug()}));
+ } else {
+ this.$('.o_left_field_panel').empty().append(
+ $('<div/>').addClass('o_field_tree_structure')
+ .append(QWeb.render('Export.TreeItems', {fields: records, debug: config.isDebug()}))
+ );
+ }
+
+ _.extend(this.records, _.object(_.pluck(records, 'id'), records));
+ this.$records = this.$('.o_export_tree_item');
+ this.$records.each(function (i, el) {
+ var $el = $(el);
+ $el.find('.o_tree_column').first().toggleClass('o_required', !!self.records[$el.data('id')].required);
+ });
+ },
+ /**
+ * @private
+ */
+ _addNewTemplate: function () {
+ this.$('.o_exported_lists').addClass('d-none');
+
+ this.$(".o_save_list")
+ .show()
+ .find(".o_save_list_name")
+ .val("")
+ .focus();
+ },
+ /**
+ * @private
+ */
+ _resetTemplateField: function () {
+ this.$('.o_exported_lists_select').val("");
+ this.$('.o_delete_exported_list').addClass('d-none');
+ this.$('.o_exported_lists').removeClass('d-none');
+
+ this.$(".o_save_list")
+ .hide()
+ .find(".o_save_list_name").val("");
+ },
+ /**
+ * If relational fields info is already fetched then this method is
+ * used to display fields.
+ *
+ * @private
+ * @param {string} fieldID
+ */
+ _showContent: function (fieldID) {
+ var $item = this.$('.o_export_tree_item[data-id="' + fieldID + '"]');
+ $item.toggleClass('show');
+ var isOpen = $item.hasClass('show');
+
+ $item.children('.o_expand_parent').toggleClass('fa-chevron-down', !!isOpen).toggleClass('fa-chevron-right', !isOpen);
+
+ var $childField = $item.find('.o_export_tree_item');
+ var childLength = (fieldID.split('/')).length + 1;
+ for (var i = 0 ; i < $childField.length ; i++) {
+ var $child = $childField.eq(i);
+ if (!isOpen) {
+ $child.hide();
+ } else if (childLength === $childField.eq(i).data('id').split('/').length) {
+ if ($child.hasClass('show')) {
+ $child.removeClass('show');
+ $child.children('.o_expand_parent').removeClass('fa-chevron-down').addClass('fa-chevron-right');
+ }
+ $child.show();
+ }
+ }
+ },
+ /**
+ * Fetches the saved export list for the current model
+ *
+ * @private
+ * @returns {Deferred}
+ */
+ _showExportsList: function () {
+ var self = this;
+ if (this.$('.o_exported_lists_select').is(':hidden')) {
+ this.$('.o_exported_lists').show();
+ return Promise.resolve();
+ }
+
+ return this._rpc({
+ model: 'ir.exports',
+ method: 'search_read',
+ fields: ['name'],
+ domain: [['resource', '=', this.record.model]]
+ }).then(function (exportList) {
+ self.$('.o_exported_lists').append(QWeb.render('Export.SavedList', {
+ existing_exports: exportList,
+ }));
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method will fill fields to export when user change exported field list
+ *
+ * @private
+ */
+ _onChangeExportList: function () {
+ var self = this;
+ var exportID = this.$('.o_exported_lists_select option:selected').val();
+ this.$('.o_delete_exported_list').toggleClass('d-none', !exportID);
+ if (exportID && exportID !== 'new_template') {
+ this.$('.o_fields_list').empty();
+ this._rpc({
+ route: '/web/export/namelist',
+ params: {
+ model: this.record.model,
+ export_id: parseInt(exportID, 10),
+ },
+ }).then(function (fieldList) {
+ _.each(fieldList, function (field) {
+ self._addField(field.name, field.label);
+ });
+ });
+ } else if (exportID === 'new_template') {
+ self._addNewTemplate();
+ }
+ },
+ /**
+ * @private
+ * @returns {Deferred}
+ */
+ _onChangeCompatibleInput: function () {
+ var self = this;
+ this.isCompatibleMode = this.$('.o_import_compat input').is(':checked');
+
+ this.$('.o_field_tree_structure').remove();
+ this._resetTemplateField();
+ return this._rpc({
+ route: '/web/export/get_fields',
+ params: {
+ model: this.record.model,
+ import_compat: this.isCompatibleMode,
+ },
+ }).then(function (records) {
+ var compatibleFields = _.map(records, function (record) { return record.id; });
+ self._onShowData(records);
+ self.$('.o_fields_list').empty();
+
+ _.chain(self.$fieldsList.find('.o_export_field'))
+ .map(function (field) { return $(field).data('field_id'); })
+ .union(self.defaultExportFields)
+ .intersection(compatibleFields)
+ .each(function (field) {
+ var record = _.find(records, function (rec) {
+ return rec.id === field;
+ });
+ self._addField(record.id, record.string);
+ });
+ self.$('#o-export-search-filter').val('');
+ });
+ },
+ /**
+ * Add a field to export list
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onClickAddField: function(ev) {
+ ev.stopPropagation();
+ var $field = $(ev.currentTarget);
+ this._resetTemplateField();
+ this._addField($field.closest('.o_export_tree_item').data('id'), $field.closest('.o_tree_column').text());
+ },
+ /**
+ * Delete selected export list item from the saved export list
+ *
+ * @private
+ */
+ _onClickDeleteExportListBtn: function () {
+ var self = this;
+ var selectExp = this.$('.o_exported_lists_select option:selected');
+ var options = {
+ confirm_callback: function () {
+ if (selectExp.val()) {
+ self.exports.unlink([parseInt(selectExp.val(), 10)]);
+ selectExp.remove();
+ if (self.$('.o_exported_lists_select option').length <= 1) {
+ self.$('.o_exported_lists').hide();
+ }
+ }
+ }
+ };
+ Dialog.confirm(this, _t("Do you really want to delete this export template?"), options);
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onClickExpand: function (ev) {
+ this._onExpandAction(this.records[$(ev.target).closest('.o_export_tree_item').data('id')]);
+ },
+ /**
+ * Remove selected field from export field list
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onClickRemoveField: function (ev) {
+ $(ev.currentTarget).closest('.o_export_field').remove();
+ this._resetTemplateField();
+ },
+ /**
+ * This method will create a record in 'ir.exports' model with list of
+ * selected fields.
+ *
+ * @private
+ */
+ _onClickSaveListBtn: function () {
+ var self = this;
+ var $saveList = this.$('.o_save_list');
+
+ var value = $saveList.find('input').val();
+ if (!value) {
+ Dialog.alert(this, _t("Please enter save field list name"));
+ return;
+ }
+
+ var fields = this._getFields();
+ if (fields.length === 0) {
+ return;
+ }
+
+ $saveList.hide();
+
+ this.exports.create({
+ name: value,
+ resource: this.record.model,
+ export_fields: _.map(fields, function (field) {
+ return [0, 0, { name: field }];
+ }),
+ }).then(function (exportListID) {
+ if (!exportListID) {
+ return;
+ }
+ var $select = self.$('.o_exported_lists_select');
+ if ($select.length === 0 || $select.is(':hidden')) {
+ self._showExportsList();
+ }
+ $select.append(new Option(value, exportListID));
+ self.$('.o_exported_lists').removeClass('d-none');
+ $select.val(exportListID);
+ });
+ },
+ /**
+ * @private
+ * @param ev
+ */
+ _onClickTreeItem: function (ev) {
+ ev.stopPropagation();
+ var $elem = $(ev.currentTarget);
+
+ var rowIndex = $elem.prevAll('.o_export_tree_item').length;
+ var rowIndexLevel = $elem.parents('.o_export_tree_item').length;
+
+ if (ev.shiftKey && rowIndexLevel === this.rowIndexLevel) {
+ var minIndex = Math.min(rowIndex, this.rowIndex);
+ var maxIndex = Math.max(rowIndex, this.rowIndex);
+
+ this.$records.filter(function () { return ($elem.parent()[0] === $(this).parent()[0]); })
+ .slice(minIndex, maxIndex + 1)
+ .addClass('o_selected')
+ .filter(':not(:last)')
+ .each(processChildren);
+ }
+
+ this.rowIndex = rowIndex;
+ this.rowIndexLevel = rowIndexLevel;
+
+ if (ev.ctrlKey) {
+ $elem.toggleClass('o_selected').focus();
+ } else if (ev.shiftKey) {
+ $elem.addClass('o_selected').focus();
+ } else {
+ this.$('.o_selected').removeClass('o_selected');
+ $elem.addClass('o_selected').focus();
+ }
+
+ function processChildren() {
+ var $child = $(this);
+ if ($child.hasClass('show')) {
+ $child.children('.o_export_tree_item')
+ .addClass('o_selected')
+ .each(processChildren);
+ }
+ }
+ },
+ /**
+ * Submit the user data and export the file
+ *
+ * @private
+ */
+ _onExportData() {
+ let exportedFields = this.$('.o_export_field').map((i, field) => ({
+ name: $(field).data('field_id'),
+ label: field.textContent,
+ }
+ )).get();
+ let exportFormat = this.$exportFormatInputs.filter(':checked').val();
+ this._exportData(exportedFields, exportFormat, this.idsToExport);
+ },
+ /**
+ * Add a field to export field list on double click
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onDblclickTreeItem: function (ev) {
+ var self = this;
+ this._resetTemplateField();
+ function addElement(el) {
+ self._addField(el.getAttribute('data-id'), el.querySelector('.o_tree_column').textContent);
+ }
+ var target = ev.currentTarget;
+ target.classList.remove('o_selected');
+ // Add parent fields to export
+ [].reverse.call($(target).parents('.o_export_tree_item')).each(function () {
+ addElement(this);
+ });
+ // add field itself
+ addElement(target);
+ },
+ /**
+ * @private
+ * @param ev
+ */
+ _onKeydownSaveList: function (ev) {
+ if (ev.keyCode === $.ui.keyCode.ENTER) {
+ this._onClickSaveListBtn();
+ }
+ },
+ /**
+ * Handles the keyboard navigation for the fields
+ *
+ * @private
+ * @param ev
+ */
+ _onKeydownTreeItem: function (ev) {
+ ev.stopPropagation();
+ var $el = $(ev.currentTarget);
+ var record = this.records[$el.data('id')];
+
+ switch (ev.keyCode || ev.which) {
+ case $.ui.keyCode.LEFT:
+ if ($el.hasClass('show')) {
+ this._onExpandAction(record);
+ }
+ break;
+ case $.ui.keyCode.RIGHT:
+ if (!$el.hasClass('show')) {
+ this._onExpandAction(record);
+ }
+ break;
+ case $.ui.keyCode.UP:
+ var $prev = $el.prev('.o_export_tree_item');
+ if ($prev.length === 1) {
+ while ($prev.hasClass('show')) {
+ $prev = $prev.children('.o_export_tree_item').last();
+ }
+ } else {
+ $prev = $el.parent('.o_export_tree_item');
+ if ($prev.length === 0) {
+ break;
+ }
+ }
+
+ $el.removeClass('o_selected').blur();
+ $prev.addClass("o_selected").focus();
+ break;
+ case $.ui.keyCode.DOWN:
+ var $next;
+ if ($el.hasClass('show')) {
+ $next = $el.children('.o_export_tree_item').first();
+ } else {
+ $next = $el.next('.o_export_tree_item');
+ if ($next.length === 0) {
+ $next = $el.parent('.o_export_tree_item').next('.o_export_tree_item');
+ if ($next.length === 0) {
+ break;
+ }
+ }
+ }
+
+ $el.removeClass('o_selected').blur();
+ $next.addClass('o_selected').focus();
+ break;
+ }
+ },
+ /**
+ * Search fields from a field list.
+ *
+ * @private
+ */
+ _onSearchInput: function (ev) {
+ var searchText = $(ev.currentTarget).val().trim().toUpperCase();
+ if (!searchText) {
+ this.$('.o_no_match').remove();
+ this.$(".o_export_tree_item").show();
+ this.$(".o_export_tree_item.haschild:not(.show) .o_export_tree_item").hide();
+ return;
+ }
+
+ var matchItems = this.$(".o_tree_column").filter(function () {
+ var title = this.getAttribute('title');
+ return this.innerText.toUpperCase().indexOf(searchText) >= 0
+ || title && title.toUpperCase().indexOf(searchText) >= 0;
+ }).parent();
+ this.$(".o_export_tree_item").hide();
+ if (matchItems.length) {
+ this.$('.o_no_match').remove();
+ _.each(matchItems, function (col) {
+ var $col = $(col);
+ $col.show();
+ $col.parents('.haschild.show').show();
+ if (!$col.parent().hasClass('show') && !$col.parent().hasClass('o_field_tree_structure')) {
+ $col.hide();
+ }
+ });
+ } else if (!this.$('.o_no_match').length) {
+ this.$(".o_field_tree_structure").append($("<h3/>", {
+ class: 'text-center text-muted mt-5 o_no_match',
+ text: _t("No match found.")
+ }));
+ }
+ },
+});
+
+return DataExport;
+
+});
diff --git a/addons/web/static/src/js/widgets/date_picker.js b/addons/web/static/src/js/widgets/date_picker.js
new file mode 100644
index 00000000..fdbd1f7d
--- /dev/null
+++ b/addons/web/static/src/js/widgets/date_picker.js
@@ -0,0 +1,358 @@
+odoo.define('web.datepicker', function (require) {
+"use strict";
+
+var core = require('web.core');
+var field_utils = require('web.field_utils');
+var time = require('web.time');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+
+var DateWidget = Widget.extend({
+ template: "web.datepicker",
+ type_of_date: "date",
+ events: {
+ 'error.datetimepicker': 'errorDatetime',
+ 'change .o_datepicker_input': 'changeDatetime',
+ 'click input': '_onInputClicked',
+ 'input input': '_onInput',
+ 'keydown': '_onKeydown',
+ 'show.datetimepicker': '_onDateTimePickerShow',
+ 'hide.datetimepicker': '_onDateTimePickerHide',
+ },
+ /**
+ * @override
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+
+ this.name = parent.name;
+ this.options = _.extend({
+ locale: moment.locale(),
+ format : this.type_of_date === 'datetime' ? time.getLangDatetimeFormat() : time.getLangDateFormat(),
+ minDate: moment({ y: 1000 }),
+ maxDate: moment({ y: 9999, M: 11, d: 31 }),
+ useCurrent: false,
+ icons: {
+ time: 'fa fa-clock-o',
+ date: 'fa fa-calendar',
+ up: 'fa fa-chevron-up',
+ down: 'fa fa-chevron-down',
+ previous: 'fa fa-chevron-left',
+ next: 'fa fa-chevron-right',
+ today: 'fa fa-calendar-check-o',
+ clear: 'fa fa-trash',
+ close: 'fa fa-check primary',
+ },
+ calendarWeeks: true,
+ buttons: {
+ showToday: false,
+ showClear: false,
+ showClose: false,
+ },
+ widgetParent: 'body',
+ keyBinds: null,
+ }, options || {});
+
+ this.__libInput = 0;
+ // tempusdominus doesn't offer any elegant way to check whether the
+ // datepicker is open or not, so we have to listen to hide/show events
+ // and manually keep track of the 'open' state
+ this.__isOpen = false;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$input = this.$('input.o_datepicker_input');
+ this.__libInput++;
+ this.$el.datetimepicker(this.options);
+ this.__libInput--;
+ this._setReadonly(false);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (this._onScroll) {
+ window.removeEventListener('wheel', this._onScroll, true);
+ }
+ this.__libInput++;
+ this.$el.datetimepicker('destroy');
+ this.__libInput--;
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * set datetime value
+ */
+ changeDatetime: function () {
+ if (this.__libInput > 0) {
+ if (this.options.warn_future) {
+ this._warnFuture(this.getValue());
+ }
+ this.trigger("datetime_changed");
+ return;
+ }
+ var oldValue = this.getValue();
+ if (this.isValid()) {
+ this._setValueFromUi();
+ var newValue = this.getValue();
+ var hasChanged = !oldValue !== !newValue;
+ if (oldValue && newValue) {
+ var formattedOldValue = oldValue.format(time.getLangDatetimeFormat());
+ var formattedNewValue = newValue.format(time.getLangDatetimeFormat());
+ if (formattedNewValue !== formattedOldValue) {
+ hasChanged = true;
+ }
+ }
+ if (hasChanged) {
+ if (this.options.warn_future) {
+ this._warnFuture(newValue);
+ }
+ this.trigger("datetime_changed");
+ }
+ } else {
+ var formattedValue = oldValue ? this._formatClient(oldValue) : null;
+ this.$input.val(formattedValue);
+ }
+ },
+ /**
+ * Library clears the wrong date format so just ignore error
+ */
+ errorDatetime: function (e) {
+ return false;
+ },
+ /**
+ * Focuses the datepicker input. This function must be called in order to
+ * prevent 'input' events triggered by the lib to bubble up, and to cause
+ * unwanted effects (like triggering 'field_changed' events)
+ */
+ focus: function () {
+ this.__libInput++;
+ this.$input.focus();
+ this.__libInput--;
+ },
+ /**
+ * @returns {Moment|false}
+ */
+ getValue: function () {
+ var value = this.get('value');
+ return value && value.clone();
+ },
+ /**
+ * @returns {boolean}
+ */
+ isValid: function () {
+ var value = this.$input.val();
+ if (value === "") {
+ return true;
+ } else {
+ try {
+ this._parseClient(value);
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+ },
+ /**
+ * @returns {Moment|false} value
+ */
+ maxDate: function (date) {
+ this.__libInput++;
+ this.$el.datetimepicker('maxDate', date || null);
+ this.__libInput--;
+ },
+ /**
+ * @returns {Moment|false} value
+ */
+ minDate: function (date) {
+ this.__libInput++;
+ this.$el.datetimepicker('minDate', date || null);
+ this.__libInput--;
+ },
+ /**
+ * @param {Moment|false} value
+ */
+ setValue: function (value) {
+ this.set({'value': value});
+ var formatted_value = value ? this._formatClient(value) : null;
+ this.$input.val(formatted_value);
+ this._setLibInputValue(value);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * add a warning to communicate that a date in the future has been set
+ *
+ * @private
+ * @param {Moment} currentDate
+ */
+ _warnFuture: function (currentDate) {
+ if (!this.$warning) {
+ this.$warning = $('<span>', {
+ class: 'fa fa-exclamation-triangle o_tz_warning o_datepicker_warning',
+ });
+ var title = _t("This date is in the future. Make sure this is what you expect.");
+ this.$warning.attr('title', title);
+ this.$input.after(this.$warning);
+ }
+ // Get rid of time and TZ crap for comparison
+ if (currentDate && currentDate.format('YYYY-MM-DD') > moment().format('YYYY-MM-DD')) {
+ this.$warning.show();
+ } else {
+ this.$warning.hide();
+ }
+ },
+
+ /**
+ * @private
+ * @param {Moment} v
+ * @returns {string}
+ */
+ _formatClient: function (v) {
+ return field_utils.format[this.type_of_date](v, null, {timezone: false});
+ },
+ /**
+ * @private
+ * @param {string|false} v
+ * @returns {Moment}
+ */
+ _parseClient: function (v) {
+ return field_utils.parse[this.type_of_date](v, null, {timezone: false});
+ },
+ /**
+ * @private
+ * @param {Moment|false} value
+ */
+ _setLibInputValue: function (value) {
+ this.__libInput++;
+ this.$el.datetimepicker('date', value || null);
+ this.__libInput--;
+ },
+ /**
+ * @private
+ * @param {boolean} readonly
+ */
+ _setReadonly: function (readonly) {
+ this.readonly = readonly;
+ this.$input.prop('readonly', this.readonly);
+ },
+ /**
+ * set the value from the input value
+ *
+ * @private
+ */
+ _setValueFromUi: function () {
+ var value = this.$input.val() || false;
+ this.setValue(this._parseClient(value));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Reacts to the datetimepicker being hidden
+ * Used to unbind the scroll event from the datetimepicker
+ *
+ * @private
+ */
+ _onDateTimePickerHide: function () {
+ this.__isOpen = false;
+ this.changeDatetime();
+ if (this._onScroll) {
+ window.removeEventListener('wheel', this._onScroll, true);
+ }
+ this.changeDatetime();
+ },
+ /**
+ * Reacts to the datetimepicker being shown
+ * Could set/verify our widget value
+ * And subsequently update the datetimepicker
+ *
+ * @private
+ */
+ _onDateTimePickerShow: function () {
+ this.__isOpen = true;
+ if (this.$input.val().length !== 0 && this.isValid()) {
+ this.$input.select();
+ }
+ var self = this;
+ this._onScroll = function (ev) {
+ if (ev.target !== self.$input.get(0)) {
+ self.__libInput++;
+ self.$el.datetimepicker('hide');
+ self.__libInput--;
+ }
+ };
+ window.addEventListener('wheel', this._onScroll, true);
+ },
+ /**
+ * @private
+ * @param {KeyEvent} ev
+ */
+ _onKeydown: function (ev) {
+ if (ev.which === $.ui.keyCode.ESCAPE) {
+ if (this.__isOpen) {
+ // we don't want any other effects than closing the datepicker,
+ // like leaving the edition of a row in editable list view
+ ev.stopImmediatePropagation();
+ this.__libInput++;
+ this.$el.datetimepicker('hide');
+ this.__libInput--;
+ this.focus();
+ }
+ }
+ },
+ /**
+ * Prevents 'input' events triggered by the library to bubble up, as they
+ * might have unwanted effects (like triggering 'field_changed' events in
+ * the context of field widgets)
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onInput: function (ev) {
+ if (this.__libInput > 0) {
+ ev.stopImmediatePropagation();
+ }
+ },
+ /**
+ * @private
+ */
+ _onInputClicked: function () {
+ this.__libInput++;
+ this.$el.datetimepicker('toggle');
+ this.__libInput--;
+ this.focus();
+ },
+});
+
+var DateTimeWidget = DateWidget.extend({
+ type_of_date: "datetime",
+ init: function (parent, options) {
+ this._super(parent, _.extend({
+ buttons: {
+ showToday: false,
+ showClear: false,
+ showClose: true,
+ },
+ }, options || {}));
+ },
+});
+
+return {
+ DateWidget: DateWidget,
+ DateTimeWidget: DateTimeWidget,
+};
+
+});
diff --git a/addons/web/static/src/js/widgets/domain_selector.js b/addons/web/static/src/js/widgets/domain_selector.js
new file mode 100644
index 00000000..9fe71ce1
--- /dev/null
+++ b/addons/web/static/src/js/widgets/domain_selector.js
@@ -0,0 +1,987 @@
+odoo.define("web.DomainSelector", function (require) {
+"use strict";
+
+var core = require("web.core");
+var datepicker = require("web.datepicker");
+var Domain = require("web.Domain");
+var field_utils = require ("web.field_utils");
+var ModelFieldSelector = require("web.ModelFieldSelector");
+var Widget = require("web.Widget");
+
+var _t = core._t;
+var _lt = core._lt;
+
+// "child_of", "parent_of", "like", "not like", "=like", "=ilike"
+// are only used if user entered them manually or if got from demo data
+var operator_mapping = {
+ "=": "=",
+ "!=": _lt("is not ="),
+ ">": ">",
+ "<": "<",
+ ">=": ">=",
+ "<=": "<=",
+ "ilike": _lt("contains"),
+ "not ilike": _lt("does not contain"),
+ "in": _lt("in"),
+ "not in": _lt("not in"),
+
+ "child_of": _lt("child of"),
+ "parent_of": _lt("parent of"),
+ "like": "like",
+ "not like": "not like",
+ "=like": "=like",
+ "=ilike": "=ilike",
+
+ // custom
+ "set": _lt("is set"),
+ "not set": _lt("is not set"),
+};
+
+/**
+ * Abstraction for widgets which can represent and allow edition of a domain.
+ */
+var DomainNode = Widget.extend({
+ events: {
+ // If click on the node add or delete button, notify the parent and let
+ // it handle the addition/removal
+ "click .o_domain_add_node_button": "_onAddButtonClick",
+ "click .o_domain_delete_node_button": "_onDeleteButtonClick",
+ // Handle visual feedback and animation
+ "mouseenter button": "_onButtonEntered",
+ "mouseleave button": "_onButtonLeft",
+ },
+ /**
+ * A DomainNode needs a model and domain to work. It can also receive a set
+ * of options.
+ *
+ * @param {Object} parent
+ * @param {string} model - the model name
+ * @param {Array|string} domain - the prefix representation of the domain
+ * @param {Object} [options] - an object with possible values:
+ * @param {boolean} [options.readonly=true] - true if is readonly
+ * @param {Array} [options.default] - default domain used when creating a
+ * new node
+ * @param {string[]} [options.operators=null]
+ * a list of available operators (null = all of supported ones)
+ * @param {boolean} [options.debugMode=false] - true if should be in debug
+ *
+ * @see ModelFieldSelector for other options
+ */
+ init: function (parent, model, domain, options) {
+ this._super.apply(this, arguments);
+
+ this.model = model;
+ this.options = _.extend({
+ readonly: true,
+ operators: null,
+ debugMode: false,
+ }, options || {});
+
+ this.readonly = this.options.readonly;
+ this.debug = this.options.debugMode;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Should return if the node is representing a well-formed domain, whose
+ * field chains properly belong to the associated model.
+ *
+ * @abstract
+ * @returns {boolean}
+ */
+ isValid: function () {},
+ /**
+ * Should return the prefix domain the widget is currently representing
+ * (an array).
+ *
+ * @abstract
+ * @returns {Array}
+ */
+ getDomain: function () {},
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the add button is clicked -> trigger_up an event to ask
+ * creation of a new child in its parent.
+ *
+ * @param {Event} e
+ */
+ _onAddButtonClick: function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.trigger_up("add_node_clicked", {newBranch: !!$(e.currentTarget).data("branch"), child: this});
+ },
+ /**
+ * Called when the delete button is clicked -> trigger_up an event to ask
+ * deletion of this node from its parent.
+ *
+ * @param {Event} e
+ */
+ _onDeleteButtonClick: function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.trigger_up("delete_node_clicked", {child: this});
+ },
+ /**
+ * Called when a "controlpanel" button is hovered -> add classes to the
+ * domain node to add animation effects.
+ *
+ * @param {Event} e
+ */
+ _onButtonEntered: function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ var $target = $(e.currentTarget);
+ this.$el.toggleClass("o_hover_btns", $target.hasClass("o_domain_delete_node_button"));
+ this.$el.toggleClass("o_hover_add_node", $target.hasClass("o_domain_add_node_button"));
+ this.$el.toggleClass("o_hover_add_inset_node", !!$target.data("branch"));
+ },
+ /**
+ * Called when a "controlpanel" button is not hovered anymore -> remove
+ * classes from the domain node to stop animation effects.
+ *
+ * @param {Event} e
+ */
+ _onButtonLeft: function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.$el.removeClass("o_hover_btns o_hover_add_node o_hover_add_inset_node");
+ },
+});
+
+/**
+ * DomainNode which can handle subdomains (a domain which is composed of
+ * multiple parts). It thus will be composed of other DomainTree instances
+ * and/or leaf parts of a domain (@see DomainLeaf).
+ */
+var DomainTree = DomainNode.extend({
+ template: "DomainTree",
+ events: _.extend({}, DomainNode.prototype.events, {
+ "click .o_domain_tree_operator_selector .dropdown-item": "_onOperatorChange",
+ }),
+ custom_events: {
+ // If a domain child sends a request to add a child or remove one, call
+ // the appropriate methods. Propagates the event until success.
+ "add_node_clicked": "_onNodeAdditionAsk",
+ "delete_node_clicked": "_onNodeDeletionAsk",
+ },
+ /**
+ * @constructor
+ * @see DomainNode.init
+ * The initialization of a DomainTree creates a "children" array attribute
+ * which will contain the the DomainNode children. It also deduces the
+ * operator from the domain.
+ * @see DomainTree._addFlattenedChildren
+ */
+ init: function (parent, model, domain) {
+ this._super.apply(this, arguments);
+ var parsedDomain = this._parseDomain(domain);
+ if (parsedDomain) {
+ this._initialize(parsedDomain);
+ }
+ },
+ /**
+ * @see DomainNode.start
+ * @returns {Promise}
+ */
+ start: function () {
+ this._postRender();
+ return Promise.all([
+ this._super.apply(this, arguments),
+ this._renderChildrenTo(this.$childrenContainer)
+ ]);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see DomainNode.isValid
+ * @returns {boolean}
+ */
+ isValid: function () {
+ for (var i = 0 ; i < this.children.length ; i++) {
+ var cValid = this.children[i].isValid();
+ if (!cValid) {
+ return cValid;
+ }
+ }
+ return this._isValid;
+ },
+ /**
+ * @see DomainNode.getDomain
+ * @returns {Array}
+ */
+ getDomain: function () {
+ var childDomains = [];
+ var nbChildren = 0;
+ _.each(this.children, function (child) {
+ var childDomain = child.getDomain();
+ if (childDomain.length) {
+ nbChildren++;
+ childDomains = childDomains.concat(child.getDomain());
+ }
+ });
+ var nbChildRequired = this.operator === "!" ? 1 : 2;
+ var operators = _.times(nbChildren - nbChildRequired + 1, _.constant(this.operator));
+ return operators.concat(childDomains);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds a domain part to the widget.
+ * -> trigger_up "domain_changed" if the child is added
+ *
+ * @private
+ * @param {Array} domain - the prefix-like domain to build and add to the
+ * widget
+ * @param {DomainNode} afterNode - the node after which the new domain part
+ * must be added (at the end if not given)
+ * @returns {boolean} true if the part was added
+ * false otherwise (the afterNode was not found)
+ */
+ _addChild: function (domain, afterNode) {
+ var i = afterNode ? _.indexOf(this.children, afterNode) : this.children.length;
+ if (i < 0) return false;
+
+ this.children.splice(i+1, 0, instantiateNode(this, this.model, domain, this.options));
+ this.trigger_up("domain_changed", {child: this});
+ return true;
+ },
+ /**
+ * Adds a child which represents the given domain. If the child has children
+ * and that the child main domain operator is the same as the current widget
+ * one, the 2-children prefix hierarchy is then simplified by making the
+ * child's children the widget's own children.
+ *
+ * @private
+ * @param {Array|string} domain - the domain of the child to add
+ */
+ _addFlattenedChildren: function (domain) {
+ var node = instantiateNode(this, this.model, domain, this.options);
+ if (node === null) {
+ return;
+ }
+ if (!node.children || node.operator !== this.operator) {
+ this.children.push(node);
+ return;
+ }
+ _.each(node.children, (function (child) {
+ child.setParent(this);
+ this.children.push(child);
+ }).bind(this));
+ node.destroy();
+ },
+ /**
+ * Changes the operator of the domain tree and notifies the parent if
+ * necessary (not silent).
+ *
+ * @private
+ * @param {string} operator - the new operator
+ * @param {boolean} silent - true if the parents should not be notified of
+ * the change
+ */
+ _changeOperator: function (operator, silent) {
+ this.operator = operator;
+ if (!silent) this.trigger_up("domain_changed", {child: this});
+ },
+ /**
+ * @see DomainTree.init
+ * @private
+ */
+ _initialize: function (domain) {
+ this._isValid = true;
+ this.operator = domain[0];
+ this.children = [];
+ if (domain.length <= 1) {
+ return;
+ }
+
+ // Add flattened children by search the appropriate number of children
+ // in the rest of the domain (after the operator)
+ var nbLeafsToFind = 1;
+ for (var i = 1 ; i < domain.length ; i++) {
+ if (domain[i] === "&" || domain[i] === "|") {
+ nbLeafsToFind++;
+ } else if (domain[i] !== "!") {
+ nbLeafsToFind--;
+ }
+
+ if (!nbLeafsToFind) {
+ var partLeft = domain.slice(1, i+1);
+ var partRight = domain.slice(i+1);
+ if (partLeft.length) {
+ this._addFlattenedChildren(partLeft);
+ }
+ if (partRight.length) {
+ this._addFlattenedChildren(partRight);
+ }
+ break;
+ }
+ }
+ this._isValid = (nbLeafsToFind === 0);
+
+ // Mark "!" tree children so that they do not allow to add other
+ // children around them
+ if (this.operator === "!") {
+ this.children[0].noControlPanel = true;
+ }
+ },
+ /**
+ * @see DomainTree.start
+ * Initializes variables which depend on the rendered widget.
+ * @private
+ */
+ _postRender: function () {
+ this.$childrenContainer = this.$("> .o_domain_node_children_container");
+ },
+ /**
+ * Removes a given child from the widget.
+ * -> trigger_up domain_changed if the child is removed
+ *
+ * @private
+ * @param {DomainNode} oldChild - the child instance to remove
+ * @returns {boolean} true if the child was removed, false otherwise (the
+ * widget does not own the child)
+ */
+ _removeChild: function (oldChild) {
+ var i = _.indexOf(this.children, oldChild);
+ if (i < 0) return false;
+
+ this.children[i].destroy();
+ this.children.splice(i, 1);
+ this.trigger_up("domain_changed", {child: this});
+ return true;
+ },
+ /**
+ * @see DomainTree.start
+ * Appends the children domain node to the given node. This is used to
+ * render the children widget in a dummy element before adding them in the
+ * DOM, otherwhise they could be misordered as they rendering is not
+ * synchronous.
+ *
+ * @private
+ * @param {jQuery} $to - the jQuery node to which the children must be added
+ * @returns {Promise}
+ */
+ _renderChildrenTo: function ($to) {
+ var $div = $("<div/>");
+ return Promise.all(_.map(this.children, (function (child) {
+ return child.appendTo($div);
+ }).bind(this))).then((function () {
+ _.each(this.children, function (child) {
+ child.$el.appendTo($to); // Forced to do it this way so that the
+ // children are not misordered
+ });
+ }).bind(this));
+ },
+ /**
+ * @param {string} domain
+ * @returns {Array[]}
+ */
+ _parseDomain: function (domain) {
+ var parsedDomain = false;
+ try {
+ parsedDomain = Domain.prototype.stringToArray(domain);
+ this.invalidDomain = false;
+ } catch (err) {
+ // TODO: domain could contain `parent` for example, which is
+ // currently not handled by the DomainSelector
+ this.invalidDomain = true;
+ this.children = [];
+ }
+ return parsedDomain;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the operator select value is changed -> change the internal
+ * operator state
+ *
+ * @param {Event} e
+ */
+ _onOperatorChange: function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ this._changeOperator($(e.target).data("operator"));
+ },
+ /**
+ * Called when a node addition was asked -> add the new domain part if on
+ * the right node or let the propagation continue.
+ *
+ * @param {OdooEvent} e
+ */
+ _onNodeAdditionAsk: function (e) {
+ var domain = this.options.default || [["id", "=", 1]];
+ if (e.data.newBranch) {
+ domain = [this.operator === "&" ? "|" : "&"].concat(domain).concat(domain);
+ }
+ if (this._addChild(domain, e.data.child)) {
+ e.stopPropagation();
+ }
+ },
+ /**
+ * Called when a node deletion was asked -> remove the domain part if on
+ * the right node or let the propagation continue.
+ *
+ * @param {OdooEvent} e
+ */
+ _onNodeDeletionAsk: function (e) {
+ if (this._removeChild(e.data.child)) {
+ e.stopPropagation();
+ }
+ },
+});
+
+/**
+ * The DomainSelector widget can be used to build prefix char domain. It is the
+ * DomainTree specialization to use to have a fully working widget.
+ *
+ * Known limitations:
+ *
+ * - Some operators like "child_of", "parent_of", "like", "not like",
+ * "=like", "=ilike" will come only if you use them from demo data or
+ * debug input.
+ * - Some kind of domain can not be build right now
+ * e.g ("country_id", "in", [1,2,3]) but you can insert from debug input.
+ */
+var DomainSelector = DomainTree.extend({
+ template: "DomainSelector",
+ events: _.extend({}, DomainTree.prototype.events, {
+ "click .o_domain_add_first_node_button": "_onAddFirstButtonClick",
+ "change .o_domain_debug_input": "_onDebugInputChange",
+ }),
+ custom_events: _.extend({}, DomainTree.prototype.custom_events, {
+ domain_changed: "_onDomainChange",
+ }),
+
+ start: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ if (self.invalidDomain) {
+ var msg = _t("This domain is not supported.");
+ self.$el.html(msg);
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Changes the internal domain value and forces a reparsing and rerendering.
+ * If the internal domain value was already equal to the given one, this
+ * does nothing.
+ *
+ * @param {string} domain
+ * @returns {Promise} resolved when the rerendering is finished
+ */
+ setDomain: function (domain) {
+ if (domain === Domain.prototype.arrayToString(this.getDomain())) {
+ return Promise.resolve();
+ }
+ var parsedDomain = this._parseDomain(domain);
+ if (parsedDomain) {
+ return this._redraw(parsedDomain);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see DomainTree._initialize
+ */
+ _initialize: function (domain) {
+ // Check if the domain starts with implicit "&" operators and make them
+ // explicit. As the DomainSelector is a specialization of a DomainTree,
+ // it is waiting for a tree and not a leaf. So [] and [A] will be made
+ // explicit with ["&"], ["&", A] so that tree parsing is made correctly.
+ // Note: the domain is considered to be a valid one
+ if (domain.length > 1) {
+ Domain.prototype.normalizeArray(domain);
+ } else {
+ domain = ["&"].concat(domain);
+ }
+ return this._super(domain);
+ },
+ /**
+ * @see DomainTree._postRender
+ * Warns the user if the domain is not valid after rendering.
+ */
+ _postRender: function () {
+ this._super.apply(this, arguments);
+
+ // Display technical domain if in debug mode
+ this.$debugInput = this.$(".o_domain_debug_input");
+ if (this.$debugInput.length) {
+ this.$debugInput.val(Domain.prototype.arrayToString(this.getDomain()));
+ }
+
+ // Warn the user if the domain is not valid after rendering
+ if (!this._isValid) {
+ this.do_warn(false, _t("Domain not supported"));
+ }
+ },
+ /**
+ * This method is ugly but achieves the right behavior without flickering.
+ *
+ * @param {Array|string} domain
+ * @returns {Promise}
+ */
+ _redraw: function (domain) {
+ var oldChildren = this.children.slice();
+ this._initialize(domain || this.getDomain());
+ return this._renderChildrenTo($("<div/>")).then((function () {
+ _.each(oldChildren, function (child) { child.destroy(); });
+ this.renderElement();
+ this._postRender();
+ _.each(this.children, (function (child) { child.$el.appendTo(this.$childrenContainer); }).bind(this));
+ }).bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the "add a filter" button is clicked -> adds a first domain
+ * node
+ */
+ _onAddFirstButtonClick: function () {
+ this._addChild(this.options.default || [["id", "=", 1]]);
+ },
+ /**
+ * Called when the debug input value is changed -> constructs the tree
+ * representation if valid or warn the user if invalid.
+ *
+ * @param {Event} e
+ */
+ _onDebugInputChange: function (e) {
+ // When the debug input changes, the string prefix domain is read. If it
+ // is syntax-valid the widget is re-rendered and notifies the parents.
+ // If not, a warning is shown to the user and the input is ignored.
+ var domain;
+ try {
+ domain = Domain.prototype.stringToArray($(e.currentTarget).val());
+ } catch (err) { // If there is a syntax error, just ignore the change
+ this.do_warn(_t("Syntax error"), _t("Domain not properly formed"));
+ return;
+ }
+ this._redraw(domain).then((function () {
+ this.trigger_up("domain_changed", {child: this, alreadyRedrawn: true});
+ }).bind(this));
+ },
+ /**
+ * Called when a (child's) domain has changed -> redraw the entire tree
+ * representation if necessary
+ *
+ * @param {OdooEvent} e
+ */
+ _onDomainChange: function (e) {
+ // If a subdomain notifies that it underwent some modifications, the
+ // DomainSelector catches the message and performs a full re-rendering.
+ if (!e.data.alreadyRedrawn) {
+ this._redraw();
+ }
+ },
+});
+
+/**
+ * DomainNode which handles a domain which cannot be split in another
+ * subdomains, i.e. composed of a field chain, an operator and a value.
+ */
+var DomainLeaf = DomainNode.extend({
+ template: "DomainLeaf",
+ events: _.extend({}, DomainNode.prototype.events, {
+ "change .o_domain_leaf_operator_select": "_onOperatorSelectChange",
+ "change .o_domain_leaf_value_input": "_onValueInputChange",
+
+ // Handle the tags widget part (TODO should be an independant widget)
+ "click .o_domain_leaf_value_add_tag_button": "on_add_tag",
+ "keyup .o_domain_leaf_value_tags input": "on_add_tag",
+ "click .o_domain_leaf_value_remove_tag_button": "on_remove_tag",
+ }),
+ custom_events: {
+ "field_chain_changed": "_onFieldChainChange",
+ },
+ /**
+ * @see DomainNode.init
+ */
+ init: function (parent, model, domain, options) {
+ this._super.apply(this, arguments);
+
+ var currentDomain = Domain.prototype.stringToArray(domain);
+ this.chain = currentDomain[0][0];
+ this.operator = currentDomain[0][1];
+ this.value = currentDomain[0][2];
+
+ this.operator_mapping = operator_mapping;
+ },
+ /**
+ * Prepares the information the rendering of the widget will need by
+ * pre-instantiating its internal field selector widget.
+ *
+ * @returns {Promise}
+ */
+ willStart: function () {
+ var defs = [this._super.apply(this, arguments)];
+
+ // In edit mode, instantiate a field selector. This is done here in
+ // willStart and prepared by appending it to a dummy element because the
+ // DomainLeaf rendering need some information which cannot be computed
+ // before the ModelFieldSelector is fully rendered (TODO).
+ this.fieldSelector = new ModelFieldSelector(
+ this,
+ this.model,
+ this.chain !== undefined ? this.chain.toString().split(".") : [],
+ this.options
+ );
+ defs.push(this.fieldSelector.appendTo($("<div/>")).then((function () {
+ var wDefs = [];
+
+ if (!this.readonly) {
+ // Set list of operators according to field type
+ var selectedField = this.fieldSelector.getSelectedField() || {};
+ this.operators = this._getOperatorsFromType(selectedField.type);
+ if (_.contains(["child_of", "parent_of", "like", "not like", "=like", "=ilike"], this.operator)) {
+ // In case user entered manually or from demo data
+ this.operators[this.operator] = operator_mapping[this.operator];
+ } else if (!this.operators[this.operator]) {
+ // In case the domain uses an unsupported operator for the
+ // field type
+ this.operators[this.operator] = "?";
+ }
+
+ // Set list of values according to field type
+ this.selectionChoices = null;
+ if (selectedField.type === "boolean") {
+ this.selectionChoices = [["1", _t("set (true)")], ["0", _t("not set (false)")]];
+ } else if (selectedField.type === "selection") {
+ this.selectionChoices = selectedField.selection;
+ }
+
+ // Adapt display value and operator for rendering
+ this.displayValue = this.value;
+ try {
+ if (selectedField && !selectedField.relation && !_.isArray(this.value)) {
+ this.displayValue = field_utils.format[selectedField.type](this.value, selectedField);
+ }
+ } catch (err) {/**/}
+ this.displayOperator = this.operator;
+ if (selectedField.type === "boolean") {
+ this.displayValue = this.value ? "1" : "0";
+ } else if ((this.operator === "!=" || this.operator === "=") && this.value === false) {
+ this.displayOperator = this.operator === "!=" ? "set" : "not set";
+ }
+
+ // TODO the value could be a m2o input, etc...
+ if (_.contains(["date", "datetime"], selectedField.type)) {
+ this.valueWidget = new (selectedField.type === "datetime" ? datepicker.DateTimeWidget : datepicker.DateWidget)(this);
+ wDefs.push(this.valueWidget.appendTo("<div/>").then((function () {
+ this.valueWidget.$el.addClass("o_domain_leaf_value_input");
+ this.valueWidget.setValue(moment(this.value));
+ this.valueWidget.on("datetime_changed", this, function () {
+ this._changeValue(this.valueWidget.getValue());
+ });
+ }).bind(this)));
+ }
+
+ return Promise.all(wDefs);
+ }
+ }).bind(this)));
+
+ return Promise.all(defs);
+ },
+ /**
+ * @see DomainNode.start
+ * Appends the prepared field selector and value widget.
+ *
+ * @returns {Promise}
+ */
+ start: function () {
+ this.fieldSelector.$el.prependTo(this.$("> .o_domain_leaf_info, > .o_domain_leaf_edition")); // place the field selector
+ if (!this.readonly && this.valueWidget) { // In edit mode, place the value widget if any
+ this.$(".o_domain_leaf_value_input").replaceWith(this.valueWidget.$el);
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see DomainNode.isValid
+ * @returns {boolean}
+ */
+ isValid: function () {
+ return this.fieldSelector && this.fieldSelector.isValid();
+ },
+ /**
+ * @see DomainNode.getDomain
+ * @returns {Array}
+ */
+ getDomain: function () {
+ return [[this.chain, this.operator, this.value]];
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Handles a field chain change in the domain. In that case, the operator
+ * should be adapted to a valid one for the new field and the value should
+ * also be adapted to the new field and/or operator.
+ *
+ * -> trigger_up domain_changed event to ask for a re-rendering (if not
+ * silent)
+ *
+ * @param {string[]} chain - the new field chain
+ * @param {boolean} silent - true if the method call should not trigger_up a
+ * domain_changed event
+ */
+ _changeFieldChain: function (chain, silent) {
+ this.chain = chain.join(".");
+ this.fieldSelector.setChain(chain).then((function () {
+ if (!this.fieldSelector.isValid()) return;
+
+ var selectedField = this.fieldSelector.getSelectedField() || {};
+ var operators = this._getOperatorsFromType(selectedField.type);
+ if (operators[this.operator] === undefined) {
+ this._changeOperator("=", true);
+ }
+ this._changeValue(this.value, true);
+
+ if (!silent) this.trigger_up("domain_changed", {child: this});
+ }).bind(this));
+ },
+ /**
+ * Handles an operator change in the domain. In that case, the value should
+ * be adapted to a valid one for the new operator.
+ *
+ * -> trigger_up domain_changed event to ask for a re-rendering
+ * (if not silent)
+ *
+ * @param {string} operator - the new operator
+ * @param {boolean} silent - true if the method call should not trigger_up a
+ * domain_changed event
+ */
+ _changeOperator: function (operator, silent) {
+ this.operator = operator;
+
+ if (_.contains(["set", "not set"], this.operator)) {
+ this.operator = this.operator === "not set" ? "=" : "!=";
+ this.value = false;
+ } else if (_.contains(["in", "not in"], this.operator)) {
+ this.value = _.isArray(this.value) ? this.value : this.value ? ("" + this.value).split(",") : [];
+ } else {
+ if (_.isArray(this.value)) {
+ this.value = this.value.join(",");
+ }
+ this._changeValue(this.value, true);
+ }
+
+ if (!silent) this.trigger_up("domain_changed", {child: this});
+ },
+ /**
+ * Handles a formatted value change in the domain. In that case, the value
+ * should be adapted to a valid technical one.
+ *
+ * -> trigger_up "domain_changed" event to ask for a re-rendering (if not
+ * silent)
+ *
+ * @param {*} value - the new formatted value
+ * @param {boolean} silent - true if the method call should not trigger_up a
+ * domain_changed event
+ */
+ _changeValue: function (value, silent) {
+ var couldNotParse = false;
+ var selectedField = this.fieldSelector.getSelectedField() || {};
+ try {
+ this.value = field_utils.parse[selectedField.type](value, selectedField);
+ } catch (err) {
+ this.value = value;
+ couldNotParse = true;
+ }
+
+ if (selectedField.type === "boolean") {
+ if (!_.isBoolean(this.value)) { // Convert boolean-like value to boolean
+ this.value = !!parseFloat(this.value);
+ }
+ } else if (selectedField.type === "selection") {
+ if (!_.some(selectedField.selection, (function (option) { return option[0] === this.value; }).bind(this))) {
+ this.value = selectedField.selection[0][0];
+ }
+ } else if (_.contains(["date", "datetime"], selectedField.type)) {
+ if (couldNotParse || _.isBoolean(this.value)) {
+ this.value = field_utils.parse[selectedField.type](field_utils.format[selectedField.type](moment())).toJSON(); // toJSON to get date with server format
+ } else {
+ this.value = this.value.toJSON(); // toJSON to get date with server format
+ }
+ } else {
+ // Never display "true" or "false" strings from boolean value
+ if (_.isBoolean(this.value)) {
+ this.value = "";
+ } else if (_.isObject(this.value) && !_.isArray(this.value)) { // Can be object if parsed to x2x representation
+ this.value = this.value.id || value || "";
+ }
+ }
+
+ if (!silent) this.trigger_up("domain_changed", {child: this});
+ },
+ /**
+ * Returns the mapping of "technical operator" to "display operator value"
+ * of the operators which are available for the given field type.
+ *
+ * @private
+ * @param {string} type - the field type
+ * @returns {Object} a map of all associated operators and their label
+ */
+ _getOperatorsFromType: function (type) {
+ var operators = {};
+
+ switch (type) {
+ case "boolean":
+ operators = {
+ "=": _t("is"),
+ "!=": _t("is not"),
+ };
+ break;
+
+ case "char":
+ case "text":
+ case "html":
+ operators = _.pick(operator_mapping, "=", "!=", "ilike", "not ilike", "set", "not set", "in", "not in");
+ break;
+
+ case "many2many":
+ case "one2many":
+ case "many2one":
+ operators = _.pick(operator_mapping, "=", "!=", "ilike", "not ilike", "set", "not set");
+ break;
+
+ case "integer":
+ case "float":
+ case "monetary":
+ operators = _.pick(operator_mapping, "=", "!=", ">", "<", ">=", "<=", "ilike", "not ilike", "set", "not set");
+ break;
+
+ case "selection":
+ operators = _.pick(operator_mapping, "=", "!=", "set", "not set");
+ break;
+
+ case "date":
+ case "datetime":
+ operators = _.pick(operator_mapping, "=", "!=", ">", "<", ">=", "<=", "set", "not set");
+ break;
+
+ default:
+ operators = _.extend({}, operator_mapping);
+ break;
+ }
+
+ if (this.options.operators) {
+ operators = _.pick.apply(_, [operators].concat(this.options.operators));
+ }
+
+ return operators;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the operator select value is change -> change the operator
+ * internal state and adapt
+ *
+ * @param {Event} e
+ */
+ _onOperatorSelectChange: function (e) {
+ this._changeOperator($(e.currentTarget).val());
+ },
+ /**
+ * Called when the value input value is changed -> change the internal value
+ * state and adapt
+ *
+ * @param {Event} e
+ */
+ _onValueInputChange: function (e) {
+ if (e.currentTarget !== e.target) return;
+ this._changeValue($(e.currentTarget).val());
+ },
+ /**
+ * Called when the field selector value is changed -> change the internal
+ * chain state and adapt
+ *
+ * @param {OdooEvent} e
+ */
+ _onFieldChainChange: function (e) {
+ this._changeFieldChain(e.data.chain);
+ },
+
+ // TODO The two following functions should be in an independant widget
+ on_add_tag: function (e) {
+ if (e.type === "keyup" && e.which !== $.ui.keyCode.ENTER) return;
+ if (!_.contains(["not in", "in"], this.operator)) return;
+
+ var values = _.isArray(this.value) ? this.value.slice() : [];
+
+ var $input = this.$(".o_domain_leaf_value_tags input");
+ var val = $input.val().trim();
+ if (val && values.indexOf(val) < 0) {
+ values.push(val);
+ _.defer(this._changeValue.bind(this, values));
+ $input.focus();
+ }
+ },
+ on_remove_tag: function (e) {
+ var values = _.isArray(this.value) ? this.value.slice() : [];
+ var val = this.$(e.currentTarget).data("value");
+
+ var index = values.indexOf(val);
+ if (index >= 0) {
+ values.splice(index, 1);
+ _.defer(this._changeValue.bind(this, values));
+ }
+ },
+});
+
+/**
+ * Instantiates a DomainTree if the given domain contains several parts and a
+ * DomainLeaf if it only contains one part. Returns null otherwise.
+ *
+ * @param {Object} parent
+ * @param {string} model - the model name
+ * @param {Array|string} domain - the prefix representation of the domain
+ * @param {Object} options - @see DomainNode.init.options
+ * @returns {DomainTree|DomainLeaf|null}
+ */
+function instantiateNode(parent, model, domain, options) {
+ if (domain.length > 1) {
+ return new DomainTree(parent, model, domain, options);
+ } else if (domain.length === 1) {
+ return new DomainLeaf(parent, model, domain, options);
+ }
+ return null;
+}
+
+return DomainSelector;
+});
diff --git a/addons/web/static/src/js/widgets/domain_selector_dialog.js b/addons/web/static/src/js/widgets/domain_selector_dialog.js
new file mode 100644
index 00000000..f6fe7f0c
--- /dev/null
+++ b/addons/web/static/src/js/widgets/domain_selector_dialog.js
@@ -0,0 +1,54 @@
+odoo.define("web.DomainSelectorDialog", function (require) {
+"use strict";
+
+var core = require("web.core");
+var Dialog = require("web.Dialog");
+var DomainSelector = require("web.DomainSelector");
+
+var _t = core._t;
+
+/**
+ * @class DomainSelectorDialog
+ */
+return Dialog.extend({
+ init: function (parent, model, domain, options) {
+ this.model = model;
+ this.options = _.extend({
+ readonly: true,
+ debugMode: false,
+ }, options || {});
+
+ var buttons;
+ if (this.options.readonly) {
+ buttons = [
+ {text: _t("Close"), close: true},
+ ];
+ } else {
+ buttons = [
+ {text: _t("Save"), classes: "btn-primary", close: true, click: function () {
+ this.trigger_up("domain_selected", {domain: this.domainSelector.getDomain()});
+ }},
+ {text: _t("Discard"), close: true},
+ ];
+ }
+
+ this._super(parent, _.extend({}, {
+ title: _t("Domain"),
+ buttons: buttons,
+ }, options || {}));
+
+ this.domainSelector = new DomainSelector(this, model, domain, options);
+ },
+ start: function () {
+ var self = this;
+ this.opened().then(function () {
+ // this restores default modal height (bootstrap) and allows field selector to overflow
+ self.$el.css('overflow', 'visible').closest('.modal-dialog').css('height', 'auto');
+ });
+ return Promise.all([
+ this._super.apply(this, arguments),
+ this.domainSelector.appendTo(this.$el)
+ ]);
+ },
+});
+});
diff --git a/addons/web/static/src/js/widgets/iframe_widget.js b/addons/web/static/src/js/widgets/iframe_widget.js
new file mode 100644
index 00000000..8ecbd5f1
--- /dev/null
+++ b/addons/web/static/src/js/widgets/iframe_widget.js
@@ -0,0 +1,65 @@
+odoo.define('web.IFrameWidget', function (require) {
+"use strict";
+
+var Widget = require('web.Widget');
+
+/**
+ * Generic widget to create an iframe that listens for clicks
+ *
+ * It should be extended by overwriting the methods::
+ *
+ * init: function(parent) {
+ * this._super(parent, <url_of_iframe>);
+ * },
+ * _onIFrameClicked: function(e){
+ * filter the clicks you want to use and apply
+ * an action on it
+ * }
+ */
+var IFrameWidget = Widget.extend({
+ tagName: 'iframe',
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {string} url
+ */
+ init: function (parent, url) {
+ this._super(parent);
+ this.url = url;
+ },
+ /**
+ * @override
+ * @returns {Promise}
+ */
+ start: function () {
+ this.$el.css({height: '100%', width: '100%', border: 0});
+ this.$el.attr({src: this.url});
+ this.$el.on("load", this._bindEvents.bind(this));
+ return this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the iframe is ready
+ */
+ _bindEvents: function (){
+ this.$el.contents().click(this._onIFrameClicked.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {MouseEvent} event
+ */
+ _onIFrameClicked: function (event){
+ }
+});
+
+return IFrameWidget;
+
+});
diff --git a/addons/web/static/src/js/widgets/model_field_selector.js b/addons/web/static/src/js/widgets/model_field_selector.js
new file mode 100644
index 00000000..1856bccf
--- /dev/null
+++ b/addons/web/static/src/js/widgets/model_field_selector.js
@@ -0,0 +1,615 @@
+odoo.define("web.ModelFieldSelector", function (require) {
+"use strict";
+
+var core = require("web.core");
+var Widget = require("web.Widget");
+
+var _t = core._t;
+
+/**
+ * Field Selector Cache - TODO Should be improved to use external cache ?
+ * - Stores fields per model used in field selector
+ * @see ModelFieldSelector._getModelFieldsFromCache
+ */
+var modelFieldsCache = {
+ cache: {},
+ cacheDefs: {},
+};
+
+core.bus.on('clear_cache', null, function () {
+ modelFieldsCache.cache = {};
+ modelFieldsCache.cacheDefs = {};
+});
+
+/**
+ * The ModelFieldSelector widget can be used to display/select a particular
+ * field chain from a given model.
+ */
+var ModelFieldSelector = Widget.extend({
+ template: "ModelFieldSelector",
+ events: {},
+ editionEvents: {
+ // Handle popover opening and closing
+ "focusin": "_onFocusIn",
+ "focusout": "_onFocusOut",
+ "click .o_field_selector_close": "_onCloseClick",
+
+ // Handle popover field navigation
+ "click .o_field_selector_prev_page": "_onPrevPageClick",
+ "click .o_field_selector_next_page": "_onNextPageClick",
+ "click li.o_field_selector_select_button": "_onLastFieldClick",
+
+ // Handle a direct change in the debug input
+ "change input.o_field_selector_debug": "_onDebugInputChange",
+
+ // Handle a change in the search input
+ "keyup .o_field_selector_search > input": "_onSearchInputChange",
+
+ // Handle keyboard and mouse navigation to build the field chain
+ "mouseover li.o_field_selector_item": "_onItemHover",
+ "keydown": "_onKeydown",
+ },
+ /**
+ * @constructor
+ * The ModelFieldSelector requires a model and a field chain to work with.
+ *
+ * @param {string} model - the model name (e.g. "res.partner")
+ * @param {string[]} chain - list of the initial field chain parts
+ * @param {Object} [options] - some key-value options
+ * @param {string} [options.order='string']
+ * an ordering key for displayed fields
+ * @param {boolean} [options.readonly=true] - true if should be readonly
+ * @param {function} [options.filter]
+ * a function to filter the fetched fields
+ * @param {Object} [options.filters]
+ * some key-value options to filter the fetched fields
+ * @param {boolean} [options.filters.searchable=true]
+ * true if only the searchable fields have to be used
+ * @param {Object[]} [options.fields=null]
+ * the list of fields info to use when no relation has
+ * been followed (null indicates the widget has to request
+ * the fields itself)
+ * @param {boolean|function} [options.followRelations=true]
+ * true if can follow relation when building the chain
+ * @param {boolean} [options.showSearchInput=true]
+ * false to hide a search input to filter displayed fields
+ * @param {boolean} [options.debugMode=false]
+ * true if the widget is in debug mode, false otherwise
+ */
+ init: function (parent, model, chain, options) {
+ this._super.apply(this, arguments);
+
+ this.model = model;
+ this.chain = chain;
+ this.options = _.extend({
+ order: 'string',
+ readonly: true,
+ filters: {},
+ fields: null,
+ filter: function () {return true;},
+ followRelations: true,
+ debugMode: false,
+ showSearchInput: true,
+ }, options || {});
+ this.options.filters = _.extend({
+ searchable: true,
+ }, this.options.filters);
+
+ if (typeof this.options.followRelations !== 'function') {
+ this.options.followRelations = this.options.followRelations ?
+ function () {return true;} :
+ function () {return false;};
+ }
+
+ this.pages = [];
+ this.dirty = false;
+
+ if (!this.options.readonly) {
+ _.extend(this.events, this.editionEvents);
+ }
+
+ this.searchValue = '';
+ },
+ /**
+ * @see Widget.willStart()
+ * @returns {Promise}
+ */
+ willStart: function () {
+ return Promise.all([
+ this._super.apply(this, arguments),
+ this._prefill()
+ ]);
+ },
+ /**
+ * @see Widget.start
+ * @returns {Promise}
+ */
+ start: function () {
+ this.$value = this.$(".o_field_selector_value");
+ this.$popover = this.$(".o_field_selector_popover");
+ this.$input = this.$popover.find(".o_field_selector_popover_footer > input");
+ this.$searchInput = this.$popover.find(".o_field_selector_search > input");
+ this.$valid = this.$(".o_field_selector_warning");
+
+ this._render();
+
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns the field information selected by the field chain.
+ *
+ * @returns {Object}
+ */
+ getSelectedField: function () {
+ return _.findWhere(this.pages[this.chain.length - 1], {name: _.last(this.chain)});
+ },
+ /**
+ * Indicates if the field chain is valid. If the field chain has not been
+ * processed yet (the widget is not ready), this method will return
+ * undefined.
+ *
+ * @returns {boolean}
+ */
+ isValid: function () {
+ return this.valid;
+ },
+ /**
+ * Saves a new field chain (array) and re-render.
+ *
+ * @param {string[]} chain - the new field chain
+ * @returns {Promise} resolved once the re-rendering is finished
+ */
+ setChain: function (chain) {
+ if (_.isEqual(chain, this.chain)) {
+ return Promise.resolve();
+ }
+
+ this.chain = chain;
+ return this._prefill().then(this._render.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds a field name to the current field chain and marks it as dirty.
+ *
+ * @private
+ * @param {string} fieldName - the new field name to add at the end of the
+ * current field chain
+ */
+ _addChainNode: function (fieldName) {
+ this.dirty = true;
+ this.chain = this.chain.slice(0, this.pages.length-1);
+ this.chain.push(fieldName);
+
+ this.searchValue = '';
+ this.$searchInput.val('');
+ },
+ /**
+ * Searches a field in the last page by its name.
+ *
+ * @private
+ * @param {string} name - the name of the field to find
+ * @returns {Object} the field data found in the last popover page thanks
+ * to its name
+ /*/
+ _getLastPageField: function (name) {
+ return _.findWhere(_.last(this.pages), {
+ name: name,
+ });
+ },
+ /**
+ * Searches the cache for the given model fields, according to the given
+ * filter. If the cache does not know about the model, the cache is updated.
+ *
+ * @private
+ * @param {string} model
+ * @param {Object} filters @see ModelFieldSelector.init.options.filters
+ * @returns {Object[]} a list of the model fields info, sorted by field
+ * non-technical names
+ */
+ _getModelFieldsFromCache: function (model, filters) {
+ var self = this;
+ var def = modelFieldsCache.cacheDefs[model];
+ if (!def) {
+ def = modelFieldsCache.cacheDefs[model] = this._rpc({
+ model: model,
+ method: 'fields_get',
+ args: [
+ false,
+ ["store", "searchable", "type", "string", "relation", "selection", "related"]
+ ],
+ context: this.getSession().user_context,
+ })
+ .then((function (fields) {
+ modelFieldsCache.cache[model] = sortFields(fields, model, self.options.order);
+ }).bind(this));
+ }
+ return def.then((function () {
+ return _.filter(modelFieldsCache.cache[model], function (f) {
+ return (!filters.searchable || f.searchable) && self.options.filter(f);
+ });
+ }).bind(this));
+ },
+ /**
+ * Adds a new page to the popover following the given field relation and
+ * adapts the chain node according to this given field.
+ *
+ * @private
+ * @param {Object} field - the field to add to the chain node
+ */
+ _goToNextPage: function (field) {
+ if (!_.isEqual(this._getLastPageField(field.name), field)) return;
+
+ this._validate(true);
+ this._addChainNode(field.name);
+ this._pushPageData(field.relation).then(this._render.bind(this));
+ },
+ /**
+ * Removes the last page, adapts the field chain and displays the new
+ * last page.
+ *
+ * @private
+ */
+ _goToPrevPage: function () {
+ if (this.pages.length <= 0) return;
+
+ this._validate(true);
+ this._removeChainNode();
+ if (this.pages.length > 1) {
+ this.pages.pop();
+ }
+ this._render();
+ },
+ /**
+ * Closes the popover and marks the field as selected. If the field chain
+ * changed, it notifies its parents. If not open, this does nothing.
+ *
+ * @private
+ */
+ _hidePopover: function () {
+ if (!this._isOpen) return;
+
+ this._isOpen = false;
+ this.$popover.addClass('d-none');
+
+ if (this.dirty) {
+ this.dirty = false;
+ this.trigger_up("field_chain_changed", {chain: this.chain});
+ }
+ },
+ /**
+ * Prepares the popover by filling its pages according to the current field
+ * chain.
+ *
+ * @private
+ * @returns {Promise} resolved once the whole field chain has been
+ * processed
+ */
+ _prefill: function () {
+ this.pages = [];
+ return this._pushPageData(this.model).then((function () {
+ this._validate(true);
+ return (this.chain.length ? processChain.call(this, this.chain.slice().reverse()) : Promise.resolve());
+ }).bind(this));
+
+ function processChain(chain) {
+ var fieldName = chain.pop();
+ var field = this._getLastPageField(fieldName);
+ if (field && field.relation) {
+ if (chain.length) { // Fetch next chain node if any and possible
+ return this._pushPageData(field.relation).then(processChain.bind(this, chain));
+ } else { // Simply update the last popover page
+ return this._pushPageData(field.relation);
+ }
+ } else if (field && chain.length === 0) { // Last node fetched
+ return Promise.resolve();
+ } else if (!field && fieldName === "1") { // TRUE_LEAF
+ this._validate(true);
+ } else if (!field && fieldName === "0") { // FALSE_LEAF
+ this._validate(true);
+ } else { // Wrong node chain
+ this._validate(false);
+ }
+ return Promise.resolve();
+ }
+ },
+ /**
+ * Gets the fields of a particular model and adds them to a new last
+ * popover page.
+ *
+ * @private
+ * @param {string} model - the model name whose fields have to be fetched
+ * @returns {Promise} resolved once the fields have been added
+ */
+ _pushPageData: function (model) {
+ var def;
+ if (this.model === model && this.options.fields) {
+ def = Promise.resolve(sortFields(this.options.fields, model, this.options.order));
+ } else {
+ def = this._getModelFieldsFromCache(model, this.options.filters);
+ }
+ return def.then((function (fields) {
+ this.pages.push(fields);
+ }).bind(this));
+ },
+ /**
+ * Removes the last field name at the end of the current field chain and
+ * marks it as dirty.
+ *
+ * @private
+ */
+ _removeChainNode: function () {
+ this.dirty = true;
+ this.chain = this.chain.slice(0, this.pages.length-1);
+ this.chain.pop();
+ },
+ /**
+ * Updates the rendering of the value (the serie of tags separated by
+ * arrows). It also adapts the content of the popover.
+ *
+ * @private
+ */
+ _render: function () {
+
+ // Render the chain value
+ this.$value.html(core.qweb.render(this.template + ".value", {
+ chain: this.chain,
+ pages: this.pages,
+ }));
+
+ // Toggle the warning message
+ this.$valid.toggleClass('d-none', !!this.isValid());
+
+ // Adapt the popover content
+ var page = _.last(this.pages);
+ var title = "";
+ if (this.pages.length > 1) {
+ var prevField = _.findWhere(this.pages[this.pages.length - 2], {
+ name: (this.chain.length === this.pages.length) ? this.chain[this.chain.length - 2] : _.last(this.chain),
+ });
+ if (prevField) title = prevField.string;
+ }
+ this.$(".o_field_selector_popover_header .o_field_selector_title").text(title);
+
+ var lines = _.filter(page, this.options.filter);
+ if (this.searchValue) {
+ var matches = fuzzy.filter(this.searchValue, _.pluck(lines, 'string'));
+ lines = _.map(_.pluck(matches, 'index'), function (i) {
+ return lines[i];
+ });
+ }
+
+ this.$(".o_field_selector_page").replaceWith(core.qweb.render(this.template + ".page", {
+ lines: lines,
+ followRelations: this.options.followRelations,
+ debug: this.options.debugMode,
+ }));
+ this.$input.val(this.chain.join("."));
+ },
+ /**
+ * Selects the given field and adapts the chain node according to it.
+ * It also closes the popover and so notifies the parents about the change.
+ *
+ * @param {Object} field - the field to select
+ */
+ _selectField: function (field) {
+ if (!_.isEqual(this._getLastPageField(field.name), field)) return;
+
+ this._validate(true);
+ this._addChainNode(field.name);
+ this._render();
+ this._hidePopover();
+ },
+ /**
+ * Shows the popover to select the field chain. This assumes that the
+ * popover has finished its rendering (fully rendered widget or resolved
+ * deferred of @see setChain). If already open, this does nothing.
+ *
+ * @private
+ */
+ _showPopover: function () {
+ if (this._isOpen) return;
+
+ this._isOpen = true;
+ this.$popover.removeClass('d-none');
+ },
+ /**
+ * Toggles the valid status of the widget and display the error message if
+ * it is not valid.
+ *
+ * @private
+ * @param {boolean} valid - true if the widget is valid, false otherwise
+ */
+ _validate: function (valid) {
+ this.valid = !!valid;
+
+ if (!this.valid) {
+ this.do_warn(
+ false,
+ _t("Invalid field chain. You may have used a non-existing field name or followed a non-relational field.")
+ );
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the widget is focused -> opens the popover
+ */
+ _onFocusIn: function () {
+ clearTimeout(this._hidePopoverTimeout);
+ this._showPopover();
+ },
+ /**
+ * Called when the widget is blurred -> closes the popover
+ */
+ _onFocusOut: function () {
+ this._hidePopoverTimeout = _.defer(this._hidePopover.bind(this));
+ },
+ /**
+ * Called when the popover "cross" icon is clicked -> closes the popover
+ */
+ _onCloseClick: function () {
+ this._hidePopover();
+ },
+ /**
+ * Called when the popover "previous" icon is clicked -> removes last chain
+ * node
+ */
+ _onPrevPageClick: function () {
+ this._goToPrevPage();
+ },
+ /**
+ * Called when a popover relation field button is clicked -> adds it to
+ * the chain
+ *
+ * @param {Event} e
+ */
+ _onNextPageClick: function (e) {
+ e.stopPropagation();
+ this._goToNextPage(this._getLastPageField($(e.currentTarget).data("name")));
+ },
+ /**
+ * Called when a popover non-relation field button is clicked -> adds it to
+ * chain and closes the popover
+ *
+ * @param {Event} e
+ */
+ _onLastFieldClick: function (e) {
+ this._selectField(this._getLastPageField($(e.currentTarget).data("name")));
+ },
+ /**
+ * Called when the debug input value is changed -> adapts the chain
+ */
+ _onDebugInputChange: function () {
+ var userChainStr = this.$input.val();
+ var userChain = userChainStr.split(".");
+ if (!this.options.followRelations && userChain.length > 1) {
+ this.do_warn(
+ _t("Relation not allowed"),
+ _t("You cannot follow relations for this field chain construction")
+ );
+ userChain = [userChain[0]];
+ }
+ this.setChain(userChain).then((function () {
+ this.trigger_up("field_chain_changed", {chain: this.chain});
+ }).bind(this));
+ },
+ /**
+ * Called when the search input value is changed -> adapts the popover
+ */
+ _onSearchInputChange: function () {
+ this.searchValue = this.$searchInput.val();
+ this._render();
+ },
+ /**
+ * Called when a popover field button item is hovered -> toggles its
+ * "active" status
+ *
+ * @param {Event} e
+ */
+ _onItemHover: function (e) {
+ this.$("li.o_field_selector_item").removeClass("active");
+ $(e.currentTarget).addClass("active");
+ },
+ /**
+ * Called when the user uses the keyboard when the widget is focused
+ * -> handles field keyboard navigation
+ *
+ * @param {Event} e
+ */
+ _onKeydown: function (e) {
+ if (!this.$popover.is(":visible")) return;
+ var inputHasFocus = this.$input.is(":focus");
+ var searchInputHasFocus = this.$searchInput.is(":focus");
+
+ switch (e.which) {
+ case $.ui.keyCode.UP:
+ case $.ui.keyCode.DOWN:
+ e.preventDefault();
+ var $active = this.$("li.o_field_selector_item.active");
+ var $to = $active[e.which === $.ui.keyCode.DOWN ? "next" : "prev"](".o_field_selector_item");
+ if ($to.length) {
+ $active.removeClass("active");
+ $to.addClass("active");
+ this.$popover.focus();
+
+ var $page = $to.closest(".o_field_selector_page");
+ var full_height = $page.height();
+ var el_position = $to.position().top;
+ var el_height = $to.outerHeight();
+ var current_scroll = $page.scrollTop();
+ if (el_position < 0) {
+ $page.scrollTop(current_scroll - el_height);
+ } else if (full_height < el_position + el_height) {
+ $page.scrollTop(current_scroll + el_height);
+ }
+ }
+ break;
+ case $.ui.keyCode.RIGHT:
+ if (inputHasFocus) break;
+ e.preventDefault();
+ var name = this.$("li.o_field_selector_item.active").data("name");
+ if (name) {
+ var field = this._getLastPageField(name);
+ if (field.relation) {
+ this._goToNextPage(field);
+ }
+ }
+ break;
+ case $.ui.keyCode.LEFT:
+ if (inputHasFocus) break;
+ e.preventDefault();
+ this._goToPrevPage();
+ break;
+ case $.ui.keyCode.ESCAPE:
+ e.stopPropagation();
+ this._hidePopover();
+ break;
+ case $.ui.keyCode.ENTER:
+ if (inputHasFocus || searchInputHasFocus) break;
+ e.preventDefault();
+ this._selectField(this._getLastPageField(this.$("li.o_field_selector_item.active").data("name")));
+ break;
+ }
+ }
+});
+
+return ModelFieldSelector;
+
+/**
+ * Allows to transform a mapping field name -> field info in an array of the
+ * field infos, sorted by field user name ("string" value). The field infos in
+ * the final array contain an additional key "name" with the field name.
+ *
+ * @param {Object} fields - the mapping field name -> field info
+ * @param {string} model
+ * @returns {Object[]} the field infos sorted by field "string" (field infos
+ * contain additional keys "model" and "name" with the field
+ * name)
+ */
+function sortFields(fields, model, order) {
+ var array = _.chain(fields)
+ .pairs()
+ .sortBy(function (p) { return p[1].string; });
+ if (order !== 'string') {
+ array = array.sortBy(function (p) {return p[1][order]; });
+ }
+ return array.map(function (p) {
+ return _.extend({
+ name: p[0],
+ model: model,
+ }, p[1]);
+ }).value();
+}
+});
diff --git a/addons/web/static/src/js/widgets/name_and_signature.js b/addons/web/static/src/js/widgets/name_and_signature.js
new file mode 100644
index 00000000..abc58a87
--- /dev/null
+++ b/addons/web/static/src/js/widgets/name_and_signature.js
@@ -0,0 +1,662 @@
+odoo.define('web.name_and_signature', function (require) {
+'use strict';
+
+var core = require('web.core');
+var config = require('web.config');
+var utils = require('web.utils');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+
+/**
+ * This widget allows the user to input his name and to draw his signature.
+ * Alternatively the signature can also be generated automatically based on
+ * the given name and a selected font, or loaded from an image file.
+ */
+var NameAndSignature = Widget.extend({
+ template: 'web.sign_name_and_signature',
+ xmlDependencies: ['/web/static/src/xml/name_and_signature.xml'],
+ events: {
+ // name
+ 'input .o_web_sign_name_input': '_onInputSignName',
+ // signature
+ 'click .o_web_sign_signature': '_onClickSignature',
+ 'change .o_web_sign_signature': '_onChangeSignature',
+ // draw
+ 'click .o_web_sign_draw_button': '_onClickSignDrawButton',
+ 'click .o_web_sign_draw_clear a': '_onClickSignDrawClear',
+ // auto
+ 'click .o_web_sign_auto_button': '_onClickSignAutoButton',
+ 'click .o_web_sign_auto_select_style a': '_onClickSignAutoSelectStyle',
+ 'click .o_web_sign_auto_font_selection a': '_onClickSignAutoFontSelection',
+ 'mouseover .o_web_sign_auto_font_selection a': '_onMouseOverSignAutoFontSelection',
+ 'touchmove .o_web_sign_auto_font_selection a': '_onTouchStartSignAutoFontSelection',
+ // load
+ 'click .o_web_sign_load_button': '_onClickSignLoadButton',
+ 'change .o_web_sign_load_file input': '_onChangeSignLoadInput',
+ },
+
+ /**
+ * Allows options.
+ *
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} [options={}]
+ * @param {number} [options.displaySignatureRatio=3.0] - The ratio used when
+ * (re)computing the size of the signature (width = height * ratio)
+ * @param {string} [options.defaultName=''] - The default name of
+ * the signer.
+ * @param {string} [options.defaultFont=''] - The unique and default
+ * font for auto mode. If empty, all fonts are visible.
+ * * @param {string} [options.fontColor='DarkBlue'] - Color of signature
+ * (must be a string color)
+ * @param {string} [options.noInputName=false] - If set to true,
+ * the user can not enter his name. If there aren't defaultName,
+ * auto mode is hidden.
+ * @param {string} [options.mode='draw'] - @see this.setMode
+ * @param {string} [options.signatureType='signature'] - The type of
+ * signature used in 'auto' mode. Can be one of the following values:
+ *
+ * - 'signature': it will adapt the characters width to fit the whole
+ * text in the image.
+ * - 'initial': it will adapt the space between characters to fill
+ * the image with the text. The text will be the first letter of
+ * every word in the name, separated by dots.
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ options = options || {};
+ this.htmlId = _.uniqueId();
+ this.defaultName = options.defaultName || '';
+ this.defaultFont = options.defaultFont || '';
+ this.fontColor = options.fontColor || 'DarkBlue';
+ this.displaySignatureRatio = options.displaySignatureRatio || 3.0;
+ this.signatureType = options.signatureType || 'signature';
+ this.signMode = options.mode || 'draw';
+ this.noInputName = options.noInputName || false;
+ this.currentFont = 0;
+ this.drawTimeout = null;
+ this.drawPreviewTimeout = null;
+ },
+ /**
+ * Loads the fonts.
+ *
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ return Promise.all([
+ this._super.apply(this, arguments),
+ this._rpc({route: '/web/sign/get_fonts/' + self.defaultFont}).then(function (data) {
+ self.fonts = data;
+ })
+ ]);
+ },
+ /**
+ * Finds the DOM elements, initializes the signature area,
+ * and focus the name field.
+ *
+ * @override
+ */
+ start: function () {
+ var self = this;
+ // signature and name input
+ this.$signatureGroup = this.$('.o_web_sign_signature_group');
+ this.$signatureField = this.$('.o_web_sign_signature');
+ this.$nameInput = this.$('.o_web_sign_name_input');
+ this.$nameInputGroup = this.$('.o_web_sign_name_group');
+
+ // mode selection buttons
+ this.$drawButton = this.$('a.o_web_sign_draw_button');
+ this.$autoButton = this.$('a.o_web_sign_auto_button');
+ this.$loadButton = this.$('a.o_web_sign_load_button');
+
+ // mode: draw
+ this.$drawClear = this.$('.o_web_sign_draw_clear');
+
+ // mode: auto
+ this.$autoSelectStyle = this.$('.o_web_sign_auto_select_style');
+ this.$autoFontSelection = this.$('.o_web_sign_auto_font_selection');
+ this.$autoFontList = this.$('.o_web_sign_auto_font_list');
+ for (var i in this.fonts) {
+ var $img = $('<img/>').addClass('img-fluid');
+ var $a = $('<a/>').addClass('btn p-0').append($img).data('fontNb', i);
+ this.$autoFontList.append($a);
+ }
+
+ // mode: load
+ this.$loadFile = this.$('.o_web_sign_load_file');
+ this.$loadInvalid = this.$('.o_web_sign_load_invalid');
+
+ if (this.fonts && this.fonts.length < 2) {
+ this.$autoSelectStyle.hide();
+ }
+
+ if (this.noInputName) {
+ if (this.defaultName === "") {
+ this.$autoButton.hide();
+ }
+ this.$nameInputGroup.hide();
+ }
+
+ // Resize the signature area if it is resized
+ $(window).on('resize.o_web_sign_name_and_signature', _.debounce(function () {
+ if (self.isDestroyed()) {
+ // May happen since this is debounced
+ return;
+ }
+ self.resizeSignature();
+ }, 250));
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ $(window).off('resize.o_web_sign_name_and_signature');
+ },
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Focuses the name.
+ */
+ focusName: function () {
+ // Don't focus on mobile
+ if (!config.device.isMobile) {
+ this.$nameInput.focus();
+ }
+ },
+ /**
+ * Gets the name currently given by the user.
+ *
+ * @returns {string} name
+ */
+ getName: function () {
+ return this.$nameInput.val();
+ },
+ /**
+ * Gets the signature currently drawn. The data format is that produced
+ * natively by Canvas - base64 encoded (likely PNG) bitmap data.
+ *
+ * @returns {string[]} Array that contains the signature as a bitmap.
+ * The first element is the mimetype, the second element is the data.
+ */
+ getSignatureImage: function () {
+ return this.$signatureField.jSignature('getData', 'image');
+ },
+ /**
+ * Gets the signature currently drawn, in a format ready to be used in
+ * an <img/> src attribute.
+ *
+ * @returns {string} the signature currently drawn, src ready
+ */
+ getSignatureImageSrc: function () {
+ return this.$signatureField.jSignature('getData');
+ },
+ /**
+ * Returns whether the drawing area is currently empty.
+ *
+ * @returns {boolean} Whether the drawing area is currently empty.
+ */
+ isSignatureEmpty: function () {
+ var signature = this.$signatureField.jSignature('getData');
+ return signature && this.emptySignature ? this.emptySignature === signature : true;
+ },
+ resizeSignature: function() {
+ if (!this.$signatureField) {
+ return;
+ }
+ // recompute size based on the current width
+ this.$signatureField.css({width: 'unset'});
+ const width = this.$signatureField.width();
+ const height = parseInt(width / this.displaySignatureRatio);
+
+ // necessary because the lib is adding invisible div with margin
+ // signature field too tall without this code
+ this.$signatureField.css({
+ width: width,
+ height: height,
+ });
+ this.$signatureField.find('canvas').css({
+ width: width,
+ height: height,
+ });
+ return {width, height};
+ },
+ /**
+ * (Re)initializes the signature area:
+ * - set the correct width and height of the drawing based on the width
+ * of the container and the ratio option
+ * - empty any previous content
+ * - correctly reset the empty state
+ * - call @see setMode with reset
+ *
+ * @returns {Deferred}
+ */
+ resetSignature: function () {
+ if (!this.$signatureField) {
+ // no action if called before start
+ return Promise.reject();
+ }
+
+ const {width, height} = this.resizeSignature();
+
+ this.$signatureField
+ .empty()
+ .jSignature({
+ 'decor-color': '#D1D0CE',
+ 'background-color': 'rgba(255,255,255,0)',
+ 'show-stroke': false,
+ 'color': this.fontColor,
+ 'lineWidth': 2,
+ 'width': width,
+ 'height': height,
+ });
+ this.emptySignature = this.$signatureField.jSignature('getData');
+
+ this.setMode(this.signMode, true);
+
+ this.focusName();
+
+ return Promise.resolve();
+ },
+ /**
+ * Changes the signature mode. Toggles the display of the relevant
+ * controls and resets the drawing.
+ *
+ * @param {string} mode - the mode to use. Can be one of the following:
+ * - 'draw': the user draws the signature manually with the mouse
+ * - 'auto': the signature is drawn automatically using a selected font
+ * - 'load': the signature is loaded from an image file
+ * @param {boolean} [reset=false] - Set to true to reset the elements
+ * even if the @see mode has not changed. By default nothing happens
+ * if the @see mode is already selected.
+ */
+ setMode: function (mode, reset) {
+ if (reset !== true && mode === this.signMode) {
+ // prevent flickering and unnecessary compute
+ return;
+ }
+
+ this.signMode = mode;
+
+ this.$drawClear.toggleClass('d-none', this.signMode !== 'draw');
+ this.$autoSelectStyle.toggleClass('d-none', this.signMode !== 'auto');
+ this.$loadFile.toggleClass('d-none', this.signMode !== 'load');
+
+ this.$drawButton.toggleClass('active', this.signMode === 'draw');
+ this.$autoButton.toggleClass('active', this.signMode === 'auto');
+ this.$loadButton.toggleClass('active', this.signMode === 'load');
+
+ this.$signatureField.jSignature(this.signMode === 'draw' ? 'enable' : 'disable');
+ this.$signatureField.jSignature('reset');
+
+ if (this.signMode === 'auto') {
+ // draw based on name
+ this._drawCurrentName();
+ } else {
+ // close style dialog
+ this.$autoFontSelection.addClass('d-none');
+ }
+
+ if (this.signMode !== 'load') {
+ // close invalid file alert
+ this.$loadInvalid.addClass('d-none');
+ }
+ },
+ /**
+ * Gets the current name and signature, validates them, and returns
+ * the result. If they are invalid, displays the errors to the user.
+ *
+ * @returns {boolean} whether the current name and signature are valid
+ */
+ validateSignature: function () {
+ var name = this.getName();
+ var isSignatureEmpty = this.isSignatureEmpty();
+ this.$nameInput.parent().toggleClass('o_has_error', !name)
+ .find('.form-control, .custom-select').toggleClass('is-invalid', !name);
+ this.$signatureGroup.toggleClass('border-danger', isSignatureEmpty);
+ return name && !isSignatureEmpty;
+ },
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * Draws the current name with the current font in the signature field.
+ *
+ * @private
+ */
+ _drawCurrentName: function () {
+ var font = this.fonts[this.currentFont];
+ var text = this._getCleanedName();
+ var canvas = this.$signatureField.find('canvas')[0];
+ var img = this._getSVGText(font, text, canvas.width, canvas.height);
+ return this._printImage(img);
+ },
+ /**
+ * Returns the given name after cleaning it by removing characters that
+ * are not supposed to be used in a signature. If @see signatureType is set
+ * to 'initial', returns the first letter of each word, separated by dots.
+ *
+ * @private
+ * @returns {string} cleaned name
+ */
+ _getCleanedName: function () {
+ var text = this.getName();
+ if (this.signatureType === 'initial') {
+ return (text.split(' ').map(function (w) {
+ return w[0];
+ }).join('.') + '.');
+ }
+ return text;
+ },
+ /**
+ * Gets an SVG matching the given parameters, output compatible with the
+ * src attribute of <img/>.
+ *
+ * @private
+ * @param {string} font: base64 encoded font to use
+ * @param {string} text: the name to draw
+ * @param {number} width: the width of the resulting image in px
+ * @param {number} height: the height of the resulting image in px
+ * @returns {string} image = mimetype + image data
+ */
+ _getSVGText: function (font, text, width, height) {
+ var $svg = $(core.qweb.render('web.sign_svg_text', {
+ width: width,
+ height: height,
+ font: font,
+ text: text,
+ type: this.signatureType,
+ color: this.fontColor,
+ }));
+ $svg.attr({
+ 'xmlns': "http://www.w3.org/2000/svg",
+ 'xmlns:xlink': "http://www.w3.org/1999/xlink",
+ });
+
+ return "data:image/svg+xml," + encodeURI($svg[0].outerHTML);
+ },
+ /**
+ * Displays the given image in the signature field.
+ * If needed, resizes the image to fit the existing area.
+ *
+ * @private
+ * @param {string} imgSrc - data of the image to display
+ */
+ _printImage: function (imgSrc) {
+ var self = this;
+
+ var image = new Image;
+ image.onload = function () {
+ // don't slow down the UI if the drawing is slow, and prevent
+ // drawing twice when calling this method in rapid succession
+ clearTimeout(self.drawTimeout);
+ self.drawTimeout = setTimeout(function () {
+ var width = 0;
+ var height = 0;
+ var ratio = image.width / image.height;
+
+ var $canvas = self.$signatureField.find('canvas');
+ var context = $canvas[0].getContext('2d');
+
+ if (image.width / $canvas[0].width > image.height / $canvas[0].height) {
+ width = $canvas[0].width;
+ height = parseInt(width / ratio);
+ } else {
+ height = $canvas[0].height;
+ width = parseInt(height * ratio);
+ }
+ self.$signatureField.jSignature('reset');
+ var ignoredContext = _.pick(context, ['shadowOffsetX', 'shadowOffsetY']);
+ _.extend(context, {shadowOffsetX: 0, shadowOffsetY: 0});
+ context.drawImage(image,
+ 0,
+ 0,
+ image.width,
+ image.height,
+ ($canvas[0].width - width) / 2,
+ ($canvas[0].height - height) / 2,
+ width,
+ height
+ );
+ _.extend(context, ignoredContext);
+ self.trigger_up('signature_changed');
+ }, 0);
+ };
+ image.src = imgSrc;
+ },
+ /**
+ * Sets the font to use in @see mode 'auto'. Redraws the signature if
+ * the font has been changed.
+ *
+ * @private
+ * @param {number} index - index of the font in @see this.fonts
+ */
+ _setFont: function (index) {
+ if (index !== this.currentFont) {
+ this.currentFont = index;
+ this._drawCurrentName();
+ }
+ },
+ /**
+ * Updates the preview buttons by rendering the signature for each font.
+ *
+ * @private
+ */
+ _updatePreviewButtons: function () {
+ var self = this;
+ // don't slow down the UI if the drawing is slow, and prevent
+ // drawing twice when calling this method in rapid succession
+ clearTimeout(this.drawPreviewTimeout);
+ this.drawPreviewTimeout = setTimeout(function () {
+ var height = 100;
+ var width = parseInt(height * self.displaySignatureRatio);
+ var $existingButtons = self.$autoFontList.find('a');
+ for (var i = 0; i < self.fonts.length; i++) {
+ var imgSrc = self._getSVGText(
+ self.fonts[i],
+ self._getCleanedName() || _t("Your name"),
+ width,
+ height
+ );
+ $existingButtons.eq(i).find('img').attr('src', imgSrc);
+ }
+ }, 0);
+ },
+ /**
+ * Waits for the signature to be not empty and triggers up the event
+ * `signature_changed`.
+ * This is necessary because some methods of jSignature are async but
+ * they don't return a promise and don't trigger any event.
+ *
+ * @private
+ * @param {Deferred} [def=Deferred] - Deferred that will be returned by
+ * the method and resolved when the signature is not empty anymore.
+ * @returns {Deferred}
+ */
+ _waitForSignatureNotEmpty: function (def) {
+ def = def || $.Deferred();
+ if (!this.isSignatureEmpty()) {
+ this.trigger_up('signature_changed');
+ def.resolve();
+ } else {
+ // Use the existing def to prevent the method from creating a new
+ // one at every loop.
+ setTimeout(this._waitForSignatureNotEmpty.bind(this, def), 10);
+ }
+ return def;
+ },
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * Handles click on the signature: closes the font selection.
+ *
+ * @see mode 'auto'
+ * @private
+ * @param {Event} ev
+ */
+ _onClickSignature: function (ev) {
+ this.$autoFontSelection.addClass('d-none');
+ },
+ /**
+ * Handles click on the Auto button: activates @see mode 'auto'.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onClickSignAutoButton: function (ev) {
+ ev.preventDefault();
+ this.setMode('auto');
+ },
+ /**
+ * Handles click on a font: uses it and closes the font selection.
+ *
+ * @see mode 'auto'
+ * @private
+ * @param {Event} ev
+ */
+ _onClickSignAutoFontSelection: function (ev) {
+ this.$autoFontSelection.addClass('d-none').removeClass('d-flex').css('width', 0);
+ this._setFont(parseInt($(ev.currentTarget).data('font-nb')));
+ },
+ /**
+ * Handles click on Select Style: opens and updates the font selection.
+ *
+ * @see mode 'auto'
+ * @private
+ * @param {Event} ev
+ */
+ _onClickSignAutoSelectStyle: function (ev) {
+ var self = this;
+ var width = Math.min(
+ self.$autoFontSelection.find('a').first().height() * self.displaySignatureRatio * 1.25,
+ this.$signatureField.width()
+ );
+
+ ev.preventDefault();
+ self._updatePreviewButtons();
+
+ this.$autoFontSelection.removeClass('d-none').addClass('d-flex');
+ this.$autoFontSelection.show().animate({'width': width}, 500, function () {});
+ },
+ /**
+ * Handles click on the Draw button: activates @see mode 'draw'.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onClickSignDrawButton: function (ev) {
+ ev.preventDefault();
+ this.setMode('draw');
+ },
+ /**
+ * Handles click on clear: empties the signature field.
+ *
+ * @see mode 'draw'
+ * @private
+ * @param {Event} ev
+ */
+ _onClickSignDrawClear: function (ev) {
+ ev.preventDefault();
+ this.$signatureField.jSignature('reset');
+ },
+ /**
+ * Handles click on the Load button: activates @see mode 'load'.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onClickSignLoadButton: function (ev) {
+ ev.preventDefault();
+ // open file upload automatically (saves 1 click)
+ this.$loadFile.find('input').click();
+ this.setMode('load');
+ },
+ /**
+ * Triggers up the signature change event.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onChangeSignature: function (ev) {
+ this.trigger_up('signature_changed');
+ },
+ /**
+ * Handles change on load file input: displays the loaded image if the
+ * format is correct, or diplays an error otherwise.
+ *
+ * @see mode 'load'
+ * @private
+ * @param {Event} ev
+ * @return bool|undefined
+ */
+ _onChangeSignLoadInput: function (ev) {
+ var self = this;
+ var f = ev.target.files[0];
+ if (f === undefined) {
+ return false;
+ }
+ if (f.type.substr(0, 5) !== 'image') {
+ this.$signatureField.jSignature('reset');
+ this.$loadInvalid.removeClass('d-none');
+ return false;
+ }
+ this.$loadInvalid.addClass('d-none');
+
+ utils.getDataURLFromFile(f).then(function (result) {
+ self._printImage(result);
+ });
+ },
+ /**
+ * Handles input on name field: if the @see mode is 'auto', redraws the
+ * signature with the new name. Also updates the font selection if open.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onInputSignName: function (ev) {
+ if (this.signMode !== 'auto') {
+ return;
+ }
+ this._drawCurrentName();
+ if (!this.$autoFontSelection.hasClass('d-none')) {
+ this._updatePreviewButtons();
+ }
+ },
+ /**
+ * Handles mouse over on font selection: uses this font.
+ *
+ * @see mode 'auto'
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseOverSignAutoFontSelection: function (ev) {
+ this._setFont(parseInt($(ev.currentTarget).data('font-nb')));
+ },
+ /**
+ * Handles touch start on font selection: uses this font.
+ *
+ * @see mode 'auto'
+ * @private
+ * @param {Event} ev
+ */
+ _onTouchStartSignAutoFontSelection: function (ev) {
+ this._setFont(parseInt($(ev.currentTarget).data('font-nb')));
+ },
+});
+
+return {
+ NameAndSignature: NameAndSignature,
+};
+});
diff --git a/addons/web/static/src/js/widgets/notification.js b/addons/web/static/src/js/widgets/notification.js
new file mode 100644
index 00000000..0e9cdabe
--- /dev/null
+++ b/addons/web/static/src/js/widgets/notification.js
@@ -0,0 +1,176 @@
+odoo.define('web.Notification', function (require) {
+'use strict';
+
+var Widget = require('web.Widget');
+
+/**
+ * Widget which is used to display a warning/information message on the top
+ * right of the screen.
+ *
+ * If you want to display such a notification, you probably do not want to do it
+ * by importing this file. The proper way is to use the do_warn or do_notify
+ * methods on the Widget class.
+ */
+var Notification = Widget.extend({
+ template: 'Notification',
+ events: {
+ 'hidden.bs.toast': '_onClose',
+ 'click .o_notification_buttons button': '_onClickButton',
+ 'mouseenter': '_onMouseEnter',
+ 'mouseleave': '_onMouseLeave',
+ },
+ _autoCloseDelay: 4000,
+ _animation: true,
+
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {Object} params
+ * @param {string} params.title
+ * @param {string} params.subtitle
+ * @param {string} [params.message]
+ * @param {string} [params.type='warning'] 'info', 'success', 'warning', 'danger' or ''
+ * @param {boolean} [params.sticky=false] if true, the notification will
+ * stay visible until the user clicks on it.
+ * @param {string} [params.className]
+ * @param {function} [params.onClose] callback when the user click on the x
+ * or when the notification is auto close (no sticky)
+ * @param {Object[]} params.buttons
+ * @param {function} params.buttons[0].click callback on click
+ * @param {boolean} [params.buttons[0].primary] display the button as primary
+ * @param {string} [params.buttons[0].text] button label
+ * @param {string} [params.buttons[0].icon] font-awsome className or image src
+ */
+ init: function (parent, params) {
+ this._super.apply(this, arguments);
+ this.title = params.title;
+ this.subtitle = params.subtitle;
+ this.message = params.message;
+ this.buttons = params.buttons || [];
+ this.sticky = !!this.buttons.length || !!params.sticky;
+ this.type = params.type === undefined ? 'warning' : params.type;
+ this.className = params.className || '';
+ this._closeCallback = params.onClose;
+
+ if (this.type === 'danger') {
+ this.icon = 'fa-exclamation';
+ this.className += ' bg-danger';
+ } else if (this.type === 'warning') {
+ this.icon = 'fa-lightbulb-o';
+ this.className += ' bg-warning';
+ } else if (this.type === 'success') {
+ this.icon = 'fa-check';
+ this.className += ' bg-success';
+ } else if (this.type === 'info') {
+ this.icon = 'fa-info';
+ this.className += ' bg-info';
+ }
+
+ if (this.buttons && this.buttons.length) {
+ this.icon = 'fa-question-circle-o';
+ }
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$el.toast({
+ animation: this._animation,
+ autohide: false,
+ });
+ void this.$el[0].offsetWidth; // Force a paint refresh before showing the toast
+ if (!this.sticky) {
+ this.autohide = _.cancellableThrottleRemoveMeSoon(this.close, this._autoCloseDelay, {leading: false});
+ this.$el.on('shown.bs.toast', () => {
+ this.autohide();
+ });
+ }
+ this.$el.toast('show');
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this.$el.toast('dispose');
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Destroys the widget with a nice animation.
+ *
+ * @private
+ * @param {boolean} [silent=false] if true, the notification does not call
+ * _closeCallback method
+ */
+ close: function (silent) {
+ this.silent = silent;
+ this.$el.toast('hide');
+
+ // Make 'close' work if the notification is not shown yet but will be.
+ // Should not be needed but the calendar notification system is an
+ // example of feature that does not work without this yet.
+ var self = this;
+ this.$el.one('shown.bs.toast', function () {
+ self.$el.toast('hide');
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickButton: function (ev) {
+ ev.preventDefault();
+ if (this._buttonClicked) {
+ return;
+ }
+ this._buttonClicked = true;
+ var index = $(ev.currentTarget).index();
+ var button = this.buttons[index];
+ if (button.click) {
+ button.click();
+ }
+ this.close(true);
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onClose: function (ev) {
+ this.trigger_up('close');
+ if (!this.silent && !this._buttonClicked) {
+ if (this._closeCallback) {
+ this._closeCallback();
+ }
+ }
+ this.destroy();
+ },
+ /**
+ * @private
+ */
+ _onMouseEnter: function () {
+ if (!this.sticky) {
+ this.autohide.cancel();
+ }
+ },
+ /**
+ * @private
+ */
+ _onMouseLeave: function () {
+ if (!this.sticky) {
+ this.autohide();
+ }
+ },
+});
+
+return Notification;
+});
diff --git a/addons/web/static/src/js/widgets/pie_chart.js b/addons/web/static/src/js/widgets/pie_chart.js
new file mode 100644
index 00000000..f4254ff3
--- /dev/null
+++ b/addons/web/static/src/js/widgets/pie_chart.js
@@ -0,0 +1,102 @@
+odoo.define('web.PieChart', function (require) {
+"use strict";
+
+/**
+ * This widget render a Pie Chart. It is used in the dashboard view.
+ */
+
+var core = require('web.core');
+var Domain = require('web.Domain');
+var viewRegistry = require('web.view_registry');
+var Widget = require('web.Widget');
+var widgetRegistry = require('web.widget_registry');
+
+var qweb = core.qweb;
+
+var PieChart = Widget.extend({
+ className: 'o_pie_chart',
+ xmlDependencies: ['/web/static/src/xml/chart.xml'],
+
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {Object} record
+ * @param {Object} node node from arch
+ */
+ init: function (parent, record, node) {
+ this._super.apply(this, arguments);
+
+ var modifiers = node.attrs.modifiers;
+ var domain = record.domain.concat(
+ Domain.prototype.stringToArray(modifiers.domain || '[]'));
+ var arch = qweb.render('web.PieChart', {
+ modifiers: modifiers,
+ title: node.attrs.title || modifiers.title || modifiers.measure,
+ });
+
+ var pieChartContext = JSON.parse(JSON.stringify(record.context));
+ delete pieChartContext.graph_mode;
+ delete pieChartContext.graph_measure;
+ delete pieChartContext.graph_groupbys;
+
+ this.subViewParams = {
+ modelName: record.model,
+ withButtons: false,
+ withControlPanel: false,
+ withSearchPanel: false,
+ isEmbedded: true,
+ useSampleModel: record.isSample,
+ mode: 'pie',
+ };
+ this.subViewParams.searchQuery = {
+ context: pieChartContext,
+ domain: domain,
+ groupBy: [],
+ timeRanges: {},
+ };
+
+ this.viewInfo = {
+ arch: arch,
+ fields: record.fields,
+ viewFields: record.fieldsInfo.dashboard,
+ };
+ },
+ /**
+ * Instantiates the pie chart view and starts the graph controller.
+ *
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ var def1 = this._super.apply(this, arguments);
+
+ var SubView = viewRegistry.get('graph');
+ var subView = new SubView(this.viewInfo, this.subViewParams);
+ var def2 = subView.getController(this).then(function (controller) {
+ self.controller = controller;
+ return self.controller.appendTo(document.createDocumentFragment());
+ });
+ return Promise.all([def1, def2]);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$el.append(this.controller.$el);
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Call `on_attach_callback` for each subview
+ *
+ * @override
+ */
+ on_attach_callback: function () {
+ this.controller.on_attach_callback();
+ },
+});
+
+widgetRegistry.add('pie_chart', PieChart);
+
+return PieChart;
+
+});
diff --git a/addons/web/static/src/js/widgets/rainbow_man.js b/addons/web/static/src/js/widgets/rainbow_man.js
new file mode 100644
index 00000000..fa1b1fac
--- /dev/null
+++ b/addons/web/static/src/js/widgets/rainbow_man.js
@@ -0,0 +1,71 @@
+odoo.define('web.RainbowMan', function (require) {
+"use strict";
+
+/**
+ * The RainbowMan widget is the widget displayed by default as a 'fun/rewarding'
+ * effect in some cases. For example, when the user marked a large deal as won,
+ * or when he cleared its inbox.
+ *
+ * This widget is mostly a picture and a message with a rainbow animation around
+ * If you want to display a RainbowMan, you probably do not want to do it by
+ * importing this file. The usual way to do that would be to use the effect
+ * service (by triggering the 'show_effect' event)
+ */
+
+var Widget = require('web.Widget');
+var core = require('web.core');
+
+var _t = core._t;
+
+var RainbowMan = Widget.extend({
+ template: 'rainbow_man.notification',
+ xmlDependencies: ['/web/static/src/xml/rainbow_man.xml'],
+ /**
+ * @override
+ * @constructor
+ * @param {Object} [options]
+ * @param {string} [options.message] Message to be displayed on rainbowman card
+ * @param {string} [options.fadeout='medium'] Delay for rainbowman to disappear. 'fast' will make rainbowman dissapear quickly, 'medium' and 'slow' will wait little longer before disappearing (can be used when options.message is longer), 'no' will keep rainbowman on screen until user clicks anywhere outside rainbowman
+ * @param {string} [options.img_url] URL of the image to be displayed
+ */
+ init: function (options) {
+ this._super.apply(this, arguments);
+ var rainbowDelay = {slow: 4500, medium: 3500, fast: 2000, no: false};
+ this.options = _.defaults(options || {}, {
+ fadeout: 'medium',
+ img_url: '/web/static/src/img/smile.svg',
+ message: _t('Well Done!'),
+ });
+ this.delay = rainbowDelay[this.options.fadeout];
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ // destroy rainbow man when the user clicks outside
+ // this is done in a setTimeout to prevent the click that triggered the
+ // rainbow man to close it directly
+ setTimeout(function () {
+ core.bus.on('click', self, function (ev) {
+ if (ev.originalEvent && ev.target.className.indexOf('o_reward') === -1) {
+ this.destroy();
+ }
+ });
+ });
+ if (this.delay) {
+ setTimeout(function () {
+ self.$el.addClass('o_reward_fading');
+ setTimeout(function () {
+ self.destroy();
+ }, 600); // destroy only after fadeout animation is completed
+ }, this.delay);
+ }
+ this.$('.o_reward_msg_content').append(this.options.message);
+ return this._super.apply(this, arguments);
+ }
+});
+
+return RainbowMan;
+
+});
diff --git a/addons/web/static/src/js/widgets/ribbon.js b/addons/web/static/src/js/widgets/ribbon.js
new file mode 100644
index 00000000..a682c65a
--- /dev/null
+++ b/addons/web/static/src/js/widgets/ribbon.js
@@ -0,0 +1,48 @@
+odoo.define('web.ribbon', function (require) {
+ 'use strict';
+
+ /**
+ * This widget adds a ribbon on the top right side of the form
+ *
+ * - You can specify the text with the title attribute.
+ * - You can specify the tooltip with the tooltip attribute.
+ * - You can specify a background color for the ribbon with the bg_color attribute
+ * using bootstrap classes :
+ * (bg-primary, bg-secondary, bg-success, bg-danger, bg-warning, bg-info,
+ * bg-light, bg-dark, bg-white)
+ *
+ * If you don't specify the bg_color attribute the bg-success class will be used
+ * by default.
+ */
+
+ var widgetRegistry = require('web.widget_registry');
+ var Widget = require('web.Widget');
+
+ var RibbonWidget = Widget.extend({
+ template: 'web.ribbon',
+ xmlDependencies: ['/web/static/src/xml/ribbon.xml'],
+
+ /**
+ * @param {Object} options
+ * @param {string} options.attrs.title
+ * @param {string} options.attrs.text same as title
+ * @param {string} options.attrs.tooltip
+ * @param {string} options.attrs.bg_color
+ */
+ init: function (parent, data, options) {
+ this._super.apply(this, arguments);
+ this.text = options.attrs.title || options.attrs.text;
+ this.tooltip = options.attrs.tooltip;
+ this.className = options.attrs.bg_color ? options.attrs.bg_color : 'bg-success';
+ if (this.text.length > 15) {
+ this.className += ' o_small';
+ } else if (this.text.length > 10) {
+ this.className += ' o_medium';
+ }
+ },
+ });
+
+ widgetRegistry.add('web_ribbon', RibbonWidget);
+
+ return RibbonWidget;
+});
diff --git a/addons/web/static/src/js/widgets/signature.js b/addons/web/static/src/js/widgets/signature.js
new file mode 100644
index 00000000..25cb16fd
--- /dev/null
+++ b/addons/web/static/src/js/widgets/signature.js
@@ -0,0 +1,97 @@
+odoo.define('web.signature_widget', function (require) {
+"use strict";
+
+const framework = require('web.framework');
+const SignatureDialog = require('web.signature_dialog');
+const widgetRegistry = require('web.widget_registry');
+const Widget = require('web.Widget');
+
+
+const WidgetSignature = Widget.extend({
+ custom_events: Object.assign({}, Widget.prototype.custom_events, {
+ upload_signature: '_onUploadSignature',
+ }),
+ events: Object.assign({}, Widget.prototype.events, {
+ 'click .o_sign_label': '_onClickSignature',
+ }),
+ template: 'SignButton',
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} record
+ * @param {Object} nodeInfo
+ */
+ init: function (parent, record, nodeInfo) {
+ this._super.apply(this, arguments);
+ this.res_id = record.res_id;
+ this.res_model = record.model;
+ this.state = record;
+ this.node = nodeInfo;
+ // signature_field is the field on which the signature image will be
+ // saved (`signature` by default).
+ this.signature_field = this.node.attrs.signature_field || 'signature';
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Open a dialog to sign.
+ *
+ * @private
+ */
+ _onClickSignature: function () {
+ const nameAndSignatureOptions = {
+ displaySignatureRatio: 3,
+ mode: 'draw',
+ noInputName: true,
+ signatureType: 'signature',
+ };
+
+ if (this.node.attrs.full_name) {
+ let signName;
+ const fieldFullName = this.state.data[this.node.attrs.full_name];
+ if (fieldFullName && fieldFullName.type === 'record') {
+ signName = fieldFullName.data.display_name;
+ } else {
+ signName = fieldFullName;
+ }
+ nameAndSignatureOptions.defaultName = signName || undefined;
+ }
+
+ nameAndSignatureOptions.defaultFont = this.node.attrs.default_font || '';
+ this.signDialog = new SignatureDialog(this, {
+ nameAndSignatureOptions: nameAndSignatureOptions,
+ });
+ this.signDialog.open();
+ },
+ /**
+ * Upload the signature image (write it on the corresponding field) and
+ * close the dialog.
+ *
+ * @returns {Promise}
+ * @private
+ */
+ _onUploadSignature: function (ev) {
+ const file = ev.data.signatureImage[1];
+ const always = () => {
+ this.trigger_up('reload');
+ framework.unblockUI();
+ };
+ framework.blockUI();
+ const rpcProm = this._rpc({
+ model: this.res_model,
+ method: 'write',
+ args: [[this.res_id], {
+ [this.signature_field]: file,
+ }],
+ });
+ rpcProm.then(always).guardedCatch(always);
+ return rpcProm;
+ },
+});
+
+widgetRegistry.add('signature', WidgetSignature);
+
+});
diff --git a/addons/web/static/src/js/widgets/switch_company_menu.js b/addons/web/static/src/js/widgets/switch_company_menu.js
new file mode 100644
index 00000000..06d33862
--- /dev/null
+++ b/addons/web/static/src/js/widgets/switch_company_menu.js
@@ -0,0 +1,127 @@
+odoo.define('web.SwitchCompanyMenu', function(require) {
+"use strict";
+
+/**
+ * When Odoo is configured in multi-company mode, users should obviously be able
+ * to switch their interface from one company to the other. This is the purpose
+ * of this widget, by displaying a dropdown menu in the systray.
+ */
+
+var config = require('web.config');
+var core = require('web.core');
+var session = require('web.session');
+var SystrayMenu = require('web.SystrayMenu');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+
+var SwitchCompanyMenu = Widget.extend({
+ template: 'SwitchCompanyMenu',
+ events: {
+ 'click .dropdown-item[data-menu] div.log_into': '_onSwitchCompanyClick',
+ 'keydown .dropdown-item[data-menu] div.log_into': '_onSwitchCompanyClick',
+ 'click .dropdown-item[data-menu] div.toggle_company': '_onToggleCompanyClick',
+ 'keydown .dropdown-item[data-menu] div.toggle_company': '_onToggleCompanyClick',
+ },
+ // force this item to be the first one to the left of the UserMenu in the systray
+ sequence: 1,
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.isMobile = config.device.isMobile;
+ this._onSwitchCompanyClick = _.debounce(this._onSwitchCompanyClick, 1500, true);
+ },
+
+ /**
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ this.allowed_company_ids = String(session.user_context.allowed_company_ids)
+ .split(',')
+ .map(function (id) {return parseInt(id);});
+ this.user_companies = session.user_companies.allowed_companies;
+ this.current_company = this.allowed_company_ids[0];
+ this.current_company_name = _.find(session.user_companies.allowed_companies, function (company) {
+ return company[0] === self.current_company;
+ })[1];
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent|KeyEvent} ev
+ */
+ _onSwitchCompanyClick: function (ev) {
+ if (ev.type == 'keydown' && ev.which != $.ui.keyCode.ENTER && ev.which != $.ui.keyCode.SPACE) {
+ return;
+ }
+ ev.preventDefault();
+ ev.stopPropagation();
+ var dropdownItem = $(ev.currentTarget).parent();
+ var dropdownMenu = dropdownItem.parent();
+ var companyID = dropdownItem.data('company-id');
+ var allowed_company_ids = this.allowed_company_ids;
+ if (dropdownItem.find('.fa-square-o').length) {
+ // 1 enabled company: Stay in single company mode
+ if (this.allowed_company_ids.length === 1) {
+ if (this.isMobile) {
+ dropdownMenu = dropdownMenu.parent();
+ }
+ dropdownMenu.find('.fa-check-square').removeClass('fa-check-square').addClass('fa-square-o');
+ dropdownItem.find('.fa-square-o').removeClass('fa-square-o').addClass('fa-check-square');
+ allowed_company_ids = [companyID];
+ } else { // Multi company mode
+ allowed_company_ids.push(companyID);
+ dropdownItem.find('.fa-square-o').removeClass('fa-square-o').addClass('fa-check-square');
+ }
+ }
+ $(ev.currentTarget).attr('aria-pressed', 'true');
+ session.setCompanies(companyID, allowed_company_ids);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent|KeyEvent} ev
+ */
+ _onToggleCompanyClick: function (ev) {
+ if (ev.type == 'keydown' && ev.which != $.ui.keyCode.ENTER && ev.which != $.ui.keyCode.SPACE) {
+ return;
+ }
+ ev.preventDefault();
+ ev.stopPropagation();
+ var dropdownItem = $(ev.currentTarget).parent();
+ var companyID = dropdownItem.data('company-id');
+ var allowed_company_ids = this.allowed_company_ids;
+ var current_company_id = allowed_company_ids[0];
+ if (dropdownItem.find('.fa-square-o').length) {
+ allowed_company_ids.push(companyID);
+ dropdownItem.find('.fa-square-o').removeClass('fa-square-o').addClass('fa-check-square');
+ $(ev.currentTarget).attr('aria-checked', 'true');
+ } else {
+ allowed_company_ids.splice(allowed_company_ids.indexOf(companyID), 1);
+ dropdownItem.find('.fa-check-square').addClass('fa-square-o').removeClass('fa-check-square');
+ $(ev.currentTarget).attr('aria-checked', 'false');
+ }
+ session.setCompanies(current_company_id, allowed_company_ids);
+ },
+
+});
+
+if (session.display_switch_company_menu) {
+ SystrayMenu.Items.push(SwitchCompanyMenu);
+}
+
+return SwitchCompanyMenu;
+
+});
diff --git a/addons/web/static/src/js/widgets/translation_dialog.js b/addons/web/static/src/js/widgets/translation_dialog.js
new file mode 100644
index 00000000..efdba7b3
--- /dev/null
+++ b/addons/web/static/src/js/widgets/translation_dialog.js
@@ -0,0 +1,183 @@
+odoo.define('web.TranslationDialog', function (require) {
+ 'use strict';
+
+ var core = require('web.core');
+ var Dialog = require('web.Dialog');
+ var session = require('web.session');
+
+ var _t = core._t;
+
+ var TranslationDialog = Dialog.extend({
+ xmlDependencies: (Dialog.prototype.xmlDependencies || [])
+ .concat(['/web/static/src/xml/translation_dialog.xml']),
+ template: 'TranslationDialog',
+
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} [options]
+ * @param {string} [options.domain] the domain needed to get the translation terms
+ * @param {string} [options.fieldName] the name of the field currently translated (from the model of the form view)
+ * @param {string} [options.searchName] the name of the actual field that is the reference for translation (in the form of model,field)
+ * @param {string} [options.userLanguageValue] the value of the translation in the language of the user, as seen in the from view (might be empty)
+ * @param {string} [options.dataPointID] the data point id of the record for which we do the translations
+ * @param {boolean} [options.isComingFromTranslationAlert] the initiator of the dialog, might be a link on a field or the translation alert on top of the form
+ * @param {boolean} [options.isText] is the field a text field (multiline) or char (single line)
+ * @param {boolean} [options.showSrc] is the source of the translation should be rendered (for partial translations, i.e. XML content)
+ *
+ */
+ init: function (parent, options) {
+ options = options || {};
+
+ this.fieldName = options.fieldName;
+ this.domain = options.domain;
+ this.searchName = options.searchName;
+ this.userLanguageValue = options.userLanguageValue;
+ this.domain.push(['name', "=", `${this.searchName}`]);
+ this.dataPointID = options.dataPointID;
+ this.isComingFromTranslationAlert = options.isComingFromTranslationAlert;
+ this.currentInterfaceLanguage = session.user_context.lang;
+ this.isText = options.isText;
+ this.showSrc = options.showSrc;
+
+ this._super(parent, _.extend({
+ size: 'large',
+ title: _t('Translate: ') + `${this.fieldName}`,
+ buttons: [
+ { text: _t('Save'), classes: 'btn-primary', close: true, click: this._onSave.bind(this) },
+ { text: _t('Discard'), close: true },
+ ],
+ }, options));
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ return Promise.all([
+ this._super(),
+ this._loadLanguages().then((l) => {
+ this.languages = l;
+ return this._loadTranslations().then((t) => {
+ this.translations = t;
+ });
+ }),
+ ]).then(() => {
+ this.data = this.translations.map((term) => {
+ let relatedLanguage = this.languages.find((language) => language[0] === term.lang);
+ if (!term.value && !this.showSrc) {
+ term.value = term.src;
+ }
+ return {
+ id: term.id,
+ lang: term.lang,
+ langName: relatedLanguage[1],
+ source: term.src,
+ // we set the translation value coming from the database, except for the language
+ // the user is currently utilizing. Then we set the translation value coming
+ // from the value of the field in the form
+ value: (term.lang === this.currentInterfaceLanguage &&
+ !this.showSrc &&
+ !this.isComingFromTranslationAlert) ?
+ this.userLanguageValue : term.value || ''
+ };
+ });
+ this.data.sort((left, right) =>
+ (left.langName < right.langName || (left.langName === right.langName && left.source < right.source)) ? -1 : 1);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+ /**
+ * Load the translation terms for the installed language, for the current model and res_id
+ * @private
+ */
+ _loadTranslations: function () {
+ const domain = [...this.domain, ['lang', 'in', this.languages.map(l => l[0])]];
+ return this._rpc({
+ model: 'ir.translation',
+ method: 'search_read',
+ fields: ['lang', 'src', 'value'],
+ domain: domain,
+ });
+ },
+ /**
+ * Load the installed languages long names and code
+ *
+ * The result of the call is put in cache on the prototype of this dialog.
+ * If any new language is installed, a full page refresh will happen,
+ * so there is no need invalidate it.
+ * @private
+ */
+ _loadLanguages: function () {
+ if (TranslationDialog.prototype.installedLanguagesCache)
+ return Promise.resolve(TranslationDialog.prototype.installedLanguagesCache);
+
+ return this._rpc({
+ model: 'res.lang',
+ method: 'get_installed',
+ fields: ['code', 'name', 'iso_code'],
+ }).then((installedLanguages) => {
+ TranslationDialog.prototype.installedLanguagesCache = installedLanguages;
+ return TranslationDialog.prototype.installedLanguagesCache
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+ /**
+ * Save all the terms that have been updated
+ * @private
+ * @returns a promise that is resolved when all the save have occured
+ */
+ _onSave: function () {
+ var updatedTerm = {};
+ var updateFormViewField;
+
+ this.el.querySelectorAll('input[type=text],textarea').forEach((t) => {
+ var initialValue = this.data.find((d) => d.id == t.dataset.id);
+ if (initialValue.value !== t.value) {
+ updatedTerm[t.dataset.id] = t.value;
+
+ if (initialValue.lang === this.currentInterfaceLanguage && !this.showSrc) {
+ // when the user has changed the term for the language he is
+ // using in the interface, this change should be reflected
+ // in the form view
+ // partial translations being handled server side are
+ // also ignored
+ var changes = {};
+ changes[this.fieldName] = updatedTerm[initialValue.id];
+ updateFormViewField = {
+ dataPointID: this.dataPointID,
+ changes: changes,
+ doNotSetDirty: false,
+ };
+ }
+ }
+ });
+
+ // updatedTerm only contains the id and values of the terms that
+ // have been updated by the user
+ var saveUpdatedTermsProms = Object.keys(updatedTerm).map((id) => {
+ var writeTranslation = {
+ model: 'ir.translation',
+ method: 'write',
+ context: this.context,
+ args: [[parseInt(id, 10)], { value: updatedTerm[id] }]
+ };
+ return this._rpc(writeTranslation);
+ });
+ return Promise.all(saveUpdatedTermsProms).then(() => {
+ // we might have to update the value of the field on the form
+ // view that opened the translation dialog
+ if (updateFormViewField) {
+ this.trigger_up('field_changed', updateFormViewField);
+ }
+ });
+ }
+ });
+
+ return TranslationDialog;
+});