summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/chrome
diff options
context:
space:
mode:
authorGéry Debongnie <ged@odoo.com>2017-03-05 10:05:56 +0100
committerAaron Bohy <aab@odoo.com>2017-04-11 19:44:38 +0200
commit905e01921f3c3ef43ace6fc537d1d8c0d280002c (patch)
tree265c43ab21c5401a5d9f0f3d64d86f1ac417451c /addons/web/static/src/js/chrome
parent54d4c73af4ad05c7640d28c1892247c9538b8017 (diff)
[REF] web, *: redesign all JS views
This commit introduce a full redesign of all JS views. We started basically from scratch. The goal was to unify all the various views under a common framework, to make them testable, to make then usable in different conditions (in studio, or in the frontend), and to make our lives easier. Some important points are: - we introduced new coding guidelines (camelCase, 80 chars width, ...) - we have a brand new testing framework (still QUnit based) - kanban view moved to the web addon - calendar view (formerly web_calender) moved to web as well - the tree view was removed - all new code should be documented We hope that this code is the start of a new era for the Odoo web client, we want to have a high quality codebase, well documented, well tested, well designed. Work done by the framework team: mostly aab, ged, chm, dmo, qsm
Diffstat (limited to 'addons/web/static/src/js/chrome')
-rw-r--r--addons/web/static/src/js/chrome/abstract_web_client.js291
-rw-r--r--addons/web/static/src/js/chrome/action_manager.js898
-rw-r--r--addons/web/static/src/js/chrome/control_panel.js248
-rw-r--r--addons/web/static/src/js/chrome/loading.js78
-rw-r--r--addons/web/static/src/js/chrome/menu.js215
-rw-r--r--addons/web/static/src/js/chrome/pager.js222
-rw-r--r--addons/web/static/src/js/chrome/search_filters.js366
-rw-r--r--addons/web/static/src/js/chrome/search_inputs.js762
-rw-r--r--addons/web/static/src/js/chrome/search_menus.js455
-rw-r--r--addons/web/static/src/js/chrome/search_view.js615
-rw-r--r--addons/web/static/src/js/chrome/sidebar.js225
-rw-r--r--addons/web/static/src/js/chrome/systray_menu.js60
-rw-r--r--addons/web/static/src/js/chrome/user_menu.js112
-rw-r--r--addons/web/static/src/js/chrome/view_manager.js610
-rw-r--r--addons/web/static/src/js/chrome/web_client.js178
15 files changed, 5335 insertions, 0 deletions
diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js
new file mode 100644
index 00000000000..498d1b41ea4
--- /dev/null
+++ b/addons/web/static/src/js/chrome/abstract_web_client.js
@@ -0,0 +1,291 @@
+odoo.define('web.AbstractWebClient', function (require) {
+"use strict";
+
+/**
+ * AbstractWebClient
+ *
+ * This class defines a simple, basic web client. It is mostly functional.
+ * The WebClient is in some way the most important class for the web framework:
+ * - this is the class that instantiate everything else,
+ * - it is the top of the component tree,
+ * - it coordinates many events bubbling up
+ */
+
+var ActionManager = require('web.ActionManager');
+var concurrency = require('web.concurrency');
+var core = require('web.core');
+var crash_manager = require('web.crash_manager');
+var data_manager = require('web.data_manager');
+var Loading = require('web.Loading');
+var mixins = require('web.mixins');
+var NotificationManager = require('web.notification').NotificationManager;
+var session = require('web.session');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+
+var AbstractWebClient = Widget.extend(mixins.ServiceProvider, {
+ custom_events: {
+ clear_uncommitted_changes: function (e) {
+ this.clear_uncommitted_changes().then(e.data.callback);
+ },
+ toggle_fullscreen: function (event) {
+ this.toggle_fullscreen(event.data.fullscreen);
+ },
+ current_action_updated: function (e) {
+ this.current_action_updated(e.data.action);
+ },
+ // GENERIC SERVICES
+ // the next events are dedicated to generic services required by
+ // downstream widgets. Mainly side effects, such as rpcs, notifications
+ // or cache.
+
+ // notifications and warnings
+ notification: function (e) {
+ if(this.notification_manager) {
+ this.notification_manager.notify(e.data.title, e.data.message, e.data.sticky);
+ }
+ },
+ warning: function (e) {
+ if(this.notification_manager) {
+ this.notification_manager.warn(e.data.title, e.data.message, e.data.sticky);
+ }
+ },
+ load_views: function(event) {
+ var params = {
+ model: event.data.modelName,
+ context: event.data.context,
+ views_descr: event.data.views,
+ };
+ return data_manager
+ .load_views(params, event.data.options || {})
+ .then(event.data.on_success);
+ },
+ load_filters: function(event) {
+ return data_manager
+ .load_filters(event.data.dataset, event.data.action_id)
+ .then(event.data.on_success);
+ },
+ // session
+ get_session: function(event) {
+ if (event.data.callback) {
+ event.data.callback(session);
+ }
+ },
+ do_action: function (event) {
+ this.do_action(event.data.action, event.data.options || {}).then(function (result) {
+ if (event.data.on_success) {
+ event.data.on_success(result);
+ }
+ });
+ },
+ },
+ init: function(parent) {
+ this.client_options = {};
+ mixins.ServiceProvider.init.call(this);
+ this._super(parent);
+ this.origin = undefined;
+ this._current_state = null;
+ this.menu_dm = new concurrency.DropMisordered();
+ this.action_mutex = new concurrency.Mutex();
+ this.set('title_part', {"zopenerp": "Odoo"});
+ },
+ start: function() {
+ var self = this;
+
+ this.on("change:title_part", this, this._title_changed);
+ this._title_changed();
+
+ return session.is_bound
+ .then(function () {
+ self.bind_events();
+ return $.when(
+ self.set_action_manager(),
+ self.set_notification_manager(),
+ self.set_loading()
+ );
+ }).then(function () {
+ if (session.session_is_valid()) {
+ return self.show_application();
+ } else {
+ // database manager needs the webclient to keep going even
+ // though it has no valid session
+ return $.when();
+ }
+ }).then(function () {
+ // Listen to 'scroll' event and propagate it on main bus
+ self.action_manager.$el.on('scroll', core.bus.trigger.bind(core.bus, 'scroll'));
+ core.bus.trigger('web_client_ready');
+ });
+ },
+ bind_events: function() {
+ var self = this;
+ $('.oe_systray').show();
+ this.$el.on('mouseenter', '.oe_systray > div:not([data-toggle=tooltip])', function() {
+ $(this).attr('data-toggle', 'tooltip').tooltip().trigger('mouseenter');
+ });
+ this.$el.on('click', '.oe_dropdown_toggle', function(ev) {
+ ev.preventDefault();
+ var $toggle = $(this);
+ var doc_width = $(document).width();
+ var $menu = $toggle.siblings('.oe_dropdown_menu');
+ $menu = $menu.size() >= 1 ? $menu : $toggle.find('.oe_dropdown_menu');
+ var state = $menu.is('.oe_opened');
+ setTimeout(function() {
+ // Do not alter propagation
+ $toggle.add($menu).toggleClass('oe_opened', !state);
+ if (!state) {
+ // Move $menu if outside window's edge
+ var offset = $menu.offset();
+ var menu_width = $menu.width();
+ var x = doc_width - offset.left - menu_width - 2;
+ if (x < 0) {
+ $menu.offset({ left: offset.left + x }).width(menu_width);
+ }
+ }
+ }, 0);
+ });
+ core.bus.on('click', this, function(ev) {
+ $('.tooltip').remove();
+ if (!$(ev.target).is('input[type=file]')) {
+ self.$('.oe_dropdown_menu.oe_opened, .oe_dropdown_toggle.oe_opened').removeClass('oe_opened');
+ }
+ });
+ core.bus.on('connection_lost', this, this.on_connection_lost);
+ core.bus.on('connection_restored', this, this.on_connection_restored);
+
+ // crash manager integration
+ session.on('error', crash_manager, crash_manager.rpc_error);
+ window.onerror = function (message, file, line, col, error) {
+ var traceback = error ? error.stack : '';
+ crash_manager.show_error({
+ type: _t("Odoo Client Error"),
+ message: message,
+ data: {debug: file + ':' + line + "\n" + _t('Traceback:') + "\n" + traceback}
+ });
+ };
+ },
+ set_action_manager: function() {
+ this.action_manager = new ActionManager(this, {webclient: this});
+ return this.action_manager.appendTo(this.$('.o_main_content'));
+ },
+ set_notification_manager: function() {
+ this.notification_manager = new NotificationManager(this);
+ return this.notification_manager.appendTo(this.$el);
+ },
+ set_loading: function() {
+ this.loading = new Loading(this);
+ return this.loading.appendTo(this.$el);
+ },
+ show_application: function() {
+ },
+ clear_uncommitted_changes: function() {
+ var def = $.Deferred().resolve();
+ core.bus.trigger('clear_uncommitted_changes', function chain_callbacks(callback) {
+ def = def.then(callback);
+ });
+ return def;
+ },
+ destroy_content: function() {
+ _.each(_.clone(this.getChildren()), function(el) {
+ el.destroy();
+ });
+ this.$el.children().remove();
+ },
+ // --------------------------------------------------------------
+ // Window title handling
+ // --------------------------------------------------------------
+ /**
+ Sets the first part of the title of the window, dedicated to the current action.
+ */
+ set_title: function(title) {
+ this.set_title_part("action", title);
+ },
+ /**
+ Sets an arbitrary part of the title of the window. Title parts are identified by strings. Each time
+ a title part is changed, all parts are gathered, ordered by alphabetical order and displayed in the
+ title of the window separated by '-'.
+ */
+ set_title_part: function(part, title) {
+ var tmp = _.clone(this.get("title_part"));
+ tmp[part] = title;
+ this.set("title_part", tmp);
+ },
+ _title_changed: function() {
+ var parts = _.sortBy(_.keys(this.get("title_part")), function(x) { return x; });
+ var tmp = "";
+ _.each(parts, function(part) {
+ var str = this.get("title_part")[part];
+ if (str) {
+ tmp = tmp ? tmp + " - " + str : str;
+ }
+ }, this);
+ document.title = tmp;
+ },
+ // --------------------------------------------------------------
+ // do_*
+ // --------------------------------------------------------------
+ /**
+ * When do_action is performed on the WebClient, forward it to the main ActionManager
+ * This allows to widgets that are not inside the ActionManager to perform do_action
+ */
+ do_action: function() {
+ return this.action_manager.do_action.apply(this, arguments);
+ },
+ do_reload: function() {
+ var self = this;
+ return this.session.session_reload().then(function () {
+ session.load_modules(true).then(
+ self.menu.proxy('do_reload'));
+ });
+ },
+ do_push_state: function(state) {
+ if ('title' in state) {
+ this.set_title(state.title);
+ delete state.title;
+ }
+ var url = '#' + $.param(state);
+ this._current_state = $.deparam($.param(state), false); // stringify all values
+ $.bbq.pushState(url);
+ this.trigger('state_pushed', state);
+ },
+ // --------------------------------------------------------------
+ // Connection notifications
+ // --------------------------------------------------------------
+ on_connection_lost: function () {
+ this.connection_notification = this.notification_manager.notify(
+ _t('Connection lost'),
+ _t('Trying to reconnect...'),
+ true
+ );
+ },
+ on_connection_restored: function () {
+ if (this.connection_notification) {
+ this.connection_notification.destroy();
+ this.notification_manager.notify(
+ _t('Connection restored'),
+ _t('You are back online'),
+ false
+ );
+ this.connection_notification = false;
+ }
+ },
+ // Handler to be overwritten
+ current_action_updated: function () {
+ },
+ // --------------------------------------------------------------
+ // Scrolltop handling
+ // --------------------------------------------------------------
+ getScrollTop: function () {
+ },
+ //--------------------------------------------------------------
+ // Misc.
+ //--------------------------------------------------------------
+ toggle_fullscreen: function(fullscreen) {
+ this.$el.toggleClass('o_fullscreen', fullscreen);
+ },
+});
+
+return AbstractWebClient;
+
+});
diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js
new file mode 100644
index 00000000000..9dfe28e01dd
--- /dev/null
+++ b/addons/web/static/src/js/chrome/action_manager.js
@@ -0,0 +1,898 @@
+odoo.define('web.ActionManager', function (require) {
+"use strict";
+
+/**
+ * ActionManager
+ *
+ * The action manager is quite important: it makes sure that actions (the Odoo
+ * objects, such as a client action, or a act_window) are properly started,
+ * and coordinated.
+ */
+
+var Bus = require('web.Bus');
+var ControlPanel = require('web.ControlPanel');
+var core = require('web.core');
+var crash_manager = require('web.crash_manager');
+var data = require('web.data');
+var data_manager = require('web.data_manager');
+var Dialog = require('web.Dialog');
+var dom = require('web.dom');
+var framework = require('web.framework');
+var pyeval = require('web.pyeval');
+var session = require('web.session');
+var ViewManager = require('web.ViewManager');
+var Widget = require('web.Widget');
+
+/**
+ * Class representing the actions of the ActionManager
+ * Basic implementation for client actions that are functions
+ */
+var Action = core.Class.extend({
+ init: function(action) {
+ this.action_descr = action;
+ this.title = action.display_name || action.name;
+ },
+ /**
+ * This method should restore this previously loaded action
+ * Calls on_reverse_breadcrumb_callback if defined
+ * @return {Deferred} resolved when widget is enabled
+ */
+ restore: function() {
+ if (this.on_reverse_breadcrumb_callback) {
+ return this.on_reverse_breadcrumb_callback();
+ }
+ },
+ /**
+ * There is nothing to detach in the case of a client function
+ */
+ detach: function() {
+ },
+ /**
+ * Destroyer: there is nothing to destroy in the case of a client function
+ */
+ destroy: function() {
+ },
+ /**
+ * Sets the on_reverse_breadcrumb_callback to be called when coming back to that action
+ * @param {Function} [callback] the callback
+ */
+ set_on_reverse_breadcrumb: function(callback) {
+ this.on_reverse_breadcrumb_callback = callback;
+ },
+ /**
+ * Not implemented for client actions
+ */
+ setScrollTop: function() {
+ },
+ /**
+ * Stores the DOM fragment of the action
+ * @param {jQuery} [fragment] the DOM fragment
+ */
+ set_fragment: function($fragment) {
+ this.$fragment = $fragment;
+ },
+ /**
+ * Not implemented for client actions
+ * @return {int} the number of pixels the webclient is scrolled when leaving the action
+ */
+ getScrollTop: function() {
+ return 0;
+ },
+ /**
+ * @return {Object} the description of the action
+ */
+ get_action_descr: function() {
+ return this.action_descr;
+ },
+ /**
+ * @return {Object} dictionnary that will be interpreted to display the breadcrumbs
+ */
+ get_breadcrumbs: function() {
+ return { title: this.title, action: this };
+ },
+ /**
+ * @return {int} the number of views stacked, i.e. 0 for client functions
+ */
+ get_nb_views: function() {
+ return 0;
+ },
+ /**
+ * @return {jQuery} the DOM fragment of the action
+ */
+ get_fragment: function() {
+ return this.$fragment;
+ },
+ /**
+ * @return {string} the active view, i.e. empty for client actions
+ */
+ get_active_view: function() {
+ return '';
+ },
+});
+/**
+ * Specialization of Action for client actions that are Widgets
+ */
+var WidgetAction = Action.extend({
+ /**
+ * Initializes the WidgetAction
+ * Sets the title of the widget
+ */
+ init: function(action, widget) {
+ this._super(action);
+
+ this.widget = widget;
+ if (!this.widget.get('title')) {
+ this.widget.set('title', this.title);
+ }
+ this.widget.on('change:title', this, function(widget) {
+ this.title = widget.get('title');
+ });
+ },
+ /**
+ * Restores WidgetAction by calling do_show on its widget
+ */
+ restore: function() {
+ var self = this;
+ return $.when(this._super()).then(function() {
+ return self.widget.do_show();
+ });
+ },
+ /**
+ * Detaches the action's widget from the DOM
+ * @return the widget's $el
+ */
+ detach: function() {
+ // Hack to remove badly inserted nvd3 tooltips ; should be removed when upgrading nvd3 lib
+ $('body > .nvtooltip').remove();
+
+ return dom.detach([{widget: this.widget}]);
+ },
+ /**
+ * Destroys the widget
+ */
+ destroy: function() {
+ this.widget.destroy();
+ },
+});
+/**
+ * Specialization of WidgetAction for window actions (i.e. ViewManagers)
+ */
+var ViewManagerAction = WidgetAction.extend({
+ /**
+ * Restores a ViewManagerAction
+ * Switches to the requested view by calling select_view on the ViewManager
+ * @param {int} [view_index] the index of the view to select
+ */
+ restore: function(view_index) {
+ var _super = this._super.bind(this);
+ return this.widget.select_view(view_index).then(function() {
+ return _super();
+ });
+ },
+ /**
+ * Sets the on_reverse_breadcrumb_callback and the scrollTop to apply when
+ * coming back to that action
+ * @param {Function} [callback] the callback
+ * @param {int} [scrollTop] the number of pixels to scroll
+ */
+ set_on_reverse_breadcrumb: function(callback, scrollTop) {
+ this._super(callback);
+ this.setScrollTop(scrollTop);
+ },
+ /**
+ * Sets the scroll position of the widgets's active_view
+ * @param {int} [scrollTop] the number of pixels to scroll
+ */
+ setScrollTop: function(scrollTop) {
+ this.widget.active_view.controller.setScrollTop(scrollTop);
+ },
+ /**
+ * @return {int} the number of pixels the webclient is scrolled when leaving the action
+ */
+ getScrollTop: function() {
+ return this.widget.active_view.controller.getScrollTop();
+ },
+ /**
+ * @return {Array} array of Objects that will be interpreted to display the breadcrumbs
+ */
+ get_breadcrumbs: function() {
+ var self = this;
+ return this.widget.view_stack.map(function (view, index) {
+ return {
+ title: view.controller && view.controller.get('title') || self.title,
+ index: index,
+ action: self,
+ };
+ });
+ },
+ /**
+ * @return {int} the number of views stacked in the ViewManager
+ */
+ get_nb_views: function() {
+ return this.widget.view_stack.length;
+ },
+ /**
+ * @return {string} the active view of the ViewManager
+ */
+ get_active_view: function() {
+ return this.widget.active_view.type;
+ }
+});
+
+var ActionManager = Widget.extend({
+ template: 'ActionManager',
+ /**
+ * Called each time the action manager is attached into the DOM
+ */
+ on_attach_callback: function() {
+ this.is_in_DOM = true;
+ if (this.inner_widget && this.inner_widget.on_attach_callback) {
+ this.inner_widget.on_attach_callback();
+ }
+ },
+ /**
+ * Called each time the action manager is detached from the DOM
+ */
+ on_detach_callback: function() {
+ this.is_in_DOM = false;
+ if (this.inner_widget && this.inner_widget.on_detach_callback) {
+ this.inner_widget.on_detach_callback();
+ }
+ },
+ init: function(parent, options) {
+ this._super(parent);
+ this.action_stack = [];
+ this.inner_action = null;
+ this.inner_widget = null;
+ this.webclient = options && options.webclient;
+ this.dialog = null;
+ this.dialog_widget = null;
+ this.on('history_back', this, this.proxy('history_back'));
+ },
+ start: function() {
+ this._super();
+
+ // Instantiate a unique main ControlPanel used by widgets of actions in this.action_stack
+ this.main_control_panel = new ControlPanel(this);
+ // Listen to event "on_breadcrumb_click" trigerred on the control panel when
+ // clicking on a part of the breadcrumbs. Call select_action for this breadcrumb.
+ this.main_control_panel.on("on_breadcrumb_click", this, _.debounce(function(action, index) {
+ this.select_action(action, index);
+ }, 200, true));
+
+ // Listen to event "DOM_updated" to restore the scroll position
+ core.bus.on('DOM_updated', this, function() {
+ if (this.inner_action) {
+ this.trigger_up('scrollTo', {offset: this.inner_action.getScrollTop() || 0});
+ }
+ });
+
+ // Insert the main control panel into the DOM
+ return this.main_control_panel.insertBefore(this.$el);
+ },
+ dialog_stop: function (reason) {
+ if (this.dialog) {
+ this.dialog.destroy(reason);
+ }
+ this.dialog = null;
+ },
+ /**
+ * Add a new action to the action manager
+ *
+ * widget: typically, widgets added are openerp.web.ViewManager. The action manager
+ * uses the stack of actions to handle the breadcrumbs.
+ * action_descr: new action description
+ * options.on_reverse_breadcrumb: will be called when breadcrumb is clicked on
+ * options.clear_breadcrumbs: boolean, if true, action stack is destroyed
+ */
+ push_action: function(widget, action_descr, options) {
+ var self = this;
+ var old_action_stack = this.action_stack;
+ var old_action = this.inner_action;
+ var old_widget = this.inner_widget;
+ var actions_to_destroy;
+ options = options || {};
+
+ // Empty action_stack or replace last action if requested
+ if (options.clear_breadcrumbs) {
+ actions_to_destroy = this.action_stack;
+ this.action_stack = [];
+ } else if (options.replace_last_action && this.action_stack.length > 0) {
+ actions_to_destroy = [this.action_stack.pop()];
+ }
+
+ // Instantiate the new action
+ var new_action;
+ if (widget instanceof ViewManager) {
+ new_action = new ViewManagerAction(action_descr, widget);
+ } else if (widget instanceof Widget) {
+ new_action = new WidgetAction(action_descr, widget);
+ } else {
+ new_action = new Action(action_descr);
+ }
+
+ // Set on_reverse_breadcrumb callback on previous inner_action
+ if (this.webclient && old_action) {
+ old_action.set_on_reverse_breadcrumb(options.on_reverse_breadcrumb, this.webclient.getScrollTop());
+ }
+
+ // Update action_stack (must be done before appendTo to properly
+ // compute the breadcrumbs and to perform do_push_state)
+ this.action_stack.push(new_action);
+ this.inner_action = new_action;
+ this.inner_widget = widget;
+
+ if (widget.need_control_panel) {
+ // Set the ControlPanel bus on the widget to allow it to communicate its status
+ widget.set_cp_bus(this.main_control_panel.get_bus());
+ }
+
+ // render the inner_widget in a fragment, and append it to the
+ // document only when it's ready
+ var new_widget_fragment = document.createDocumentFragment();
+ return $.when(this.inner_widget.appendTo(new_widget_fragment)).done(function() {
+ // Detach the fragment of the previous action and store it within the action
+ if (old_action) {
+ old_action.set_fragment(old_action.detach());
+ }
+ if (!widget.need_control_panel) {
+ // Hide the main ControlPanel for widgets that do not use it
+ self.main_control_panel.do_hide();
+ }
+ dom.append(self.$el, new_widget_fragment, {
+ in_DOM: self.is_in_DOM,
+ callbacks: [{widget: self.inner_widget}],
+ });
+ if (actions_to_destroy) {
+ self.clear_action_stack(actions_to_destroy);
+ }
+ self.toggle_fullscreen();
+ self.trigger_up('current_action_updated', {action: new_action});
+ }).fail(function () {
+ // Destroy failed action and restore internal state
+ new_action.destroy();
+ self.action_stack = old_action_stack;
+ self.inner_action = old_action;
+ self.inner_widget = old_widget;
+ });
+ },
+ setScrollTop: function(scrollTop) {
+ if (this.inner_action) {
+ this.inner_action.setScrollTop(scrollTop);
+ }
+ },
+ get_breadcrumbs: function () {
+ return _.flatten(_.map(this.action_stack, function (action) {
+ return action.get_breadcrumbs();
+ }), true);
+ },
+ get_title: function () {
+ if (this.action_stack.length === 1) {
+ // horrible hack to display the action title instead of "New" for the actions
+ // that use a form view to edit something that do not correspond to a real model
+ // for example, point of sale "Your Session" or most settings form,
+ var action = this.action_stack[0];
+ if (action.get_breadcrumbs().length === 1) {
+ return action.title;
+ }
+ }
+ var last_breadcrumb = _.last(this.get_breadcrumbs());
+ return last_breadcrumb ? last_breadcrumb.title : "";
+ },
+ get_action_stack: function () {
+ return this.action_stack;
+ },
+ get_inner_action: function() {
+ return this.inner_action;
+ },
+ get_inner_widget: function() {
+ return this.inner_widget;
+ },
+ history_back: function() {
+ var nb_views = this.inner_action.get_nb_views();
+ if (nb_views > 1) {
+ // Stay on this action, but select the previous view
+ return this.select_action(this.inner_action, nb_views - 2);
+ }
+ if (this.action_stack.length > 1) {
+ // Select the previous action
+ var action = this.action_stack[this.action_stack.length - 2];
+ nb_views = action.get_nb_views();
+ return this.select_action(action, nb_views - 1);
+ }
+ return $.Deferred().reject();
+ },
+ select_action: function(action, index) {
+ var self = this;
+ var def = this.webclient && this.webclient.clear_uncommitted_changes() || $.when();
+
+ return def.then(function() {
+ // Set the new inner_action/widget and update the action stack
+ var old_action = self.inner_action;
+ var action_index = self.action_stack.indexOf(action);
+ var to_destroy = self.action_stack.splice(action_index + 1);
+ self.inner_action = action;
+ self.inner_widget = action.widget;
+
+ return $.when(action.restore(index)).done(function() {
+ // Hide the ControlPanel if the widget doesn't use it
+ if (!self.inner_widget.need_control_panel) {
+ self.main_control_panel.do_hide();
+ }
+ // Attach the DOM of the action and restore the scroll position only if necessary
+ if (action !== old_action) {
+ // Clear the action stack (this also removes the current action from the DOM)
+ self.clear_action_stack(to_destroy);
+
+ // Append the fragment of the action to restore to self.$el
+ dom.append(self.$el, action.get_fragment(), {
+ in_DOM: self.is_in_DOM,
+ callbacks: [{widget: action.widget}],
+ });
+ }
+ self.trigger_up('current_action_updated', {action: action});
+ });
+ }).fail(function() {
+ return $.Deferred().reject();
+ });
+ },
+ clear_action_stack: function(action_stack) {
+ _.map(action_stack || this.action_stack, function(action) {
+ action.destroy();
+ });
+ if (!action_stack) {
+ this.action_stack = [];
+ this.inner_action = null;
+ this.inner_widget = null;
+ }
+ this.toggle_fullscreen();
+ },
+ toggle_fullscreen: function() {
+ var is_fullscreen = _.some(this.action_stack, function(action) {
+ return action.action_descr.target === "fullscreen";
+ });
+ this.trigger_up("toggle_fullscreen", {fullscreen: is_fullscreen});
+ },
+ do_push_state: function(state) {
+ if (!this.webclient || this.dialog) {
+ return;
+ }
+ state = state || {};
+ if (this.inner_action) {
+ var inner_action_descr = this.inner_action.get_action_descr();
+ if (inner_action_descr._push_me === false) {
+ // this action has been explicitly marked as not pushable
+ return;
+ }
+ state.title = this.get_title();
+ if(inner_action_descr.type == 'ir.actions.act_window') {
+ state.model = inner_action_descr.res_model;
+ }
+ if (inner_action_descr.menu_id) {
+ state.menu_id = inner_action_descr.menu_id;
+ }
+ if (inner_action_descr.id) {
+ state.action = inner_action_descr.id;
+ } else if (inner_action_descr.type == 'ir.actions.client') {
+ state.action = inner_action_descr.tag;
+ var params = {};
+ _.each(inner_action_descr.params, function(v, k) {
+ if(_.isString(v) || _.isNumber(v)) {
+ params[k] = v;
+ }
+ });
+ state = _.extend(params || {}, state);
+ }
+ if (inner_action_descr.context) {
+ var active_id = inner_action_descr.context.active_id;
+ if (active_id) {
+ state.active_id = active_id;
+ }
+ var active_ids = inner_action_descr.context.active_ids;
+ if (active_ids && !(active_ids.length === 1 && active_ids[0] === active_id)) {
+ // We don't push active_ids if it's a single element array containing the active_id
+ // This makes the url shorter in most cases.
+ state.active_ids = inner_action_descr.context.active_ids.join(',');
+ }
+ }
+ }
+ this.webclient.do_push_state(state);
+ },
+ do_load_state: function(state, warm) {
+ var self = this;
+ var action_loaded;
+ if (state.action) {
+ if (_.isString(state.action) && core.action_registry.contains(state.action)) {
+ var action_client = {
+ type: "ir.actions.client",
+ tag: state.action,
+ params: state,
+ _push_me: state._push_me,
+ };
+ if (warm) {
+ this.null_action();
+ }
+ action_loaded = this.do_action(action_client);
+ } else {
+ var run_action = (!this.inner_widget || !this.inner_widget.action) || this.inner_widget.action.id !== state.action;
+ if (run_action) {
+ var add_context = {};
+ if (state.active_id) {
+ add_context.active_id = state.active_id;
+ }
+ if (state.active_ids) {
+ // The jQuery BBQ plugin does some parsing on values that are valid integers.
+ // It means that if there's only one item, it will do parseInt() on it,
+ // otherwise it will keep the comma seperated list as string.
+ add_context.active_ids = state.active_ids.toString().split(',').map(function(id) {
+ return parseInt(id, 10) || id;
+ });
+ } else if (state.active_id) {
+ add_context.active_ids = [state.active_id];
+ }
+ add_context.params = state;
+ if (warm) {
+ this.null_action();
+ }
+ action_loaded = this.do_action(state.action, {
+ additional_context: add_context,
+ res_id: state.id,
+ view_type: state.view_type,
+ });
+ }
+ }
+ } else if (state.model && state.id) {
+ // TODO handle context & domain ?
+ if (warm) {
+ this.null_action();
+ }
+ var action = {
+ res_model: state.model,
+ res_id: state.id,
+ type: 'ir.actions.act_window',
+ views: [[_.isNumber(state.view_id) ? state.view_id : false, 'form']]
+ };
+ action_loaded = this.do_action(action);
+ } else if (state.sa) {
+ // load session action
+ if (warm) {
+ this.null_action();
+ }
+ action_loaded = this.rpc('/web/session/get_session_action', {key: state.sa}).then(function(action) {
+ if (action) {
+ return self.do_action(action);
+ }
+ });
+ }
+
+ return $.when(action_loaded || null).done(function() {
+ if (self.inner_widget && self.inner_widget.do_load_state) {
+ return self.inner_widget.do_load_state(state, warm);
+ }
+ });
+ },
+ /**
+ * Execute an OpenERP action
+ *
+ * @param {Number|String|String|Object} Can be either an action id, an action XML id, a client action tag or an action descriptor.
+ * @param {Object} [options]
+ * @param {Boolean} [options.clear_breadcrumbs=false] Clear the breadcrumbs history list
+ * @param {Boolean} [options.replace_breadcrumb=false] Replace the current breadcrumb with the action
+ * @param {Function} [options.on_reverse_breadcrumb] Callback to be executed whenever an anterior breadcrumb item is clicked on.
+ * @param {Function} [options.hide_breadcrumb] Do not display this widget's title in the breadcrumb
+ * @param {Function} [options.on_close] Callback to be executed when the dialog is closed (only relevant for target=new actions)
+ * @param {Function} [options.action_menu_id] Manually set the menu id on the fly.
+ * @param {Object} [options.additional_context] Additional context to be merged with the action's context.
+ * @return {jQuery.Deferred} Action loaded
+ */
+ do_action: function(action, options) {
+ options = _.defaults(options || {}, {
+ clear_breadcrumbs: false,
+ replace_last_action: false,
+ on_reverse_breadcrumb: function() {},
+ hide_breadcrumb: false,
+ on_close: function() {},
+ on_load: function() {},
+ action_menu_id: null,
+ additional_context: {},
+ });
+ if (action === false) {
+ action = { type: 'ir.actions.act_window_close' };
+ } else if (_.isString(action) && core.action_registry.contains(action)) {
+ var action_client = { type: "ir.actions.client", tag: action, params: {} };
+ return this.do_action(action_client, options);
+ } else if (_.isNumber(action) || _.isString(action)) {
+ var self = this;
+ var additional_context = {
+ active_id : options.additional_context.active_id,
+ active_ids : options.additional_context.active_ids,
+ active_model : options.additional_context.active_model
+ };
+ return data_manager.load_action(action, additional_context).then(function(result) {
+ return self.do_action(result, options);
+ });
+ }
+
+ core.bus.trigger('action', action);
+
+ // Force clear breadcrumbs if action's target is main
+ options.clear_breadcrumbs = (action.target === 'main') || options.clear_breadcrumbs;
+
+ // Ensure context & domain are evaluated and can be manipulated/used
+ var user_context = this.getSession().user_context;
+ var ncontext = new data.CompoundContext(user_context, options.additional_context, action.context || {});
+ action.context = pyeval.eval('context', ncontext);
+ if (action.context.active_id || action.context.active_ids) {
+ // Here we assume that when an `active_id` or `active_ids` is used
+ // in the context, we are in a `related` action, so we disable the
+ // searchview's default custom filters.
+ action.context.search_disable_custom_filters = true;
+ }
+ if (action.domain) {
+ action.domain = pyeval.eval(
+ 'domain', action.domain, action.context || {});
+ }
+
+ if (!action.type) {
+ console.error("No type for action", action);
+ return $.Deferred().reject();
+ }
+
+ var type = action.type.replace(/\./g,'_');
+ action.menu_id = options.action_menu_id;
+ action.res_id = options.res_id || action.res_id;
+ action.context.params = _.extend({ 'action' : action.id }, action.context.params);
+ if (!(type in this)) {
+ console.error("Action manager can't handle action of type " + action.type, action);
+ return $.Deferred().reject();
+ }
+
+ // Special case for Dashboards, this should definitively be done upstream
+ if (action.res_model === 'board.board' && action.view_mode === 'form') {
+ action.target = 'inline';
+ _.extend(action.flags, {
+ headless: true,
+ views_switcher: false,
+ display_title: false,
+ search_view: false,
+ pager: false,
+ sidebar: false,
+ action_buttons: false
+ });
+ } else {
+ var popup = action.target === 'new';
+ var inline = action.target === 'inline' || action.target === 'inlineview';
+ var form = _.str.startsWith(action.view_mode, 'form');
+ action.flags = _.defaults(action.flags || {}, {
+ views_switcher : !popup && !inline,
+ search_view : !(popup && form) && !inline,
+ action_buttons : !popup && !inline,
+ sidebar : !popup && !inline,
+ pager : (!popup || !form) && !inline,
+ display_title : !popup,
+ headless: (popup || inline) && form,
+ search_disable_custom_filters: action.context && action.context.search_disable_custom_filters,
+ });
+ }
+
+ return $.when(this[type](action, options)).then(function (executor_action) {
+ options.on_load(executor_action);
+ return action;
+ });
+ },
+ null_action: function() {
+ this.dialog_stop();
+ this.clear_action_stack();
+ },
+ /**
+ *
+ * @param {Object} executor
+ * @param {Object} executor.action original action
+ * @param {Function<instance.web.Widget>} executor.widget function used to fetch the widget instance
+ * @param {String} executor.klass CSS class to add on the dialog root, if action.target=new
+ * @param {Function<instance.web.Widget, undefined>} executor.post_process cleanup called after a widget has been added as inner_widget
+ * @param {Object} options
+ * @return {*}
+ */
+ ir_actions_common: function(executor, options) {
+ var self = this;
+ if (executor.action.target === 'new') {
+ var pre_dialog = (this.dialog && !this.dialog.isDestroyed()) ? this.dialog : null;
+ if (pre_dialog){
+ // prevent previous dialog to consider itself closed,
+ // right now, as we're opening a new one (prevents
+ // reload of original form view)
+ pre_dialog.off('closed', null, pre_dialog.on_close);
+ }
+ if (this.dialog_widget && !this.dialog_widget.isDestroyed()) {
+ this.dialog_widget.destroy();
+ }
+ // explicitly passing a closing action to dialog_stop() prevents
+ // it from reloading the original form view
+ this.dialog_stop(executor.action);
+ this.dialog = new Dialog(this, _.defaults(options || {}, {
+ title: executor.action.name,
+ dialogClass: executor.klass,
+ buttons: [],
+ size: executor.action.context.dialog_size,
+ }));
+
+ // chain on_close triggers with previous dialog, if any
+ this.dialog.on_close = function(){
+ options.on_close.apply(null, arguments);
+ if (pre_dialog && pre_dialog.on_close){
+ // no parameter passed to on_close as this will
+ // only be called when the last dialog is truly
+ // closing, and *should* trigger a reload of the
+ // underlying form view (see comments above)
+ pre_dialog.on_close();
+ }
+ if (!pre_dialog) {
+ self.dialog = null;
+ }
+ };
+ this.dialog.on("closed", null, this.dialog.on_close);
+ this.dialog_widget = executor.widget();
+ if (this.dialog_widget instanceof ViewManager) {
+ executor.action.viewManager = this.dialog_widget;
+ _.defaults(this.dialog_widget.flags, {
+ $buttons: this.dialog.$footer,
+ footer_to_buttons: true,
+ });
+ if (this.dialog_widget.action.view_mode === 'form') {
+ this.dialog_widget.flags.headless = true;
+ }
+ }
+ if (this.dialog_widget.need_control_panel) {
+ // Set a fake bus to Dialogs needing a ControlPanel as they should not
+ // communicate with the main ControlPanel
+ this.dialog_widget.set_cp_bus(new Bus());
+ }
+ this.dialog_widget.setParent(this.dialog);
+
+ var fragment = document.createDocumentFragment();
+ return this.dialog_widget.appendTo(fragment).then(function() {
+ self.dialog.open();
+ dom.append(self.dialog.$el, fragment, {
+ in_DOM: true,
+ callbacks: [{widget: self.dialog_widget}],
+ });
+ if(options.state && self.dialog_widget.do_load_state) {
+ return self.dialog_widget.do_load_state(options.state);
+ }
+ }).then(function () {
+ return executor.action;
+ });
+ }
+ var def = this.inner_action && this.webclient && this.webclient.clear_uncommitted_changes() || $.when();
+ return def.then(function() {
+ self.dialog_stop(executor.action);
+ return self.push_action(executor.action.viewManager = executor.widget(), executor.action, options).then(function () {
+ return executor.action;
+ });
+ }).fail(function() {
+ return $.Deferred().reject();
+ });
+ },
+ ir_actions_act_window: function (action, options) {
+ var self = this;
+
+ var flags = action.flags || {};
+ if (!('auto_search' in flags)) {
+ flags.auto_search = action.auto_search !== false;
+ }
+ options.action = action;
+ options.action_manager = this;
+ var dataset = new data.DataSetSearch(this, action.res_model, action.context, action.domain);
+ if (action.res_id) {
+ dataset.ids.push(action.res_id);
+ dataset.index = 0;
+ }
+ var views = action.views;
+
+ return this.ir_actions_common({
+ widget: function () {
+ return new ViewManager(self, dataset, views, flags, options);
+ },
+ action: action,
+ klass: 'o_act_window',
+ }, options);
+ },
+ ir_actions_client: function (action, options) {
+ var self = this;
+ var ClientWidget = core.action_registry.get(action.tag);
+ if (!ClientWidget) {
+ return self.do_warn("Action Error", "Could not find client action '" + action.tag + "'.");
+ }
+ if (!(ClientWidget.prototype instanceof Widget)) {
+ var next;
+ if ((next = ClientWidget(this, action))) {
+ return this.do_action(next, options);
+ }
+ return $.when();
+ }
+
+ return this.ir_actions_common({
+ widget: function () {
+ return new ClientWidget(self, action, options);
+ },
+ action: action,
+ klass: 'oe_act_client',
+ }, options).then(function () {
+ if (action.tag !== 'reload') {self.do_push_state({});}
+ });
+ },
+ ir_actions_act_window_close: function (action, options) {
+ if (!this.dialog) {
+ options.on_close();
+ }
+ this.dialog_stop();
+ return $.when();
+ },
+ ir_actions_server: function (action, options) {
+ var self = this;
+ return this.performRPC('/web/action/run', {
+ action_id: action.id,
+ context: action.context || {}
+ }).then(function (action) {
+ return self.do_action(action, options);
+ });
+ },
+ ir_actions_report_xml: function(action, options) {
+ var self = this;
+ framework.blockUI();
+ action = _.clone(action);
+ var eval_contexts = ([session.user_context] || []).concat([action.context]);
+ action.context = pyeval.eval('contexts',eval_contexts);
+
+ // iOS devices doesn't allow iframe use the way we do it,
+ // opening a new window seems the best way to workaround
+ if (navigator.userAgent.match(/(iPod|iPhone|iPad)/)) {
+ var params = {
+ action: JSON.stringify(action),
+ token: new Date().getTime()
+ };
+ var url = self.session.url('/web/report', params);
+ framework.unblockUI();
+ $('<a href="'+url+'" target="_blank"></a>')[0].click();
+ return;
+ }
+ var c = crash_manager;
+ return $.Deferred(function (d) {
+ self.session.get_file({
+ url: '/web/report',
+ data: {action: JSON.stringify(action)},
+ complete: framework.unblockUI,
+ success: function(){
+ if (!self.dialog) {
+ options.on_close();
+ }
+ self.dialog_stop();
+ d.resolve();
+ },
+ error: function () {
+ c.rpc_error.apply(c, arguments);
+ d.reject();
+ }
+ });
+ });
+ },
+ ir_actions_act_url: function (action) {
+ var url = action.url;
+ if (session.debug && url && url.length && url[0] === '/') {
+ url = $.param.querystring(url, {debug: session.debug});
+ }
+
+ if (action.target === 'self') {
+ framework.redirect(url);
+ return $.Deferred(); // The action is finished only when the redirection is done
+ } else {
+ window.open(url, '_blank');
+ }
+ return $.when();
+ },
+});
+
+return ActionManager;
+
+});
diff --git a/addons/web/static/src/js/chrome/control_panel.js b/addons/web/static/src/js/chrome/control_panel.js
new file mode 100644
index 00000000000..173d91aea25
--- /dev/null
+++ b/addons/web/static/src/js/chrome/control_panel.js
@@ -0,0 +1,248 @@
+odoo.define('web.ControlPanelMixin', function (require) {
+"use strict";
+
+/**
+ * Mixin allowing widgets to communicate with the ControlPanel. Widgets needing a
+ * ControlPanel should use this mixin and call update_control_panel(cp_status) where
+ * cp_status contains information for the ControlPanel to update itself.
+ */
+var ControlPanelMixin = {
+ need_control_panel: true,
+ /**
+ * @param {web.Bus} [cp_bus] Bus to communicate with the ControlPanel
+ */
+ set_cp_bus: function(cp_bus) {
+ this.cp_bus = cp_bus;
+ },
+ /**
+ * Triggers 'update' on the cp_bus to update the ControlPanel according to cp_status
+ * @param {Object} [cp_status] see web.ControlPanel.update() for a description
+ * @param {Object} [options] see web.ControlPanel.update() for a description
+ */
+ update_control_panel: function(cp_status, options) {
+ this.cp_bus.trigger("update", cp_status || {}, options || {});
+ },
+};
+
+return ControlPanelMixin;
+
+});
+
+odoo.define('web.ControlPanel', function (require) {
+"use strict";
+
+var Bus = require('web.Bus');
+var data = require('web.data');
+var Widget = require('web.Widget');
+
+var ControlPanel = Widget.extend({
+ template: 'ControlPanel',
+ /**
+ * @param {String} [template] the QWeb template to render the ControlPanel.
+ * By default, the template 'ControlPanel' will be used
+ */
+ init: function(parent, template) {
+ this._super(parent);
+ if (template) {
+ this.template = template;
+ }
+
+ this.bus = new Bus();
+ this.bus.on("update", this, this.update);
+ },
+ /**
+ * Renders the control panel and creates a dictionnary of its exposed elements
+ * @return {jQuery.Deferred}
+ */
+ start: function() {
+ // Exposed jQuery nodesets
+ this.nodes = {
+ $breadcrumbs: this.$('.breadcrumb'),
+ $buttons: this.$('.o_cp_buttons'),
+ $pager: this.$('.o_cp_pager'),
+ $searchview: this.$('.o_cp_searchview'),
+ $searchview_buttons: this.$('.o_search_options'),
+ $sidebar: this.$('.o_cp_sidebar'),
+ $switch_buttons: this.$('.o_cp_switch_buttons'),
+ };
+
+ // Prevent the search dropdowns to close when clicking inside them
+ this.$el.on('click.bs.dropdown', '.o_search_options .dropdown-menu', function (e) {
+ e.stopPropagation();
+ });
+
+ // By default, hide the ControlPanel and remove its contents from the DOM
+ this._toggle_visibility(false);
+
+ return this._super();
+ },
+ destroy: function() {
+ this._clear_breadcrumbs_handlers();
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @return {Object} the Bus the ControlPanel is listening on
+ */
+ get_bus: function() {
+ return this.bus;
+ },
+ /**
+ * Updates the content and displays the ControlPanel
+ * @param {Object} [status.active_view] the current active view
+ * @param {Array} [status.breadcrumbs] the breadcrumbs to display (see _render_breadcrumbs() for
+ * precise description)
+ * @param {Object} [status.cp_content] dictionnary containing the new ControlPanel jQuery elements
+ * @param {Boolean} [status.hidden] true if the ControlPanel should be hidden
+ * @param {openerp.web.SearchView} [status.searchview] the searchview widget
+ * @param {Boolean} [status.search_view_hidden] true if the searchview is hidden, false otherwise
+ * @param {Boolean} [options.clear] set to true to clear from control panel
+ * elements that are not in status.cp_content
+ */
+ update: function(status, options) {
+ this._toggle_visibility(!status.hidden);
+
+ // Don't update the ControlPanel in headless mode as the views have
+ // inserted themselves the buttons where they want, so inserting them
+ // again in the ControlPanel will remove them from where they should be
+ if (!status.hidden) {
+ options = _.defaults({}, options, {
+ clear: true, // clear control panel by default
+ });
+ var new_cp_content = status.cp_content || {};
+
+ // Render the breadcrumbs
+ if (status.breadcrumbs) {
+ this._clear_breadcrumbs_handlers();
+ this.$breadcrumbs = this._render_breadcrumbs(status.breadcrumbs);
+ new_cp_content.$breadcrumbs = this.$breadcrumbs;
+ }
+
+ // Detach control_panel old content and attach new elements
+ if (options.clear) {
+ this._detach_content(this.nodes);
+ // Show the searchview buttons area, which might have been hidden by
+ // the searchview, as client actions may insert elements into it
+ this.nodes.$searchview_buttons.show();
+ } else {
+ this._detach_content(_.pick(this.nodes, _.keys(new_cp_content)));
+ }
+ this._attach_content(new_cp_content);
+
+ // Update the searchview and switch buttons
+ this._update_search_view(status.searchview, status.search_view_hidden);
+ if (status.active_view_selector) {
+ this._update_switch_buttons(status.active_view_selector);
+ }
+ }
+ },
+ /**
+ * Private function that hides (or shows) the ControlPanel in headless (resp. non-headless) mode
+ * Also detaches or attaches its contents to clean the DOM
+ * @param {Boolean} [visible] true to show the control panel, false to hide it
+ */
+ _toggle_visibility: function(visible) {
+ this.do_toggle(visible);
+ if (!visible && !this.$content) {
+ this.$content = this.$el.contents().detach();
+ } else if (this.$content) {
+ this.$content.appendTo(this.$el);
+ this.$content = null;
+ }
+ },
+ /**
+ * Private function that detaches the content of the ControlPanel
+ * @param {Object} [elements_to_detach] subset of this.nodes to detach
+ */
+ _detach_content: function(elements_to_detach) {
+ _.each(elements_to_detach, function($nodeset) {
+ $nodeset.contents().detach();
+ });
+ },
+ /**
+ * Private function that attaches content to the ControlPanel
+ * @param {Object} [content] dictionnary of jQuery elements to attach, whose keys
+ * are jQuery nodes identifiers in this.nodes
+ */
+ _attach_content: function(content) {
+ var self = this;
+ _.each(content, function($nodeset, $element) {
+ if ($nodeset && self.nodes[$element]) {
+ self.nodes[$element].append($nodeset);
+ }
+ });
+ },
+ /**
+ * Private function that removes active class on all switch-buttons and adds
+ * it to the one of the active view
+ * @param {Object} [active_view_selector] the selector of the div to activate
+ */
+ _update_switch_buttons: function(active_view_selector) {
+ _.each(this.nodes.$switch_buttons.find('button'), function(button) {
+ $(button).removeClass('active');
+ });
+ this.$(active_view_selector).addClass('active');
+ },
+ /**
+ * Private function that renders the breadcrumbs
+ * @param {Array} [breadcrumbs] list of objects containing the following keys:
+ * - action: the action to execute when clicking on this part of the breadcrumbs
+ * - index: the index in the breadcrumbs (starting at 0)
+ * - title: what to display in the breadcrumbs
+ * @return {Array} list of breadcrumbs' li jQuery elements
+ */
+ _render_breadcrumbs: function (breadcrumbs) {
+ var self = this;
+ return breadcrumbs.map(function (bc, index) {
+ return self._render_breadcrumbs_li(bc, index, breadcrumbs.length);
+ });
+ },
+ /**
+ * Private function that renders a breadcrumbs' li Jquery element
+ */
+ _render_breadcrumbs_li: function (bc, index, length) {
+ var self = this;
+ var is_last = (index === length-1);
+ var li_content = bc.title && _.escape(bc.title.trim()) || data.noDisplayContent;
+ var $bc = $('<li>')
+ .append(is_last ? li_content : $('<a>').html(li_content))
+ .toggleClass('active', is_last);
+ if (!is_last) {
+ $bc.click(function () {
+ self.trigger("on_breadcrumb_click", bc.action, bc.index);
+ });
+ }
+ return $bc;
+ },
+ /**
+ * Private function that removes event handlers attached on the currently
+ * displayed breadcrumbs.
+ */
+ _clear_breadcrumbs_handlers: function () {
+ if (this.$breadcrumbs) {
+ _.each(this.$breadcrumbs, function ($bc) {
+ $bc.off();
+ });
+ }
+ },
+ /**
+ * Private function that updates the SearchView's visibility and extend the
+ * breadcrumbs area if the SearchView is not visible
+ * @param {openerp.web.SearchView} [searchview] the searchview Widget
+ * @param {Boolean} [is_hidden] visibility of the searchview
+ */
+ _update_search_view: function(searchview, is_hidden) {
+ if (searchview) {
+ // Set the $buttons div (in the DOM) of the searchview as the $buttons
+ // have been appended to a jQuery node not in the DOM at SearchView initialization
+ searchview.$buttons = this.nodes.$searchview_buttons;
+ searchview.toggle_visibility(!is_hidden);
+ }
+
+ this.nodes.$searchview.toggle(!is_hidden);
+ this.$el.toggleClass('o_breadcrumb_full', !!is_hidden);
+ },
+});
+
+return ControlPanel;
+
+});
diff --git a/addons/web/static/src/js/chrome/loading.js b/addons/web/static/src/js/chrome/loading.js
new file mode 100644
index 00000000000..0c1b5e5025f
--- /dev/null
+++ b/addons/web/static/src/js/chrome/loading.js
@@ -0,0 +1,78 @@
+odoo.define('web.Loading', function (require) {
+"use strict";
+
+/**
+ * Loading Indicator
+ *
+ * When the user performs an action, it is good to give him some feedback that
+ * something is currently happening. The purpose of the Loading Indicator is to
+ * display a small rectangle on the bottom right of the screen with just the
+ * text 'Loading' and the number of currently running rpcs.
+ *
+ * After a delay of 3s, if a rpc is still not completed, we also block the UI.
+ */
+
+var core = require('web.core');
+var framework = require('web.framework');
+var session = require('web.session');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+
+var Loading = Widget.extend({
+ template: "Loading",
+
+ init: function(parent) {
+ this._super(parent);
+ this.count = 0;
+ this.blocked_ui = false;
+ session.on("request", this, this.request_call);
+ session.on("response", this, this.response_call);
+ session.on("response_failed", this, this.response_call);
+ },
+ destroy: function() {
+ this.on_rpc_event(-this.count);
+ this._super();
+ },
+ request_call: function() {
+ this.on_rpc_event(1);
+ },
+ response_call: function() {
+ this.on_rpc_event(-1);
+ },
+ on_rpc_event : function(increment) {
+ var self = this;
+ if (!this.count && increment === 1) {
+ // Block UI after 3s
+ this.long_running_timer = setTimeout(function () {
+ self.blocked_ui = true;
+ framework.blockUI();
+ }, 3000);
+ }
+
+ this.count += increment;
+ if (this.count > 0) {
+ if (session.debug) {
+ this.$el.text(_.str.sprintf( _t("Loading (%d)"), this.count));
+ } else {
+ this.$el.text(_t("Loading"));
+ }
+ this.$el.show();
+ this.getParent().$el.addClass('oe_wait');
+ } else {
+ this.count = 0;
+ clearTimeout(this.long_running_timer);
+ // Don't unblock if blocked by somebody else
+ if (self.blocked_ui) {
+ this.blocked_ui = false;
+ framework.unblockUI();
+ }
+ this.$el.fadeOut();
+ this.getParent().$el.removeClass('oe_wait');
+ }
+ }
+});
+
+return Loading;
+});
+
diff --git a/addons/web/static/src/js/chrome/menu.js b/addons/web/static/src/js/chrome/menu.js
new file mode 100644
index 00000000000..5fdfec5fb37
--- /dev/null
+++ b/addons/web/static/src/js/chrome/menu.js
@@ -0,0 +1,215 @@
+odoo.define('web.Menu', function (require) {
+"use strict";
+
+var core = require('web.core');
+var session = require('web.session');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+
+var Menu = Widget.extend({
+ init: function() {
+ var self = this;
+ this._super.apply(this, arguments);
+ this.is_bound = $.Deferred();
+ this.data = {data:{children:[]}};
+ core.bus.on('change_menu_section', this, this.on_change_top_menu);
+ },
+ start: function() {
+ this._super.apply(this, arguments);
+ return this.bind_menu();
+ },
+ do_reload: function() {
+ var self = this;
+ self.bind_menu();
+ },
+ bind_menu: function() {
+ var self = this;
+ this.$secondary_menus = this.$el.parents().find('.o_sub_menu');
+ this.$secondary_menus.on('click', 'a[data-menu]', this.on_menu_click);
+ this.$el.on('click', 'a[data-menu]', function (event) {
+ event.preventDefault();
+ var menu_id = $(event.currentTarget).data('menu');
+ core.bus.trigger('change_menu_section', menu_id);
+ });
+
+ // Hide second level submenus
+ this.$secondary_menus.find('.oe_menu_toggler').siblings('.oe_secondary_submenu').addClass('o_hidden');
+ if (self.current_menu) {
+ self.open_menu(self.current_menu);
+ }
+ this.trigger('menu_bound');
+
+ var lazyreflow = _.debounce(this.reflow.bind(this), 200);
+ core.bus.on('resize', this, function() {
+ if ($(window).width() < 768 ) {
+ lazyreflow('all_outside');
+ } else {
+ lazyreflow();
+ }
+ });
+ core.bus.trigger('resize');
+
+ this.is_bound.resolve();
+ },
+
+ /**
+ * Reflow the menu items and dock overflowing items into a "More" menu item.
+ * Automatically called when 'menu_bound' event is triggered and on window resizing.
+ *
+ * @param {string} behavior If set to 'all_outside', all the items are displayed.
+ * If not set, only the overflowing items are hidden.
+ */
+ reflow: function(behavior) {
+ var self = this;
+ var $more_container = this.$('#menu_more_container').hide();
+ var $more = this.$('#menu_more');
+ var $systray = this.$el.parents().find('.oe_systray');
+
+ $more.children('li').insertBefore($more_container); // Pull all the items out of the more menu
+
+ // 'all_outside' beahavior should display all the items, so hide the more menu and exit
+ if (behavior === 'all_outside') {
+ // Show list of menu items
+ self.$el.show();
+ this.$el.find('li').show();
+ $more_container.hide();
+ return;
+ }
+
+ // Hide all menu items
+ var $toplevel_items = this.$el.find('li').not($more_container).not($systray.find('li')).hide();
+ // Show list of menu items (which is empty for now since all menu items are hidden)
+ self.$el.show();
+ $toplevel_items.each(function() {
+ var remaining_space = self.$el.parent().width() - $more_container.outerWidth();
+ self.$el.parent().children(':visible').each(function() {
+ remaining_space -= $(this).outerWidth();
+ });
+
+ if ($(this).width() >= remaining_space) {
+ return false; // the current item will be appended in more_container
+ }
+ $(this).show(); // show the current item in menu bar
+ });
+ $more.append($toplevel_items.filter(':hidden').show());
+ $more_container.toggle(!!$more.children().length);
+ // Hide toplevel item if there is only one
+ var $toplevel = self.$el.children("li:visible");
+ if ($toplevel.length === 1) {
+ $toplevel.hide();
+ }
+ },
+ /**
+ * Opens a given menu by id, as if a user had browsed to that menu by hand
+ * except does not trigger any event on the way
+ *
+ * @param {Number} id database id of the terminal menu to select
+ */
+ open_menu: function (id) {
+ this.current_menu = id;
+ session.active_id = id;
+ var $clicked_menu, $sub_menu, $main_menu;
+ $clicked_menu = this.$el.add(this.$secondary_menus).find('a[data-menu=' + id + ']');
+ this.trigger('open_menu', id, $clicked_menu);
+
+ if (this.$secondary_menus.has($clicked_menu).length) {
+ $sub_menu = $clicked_menu.parents('.oe_secondary_menu');
+ $main_menu = this.$el.find('a[data-menu=' + $sub_menu.data('menu-parent') + ']');
+ } else {
+ $sub_menu = this.$secondary_menus.find('.oe_secondary_menu[data-menu-parent=' + $clicked_menu.attr('data-menu') + ']');
+ $main_menu = $clicked_menu;
+ }
+
+ // Activate current main menu
+ this.$el.find('.active').removeClass('active');
+ $main_menu.parent().addClass('active');
+
+ // Show current sub menu
+ this.$secondary_menus.find('.oe_secondary_menu').hide();
+ $sub_menu.show();
+
+ // Hide/Show the leftbar menu depending of the presence of sub-items
+ this.$secondary_menus.toggleClass('o_hidden', !$sub_menu.children().length);
+
+ // Activate current menu item and show parents
+ this.$secondary_menus.find('.active').removeClass('active');
+ if ($main_menu !== $clicked_menu) {
+ $clicked_menu.parents().removeClass('o_hidden');
+ if ($clicked_menu.is('.oe_menu_toggler')) {
+ $clicked_menu.toggleClass('oe_menu_opened').siblings('.oe_secondary_submenu:first').toggleClass('o_hidden');
+ } else {
+ $clicked_menu.parent().addClass('active');
+ }
+ }
+ // add a tooltip to cropped menu items
+ this.$secondary_menus.find('.oe_secondary_submenu li a span').each(function() {
+ $(this).tooltip(this.scrollWidth > this.clientWidth ? {title: $(this).text().trim(), placement: 'right'} :'destroy');
+ });
+ },
+ /**
+ * Call open_menu with the first menu_item matching an action_id
+ *
+ * @param {Number} id the action_id to match
+ */
+ open_action: function (id) {
+ var $menu = this.$el.add(this.$secondary_menus).find('a[data-action-id="' + id + '"]');
+ var menu_id = $menu.data('menu');
+ if (menu_id) {
+ this.open_menu(menu_id);
+ }
+ },
+ /**
+ * Process a click on a menu item
+ *
+ * @param {Number} id the menu_id
+ */
+ menu_click: function(id) {
+ if (!id) { return; }
+
+ // find back the menuitem in dom to get the action
+ var $item = this.$el.find('a[data-menu=' + id + ']');
+ if (!$item.length) {
+ $item = this.$secondary_menus.find('a[data-menu=' + id + ']');
+ }
+ var action_id = $item.data('action-id');
+ // If first level menu doesnt have action trigger first leaf
+ if (!action_id) {
+ if(this.$el.has($item).length) {
+ var $sub_menu = this.$secondary_menus.find('.oe_secondary_menu[data-menu-parent=' + id + ']');
+ var $items = $sub_menu.find('a[data-action-id]').filter('[data-action-id!=""]');
+ if($items.length) {
+ action_id = $items.data('action-id');
+ id = $items.data('menu');
+ }
+ }
+ }
+ if (action_id) {
+ this.trigger('menu_click', {
+ action_id: action_id,
+ id: id,
+ previous_menu_id: this.current_menu // Here we don't know if action will fail (in which case we have to revert menu)
+ }, $item);
+ } else {
+ console.log('Menu no action found web test 04 will fail');
+ }
+ this.open_menu(id);
+ },
+
+ /**
+ * Change the current top menu
+ *
+ * @param {int} [menu_id] the top menu id
+ */
+ on_change_top_menu: function(menu_id) {
+ var self = this;
+ this.menu_click(menu_id);
+ },
+ on_menu_click: function(ev) {
+ ev.preventDefault();
+ this.menu_click($(ev.currentTarget).data('menu'));
+ },
+});
+
+return Menu;
+});
diff --git a/addons/web/static/src/js/chrome/pager.js b/addons/web/static/src/js/chrome/pager.js
new file mode 100644
index 00000000000..5ecf11b391c
--- /dev/null
+++ b/addons/web/static/src/js/chrome/pager.js
@@ -0,0 +1,222 @@
+odoo.define('web.Pager', function (require) {
+"use strict";
+
+var utils = require('web.utils');
+var Widget = require('web.Widget');
+
+var direction = {
+ previous: -1,
+ next: 1,
+};
+
+var Pager = Widget.extend({
+ template: "Pager",
+ events: {
+ 'click .o_pager_previous': 'previous',
+ 'click .o_pager_next': 'next',
+ 'click .o_pager_value': '_edit',
+ },
+ /**
+ * The pager goes from 1 to size (included).
+ * The current value is current_min if limit === 1
+ * or the interval [current_min, current_min + limit[ if limit > 1
+ *
+ * @param {Widget} [parent] the parent widget
+ * @param {int} [size] the total number of elements
+ * @param {int} [current_min] the first element of the current_page
+ * @param {int} [limit] the number of elements per page
+ * @param {boolean} [options.can_edit] editable feature of the pager
+ * @param {boolean} [options.single_page_hidden] (not) to display the pager
+ * if only one page
+ * @param {function} [options.validate] callback returning a Deferred to
+ * validate changes
+ */
+ init: function (parent, size, current_min, limit, options) {
+ this.state = {
+ size: size,
+ current_min: current_min,
+ limit: limit,
+ };
+ Object.defineProperty(this.state, 'current_max', {
+ get: function() {
+ return Math.min(this.current_min + this.limit - 1, this.size);
+ }
+ });
+ this.options = _.defaults({}, options, {
+ can_edit: true, // editable
+ single_page_hidden: false, // displayed even if there is a single page
+ validate: function() {
+ return $.Deferred().resolve();
+ },
+ withAccessKey: true, // can be disabled, for example, for x2m widgets
+ });
+ this._super(parent);
+ },
+ /**
+ * Renders the pager
+ *
+ * @returns {jQuery.Deferred}
+ */
+ start: function () {
+ this.$value = this.$('.o_pager_value');
+ this.$limit = this.$('.o_pager_limit');
+ this._render();
+ return this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Disables the pager's arrows
+ */
+ disable: function() {
+ this.$('button').prop("disabled", true);
+ },
+ /**
+ * Enables the pager's arrows
+ */
+ enable: function() {
+ this.$('button').prop("disabled", false);
+ },
+ /**
+ * Executes the next action on the pager
+ */
+ next: function() {
+ this._change_selection(direction.next);
+ },
+ /**
+ * Executes the previous action on the pager
+ */
+ previous: function() {
+ this._change_selection(direction.previous);
+ },
+ /**
+ * Sets the state of the pager and renders it
+ * @param {Object} [state] the values to update (size, current_min and limit)
+ */
+ updateState: function (state) {
+ _.extend(this.state, state);
+ this._render();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Private function that updates the pager's state according to a pager action
+ *
+ * @param {int} [direction] the action (previous or next) on the pager
+ */
+ _change_selection: function (direction) {
+ var self = this;
+ this.options.validate().then(function() {
+ var size = self.state.size;
+ var current_min = self.state.current_min;
+ var limit = self.state.limit;
+
+ // Compute the new current_min
+ current_min = (current_min + limit*direction);
+ if (current_min > size) {
+ current_min = 1;
+ } else if ((current_min < 1) && (limit === 1)) {
+ current_min = size;
+ } else if ((current_min < 1) && (limit > 1)) {
+ current_min = size - ((size % limit) || limit) + 1;
+ }
+
+ self.state.current_min = current_min;
+ self.trigger('pager_changed', _.clone(self.state));
+ self._render();
+ });
+ },
+ /**
+ * Private function that displays an input to edit the pager's state
+ */
+ _edit: function() {
+ if (this.options.can_edit) {
+ var self = this;
+ var $input = $('<input>', {type: 'text', value: this.$value.html()});
+
+ this.$value.html($input);
+ $input.focus();
+
+ // Event handlers
+ $input.click(function(ev) {
+ ev.stopPropagation(); // ignore clicks on the input
+ });
+ $input.blur(function(ev) {
+ self._save($(ev.target)); // save the state when leaving the input
+ });
+ $input.on('keydown', function (ev) {
+ ev.stopPropagation();
+ if (ev.which === $.ui.keyCode.ENTER) {
+ self._save($(ev.target)); // save on enter
+ } else if (ev.which === $.ui.keyCode.ESCAPE) {
+ self._render(); // leave on escape
+ }
+ });
+ }
+ },
+ /**
+ * Private function that renders the pager's state
+ */
+ _render: function () {
+ var size = this.state.size;
+ var current_min = this.state.current_min;
+ var current_max = this.state.current_max;
+ var single_page = 1 === current_min && current_max === size;
+
+ if (size === 0 || (single_page && this.options.single_page_hidden)) {
+ this.do_hide();
+ } else {
+ this.do_show();
+
+ if (single_page) {
+ this.disable();
+ } else {
+ this.enable();
+ }
+
+ var value = "" + current_min;
+ if (this.state.limit > 1) {
+ value += "-" + current_max;
+ }
+ this.$value.html(value);
+ this.$limit.html(size);
+ }
+ }, /**
+ * Private function that saves the state from the content of the input
+ *
+ * @param {jQuery} [$input] the jQuery element containing the new state
+ */
+ _save: function($input) {
+ var self = this;
+ this.options.validate().then(function() {
+ var value = $input.val().split("-");
+ var min = utils.confine(parseInt(value[0], 10), 1, self.state.size);
+ var max = utils.confine(parseInt(value[1], 10), 1, self.state.size);
+
+ if (!isNaN(min)) {
+ self.state.current_min = min;
+ if (!isNaN(max)) {
+ self.state.limit = utils.confine(max-min+1, 1, self.state.size);
+ } else {
+ // The state has been given as a single value -> set the limit to 1
+ self.state.limit = 1;
+ }
+ self.trigger('pager_changed', _.clone(self.state));
+ }
+ }).always(function() {
+ // Render the pager's new state (removes the input)
+ self._render();
+ });
+ },
+
+});
+
+return Pager;
+
+});
diff --git a/addons/web/static/src/js/chrome/search_filters.js b/addons/web/static/src/js/chrome/search_filters.js
new file mode 100644
index 00000000000..e4d58536df2
--- /dev/null
+++ b/addons/web/static/src/js/chrome/search_filters.js
@@ -0,0 +1,366 @@
+odoo.define('web.search_filters', function (require) {
+"use strict";
+
+var core = require('web.core');
+var datepicker = require('web.datepicker');
+var formats = require('web.formats');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+var _lt = core._lt;
+
+var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.ExtendedSearchProposition# */{
+ template: 'SearchView.extended_search.proposition',
+ events: {
+ 'change .o_searchview_extended_prop_field': 'changed',
+ 'change .o_searchview_extended_prop_op': 'operator_changed',
+ 'click .o_searchview_extended_delete_prop': function (e) {
+ e.stopPropagation();
+ this.getParent().remove_proposition(this);
+ },
+ },
+ /**
+ * @constructs instance.web.search.ExtendedSearchProposition
+ * @extends instance.web.Widget
+ *
+ * @param parent
+ * @param fields
+ */
+ init: function (parent, fields) {
+ this._super(parent);
+ this.fields = _(fields).chain()
+ .map(function (val, key) { return _.extend({}, val, {'name': key}); })
+ .filter(function (field) { return !field.deprecated && field.searchable; })
+ .sortBy(function (field) {return field.string;})
+ .value();
+ this.attrs = {_: _, fields: this.fields, selected: null};
+ this.value = null;
+ },
+ start: function () {
+ return this._super().done(this.proxy('changed'));
+ },
+ changed: function () {
+ var nval = this.$(".o_searchview_extended_prop_field").val();
+ if(this.attrs.selected === null || this.attrs.selected === undefined || nval != this.attrs.selected.name) {
+ this.select_field(_.detect(this.fields, function (x) {return x.name == nval;}));
+ }
+ },
+ operator_changed: function (e) {
+ this.value.show_inputs($(e.target));
+ },
+ /**
+ * Selects the provided field object
+ *
+ * @param field a field descriptor object (as returned by fields_get, augmented by the field name)
+ */
+ select_field: function (field) {
+ var self = this;
+ if(this.attrs.selected !== null && this.attrs.selected !== undefined) {
+ this.value.destroy();
+ this.value = null;
+ this.$('.o_searchview_extended_prop_op').html('');
+ }
+ this.attrs.selected = field;
+ if(field === null || field === undefined) {
+ return;
+ }
+
+ var type = field.type;
+ var Field = core.search_filters_registry.getAny([type, "char"]);
+
+ this.value = new Field(this, field);
+ _.each(this.value.operators, function (operator) {
+ $('<option>', {value: operator.value})
+ .text(String(operator.text))
+ .appendTo(self.$('.o_searchview_extended_prop_op'));
+ });
+ var $value_loc = this.$('.o_searchview_extended_prop_value').show().empty();
+ this.value.appendTo($value_loc);
+
+ },
+ get_filter: function () {
+ if (this.attrs.selected === null || this.attrs.selected === undefined)
+ return null;
+ var field = this.attrs.selected,
+ op_select = this.$('.o_searchview_extended_prop_op')[0],
+ operator = op_select.options[op_select.selectedIndex];
+
+ return {
+ attrs: {
+ domain: this.value.get_domain(field, operator),
+ string: this.value.get_label(field, operator),
+ },
+ children: [],
+ tag: 'filter',
+ };
+ },
+});
+
+ExtendedSearchProposition.Field = Widget.extend({
+ init: function (parent, field) {
+ this._super(parent);
+ this.field = field;
+ },
+ get_label: function (field, operator) {
+ var format;
+ switch (operator.value) {
+ case '∃': case '∄': format = _t('%(field)s %(operator)s'); break;
+ default: format = _t('%(field)s %(operator)s "%(value)s"'); break;
+ }
+ return this.format_label(format, field, operator);
+ },
+ format_label: function (format, field, operator) {
+ return _.str.sprintf(format, {
+ field: field.string,
+ // According to spec, HTMLOptionElement#label should return
+ // HTMLOptionElement#text when not defined/empty, but it does
+ // not in older Webkit (between Safari 5.1.5 and Chrome 17) and
+ // Gecko (pre Firefox 7) browsers, so we need a manual fallback
+ // for those
+ operator: operator.label || operator.text,
+ value: this
+ });
+ },
+ get_domain: function (field, operator) {
+ switch (operator.value) {
+ case '∃': return [[field.name, '!=', false]];
+ case '∄': return [[field.name, '=', false]];
+ default: return [[field.name, operator.value, this.get_value()]];
+ }
+ },
+ show_inputs: function ($operator) {
+ var $value = this.$el.parent();
+ switch ($operator.val()) {
+ case '∃':
+ case '∄':
+ $value.hide();
+ break;
+ default:
+ $value.show();
+ }
+ },
+ /**
+ * Returns a human-readable version of the value, in case the "logical"
+ * and the "semantic" values of a field differ (as for selection fields,
+ * for instance).
+ *
+ * The default implementation simply returns the value itself.
+ *
+ * @return {String} human-readable version of the value
+ */
+ toString: function () {
+ return this.get_value();
+ }
+});
+
+ExtendedSearchProposition.Char = ExtendedSearchProposition.Field.extend({
+ tagName: 'input',
+ attributes: {
+ type: 'text'
+ },
+ operators: [
+ {value: "ilike", text: _lt("contains")},
+ {value: "not ilike", text: _lt("doesn't contain")},
+ {value: "=", text: _lt("is equal to")},
+ {value: "!=", text: _lt("is not equal to")},
+ {value: "∃", text: _lt("is set")},
+ {value: "∄", text: _lt("is not set")}
+ ],
+ get_value: function () {
+ return this.$el.val();
+ }
+});
+
+ExtendedSearchProposition.DateTime = ExtendedSearchProposition.Field.extend({
+ tagName: 'span',
+ attributes: {
+ type: 'datetime'
+ },
+ operators: [
+ {value: "=", text: _lt("is equal to")},
+ {value: "!=", text: _lt("is not equal to")},
+ {value: ">=", text: _lt("is after")},
+ {value: "<=", text: _lt("is before")},
+ {value: "between", text: _lt("is between")},
+ {value: "∃", text: _lt("is set")},
+ {value: "∄", text: _lt("is not set")}
+ ],
+ get_value: function (index) {
+ return this["datewidget_" + (index || 0)].get_value();
+ },
+ get_domain: function (field, operator) {
+ switch (operator.value) {
+ case '∃':
+ return [[field.name, '!=', false]];
+ case '∄':
+ return [[field.name, '=', false]];
+ case 'between':
+ return [[field.name, '>=', this.get_value()], [field.name, '<=', this.get_value(1)]];
+ default:
+ return [[field.name, operator.value, this.get_value()]];
+ }
+ },
+ show_inputs: function ($operator) {
+ this._super.apply(this, arguments);
+
+ if ($operator.val() === "between") {
+ if (!this.datewidget_1) {
+ this._create_new_widget("datewidget_1");
+ } else {
+ this.datewidget_1.do_show();
+ }
+ } else {
+ if (this.datewidget_1) {
+ this.datewidget_1.do_hide();
+ }
+ }
+ },
+ toString: function () {
+ var str = formats.format_value(this.get_value(), {type: this.attributes['type']});
+ var date_1_value = this.datewidget_1 && this.get_value(1);
+ if (date_1_value) {
+ str += _lt(" and ") + formats.format_value(date_1_value, {type: this.attributes['type']});
+ }
+ return str;
+ },
+ start: function () {
+ return $.when(
+ this._super.apply(this, arguments),
+ this._create_new_widget("datewidget_0")
+ );
+ },
+ _create_new_widget: function (name) {
+ this[name] = new (this._get_widget_class())(this);
+ return this[name].appendTo(this.$el).then((function () {
+ this[name].set_value(new Date());
+ }).bind(this));
+ },
+ _get_widget_class: function () {
+ return datepicker.DateTimeWidget;
+ },
+});
+
+ExtendedSearchProposition.Date = ExtendedSearchProposition.DateTime.extend({
+ attributes: {
+ type: 'date'
+ },
+ _get_widget_class: function () {
+ return datepicker.DateWidget;
+ },
+});
+
+ExtendedSearchProposition.Integer = ExtendedSearchProposition.Field.extend({
+ tagName: 'input',
+ attributes: {
+ type: 'number',
+ value: '0',
+ },
+ operators: [
+ {value: "=", text: _lt("is equal to")},
+ {value: "!=", text: _lt("is not equal to")},
+ {value: ">", text: _lt("greater than")},
+ {value: "<", text: _lt("less than")},
+ {value: ">=", text: _lt("greater than or equal to")},
+ {value: "<=", text: _lt("less than or equal to")},
+ {value: "∃", text: _lt("is set")},
+ {value: "∄", text: _lt("is not set")}
+ ],
+ toString: function () {
+ return this.$el.val();
+ },
+ get_value: function () {
+ try {
+ var val =this.$el.val();
+ return formats.parse_value(val === "" ? 0 : val, {'widget': 'integer'});
+ } catch (e) {
+ return "";
+ }
+ }
+});
+
+ExtendedSearchProposition.Id = ExtendedSearchProposition.Integer.extend({
+ operators: [{value: "=", text: _lt("is")}]
+});
+
+ExtendedSearchProposition.Float = ExtendedSearchProposition.Field.extend({
+ template: 'SearchView.extended_search.proposition.float',
+ operators: [
+ {value: "=", text: _lt("is equal to")},
+ {value: "!=", text: _lt("is not equal to")},
+ {value: ">", text: _lt("greater than")},
+ {value: "<", text: _lt("less than")},
+ {value: ">=", text: _lt("greater than or equal to")},
+ {value: "<=", text: _lt("less than or equal to")},
+ {value: "∃", text: _lt("is set")},
+ {value: "∄", text: _lt("is not set")}
+ ],
+ init: function (parent) {
+ this._super(parent);
+ this.decimal_point = _t.database.parameters.decimal_point;
+ },
+ toString: function () {
+ return this.$el.val();
+ },
+ get_value: function () {
+ try {
+ var val =this.$el.val();
+ return formats.parse_value(val === "" ? 0.0 : val, {'widget': 'float'});
+ } catch (e) {
+ return "";
+ }
+ }
+});
+
+ExtendedSearchProposition.Selection = ExtendedSearchProposition.Field.extend({
+ template: 'SearchView.extended_search.proposition.selection',
+ operators: [
+ {value: "=", text: _lt("is")},
+ {value: "!=", text: _lt("is not")},
+ {value: "∃", text: _lt("is set")},
+ {value: "∄", text: _lt("is not set")}
+ ],
+ toString: function () {
+ var select = this.$el[0];
+ var option = select.options[select.selectedIndex];
+ return option.label || option.text;
+ },
+ get_value: function () {
+ return this.$el.val();
+ }
+});
+
+ExtendedSearchProposition.Boolean = ExtendedSearchProposition.Field.extend({
+ tagName: 'span',
+ operators: [
+ {value: "=", text: _lt("is true")},
+ {value: "!=", text: _lt("is false")}
+ ],
+ get_label: function (field, operator) {
+ return this.format_label(
+ _t('%(field)s %(operator)s'), field, operator);
+ },
+ get_value: function () {
+ return true;
+ }
+});
+
+core.search_filters_registry
+ .add('char', ExtendedSearchProposition.Char)
+ .add('text', ExtendedSearchProposition.Char)
+ .add('one2many', ExtendedSearchProposition.Char)
+ .add('many2one', ExtendedSearchProposition.Char)
+ .add('many2many', ExtendedSearchProposition.Char)
+ .add('datetime', ExtendedSearchProposition.DateTime)
+ .add('date', ExtendedSearchProposition.Date)
+ .add('integer', ExtendedSearchProposition.Integer)
+ .add('float', ExtendedSearchProposition.Float)
+ .add('monetary', ExtendedSearchProposition.Float)
+ .add('boolean', ExtendedSearchProposition.Boolean)
+ .add('selection', ExtendedSearchProposition.Selection)
+ .add('id', ExtendedSearchProposition.Id);
+
+return {
+ ExtendedSearchProposition: ExtendedSearchProposition
+};
+
+});
diff --git a/addons/web/static/src/js/chrome/search_inputs.js b/addons/web/static/src/js/chrome/search_inputs.js
new file mode 100644
index 00000000000..99535f29797
--- /dev/null
+++ b/addons/web/static/src/js/chrome/search_inputs.js
@@ -0,0 +1,762 @@
+odoo.define('web.search_inputs', function (require) {
+"use strict";
+
+var core = require('web.core');
+var data = require('web.data');
+var formats = require('web.formats');
+var Model = require('web.DataModel');
+var pyeval = require('web.pyeval');
+var time = require('web.time');
+var utils = require('web.utils');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+var _lt = core._lt;
+
+var Input = Widget.extend( /** @lends instance.web.search.Input# */{
+ /**
+ * @constructs instance.web.search.Input
+ * @extends instance.web.Widget
+ *
+ * @param parent
+ */
+ init: function (parent) {
+ this._super(parent);
+ this.searchview = parent;
+ this.load_attrs({});
+ },
+ /**
+ * Fetch auto-completion values for the widget.
+ *
+ * The completion values should be an array of objects with keys category,
+ * label, value prefixed with an object with keys type=section and label
+ *
+ * @param {String} value value to complete
+ * @returns {jQuery.Deferred<null|Array>}
+ */
+ complete: function (value) {
+ return $.when(null);
+ },
+ /**
+ * Returns a Facet instance for the provided defaults if they apply to
+ * this widget, or null if they don't.
+ *
+ * This default implementation will try calling
+ * :js:func:`instance.web.search.Input#facet_for` if the widget's name
+ * matches the input key
+ *
+ * @param {Object} defaults
+ * @returns {jQuery.Deferred<null|Object>}
+ */
+ facet_for_defaults: function (defaults) {
+ if (!this.attrs ||
+ !(this.attrs.name in defaults && defaults[this.attrs.name])) {
+ return $.when(null);
+ }
+ return this.facet_for(defaults[this.attrs.name]);
+ },
+ get_context: function () {
+ throw new Error(
+ "get_context not implemented for widget " + this.attrs.type);
+ },
+ get_groupby: function () {
+ throw new Error(
+ "get_groupby not implemented for widget " + this.attrs.type);
+ },
+ get_domain: function () {
+ throw new Error(
+ "get_domain not implemented for widget " + this.attrs.type);
+ },
+ load_attrs: function (attrs) {
+ if (!_.isObject(attrs.modifiers)) {
+ attrs.modifiers = attrs.modifiers ? JSON.parse(attrs.modifiers) : {};
+ }
+ this.attrs = attrs;
+ },
+ /**
+ * Returns whether the input is "visible". The default behavior is to
+ * query the ``modifiers.invisible`` flag on the input's description or
+ * view node.
+ *
+ * @returns {Boolean}
+ */
+ visible: function () {
+ return !this.attrs.modifiers.invisible;
+ },
+});
+
+var Field = Input.extend( /** @lends instance.web.search.Field# */ {
+ template: 'SearchView.field',
+ default_operator: '=',
+ /**
+ * @constructs instance.web.search.Field
+ * @extends instance.web.search.Input
+ *
+ * @param view_section
+ * @param field
+ * @param parent
+ */
+ init: function (view_section, field, parent) {
+ this._super(parent);
+ this.load_attrs(_.extend({}, field, view_section.attrs));
+ },
+ facet_for: function (value) {
+ return $.when({
+ field: this,
+ category: this.attrs.string || this.attrs.name,
+ values: [{label: String(value), value: value}]
+ });
+ },
+ value_from: function (facetValue) {
+ return facetValue.get('value');
+ },
+ get_context: function (facet) {
+ var self = this;
+ // A field needs a context to send when active
+ var context = this.attrs.context;
+ if (_.isEmpty(context) || !facet.values.length) {
+ return;
+ }
+ var contexts = facet.values.map(function (facetValue) {
+ return new data.CompoundContext(context)
+ .set_eval_context({self: self.value_from(facetValue)});
+ });
+
+ if (contexts.length === 1) { return contexts[0]; }
+
+ return _.extend(new data.CompoundContext(), {
+ __contexts: contexts
+ });
+ },
+ get_groupby: function () { },
+ /**
+ * Function creating the returned domain for the field, override this
+ * methods in children if you only need to customize the field's domain
+ * without more complex alterations or tests (and without the need to
+ * change override the handling of filter_domain)
+ *
+ * @param {String} name the field's name
+ * @param {String} operator the field's operator (either attribute-specified or default operator for the field
+ * @param {Number|String} facet parsed value for the field
+ * @returns {Array<Array>} domain to include in the resulting search
+ */
+ make_domain: function (name, operator, facet) {
+ return [[name, operator, this.value_from(facet)]];
+ },
+ get_domain: function (facet) {
+ if (!facet.values.length) { return; }
+
+ var value_to_domain;
+ var self = this;
+ var domain = this.attrs.filter_domain;
+ if (domain) {
+ value_to_domain = function (facetValue) {
+ return new data.CompoundDomain(domain)
+ .set_eval_context({self: self.value_from(facetValue)});
+ };
+ } else {
+ value_to_domain = function (facetValue) {
+ return self.make_domain(
+ self.attrs.name,
+ self.attrs.operator || self.default_operator,
+ facetValue);
+ };
+ }
+ var domains = facet.values.map(value_to_domain);
+
+ if (domains.length === 1) { return domains[0]; }
+ for (var i = domains.length; --i;) {
+ domains.unshift(['|']);
+ }
+
+ return _.extend(new data.CompoundDomain(), {
+ __domains: domains
+ });
+ }
+});
+
+/**
+ * Implementation of the ``char`` OpenERP field type:
+ *
+ * * Default operator is ``ilike`` rather than ``=``
+ *
+ * * The Javascript and the HTML values are identical (strings)
+ *
+ * @class
+ * @extends instance.web.search.Field
+ */
+var CharField = Field.extend( /** @lends instance.web.search.CharField# */ {
+ default_operator: 'ilike',
+ complete: function (value) {
+ if (_.isEmpty(value)) { return $.when(null); }
+ var label = _.str.sprintf(_.str.escapeHTML(
+ _t("Search %(field)s for: %(value)s")), {
+ field: '<em>' + _.escape(this.attrs.string) + '</em>',
+ value: '<strong>' + _.escape(value) + '</strong>'});
+ return $.when([{
+ label: label,
+ facet: {
+ category: this.attrs.string,
+ field: this,
+ values: [{label: value, value: value}]
+ }
+ }]);
+ }
+});
+
+var NumberField = Field.extend(/** @lends instance.web.search.NumberField# */{
+ complete: function (value) {
+ var val = this.parse(value);
+ if (isNaN(val)) { return $.when(); }
+ var label = _.str.sprintf(
+ _t("Search %(field)s for: %(value)s"), {
+ field: '<em>' + _.escape(this.attrs.string) + '</em>',
+ value: '<strong>' + _.escape(value) + '</strong>'});
+ return $.when([{
+ label: label,
+ facet: {
+ category: this.attrs.string,
+ field: this,
+ values: [{label: value, value: val}]
+ }
+ }]);
+ },
+});
+
+/**
+ * @class
+ * @extends instance.web.search.NumberField
+ */
+var IntegerField = NumberField.extend(/** @lends instance.web.search.IntegerField# */{
+ error_message: _t("not a valid integer"),
+ parse: function (value) {
+ try {
+ return formats.parse_value(value, {'widget': 'integer'});
+ } catch (e) {
+ return NaN;
+ }
+ }
+});
+
+/**
+ * @class
+ * @extends instance.web.search.NumberField
+ */
+var FloatField = NumberField.extend(/** @lends instance.web.search.FloatField# */{
+ error_message: _t("not a valid number"),
+ parse: function (value) {
+ try {
+ return formats.parse_value(value, {'widget': 'float'});
+ } catch (e) {
+ return NaN;
+ }
+ }
+});
+
+/**
+ * Utility function for m2o & selection fields taking a selection/name_get pair
+ * (value, name) and converting it to a Facet descriptor
+ *
+ * @param {instance.web.search.Field} field holder field
+ * @param {Array} pair pair value to convert
+ */
+function facet_from(field, pair) {
+ return {
+ field: field,
+ category: field.attrs.string,
+ values: [{label: pair[1], value: pair[0]}]
+ };
+}
+
+/**
+ * @class
+ * @extends instance.web.search.Field
+ */
+var SelectionField = Field.extend(/** @lends instance.web.search.SelectionField# */{
+ // This implementation is a basic <select> field, but it may have to be
+ // altered to be more in line with the GTK client, which uses a combo box
+ // (~ jquery.autocomplete):
+ // * If an option was selected in the list, behave as currently
+ // * If something which is not in the list was entered (via the text input),
+ // the default domain should become (`ilike` string_value) but **any
+ // ``context`` or ``filter_domain`` becomes falsy, idem if ``@operator``
+ // is specified. So at least get_domain needs to be quite a bit
+ // overridden (if there's no @value and there is no filter_domain and
+ // there is no @operator, return [[name, 'ilike', str_val]]
+ template: 'SearchView.field.selection',
+ init: function () {
+ this._super.apply(this, arguments);
+ // prepend empty option if there is no empty option in the selection list
+ this.prepend_empty = !_(this.attrs.selection).detect(function (item) {
+ return !item[1];
+ });
+ },
+ complete: function (needle) {
+ var self = this;
+ var results = _(this.attrs.selection).chain()
+ .filter(function (sel) {
+ var value = sel[0], label = sel[1];
+ if (value === undefined || !label) { return false; }
+ return label.toLowerCase().indexOf(needle.toLowerCase()) !== -1;
+ })
+ .map(function (sel) {
+ return {
+ label: _.escape(sel[1]),
+ indent: true,
+ facet: facet_from(self, sel)
+ };
+ }).value();
+ if (_.isEmpty(results)) { return $.when(null); }
+ return $.when.call(null, [{
+ label: _.escape(this.attrs.string)
+ }].concat(results));
+ },
+ facet_for: function (value) {
+ var match = _(this.attrs.selection).detect(function (sel) {
+ return sel[0] === value;
+ });
+ if (!match) { return $.when(null); }
+ return $.when(facet_from(this, match));
+ }
+});
+
+var BooleanField = SelectionField.extend(/** @lends instance.web.search.BooleanField# */{
+ /**
+ * @constructs instance.web.search.BooleanField
+ * @extends instance.web.search.BooleanField
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.attrs.selection = [
+ [true, _t("Yes")],
+ [false, _t("No")]
+ ];
+ }
+});
+
+/**
+ * @class
+ * @extends instance.web.search.DateField
+ */
+var DateField = Field.extend(/** @lends instance.web.search.DateField# */{
+ value_from: function (facetValue) {
+ return time.date_to_str(facetValue.get('value'));
+ },
+ complete: function (needle) {
+ // Make sure the needle has a correct format before the creation of the moment object. See
+ // issue https://github.com/moment/moment/issues/1407
+ var t, v;
+ try {
+ t = (this.attrs && this.attrs.type === 'datetime') ? 'datetime' : 'date';
+ v = formats.parse_value(needle, {'widget': t});
+ } catch (e) {
+ return $.when(null);
+ }
+
+ var m = moment(v, t === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD');
+ if (!m.isValid()) { return $.when(null); }
+ var d = m.toDate();
+ var date_string = formats.format_value(d, this.attrs);
+ var label = _.str.sprintf(_.str.escapeHTML(
+ _t("Search %(field)s at: %(value)s")), {
+ field: '<em>' + _.escape(this.attrs.string) + '</em>',
+ value: '<strong>' + date_string + '</strong>'});
+ return $.when([{
+ label: label,
+ facet: {
+ category: this.attrs.string,
+ field: this,
+ values: [{label: date_string, value: d}]
+ }
+ }]);
+ }
+});
+
+/**
+ * Implementation of the ``datetime`` openerp field type:
+ *
+ * * Uses the same widget as the ``date`` field type (a simple date)
+ *
+ * * Builds a slighly more complex, it's a datetime range (includes time)
+ * spanning the whole day selected by the date widget
+ *
+ * @class
+ * @extends instance.web.DateField
+ */
+var DateTimeField = DateField.extend(/** @lends instance.web.search.DateTimeField# */{
+ value_from: function (facetValue) {
+ return time.datetime_to_str(facetValue.get('value'));
+ }
+});
+
+var ManyToOneField = CharField.extend({
+ default_operator: {},
+ init: function (view_section, field, parent) {
+ this._super(view_section, field, parent);
+ this.model = new Model(this.attrs.relation);
+ this.searchview = parent;
+ },
+
+ complete: function (value) {
+ if (_.isEmpty(value)) { return $.when(null); }
+ var label = _.str.sprintf(_.str.escapeHTML(
+ _t("Search %(field)s for: %(value)s")), {
+ field: '<em>' + _.escape(this.attrs.string) + '</em>',
+ value: '<strong>' + _.escape(value) + '</strong>'});
+ return $.when([{
+ label: label,
+ facet: {
+ category: this.attrs.string,
+ field: this,
+ values: [{label: value, value: value, operator: 'ilike'}]
+ },
+ expand: this.expand.bind(this),
+ }]);
+ },
+
+ expand: function (needle) {
+ var self = this;
+ // FIXME: "concurrent" searches (multiple requests, mis-ordered responses)
+ var context = pyeval.eval(
+ 'contexts', [this.searchview.dataset.get_context()]);
+ var args = this.attrs.domain;
+ if (typeof args === 'string') {
+ try {
+ args = pyeval.eval('domain', args);
+ } catch(e) {
+ args = [];
+ }
+ }
+ return this.model.call('name_search', [], {
+ name: needle,
+ args: args,
+ limit: 8,
+ context: context
+ }).then(function (results) {
+ if (_.isEmpty(results)) { return null; }
+ return _(results).map(function (result) {
+ return {
+ label: _.escape(result[1]),
+ facet: facet_from(self, result)
+ };
+ });
+ });
+ },
+ facet_for: function (value) {
+ var self = this;
+ if (value instanceof Array) {
+ if (value.length === 2 && _.isString(value[1])) {
+ return $.when(facet_from(this, value));
+ }
+ utils.assert(value.length <= 1,
+ _t("M2O search fields do not currently handle multiple default values"));
+ // there are many cases of {search_default_$m2ofield: [id]}, need
+ // to handle this as if it were a single value.
+ value = value[0];
+ }
+ var context = pyeval.eval('contexts', [this.searchview.dataset.get_context()]);
+ return this.model.call('name_get', [value], {context: context}).then(function (names) {
+ if (_(names).isEmpty()) { return null; }
+ return facet_from(self, names[0]);
+ });
+ },
+ value_from: function (facetValue) {
+ return facetValue.get('label');
+ },
+ make_domain: function (name, operator, facetValue) {
+ operator = facetValue.get('operator') || operator;
+
+ switch(operator){
+ case this.default_operator:
+ return [[name, '=', facetValue.get('value')]];
+ case 'ilike':
+ return [[name, 'ilike', facetValue.get('value')]];
+ case 'child_of':
+ return [[name, 'child_of', facetValue.get('value')]];
+ }
+ return this._super(name, operator, facetValue);
+ },
+ get_context: function (facet) {
+ var values = facet.values;
+ if (_.isEmpty(this.attrs.context) && values.length === 1) {
+ var c = {};
+ var v = values.at(0);
+ if (v.get('operator') !== 'ilike') {
+ c['default_' + this.attrs.name] = v.get('value');
+ }
+ return c;
+ }
+ return this._super(facet);
+ }
+});
+
+var FilterGroup = Input.extend(/** @lends instance.web.search.FilterGroup# */{
+ template: 'SearchView.filters',
+ icon: "fa-filter",
+ completion_label: _lt("Filter on: %s"),
+ /**
+ * Inclusive group of filters, creates a continuous "button" with clickable
+ * sections (the normal display for filters is to be a self-contained button)
+ *
+ * @constructs instance.web.search.FilterGroup
+ * @extends instance.web.search.Input
+ *
+ * @param {Array<instance.web.search.Filter>} filters elements of the group
+ * @param {instance.web.SearchView} parent parent in which the filters are contained
+ */
+ init: function (filters, parent) {
+ // If all filters are group_by and we're not initializing a GroupbyGroup,
+ // create a GroupbyGroup instead of the current FilterGroup
+ if (!(this instanceof GroupbyGroup) &&
+ _(filters).all(function (f) {
+ if (!f.attrs.context) { return false; }
+ var c = pyeval.eval('context', f.attrs.context);
+ return !_.isEmpty(c.group_by);})) {
+ return new GroupbyGroup(filters, parent);
+ }
+ this._super(parent);
+ this.filters = filters;
+ this.searchview = parent;
+ this.searchview.query.on('add remove change reset', this.proxy('search_change'));
+ },
+ start: function () {
+ this.$el.on('click', 'a', this.proxy('toggle_filter'));
+ return $.when(null);
+ },
+ /**
+ * Handles change of the search query: any of the group's filter which is
+ * in the search query should be visually checked in the drawer
+ */
+ search_change: function () {
+ var self = this;
+ var $filters = this.$el.removeClass('selected');
+ var facet = this.searchview.query.find(_.bind(this.match_facet, this));
+ if (!facet) { return; }
+ facet.values.each(function (v) {
+ var i = _(self.filters).indexOf(v.get('value'));
+ if (i === -1) { return; }
+ $filters.filter(function () {
+ return Number($(this).data('index')) === i;
+ }).addClass('selected');
+ });
+ },
+ /**
+ * Matches the group to a facet, in order to find if the group is
+ * represented in the current search query
+ */
+ match_facet: function (facet) {
+ return facet.get('field') === this;
+ },
+ make_facet: function (values) {
+ return {
+ category: _t("Filter"),
+ icon: this.icon,
+ separator: _t(" or "),
+ values: values,
+ field: this
+ };
+ },
+ make_value: function (filter) {
+ return {
+ label: filter.attrs.string || filter.attrs.help || filter.attrs.name,
+ value: filter
+ };
+ },
+ facet_for_defaults: function (defaults) {
+ var self = this;
+ var fs = _(this.filters).chain()
+ .filter(function (f) {
+ return f.attrs && f.attrs.name && !!defaults[f.attrs.name];
+ }).map(function (f) {
+ return self.make_value(f);
+ }).value();
+ if (_.isEmpty(fs)) { return $.when(null); }
+ return $.when(this.make_facet(fs));
+ },
+ /**
+ * Fetches contexts for all enabled filters in the group
+ *
+ * @param {openerp.web.search.Facet} facet
+ * @return {*} combined contexts of the enabled filters in this group
+ */
+ get_context: function (facet) {
+ var contexts = facet.values.chain()
+ .map(function (f) { return f.get('value').attrs.context; })
+ .without('{}')
+ .reject(_.isEmpty)
+ .value();
+
+ if (!contexts.length) { return; }
+ if (contexts.length === 1) { return contexts[0]; }
+ return _.extend(new data.CompoundContext(), {
+ __contexts: contexts
+ });
+ },
+ /**
+ * Fetches group_by sequence for all enabled filters in the group
+ *
+ * @param {VS.model.SearchFacet} facet
+ * @return {Array} enabled filters in this group
+ */
+ get_groupby: function (facet) {
+ return facet.values.chain()
+ .map(function (f) { return f.get('value').attrs.context; })
+ .without('{}')
+ .reject(_.isEmpty)
+ .value();
+ },
+ /**
+ * Handles domains-fetching for all the filters within it: groups them.
+ *
+ * @param {VS.model.SearchFacet} facet
+ * @return {*} combined domains of the enabled filters in this group
+ */
+ get_domain: function (facet) {
+ var domains = facet.values.chain()
+ .map(function (f) { return f.get('value').attrs.domain; })
+ .without('[]')
+ .reject(_.isEmpty)
+ .value();
+
+ if (!domains.length) { return; }
+ if (domains.length === 1) { return domains[0]; }
+ for (var i=domains.length; --i;) {
+ domains.unshift(['|']);
+ }
+ return _.extend(new data.CompoundDomain(), {
+ __domains: domains
+ });
+ },
+ toggle_filter: function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.toggle(this.filters[Number($(e.target).parent().data('index'))]);
+ },
+ toggle: function (filter, options) {
+ this.searchview.query.toggle(this.make_facet([this.make_value(filter)]), options);
+ },
+ is_visible: function () {
+ return _.some(this.filters, function (filter) {
+ return !filter.attrs.invisible;
+ });
+ },
+ complete: function (item) {
+ var self = this;
+ item = item.toLowerCase();
+ var facet_values = _(this.filters).chain()
+ .filter(function (filter) { return filter.visible(); })
+ .filter(function (filter) {
+ var at = {
+ string: filter.attrs.string || '',
+ help: filter.attrs.help || '',
+ name: filter.attrs.name || ''
+ };
+ var include = _.str.include;
+ return include(at.string.toLowerCase(), item)
+ || include(at.help.toLowerCase(), item)
+ || include(at.name.toLowerCase(), item);
+ })
+ .map(this.make_value)
+ .value();
+ if (_(facet_values).isEmpty()) { return $.when(null); }
+ return $.when(_.map(facet_values, function (facet_value) {
+ return {
+ label: _.str.sprintf(self.completion_label.toString(),
+ _.escape(facet_value.label)),
+ facet: self.make_facet([facet_value])
+ };
+ }));
+ }
+});
+
+var GroupbyGroup = FilterGroup.extend({
+ icon: 'fa-bars',
+ completion_label: _lt("Group by: %s"),
+ init: function (filters, parent) {
+ this._super(filters, parent);
+ this.searchview = parent;
+ // Not flanders: facet unicity is handled through the
+ // (category, field) pair of facet attributes. This is all well and
+ // good for regular filter groups where a group matches a facet, but for
+ // groupby we want a single facet. So cheat: add an attribute on the
+ // view which proxies to the first GroupbyGroup, so it can be used
+ // for every GroupbyGroup and still provides the various methods needed
+ // by the search view. Use weirdo name to avoid risks of conflicts
+ if (!this.searchview._s_groupby) {
+ this.searchview._s_groupby = {
+ help: "See GroupbyGroup#init",
+ get_context: this.proxy('get_context'),
+ get_domain: this.proxy('get_domain'),
+ get_groupby: this.proxy('get_groupby')
+ };
+ }
+ },
+ match_facet: function (facet) {
+ return facet.get('field') === this.searchview._s_groupby;
+ },
+ make_facet: function (values) {
+ return {
+ category: _t("Group By"),
+ icon: this.icon,
+ separator: " > ",
+ values: values,
+ field: this.searchview._s_groupby
+ };
+ }
+});
+
+var Filter = Input.extend(/** @lends instance.web.search.Filter# */{
+ template: 'SearchView.filter',
+ /**
+ * Implementation of the OpenERP filters (button with a context and/or
+ * a domain sent as-is to the search view)
+ *
+ * Filters are only attributes holder, the actual work (compositing
+ * domains and contexts, converting between facets and filters) is
+ * performed by the filter group.
+ *
+ * @constructs instance.web.search.Filter
+ * @extends instance.web.search.Input
+ *
+ * @param node
+ * @param parent
+ */
+ init: function (node, parent) {
+ this._super(parent);
+ this.load_attrs(node.attrs);
+ },
+ facet_for: function () { return $.when(null); },
+ get_context: function () { },
+ get_domain: function () { },
+});
+
+/**
+ * Registry of search fields, called by :js:class:`instance.web.SearchView` to
+ * find and instantiate its field widgets.
+ */
+
+core.search_widgets_registry
+ .add('char', CharField)
+ .add('text', CharField)
+ .add('html', CharField)
+ .add('boolean', BooleanField)
+ .add('integer', IntegerField)
+ .add('id', IntegerField)
+ .add('float', FloatField)
+ .add('monetary', FloatField)
+ .add('selection', SelectionField)
+ .add('datetime', DateTimeField)
+ .add('date', DateField)
+ .add('many2one', ManyToOneField)
+ .add('many2many', CharField)
+ .add('one2many', CharField);
+
+
+return {
+ FilterGroup: FilterGroup,
+ Filter: Filter,
+ Field: Field,
+ GroupbyGroup: GroupbyGroup,
+};
+
+});
diff --git a/addons/web/static/src/js/chrome/search_menus.js b/addons/web/static/src/js/chrome/search_menus.js
new file mode 100644
index 00000000000..7eff17d7ed6
--- /dev/null
+++ b/addons/web/static/src/js/chrome/search_menus.js
@@ -0,0 +1,455 @@
+odoo.define('web.FavoriteMenu', function (require) {
+"use strict";
+
+var core = require('web.core');
+var data_manager = require('web.data_manager');
+var pyeval = require('web.pyeval');
+var session = require('web.session');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+
+return Widget.extend({
+ template: 'SearchView.FavoriteMenu',
+ events: {
+ 'click li': function (event) {
+ event.stopImmediatePropagation();
+ },
+ 'click li a': function (event) {
+ event.preventDefault();
+ },
+ 'click .o_save_search a': function (event) {
+ event.preventDefault();
+ this.toggle_save_menu();
+ },
+ 'click .o_save_name button': 'save_favorite',
+ 'hidden.bs.dropdown': 'close_menus',
+ 'keyup .o_save_name input': function (ev) {
+ if (ev.which === $.ui.keyCode.ENTER) {
+ this.save_favorite();
+ }
+ },
+ },
+ init: function (parent, query, target_model, action_id, filters) {
+ this._super.apply(this,arguments);
+ this.searchview = parent;
+ this.query = query;
+ this.target_model = target_model;
+ this.action_id = action_id;
+ this.filters = {};
+ _.each(filters, this.add_filter.bind(this));
+ },
+ start: function () {
+ var self = this;
+ this.$filters = {};
+ this.$save_search = this.$('.o_save_search');
+ this.$save_name = this.$('.o_save_name');
+ this.$inputs = this.$save_name.find('input');
+ this.$user_divider = this.$('.divider.user_filter');
+ this.$shared_divider = this.$('.divider.shared_filter');
+ this.$inputs.eq(0).val(this.searchview.get_title());
+ var $shared_filter = this.$inputs.eq(1),
+ $default_filter = this.$inputs.eq(2);
+ $shared_filter.click(function () {$default_filter.prop('checked', false);});
+ $default_filter.click(function () {$shared_filter.prop('checked', false);});
+
+ this.query
+ .on('remove', function (facet) {
+ if (facet.get('is_custom_filter')) {
+ self.clear_selection();
+ }
+ })
+ .on('reset', this.proxy('clear_selection'));
+
+ _.each(this.filters, this.append_filter.bind(this));
+
+ return this._super();
+ },
+ toggle_save_menu: function (is_open) {
+ this.$save_search
+ .toggleClass('o_closed_menu', !(_.isUndefined(is_open)) ? !is_open : undefined)
+ .toggleClass('o_open_menu', is_open);
+ this.$save_name.toggle(is_open);
+ if (this.$save_search.hasClass('o_open_menu')) {
+ this.$save_name.find('input').first().focus();
+ }
+ },
+ close_menus: function () {
+ this.toggle_save_menu(false);
+ },
+ save_favorite: function () {
+ var self = this,
+ filter_name = this.$inputs[0].value,
+ default_filter = this.$inputs[1].checked,
+ shared_filter = this.$inputs[2].checked;
+ if (!filter_name.length){
+ this.do_warn(_t("Error"), _t("Filter name is required."));
+ this.$inputs.first().focus();
+ return;
+ }
+ if (_.chain(this.filters)
+ .pluck('name')
+ .contains(filter_name).value()) {
+ this.do_warn(_t("Error"), _t("Filter with same name already exists."));
+ this.$inputs.first().focus();
+ return;
+ }
+ var user_context = this.getSession().user_context;
+ var search = this.searchview.build_search_data();
+ var view_manager = this.findAncestor(function (a) {
+ // HORRIBLE HACK. PLEASE SAVE ME FROM MYSELF (BUT IN A PAINLESS WAY IF POSSIBLE)
+ return 'active_view' in a;
+ });
+ var view_context = view_manager ? view_manager.active_view.controller.get_context() : {};
+ var results = pyeval.eval_domains_and_contexts({
+ domains: search.domains,
+ contexts: [user_context].concat(search.contexts.concat(view_context || [])),
+ group_by_seq: search.groupbys || [],
+ });
+ if (!_.isEmpty(results.group_by)) {
+ results.context.group_by = results.group_by;
+ }
+ // Don't save user_context keys in the custom filter, otherwise end
+ // up with e.g. wrong uid or lang stored *and used in subsequent
+ // reqs*
+ var ctx = results.context;
+ _(_.keys(session.user_context)).each(function (key) {
+ delete ctx[key];
+ });
+ var filter = {
+ name: filter_name,
+ user_id: shared_filter ? false : session.uid,
+ model_id: this.target_model,
+ context: results.context,
+ domain: results.domain,
+ is_default: default_filter,
+ action_id: this.action_id,
+ };
+ return data_manager.create_filter(filter).done(function (id) {
+ filter.id = id;
+ self.toggle_save_menu(false);
+ self.$save_name.find('input').val('').prop('checked', false);
+ self.add_filter(filter);
+ self.append_filter(filter);
+ self.toggle_filter(filter, true);
+ });
+ },
+ get_default_filter: function () {
+ var personal_filter = _.find(this.filters, function (filter) {
+ return filter.user_id && filter.is_default;
+ });
+ if (personal_filter) {
+ return personal_filter;
+ }
+ return _.find(this.filters, function (filter) {
+ return !filter.user_id && filter.is_default;
+ });
+ },
+ /**
+ * Generates a mapping key (in the filters and $filter mappings) for the
+ * filter descriptor object provided (as returned by ``get_filters``).
+ *
+ * The mapping key is guaranteed to be unique for a given (user_id, name)
+ * pair.
+ *
+ * @param {Object} filter
+ * @param {String} filter.name
+ * @param {Number|Pair<Number, String>} [filter.user_id]
+ * @return {String} mapping key corresponding to the filter
+ */
+ key_for: function (filter) {
+ var user_id = filter.user_id,
+ action_id = filter.action_id,
+ uid = (user_id instanceof Array) ? user_id[0] : user_id,
+ act_id = (action_id instanceof Array) ? action_id[0] : action_id;
+ return _.str.sprintf('(%s)(%s)%s', uid, act_id, filter.name);
+ },
+ /**
+ * Generates a :js:class:`~instance.web.search.Facet` descriptor from a
+ * filter descriptor
+ *
+ * @param {Object} filter
+ * @param {String} filter.name
+ * @param {Object} [filter.context]
+ * @param {Array} [filter.domain]
+ * @return {Object}
+ */
+ facet_for: function (filter) {
+ return {
+ category: _t("Custom Filter"),
+ icon: 'fa-star',
+ field: {
+ get_context: function () { return filter.context; },
+ get_groupby: function () { return [filter.context]; },
+ get_domain: function () { return filter.domain; }
+ },
+ _id: filter.id,
+ is_custom_filter: true,
+ values: [{label: filter.name, value: null}]
+ };
+ },
+ clear_selection: function () {
+ this.$('li.selected').removeClass('selected');
+ },
+ /**
+ * Adds a filter description to the filters dict
+ * @param {Object} [filter] the filter description
+ */
+ add_filter: function (filter) {
+ this.filters[this.key_for(filter)] = filter;
+ },
+ /**
+ * Creates a $filter JQuery node, adds it to the $filters dict and appends it to the filter menu
+ * @param {Object} [filter] the filter description
+ */
+ append_filter: function (filter) {
+ var self = this;
+ var key = this.key_for(filter);
+
+ if (filter.user_id) {
+ this.$user_divider.show();
+ } else {
+ this.$shared_divider.show();
+ }
+ if (!(key in this.$filters)) {
+ var $filter = $('<li>')
+ .addClass(filter.user_id ? 'o-searchview-custom-private'
+ : 'o-searchview-custom-public')
+ .append($('<a>', {'href': '#'}).text(filter.name))
+ .append($('<span>', {
+ class: 'fa fa-trash-o o-remove-filter',
+ on: {
+ click: function (event) {
+ event.stopImmediatePropagation();
+ self.remove_filter(filter, $filter, key);
+ },
+ },
+ }))
+ .insertBefore(filter.user_id ? this.$user_divider : this.$shared_divider);
+ this.$filters[key] = $filter;
+ }
+ this.$filters[key].unbind('click').click(function () {
+ self.toggle_filter(filter);
+ });
+ },
+ toggle_filter: function (filter, preventSearch) {
+ var current = this.query.find(function (facet) {
+ return facet.get('_id') === filter.id;
+ });
+ if (current) {
+ this.query.remove(current);
+ this.$filters[this.key_for(filter)].removeClass('selected');
+ return;
+ }
+ this.query.reset([this.facet_for(filter)], {
+ preventSearch: preventSearch || false});
+ this.$filters[this.key_for(filter)].addClass('selected');
+ },
+ remove_filter: function (filter, $filter, key) {
+ var self = this;
+ var global_warning = _t("This filter is global and will be removed for everybody if you continue."),
+ warning = _t("Are you sure that you want to remove this filter?");
+ if (!confirm(filter.user_id ? warning : global_warning)) {
+ return;
+ }
+ return data_manager
+ .delete_filter(filter)
+ .done(function () {
+ $filter.remove();
+ delete self.$filters[key];
+ delete self.filters[key];
+ var has_user_filter = _.find(self.filters, function(filter) { return filter.user_id; });
+ var has_shared_filer = _.find(self.filters, function(filter) { return !filter.user_id; });
+ if (!has_user_filter) {
+ self.$user_divider.hide();
+ }
+ if (!has_shared_filer) {
+ self.$shared_divider.hide();
+ }
+ });
+ },
+});
+
+});
+
+odoo.define('web.FilterMenu', function (require) {
+"use strict";
+
+var search_filters = require('web.search_filters');
+var search_inputs = require('web.search_inputs');
+var Widget = require('web.Widget');
+
+return Widget.extend({
+ template: 'SearchView.FilterMenu',
+ events: {
+ 'click .o_add_filter': function (event) {
+ event.preventDefault();
+ this.toggle_custom_filter_menu();
+ },
+ 'click li': function (event) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ },
+ 'hidden.bs.dropdown': function () {
+ this.toggle_custom_filter_menu(false);
+ },
+ 'click .o_add_condition': 'append_proposition',
+ 'click .o_apply_filter': 'commit_search',
+ 'keyup .o_searchview_extended_prop_value': function (ev) {
+ if (ev.which === $.ui.keyCode.ENTER) {
+ this.commit_search();
+ }
+ },
+ },
+ init: function (parent, filters, fields) {
+ this._super(parent);
+ this.filters = filters || [];
+ this.searchview = parent;
+ this.propositions = [];
+ this.custom_filters_open = false;
+ this.fields = _.pick(fields, function (field, name) {
+ return field.selectable !== false && name !== 'id';
+ });
+ this.fields.id = { string: 'ID', type: 'id', searchable: true };
+ },
+ start: function () {
+ var self = this;
+ this.$menu = this.$('.o_filters_menu');
+ this.$add_filter = this.$('.o_add_filter');
+ this.$apply_filter = this.$('.o_apply_filter');
+ this.$add_filter_menu = this.$('.o_add_filter_menu');
+ _.each(this.filters, function (group) {
+ if (group.is_visible()) {
+ group.insertBefore(self.$add_filter);
+ $('<li class="divider">').insertBefore(self.$add_filter);
+ }
+ });
+ },
+ toggle_custom_filter_menu: function (is_open) {
+ var self = this;
+ this.custom_filters_open = !_.isUndefined(is_open) ? is_open : !this.custom_filters_open;
+ var def;
+ if (this.custom_filters_open && !this.propositions.length) {
+ def = this.append_proposition();
+ }
+ $.when(def).then(function () {
+ self.$add_filter
+ .toggleClass('o_closed_menu', !self.custom_filters_open)
+ .toggleClass('o_open_menu', self.custom_filters_open);
+ self.$add_filter_menu.toggle(self.custom_filters_open);
+ self.$('.o_filter_condition').toggle(self.custom_filters_open);
+ });
+ },
+ append_proposition: function () {
+ var prop = new search_filters.ExtendedSearchProposition(this, this.fields);
+ this.propositions.push(prop);
+ this.$apply_filter.prop('disabled', false);
+ return prop.insertBefore(this.$add_filter_menu);
+ },
+ remove_proposition: function (prop) {
+ this.propositions = _.without(this.propositions, prop);
+ if (!this.propositions.length) {
+ this.$apply_filter.prop('disabled', true);
+ }
+ prop.destroy();
+ },
+ commit_search: function () {
+ var filters = _.invoke(this.propositions, 'get_filter'),
+ filters_widgets = _.map(filters, function (filter) {
+ return new search_inputs.Filter(filter, this);
+ }),
+ filter_group = new search_inputs.FilterGroup(filters_widgets, this.searchview),
+ facets = filters_widgets.map(function (filter) {
+ return filter_group.make_facet([filter_group.make_value(filter)]);
+ });
+ filter_group.insertBefore(this.$add_filter);
+ $('<li class="divider">').insertBefore(this.$add_filter);
+ this.searchview.query.add(facets, {silent: true});
+ this.searchview.query.trigger('reset');
+
+ _.invoke(this.propositions, 'destroy');
+ this.propositions = [];
+ this.append_proposition();
+ this.toggle_custom_filter_menu(false);
+ },
+});
+
+});
+
+odoo.define('web.GroupByMenu', function (require) {
+"use strict";
+
+var core = require('web.core');
+var search_inputs = require('web.search_inputs');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+
+return Widget.extend({
+ template: 'SearchView.GroupByMenu',
+ events: {
+ 'click li': function (event) {
+ event.stopImmediatePropagation();
+ },
+ 'hidden.bs.dropdown': function () {
+ this.toggle_add_menu(false);
+ },
+ 'click .o_add_custom_group a': function (event) {
+ event.preventDefault();
+ this.toggle_add_menu();
+ },
+ },
+ init: function (parent, groups, fields) {
+ var self = this;
+ this._super(parent);
+ this.searchview = parent;
+ this.groups = groups || [];
+ this.groupableFields = [];
+ var groupable_types = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime'];
+ _.each(fields, function (field, name) {
+ if (field.store && _.contains(groupable_types, field.type)) {
+ self.groupableFields.push(_.extend({}, field, {name: name}));
+ }
+ });
+ self.groupableFields = _.sortBy(this.groupableFields, 'string');
+ },
+ start: function () {
+ var self = this;
+ this.$menu = this.$('.o_group_by_menu');
+ var divider = this.$menu.find('.divider');
+ _.invoke(this.groups, 'insertBefore', divider);
+ if (this.groups.length) {
+ divider.show();
+ }
+ this.$add_group = this.$menu.find('.o_add_custom_group');
+ this.$menu.append(QWeb.render('GroupByMenuSelector', this));
+ this.$add_group_menu = this.$('.o_add_group');
+ this.$group_selector = this.$('.o_group_selector');
+ this.$('.o_select_group').click(function () {
+ self.toggle_add_menu(false);
+ var field = self.$group_selector.find(':selected').data('name');
+ self.add_groupby_to_menu(field);
+ });
+ },
+ toggle_add_menu: function (is_open) {
+ this.$add_group
+ .toggleClass('o_closed_menu', !(_.isUndefined(is_open)) ? !is_open : undefined)
+ .toggleClass('o_open_menu', is_open);
+ this.$add_group_menu.toggle(is_open);
+ if (this.$add_group.hasClass('o_open_menu')) {
+ this.$group_selector.focus();
+ }
+ },
+ add_groupby_to_menu: function (field_name) {
+ var filter = new search_inputs.Filter({attrs:{
+ context:"{'group_by':'" + field_name + "''}",
+ name: _.find(this.groupableFields, {name: field_name}).string,
+ }}, this.searchview);
+ var group = new search_inputs.FilterGroup([filter], this.searchview),
+ divider = this.$('.divider').show();
+ group.insertBefore(divider);
+ group.toggle(filter);
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/chrome/search_view.js b/addons/web/static/src/js/chrome/search_view.js
new file mode 100644
index 00000000000..26305817e55
--- /dev/null
+++ b/addons/web/static/src/js/chrome/search_view.js
@@ -0,0 +1,615 @@
+odoo.define('web.SearchView', function (require) {
+"use strict";
+
+var AutoComplete = require('web.AutoComplete');
+var config = require('web.config');
+var core = require('web.core');
+var FavoriteMenu = require('web.FavoriteMenu');
+var FilterMenu = require('web.FilterMenu');
+var GroupByMenu = require('web.GroupByMenu');
+var pyeval = require('web.pyeval');
+var search_inputs = require('web.search_inputs');
+var Widget = require('web.Widget');
+var _t = core._t;
+
+var Backbone = window.Backbone;
+
+var FacetValue = Backbone.Model.extend({});
+
+var FacetValues = Backbone.Collection.extend({
+ model: FacetValue
+});
+
+var Facet = Backbone.Model.extend({
+ initialize: function (attrs) {
+ var values = attrs.values;
+ delete attrs.values;
+
+ Backbone.Model.prototype.initialize.apply(this, arguments);
+
+ this.values = new FacetValues(values || []);
+ this.values.on('add remove change reset', function (_, options) {
+ this.trigger('change', this, options);
+ }, this);
+ },
+ get: function (key) {
+ if (key !== 'values') {
+ return Backbone.Model.prototype.get.call(this, key);
+ }
+ return this.values.toJSON();
+ },
+ set: function (key, value) {
+ if (key !== 'values') {
+ return Backbone.Model.prototype.set.call(this, key, value);
+ }
+ this.values.reset(value);
+ },
+ toJSON: function () {
+ var out = {};
+ var attrs = this.attributes;
+ for(var att in attrs) {
+ if (!attrs.hasOwnProperty(att) || att === 'field') {
+ continue;
+ }
+ out[att] = attrs[att];
+ }
+ out.values = this.values.toJSON();
+ return out;
+ }
+});
+
+var SearchQuery = Backbone.Collection.extend({
+ model: Facet,
+ initialize: function () {
+ Backbone.Collection.prototype.initialize.apply(
+ this, arguments);
+ this.on('change', function (facet) {
+ if(!facet.values.isEmpty()) { return; }
+
+ this.remove(facet, {silent: true});
+ }, this);
+ },
+ add: function (values, options) {
+ options = options || {};
+
+ if (!values) {
+ values = [];
+ } else if (!(values instanceof Array)) {
+ values = [values];
+ }
+
+ _(values).each(function (value) {
+ var model = this._prepareModel(value, options);
+ var previous = this.detect(function (facet) {
+ return facet.get('category') === model.get('category') &&
+ facet.get('field') === model.get('field');
+ });
+ if (previous) {
+ previous.values.add(model.get('values'), _.omit(options, 'at', 'merge'));
+ return;
+ }
+ Backbone.Collection.prototype.add.call(this, model, options);
+ }, this);
+ // warning: in backbone 1.0+ add is supposed to return the added models,
+ // but here toggle may delegate to add and return its value directly.
+ // return value of neither seems actually used but should be tested
+ // before change, probably
+ return this;
+ },
+ toggle: function (value, options) {
+ options = options || {};
+
+ var facet = this.detect(function (facet) {
+ return facet.get('category') === value.category
+ && facet.get('field') === value.field;
+ });
+ if (!facet) {
+ return this.add(value, options);
+ }
+
+ var changed = false;
+ _(value.values).each(function (val) {
+ var already_value = facet.values.detect(function (v) {
+ return v.get('value') === val.value
+ && v.get('label') === val.label;
+ });
+ // toggle value
+ if (already_value) {
+ facet.values.remove(already_value, {silent: true});
+ } else {
+ facet.values.add(val, {silent: true});
+ }
+ changed = true;
+ });
+ // "Commit" changes to values array as a single call, so observers of
+ // change event don't get misled by intermediate incomplete toggling
+ // states
+ facet.trigger('change', facet);
+ return this;
+ }
+});
+
+var InputView = Widget.extend({
+ template: 'SearchView.InputView',
+ events: {
+ focus: function () { this.trigger('focused', this); },
+ blur: function () { this.$el.val(''); this.trigger('blurred', this); },
+ keydown: 'onKeydown',
+ },
+ onKeydown: function (e) {
+ switch (e.which) {
+ case $.ui.keyCode.BACKSPACE:
+ if(this.$el.val() === '') {
+ var preceding = this.getParent().siblingSubview(this, -1);
+ if (preceding && (preceding instanceof FacetView)) {
+ preceding.model.destroy();
+ }
+ }
+ break;
+
+ case $.ui.keyCode.LEFT: // Stop propagation to parent if not at beginning of input value
+ if(this.el.selectionStart > 0) {
+ e.stopPropagation();
+ }
+ break;
+
+ case $.ui.keyCode.RIGHT: // Stop propagation to parent if not at end of input value
+ if(this.el.selectionStart < this.$el.val().length) {
+ e.stopPropagation();
+ }
+ break;
+ }
+ }
+});
+
+var FacetView = Widget.extend({
+ template: 'SearchView.FacetView',
+ events: {
+ 'focus': function () { this.trigger('focused', this); },
+ 'blur': function () { this.trigger('blurred', this); },
+ 'click': function (e) {
+ if ($(e.target).hasClass('o_facet_remove')) {
+ this.model.destroy();
+ return false;
+ }
+ this.$el.focus();
+ e.stopPropagation();
+ },
+ 'keydown': function (e) {
+ var keys = $.ui.keyCode;
+ switch (e.which) {
+ case keys.BACKSPACE:
+ case keys.DELETE:
+ this.model.destroy();
+ return false;
+ }
+ }
+ },
+ init: function (parent, model) {
+ this._super(parent);
+ this.model = model;
+ this.model.on('change', this.model_changed, this);
+ },
+ destroy: function () {
+ this.model.off('change', this.model_changed, this);
+ this._super();
+ },
+ start: function () {
+ var self = this;
+ var $e = this.$('.o_facet_values').last();
+ return $.when(this._super()).then(function () {
+ return $.when.apply(null, self.model.values.map(function (value, index) {
+ if (index > 0) {
+ $('<span/>', {html: self.model.get('separator') || _t(" or ")}).addClass('o_facet_values_sep').appendTo($e);
+ }
+ return new FacetValueView(self, value).appendTo($e);
+ }));
+ });
+ },
+ model_changed: function () {
+ this.$el.text(this.$el.text() + '*');
+ }
+});
+
+var FacetValueView = Widget.extend({
+ template: 'SearchView.FacetView.Value',
+ init: function (parent, model) {
+ this._super(parent);
+ this.model = model;
+ this.model.on('change', this.model_changed, this);
+ },
+ destroy: function () {
+ this.model.off('change', this.model_changed, this);
+ this._super();
+ },
+ model_changed: function () {
+ this.$el.text(this.$el.text() + '*');
+ }
+});
+
+var SearchView = Widget.extend({
+ events: {
+ 'click .o_searchview_more': function (e) {
+ $(e.target).toggleClass('fa-search-plus fa-search-minus');
+ var visibleSearchMenu = this.call('local_storage', 'getItem', 'visible_search_menu');
+ this.call('local_storage', 'setItem', visibleSearchMenu !== 'true');
+ this.toggle_buttons();
+ },
+ 'keydown .o_searchview_input, .o_searchview_facet': function (e) {
+ switch(e.which) {
+ case $.ui.keyCode.LEFT:
+ this.focusPreceding(e.target);
+ e.preventDefault();
+ break;
+ case $.ui.keyCode.RIGHT:
+ if(!this.autocomplete.is_expandable()) {
+ this.focusFollowing(e.target);
+ }
+ e.preventDefault();
+ break;
+ }
+ },
+ },
+ defaults: _.extend({}, Widget.prototype.defaults, {
+ hidden: false,
+ disable_filters: false,
+ disable_groupby: false,
+ disable_favorites: false,
+ disable_custom_filters: false,
+ }),
+ template: "SearchView",
+
+ /**
+ * @constructs SearchView
+ * @extends View
+ *
+ * @param parent
+ * @param dataset
+ * @param fields_view
+ * @param {Object} [options]
+ * @param {Boolean} [options.hidden=false] hide the search view
+ * @param {Boolean} [options.disable_custom_filters=false] do not load custom filters from ir.filters
+ */
+ init: function (parent, dataset, fvg, options) {
+ this._super.apply(this, arguments);
+ this.options = options;
+ this.dataset = dataset;
+ this.fields_view = fvg;
+ this.fields = this.fields_view.fields;
+ this.query = undefined;
+ this.title = this.options.action && this.options.action.name;
+ this.action_id = this.options.action && this.options.action.id;
+ this.search_fields = [];
+ this.filters = [];
+ this.groupbys = [];
+ var visibleSearchMenu = this.call('local_storage', 'getItem', 'visible_search_menu');
+ this.visible_filters = (visibleSearchMenu === 'true');
+ this.input_subviews = []; // for user input in searchbar
+ this.search_defaults = this.options.search_defaults || {};
+ this.headless = this.options.hidden && _.isEmpty(this.search_defaults);
+ this.$buttons = this.options.$buttons;
+
+ this.filter_menu = undefined;
+ this.groupby_menu = undefined;
+ this.favorite_menu = undefined;
+ },
+ willStart: function () {
+ var self = this;
+ var def;
+ if (!this.options.disable_favorites) {
+ def = this.loadFilters(this.dataset, this.action_id).then(function (filters) {
+ self.favorite_filters = filters;
+ });
+ }
+ return $.when(this._super(), def);
+ },
+ start: function () {
+ if (this.headless) {
+ this.do_hide();
+ }
+ this.toggle_visibility(false);
+ this.setup_global_completion();
+ this.query = new SearchQuery()
+ .on('add change reset remove', this.proxy('do_search'))
+ .on('change', this.proxy('renderChangedFacets'))
+ .on('add reset remove', this.proxy('renderFacets'));
+ this.$('.o_searchview_more')
+ .toggleClass('fa-search-minus', this.visible_filters)
+ .toggleClass('fa-search-plus', !this.visible_filters);
+ var menu_defs = [];
+ this.prepare_search_inputs();
+ if (this.$buttons) {
+ if (!this.options.disable_filters) {
+ this.filter_menu = new FilterMenu(this, this.filters, this.fields);
+ menu_defs.push(this.filter_menu.appendTo(this.$buttons));
+ }
+ if (!this.options.disable_groupby) {
+ this.groupby_menu = new GroupByMenu(this, this.groupbys, this.fields);
+ menu_defs.push(this.groupby_menu.appendTo(this.$buttons));
+ }
+ if (!this.options.disable_favorites) {
+ this.favorite_menu = new FavoriteMenu(this, this.query, this.dataset.model, this.action_id, this.favorite_filters);
+ menu_defs.push(this.favorite_menu.appendTo(this.$buttons));
+ }
+ }
+ return $.when.apply($, menu_defs).then(this.set_default_filters.bind(this));
+ },
+ get_title: function () {
+ return this.title;
+ },
+ set_default_filters: function () {
+ var self = this,
+ default_custom_filter = this.$buttons && this.favorite_menu.get_default_filter();
+ if (!self.options.disable_custom_filters && default_custom_filter) {
+ return this.favorite_menu.toggle_filter(default_custom_filter, true);
+ }
+ if (!_.isEmpty(this.search_defaults)) {
+ var inputs = this.search_fields.concat(this.filters, this.groupbys),
+ search_defaults = _.invoke(inputs, 'facet_for_defaults', this.search_defaults);
+ return $.when.apply(null, search_defaults).then(function () {
+ self.query.reset(_(arguments).compact(), {preventSearch: true});
+ });
+ }
+ this.query.reset([], {preventSearch: true});
+ return $.when();
+ },
+ /**
+ * Performs the search view collection of widget data.
+ *
+ * If the collection went well (all fields are valid), then triggers
+ * :js:func:`instance.web.SearchView.on_search`.
+ *
+ * If at least one field failed its validation, triggers
+ * :js:func:`instance.web.SearchView.on_invalid` instead.
+ *
+ * @param [_query]
+ * @param {Object} [options]
+ */
+ do_search: function (_query, options) {
+ if (options && options.preventSearch) {
+ return;
+ }
+ var search = this.build_search_data();
+ this.trigger_up('search', search);
+ },
+ /**
+ * Extract search data from the view's facets.
+ *
+ * Result is an object with 3 (own) properties:
+ *
+ * domains
+ * Array of domains
+ * contexts
+ * Array of contexts
+ * groupbys
+ * Array of domains, in groupby order rather than view order
+ *
+ * @return {Object}
+ */
+ build_search_data: function () {
+ var domains = [], contexts = [], groupbys = [];
+
+ this.query.each(function (facet) {
+ var field = facet.get('field');
+ var domain = field.get_domain(facet);
+ if (domain) {
+ domains.push(domain);
+ }
+ var context = field.get_context(facet);
+ if (context) {
+ contexts.push(context);
+ }
+ var group_by = field.get_groupby(facet);
+ if (group_by) {
+ groupbys.push.apply(groupbys, group_by);
+ }
+ });
+ return {
+ domains: domains,
+ contexts: contexts,
+ groupbys: groupbys,
+ };
+ },
+ toggle_visibility: function (is_visible) {
+ this.do_toggle(!this.headless && is_visible);
+ if (this.$buttons) {
+ this.$buttons.toggle(!this.headless && is_visible && this.visible_filters);
+ }
+ if (!config.device.touch && config.device.size_class >= config.device.SIZES.SM) {
+ this.$('input').focus();
+ }
+ },
+ toggle_buttons: function (is_visible) {
+ this.visible_filters = is_visible || !this.visible_filters;
+ if (this.$buttons) {
+ this.$buttons.toggle(this.visible_filters);
+ }
+ },
+ /**
+ * Sets up search view's view-wide auto-completion widget
+ */
+ setup_global_completion: function () {
+ var self = this;
+ this.autocomplete = new AutoComplete(this, {
+ source: this.proxy('complete_global_search'),
+ select: this.proxy('select_completion'),
+ get_search_string: function () {
+ return self.$('.o_searchview_input').val().trim();
+ },
+ });
+ this.autocomplete.appendTo(this.$el);
+ },
+ /**
+ * Provide auto-completion result for req.term (an array to `resp`)
+ *
+ * @param {Object} req request to complete
+ * @param {String} req.term searched term to complete
+ * @param {Function} resp response callback
+ */
+ complete_global_search: function (req, resp) {
+ var inputs = this.search_fields.concat(this.filters, this.groupbys);
+ $.when.apply(null, _(inputs).chain()
+ .filter(function (input) { return input.visible(); })
+ .invoke('complete', req.term)
+ .value()).then(function () {
+ resp(_(arguments).chain()
+ .compact()
+ .flatten(true)
+ .value());
+ });
+ },
+ /**
+ * Action to perform in case of selection: create a facet (model)
+ * and add it to the search collection
+ *
+ * @param {Object} e selection event, preventDefault to avoid setting value on object
+ * @param {Object} ui selection information
+ * @param {Object} ui.item selected completion item
+ */
+ select_completion: function (e, ui) {
+ e.preventDefault();
+ if(ui.item.facet && ui.item.facet.values && ui.item.facet.values.length && String(ui.item.facet.values[0].value).trim() !== "") {
+ this.query.add(ui.item.facet);
+ } else {
+ this.query.trigger('add');
+ }
+ },
+ subviewForRoot: function (subview_root) {
+ return _(this.input_subviews).detect(function (subview) {
+ return subview.$el[0] === subview_root;
+ });
+ },
+ siblingSubview: function (subview, direction, wrap_around) {
+ var index = _(this.input_subviews).indexOf(subview) + direction;
+ if (wrap_around && index < 0) {
+ index = this.input_subviews.length - 1;
+ } else if (wrap_around && index >= this.input_subviews.length) {
+ index = 0;
+ }
+ return this.input_subviews[index];
+ },
+ focusPreceding: function (subview_root) {
+ return this.siblingSubview(
+ this.subviewForRoot(subview_root), -1, true)
+ .$el.focus();
+ },
+ focusFollowing: function (subview_root) {
+ return this.siblingSubview(
+ this.subviewForRoot(subview_root), +1, true)
+ .$el.focus();
+ },
+ /**
+ * @param {openerp.web.search.SearchQuery | undefined} Undefined if event is change
+ * @param {openerp.web.search.Facet}
+ * @param {Object} [options]
+ */
+ renderFacets: function (collection, model, options) {
+ var self = this;
+ var started = [];
+ _.invoke(this.input_subviews, 'destroy');
+ this.input_subviews = [];
+
+ this.query.each(function (facet) {
+ var f = new FacetView(this, facet);
+ started.push(f.appendTo(self.$el));
+ self.input_subviews.push(f);
+ }, this);
+ var i = new InputView(this);
+ started.push(i.appendTo(self.$el));
+ self.input_subviews.push(i);
+ _.each(this.input_subviews, function (childView) {
+ childView.on('focused', self, self.proxy('childFocused'));
+ childView.on('blurred', self, self.proxy('childBlurred'));
+ });
+
+ $.when.apply(null, started).then(function () {
+ _.last(self.input_subviews).$el.focus();
+ });
+ },
+ childFocused: function () {
+ this.$el.addClass('active');
+ },
+ childBlurred: function () {
+ this.$el.val('').removeClass('active').trigger('blur');
+ this.autocomplete.close();
+ },
+ /**
+ * Call the renderFacets method with the correct arguments.
+ * This is due to the fact that change events are called with two arguments
+ * (model, options) while add, reset and remove events are called with
+ * (collection, model, options) as arguments
+ */
+ renderChangedFacets: function (model, options) {
+ this.renderFacets(undefined, model, options);
+ },
+ // it should parse the arch field of the view, instantiate the corresponding
+ // filters/fields, and put them in the correct variables:
+ // * this.search_fields is a list of all the fields,
+ // * this.filters: groups of filters
+ // * this.group_by: group_bys
+ prepare_search_inputs: function () {
+ var self = this,
+ arch = this.fields_view.arch;
+
+ var filters = [].concat.apply([], _.map(arch.children, function (item) {
+ return item.tag !== 'group' ? eval_item(item) : item.children.map(eval_item);
+ }));
+ function eval_item (item) {
+ var category = 'filters';
+ if (item.attrs.context) {
+ try {
+ var context = pyeval.eval('context', item.attrs.context);
+ if (context.group_by) {
+ category = 'group_by';
+ }
+ } catch (e) {}
+ }
+ return {
+ item: item,
+ category: category,
+ };
+ }
+ var current_group = [],
+ current_category = 'filters',
+ categories = {filters: this.filters, group_by: this.groupbys};
+
+ _.each(filters.concat({category:'filters', item: 'separator'}), function (filter) {
+ if (filter.item.tag === 'filter' && filter.category === current_category) {
+ return current_group.push(new search_inputs.Filter(filter.item, self));
+ }
+ if (current_group.length) {
+ var group = new search_inputs.FilterGroup(current_group, self);
+ categories[current_category].push(group);
+ current_group = [];
+ }
+ if (filter.item.tag === 'field') {
+ var attrs = filter.item.attrs;
+ var field = self.fields_view.fields[attrs.name];
+
+ // M2O combined with selection widget is pointless and broken in search views,
+ // but has been used in the past for unsupported hacks -> ignore it
+ if (field.type === "many2one" && attrs.widget === "selection") {
+ attrs.widget = undefined;
+ }
+
+ var Obj = core.search_widgets_registry.getAny([attrs.widget, field.type]);
+ if (Obj) {
+ self.search_fields.push(new (Obj) (filter.item, field, self));
+ }
+ }
+ if (filter.item.tag === 'filter') {
+ current_group.push(new search_inputs.Filter(filter.item, self));
+ }
+ current_category = filter.category;
+ });
+ },
+});
+
+_.extend(SearchView, {
+ SearchQuery: SearchQuery,
+ Facet: Facet,
+});
+
+return SearchView;
+
+});
diff --git a/addons/web/static/src/js/chrome/sidebar.js b/addons/web/static/src/js/chrome/sidebar.js
new file mode 100644
index 00000000000..79b06f9ee4d
--- /dev/null
+++ b/addons/web/static/src/js/chrome/sidebar.js
@@ -0,0 +1,225 @@
+odoo.define('web.Sidebar', function (require) {
+"use strict";
+
+var core = require('web.core');
+var data = require('web.data');
+var Dialog = require('web.Dialog');
+var framework = require('web.framework');
+var pyeval = require('web.pyeval');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+var Sidebar = Widget.extend({
+ init: function(parent, options) {
+ var self = this;
+ this._super(parent);
+ this.options = _.defaults(options || {}, {
+ 'editable': true
+ });
+ this.sections = options.sections || [
+ {name: 'print', label: _t('Print')},
+ {name: 'other', label: _t('Action')},
+ ];
+ this.items = options.items || {
+ print: [],
+ other: [],
+ };
+ this.fileupload_id = _.uniqueId('oe_fileupload');
+ $(window).on(this.fileupload_id, function() {
+ var args = [].slice.call(arguments).slice(1);
+ self.do_attachement_update(self.dataset, self.model_id,args);
+ framework.unblockUI();
+ });
+ },
+ start: function() {
+ var self = this;
+ this._super(this);
+ this.$el.addClass('btn-group');
+ this.redraw();
+ this.$el.on('click','.dropdown-menu li a', function(event) {
+ var section = $(this).data('section');
+ var index = $(this).data('index');
+ var item = self.items[section][index];
+ if (item.callback) {
+ item.callback.apply(self, [item]);
+ } else if (item.action) {
+ self.on_item_action_clicked(item);
+ } else if (item.url) {
+ return true;
+ }
+ event.preventDefault();
+ });
+ },
+ destroy: function() {
+ $(window).off(this.fileupload_id);
+ return this._super.apply(this, arguments);
+ },
+ redraw: function() {
+ this.$el.html(QWeb.render('Sidebar', {widget: this}));
+
+ // Hides Sidebar sections when item list is empty
+ this.$('.o_dropdown').each(function() {
+ if (!$(this).find('li').length) {
+ $(this).hide();
+ }
+ });
+ this.$("[title]").tooltip({
+ delay: { show: 500, hide: 0}
+ });
+ this.$('.o_sidebar_add_attachment .o_form_binary_form').change(this.on_attachment_changed);
+ this.$('.o_sidebar_delete_attachment').click(this.on_attachment_delete);
+ },
+ /**
+ * For each item added to the section:
+ *
+ * ``label``
+ * will be used as the item's name in the sidebar, can be html
+ *
+ * ``action``
+ * descriptor for the action which will be executed, ``action`` and
+ * ``callback`` should be exclusive
+ *
+ * ``callback``
+ * function to call when the item is clicked in the sidebar, called
+ * with the item descriptor as its first argument (so information
+ * can be stored as additional keys on the object passed to
+ * ``add_items``)
+ *
+ * ``classname`` (optional)
+ * ``@class`` set on the sidebar serialization of the item
+ *
+ * ``title`` (optional)
+ * will be set as the item's ``@title`` (tooltip)
+ *
+ * @param {String} section_code
+ * @param {Array<{label, action | callback[, classname][, title]}>} items
+ */
+ add_items: function(section_code, items) {
+ if (items) {
+ this.items[section_code].unshift.apply(this.items[section_code],items);
+ this.redraw();
+ }
+ },
+ add_toolbar: function(toolbar) {
+ var self = this;
+ _.each(['print','action','relate'], function(type) {
+ var items = toolbar[type];
+ if (items) {
+ var actions = _.map(items, function (item) {
+ return {
+ label: item.name,
+ action: item,
+ };
+ });
+ self.add_items(type === 'print' ? 'print' : 'other', actions);
+ }
+ });
+ },
+ on_item_action_clicked: function(item) {
+ var self = this;
+ self.getParent().sidebar_eval_context().done(function (sidebar_eval_context) {
+ var ids = self.getParent().getSelectedIds();
+ var domain;
+ if (self.getParent().getActiveDomain) {
+ domain = self.getParent().getActiveDomain();
+ }
+ else {
+ domain = $.Deferred().resolve(undefined);
+ }
+ if (ids.length === 0) {
+ new Dialog(this, {title: _t("Warning"), size: 'medium', $content: $("<div/>").html(_t("You must choose at least one record."))}).open();
+ return false;
+ }
+ var dataset = self.getParent().dataset;
+ var active_ids_context = {
+ active_id: ids[0],
+ active_ids: ids,
+ active_model: dataset.model,
+ };
+
+ $.when(domain).done(function (domain) {
+ if (domain !== undefined) {
+ active_ids_context.active_domain = domain;
+ }
+ var c = pyeval.eval('context',
+ new data.CompoundContext(
+ sidebar_eval_context, active_ids_context));
+
+ self.rpc("/web/action/load", {
+ action_id: item.action.id,
+ context: new data.CompoundContext(
+ dataset.get_context(), active_ids_context).eval()
+ }).done(function(result) {
+ result.context = new data.CompoundContext(
+ result.context || {}, active_ids_context)
+ .set_eval_context(c);
+ result.flags = result.flags || {};
+ result.flags.new_window = true;
+ self.do_action(result, {
+ on_close: function() {
+ // reload view
+ self.getParent().reload();
+ },
+ });
+ });
+ });
+ });
+ },
+ do_attachement_update: function(dataset, model_id, args) {
+ this.dataset = dataset;
+ this.model_id = model_id;
+ var upload_error = _.filter(args, function(attachment) {return attachment.error;});
+ if (upload_error.length) {
+ this.do_warn(_t('Uploading Error'), upload_error[0].error);
+ }
+ if (!model_id) {
+ this.on_attachments_loaded([]);
+ } else {
+ var dom = [ ['res_model', '=', dataset.model], ['res_id', '=', model_id], ['type', 'in', ['binary', 'url']] ];
+ var ds = new data.DataSetSearch(this, 'ir.attachment', dataset.get_context(), dom);
+ ds.read_slice(['name', 'url', 'type', 'create_uid', 'create_date', 'write_uid', 'write_date'], {}).done(this.on_attachments_loaded);
+ }
+ },
+ on_attachments_loaded: function(attachments) {
+ _.each(attachments,function(a) {
+ a.label = a.name;
+ if(a.type === "binary") {
+ a.url = '/web/content/' + a.id + '?download=true';
+ }
+ });
+ this.items.files = attachments;
+ this.redraw();
+ },
+ on_attachment_changed: function(e) {
+ var $e = $(e.target);
+ if ($e.val() !== '') {
+ this.$('form.o_form_binary_form').submit();
+ $e.parent().find('input[type=file]').prop('disabled', true);
+ $e.parent().find('button').prop('disabled', true).find('img, span').toggle();
+ this.$('.o_sidebar_add_attachment a').text(_t('Uploading...'));
+ framework.blockUI();
+ }
+ },
+ on_attachment_delete: function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ var self = this;
+ var $e = $(e.currentTarget);
+ var options = {
+ confirm_callback: function () {
+ new data.DataSet(this, 'ir.attachment')
+ .unlink([parseInt($e.attr('data-id'), 10)])
+ .done(function() {
+ self.do_attachement_update(self.dataset, self.model_id);
+ });
+ }
+ };
+ Dialog.confirm(this, _t("Do you really want to delete this attachment ?"), options);
+ }
+});
+
+return Sidebar;
+
+});
diff --git a/addons/web/static/src/js/chrome/systray_menu.js b/addons/web/static/src/js/chrome/systray_menu.js
new file mode 100644
index 00000000000..24fd02a3736
--- /dev/null
+++ b/addons/web/static/src/js/chrome/systray_menu.js
@@ -0,0 +1,60 @@
+odoo.define('web.SystrayMenu', function (require) {
+"use strict";
+
+var Widget = require('web.Widget');
+
+/**
+ * The SystrayMenu is the class that manage the list of icons in the top right
+ * of the menu bar.
+ */
+var SystrayMenu = Widget.extend({
+ /**
+ * This widget renders the systray menu. It creates and renders widgets
+ * pushed in instance.web.SystrayItems.
+ */
+ init: function (parent) {
+ this._super(parent);
+ this.items = [];
+ this.widgets = [];
+ this.load = $.Deferred();
+ },
+ /**
+ * @override
+ * @returns {Deferred}
+ */
+ start: function () {
+ var self = this;
+ self._super.apply(this, arguments);
+ self._loadItems();
+ $.when.apply($, self.items).always(function () {
+ self.load.resolve();
+ });
+ return self.load;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Instantiate items, using the classes located in SystrayMenu.items.
+ */
+ _loadItems: function () {
+ var self = this;
+ SystrayMenu.Items = _.sortBy(SystrayMenu.Items, function (item) {
+ return !_.isUndefined(item.prototype.sequence) ? item.prototype.sequence : 50;
+ });
+ _.each(SystrayMenu.Items, function (WidgetClass) {
+ var cur_systray_item = new WidgetClass(self);
+ self.widgets.push(cur_systray_item);
+ self.items.push(cur_systray_item.prependTo(self.$el));
+ });
+ },
+});
+
+SystrayMenu.Items = [];
+
+return SystrayMenu;
+
+});
+
diff --git a/addons/web/static/src/js/chrome/user_menu.js b/addons/web/static/src/js/chrome/user_menu.js
new file mode 100644
index 00000000000..8a33260432b
--- /dev/null
+++ b/addons/web/static/src/js/chrome/user_menu.js
@@ -0,0 +1,112 @@
+odoo.define('web.UserMenu', function (require) {
+"use strict";
+
+/**
+ * This widget is appended by the webclient to the right of the navbar.
+ * It displays the avatar and the name of the logged user (and optionally the
+ * db name, in debug mode).
+ * If clicked, it opens a dropdown allowing the user to perform actions like
+ * editing its preferences, accessing the documentation, logging out...
+ */
+
+var framework = require('web.framework');
+var Widget = require('web.Widget');
+
+
+var UserMenu = Widget.extend({
+ template: 'UserMenu',
+
+ /**
+ * @override
+ * @returns {Deferred}
+ */
+ start: function () {
+ var self = this;
+ var session = this.getSession();
+ this.$el.on('click', '.dropdown-menu li a[data-menu]', function (ev) {
+ ev.preventDefault();
+ var menu = $(this).data('menu');
+ self['_onMenu' + menu.charAt(0).toUpperCase() + menu.slice(1)]();
+ });
+ return this._super.apply(this, arguments).then(function () {
+ var $avatar = self.$('.oe_topbar_avatar');
+ if (!session.uid) {
+ $avatar.attr('src', $avatar.data('default-src'));
+ return $.when();
+ }
+ var topbar_name = session.name;
+ if (session.debug) {
+ topbar_name = _.str.sprintf("%s (%s)", topbar_name, session.db);
+ }
+ self.$('.oe_topbar_name').text(topbar_name);
+ var avatar_src = session.url('/web/image', {
+ model:'res.users',
+ field: 'image_small',
+ id: session.uid,
+ });
+ $avatar.attr('src', avatar_src);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onMenuAccount: function () {
+ var self = this;
+ this.trigger_up('clear_uncommitted_changes', {
+ callback: function () {
+ self.performRPC('/web/session/account').then(function (url) {
+ framework.redirect(url);
+ }).fail(function (result, ev){
+ ev.preventDefault();
+ framework.redirect('https://accounts.odoo.com/account');
+ });
+ },
+ });
+ },
+ /**
+ * @private
+ */
+ _onMenuDocumentation: function () {
+ window.open('https://www.odoo.com/documentation/user', '_blank');
+ },
+ /**
+ * @private
+ */
+ _onMenuLogout: function () {
+ this.trigger_up('clear_uncommitted_changes', {
+ callback: this.do_action.bind(this, 'logout'),
+ });
+ },
+ /**
+ * @private
+ */
+ _onMenuSettings: function () {
+ var self = this;
+ var session = this.getSession();
+ this.trigger_up('clear_uncommitted_changes', {
+ callback: function () {
+ self.performRPC("/web/action/load", {
+ action_id: "base.action_res_users_my",
+ }).done(function (result) {
+ result.res_id = session.uid;
+ self.do_action(result);
+ });
+ },
+ });
+ },
+ /**
+ * @private
+ */
+ _onMenuSupport: function () {
+ window.open('https://www.odoo.com/buy', '_blank');
+ },
+});
+
+return UserMenu;
+
+});
diff --git a/addons/web/static/src/js/chrome/view_manager.js b/addons/web/static/src/js/chrome/view_manager.js
new file mode 100644
index 00000000000..5b30d1a3c5c
--- /dev/null
+++ b/addons/web/static/src/js/chrome/view_manager.js
@@ -0,0 +1,610 @@
+odoo.define('web.ViewManager', function (require) {
+"use strict";
+
+var ControlPanelMixin = require('web.ControlPanelMixin');
+var core = require('web.core');
+var data = require('web.data');
+var data_manager = require('web.data_manager');
+var dom = require('web.dom');
+var pyeval = require('web.pyeval');
+var SearchView = require('web.SearchView');
+var view_registry = require('web.view_registry');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+var ViewManager = Widget.extend(ControlPanelMixin, {
+ className: "o_view_manager_content",
+ custom_events: {
+ execute_action: function(event) {
+ var data = event.data;
+ this.do_execute_action(data.action_data, data.model, data.record_id, data.on_closed)
+ .then(data.on_success, data.on_fail);
+ },
+ search: function(event) {
+ var d = event.data;
+ _.extend(this.env, this._process_search_data(d.domains, d.contexts, d.groupbys));
+ this.active_view.controller.reload(this.env);
+ },
+ switch_view: function(event) {
+ if ('res_id' in event.data) {
+ this.env.currentId = event.data.res_id;
+ }
+ var options = {};
+ if (event.data.mode) {
+ options.mode = event.data.mode;
+ }
+ this.switch_mode(event.data.view_type, options);
+ },
+ env_updated: function(event) {
+ _.extend(this.env, event.data);
+ },
+ push_state: function(event) {
+ this.do_push_state(event.data);
+ },
+ },
+ /**
+ * Called each time the view manager is attached into the DOM
+ */
+ on_attach_callback: function() {
+ this.is_in_DOM = true;
+ if (this.active_view && this.active_view.controller.on_attach_callback) {
+ this.active_view.controller.on_attach_callback();
+ }
+ },
+ /**
+ * Called each time the view manager is detached from the DOM
+ */
+ on_detach_callback: function() {
+ this.is_in_DOM = false;
+ if (this.active_view && this.active_view.controller.on_detach_callback) {
+ this.active_view.controller.on_detach_callback();
+ }
+ },
+ /**
+ * @param {Object} [dataset]
+ * @param {Array} [views] List of [view_id, view_type[, fields_view]]
+ * @param {Object} [flags] various boolean describing UI state
+ */
+ init: function(parent, dataset, views, flags, options) {
+ var self = this;
+ this._super(parent);
+
+ this.action = options && options.action || {};
+ this.action_manager = options && options.action_manager;
+ this.flags = flags || {};
+ this.dataset = dataset;
+ this.view_order = [];
+ this.views = {};
+ this.view_stack = []; // used for breadcrumbs
+ this.active_view = null;
+ this.registry = view_registry;
+ this.title = this.action.name;
+ var actionGroupBy = self.action.context.group_by;
+ if (!actionGroupBy) {
+ actionGroupBy = []
+ } else if (typeof actionGroupBy === 'string') {
+ actionGroupBy = [actionGroupBy]
+ }
+ _.each(views, function (view) {
+ var view_type = view[1] || view.view_type;
+ var View = self.registry.get(view_type); //.prototype.config.Controller;
+ if (!View) {
+ console.error("View type", "'"+view_type+"'", "is not present in the view registry.");
+ return;
+ }
+ var view_label = View.prototype.display_name;
+ var view_descr = {
+ accesskey: View.prototype.accesskey,
+ button_label: _.str.sprintf(_t('%(view_type)s view'), {'view_type': (view_label || view_type)}),
+ controller: null,
+ fields_view: view[2] || view.fields_view,
+ icon: View.prototype.icon,
+ label: view_label,
+ mobile_friendly: View.prototype.mobile_friendly,
+ multi_record: View.prototype.multi_record,
+ options: _.extend({
+ action: self.action,
+ limit: self.action.limit,
+ views: self.action.views,
+ groupBy: actionGroupBy,
+ }, self.flags, self.flags[view_type], view.options),
+ searchable: View.prototype.searchable,
+ title: self.title,
+ type: view_type,
+ view_id: view[0] || view.view_id,
+ };
+ self.view_order.push(view_descr);
+ self.views[view_type] = view_descr;
+ });
+ this.first_view = this.views[options && options.view_type]; // view to open first
+ this.default_view = this.get_default_view();
+
+ // env registers properties shared between views
+ this.env = {
+ modelName: this.dataset.model,
+ ids: this.dataset.ids.length ? this.dataset.ids : undefined,
+ currentId: this.dataset.ids.length ? this.dataset.ids[this.dataset.index] : undefined,
+ domain: undefined,
+ context: this.dataset.context,
+ groupBy: undefined,
+ };
+ },
+ willStart: function () {
+ var views_def;
+ var first_view_to_display = this.first_view || this.default_view;
+ if (!first_view_to_display.fields_view || (this.flags.search_view && !this.search_fields_view)) {
+ views_def = this.load_views();
+ }
+ return $.when(this._super(), views_def);
+ },
+ /**
+ * @return {Deferred} initial view and search view (if any) loading promise
+ */
+ start: function() {
+ var self = this;
+ var _super = this._super.bind(this, arguments);
+ var def;
+ if (this.flags.search_view) {
+ def = this.setup_search_view().then(function() {
+ // udpate domain, context and groupby in the env
+ var d = self.searchview.build_search_data();
+ _.extend(self.env, self._process_search_data(d.domains, d.contexts, d.groupbys));
+ });
+ }
+ return $.when(def).then(function() {
+ var defs = [];
+ defs.push(_super());
+
+ if (self.flags.views_switcher) {
+ self.render_switch_buttons();
+ }
+
+ // If a non multi-record first_view is given, switch to it but first push the default_view
+ // to the view_stack to complete the breadcrumbs
+ if (self.first_view && !self.first_view.multi_record && self.default_view.multi_record) {
+ self.view_stack.push(self.default_view);
+ }
+ var view_to_load = self.first_view || self.default_view;
+ var options = _.extend({}, view_to_load.options);
+ defs.push(self.switch_mode(view_to_load.type, options));
+
+ return $.when.apply($, defs).then(function() {
+ if (self.flags.on_load) {
+ self.flags.on_load(self);
+ }
+ });
+ });
+ },
+ /**
+ * Loads all missing field_views of views in this.views and the search view.
+ *
+ * @return {Deferred}
+ */
+ load_views: function () {
+ var self = this;
+ var views = [];
+ _.each(this.views, function (view) {
+ if (!view.fields_view) {
+ views.push([view.view_id, view.type]);
+ }
+ });
+ var options = {
+ action_id: this.action.id,
+ toolbar: this.flags.sidebar,
+ };
+ if (this.flags.search_view && !this.search_fields_view) {
+ options.load_filters = true;
+ var searchview_id = this.action.search_view_id && this.action.search_view_id[0];
+ views.push([searchview_id || false, 'search']);
+ }
+ var params = {
+ model: this.dataset.model,
+ context: this.dataset.get_context(),
+ views_descr: views,
+ };
+ return data_manager.load_views(params, options).then(function (fields_views) {
+ _.each(fields_views, function (fields_view, view_type) {
+ if (view_type === 'search') {
+ self.search_fields_view = fields_view;
+ } else {
+ self.views[view_type].fields_view = fields_view;
+ }
+ });
+ });
+ },
+ /**
+ * Returns the default view with the following fallbacks:
+ * - use the default_view defined in the flags, if any
+ * - use the first view in the view_order
+ *
+ * @returns {Object} the default view
+ */
+ get_default_view: function() {
+ return this.views[this.flags.default_view || this.view_order[0].type];
+ },
+ switch_mode: function(view_type, view_options) {
+ var self = this;
+ var view = this.views[view_type];
+
+ if (!view || this.currently_switching) {
+ return $.Deferred().reject();
+ } else {
+ this.currently_switching = true; // prevent overlapping switches
+ }
+
+ var old_view = this.active_view;
+ view_options = _.extend({}, view.options, view_options, this.env);
+
+ if (view_options.groupBy && !view_options.groupBy.length) {
+ var actionContext = view_options ? view_options.action.context : {};
+ var actionGroupBy = actionContext.group_by;
+ if (!actionGroupBy) {
+ actionGroupBy = []
+ } else if (typeof actionGroupBy === 'string') {
+ actionGroupBy = [actionGroupBy]
+ }
+ view_options.groupBy = actionGroupBy;
+ }
+
+ // Ensure that the fields_view has been loaded
+ var views_def;
+ if (!view.fields_view) {
+ views_def = this.load_views();
+ }
+
+ return $.when(views_def).then(function () {
+ if (view.multi_record) {
+ self.view_stack = [];
+ } else if (self.view_stack.length > 0 && !(_.last(self.view_stack).multi_record)) {
+ // Replace the last view by the new one if both are mono_record
+ self.view_stack.pop();
+ }
+ self.view_stack.push(view);
+
+ self.active_view = view;
+
+ if (!view.loaded) {
+ view.loaded = $.Deferred();
+ self.create_view(view, view_options).then(function(controller) {
+ view.controller = controller;
+ view.$fragment = $('<div>');
+ controller.appendTo(view.$fragment).done(function() {
+ // Remove the unnecessary outer div
+ view.$fragment = view.$fragment.contents();
+ view.loaded.resolve();
+ });
+ });
+ } else {
+ view.loaded = view.loaded.then(function() {
+ return view.controller.reload(view_options);
+ });
+ }
+
+ return $.when(view.loaded)
+ .then(function() {
+ self._display_view(old_view);
+ self.trigger('switch_mode', view_type, view_options);
+ }).fail(function(e) {
+ if (!(e && e.code === 200 && e.data.exception_type)) {
+ self.do_warn(_t("Error"), view.controller.display_name + _t(" view couldn't be loaded"));
+ }
+ // Restore internal state
+ self.active_view = old_view;
+ self.view_stack.pop();
+ });
+ }).always(function () {
+ self.currently_switching = false;
+ });
+ },
+ _display_view: function (old_view) {
+ var view_controller = this.active_view.controller;
+ var view_fragment = this.active_view.$fragment;
+
+ // Prepare the ControlPanel content and update it
+ var view_control_elements = this.render_view_control_elements();
+ var cp_status = {
+ active_view_selector: '.o_cp_switch_' + this.active_view.type,
+ breadcrumbs: this.action_manager && this.action_manager.get_breadcrumbs(),
+ cp_content: _.extend({}, this.searchview_elements, view_control_elements),
+ hidden: this.flags.headless,
+ searchview: this.searchview,
+ search_view_hidden: !this.active_view.searchable,
+ };
+ this.update_control_panel(cp_status);
+
+ // Detach the old view and store it
+ if (old_view && old_view !== this.active_view) {
+ // Store the scroll position
+ if (this.action_manager && this.action_manager.webclient) {
+ old_view.controller.setScrollTop(this.action_manager.webclient.getScrollTop());
+ }
+ // Do not detach ui-autocomplete elements to let jquery-ui garbage-collect them
+ var $to_detach = this.$el.contents().not('.ui-autocomplete');
+ old_view.$fragment = dom.detach([{widget: old_view.controller}], {$to_detach: $to_detach});
+ }
+
+ // If the user switches from a multi-record to a mono-record view,
+ // the action manager should be scrolled to the top.
+ if (old_view && old_view.controller.multi_record === true && view_controller.multi_record === false) {
+ view_controller.setScrollTop(0);
+ }
+
+ // Append the view fragment to this.$el
+ dom.append(this.$el, view_fragment, {
+ in_DOM: this.is_in_DOM,
+ callbacks: [{widget: view_controller}],
+ });
+ },
+ create_view: function(view, view_options) {
+ var self = this;
+ var arch = view.fields_view.arch;
+ var View = this.registry.get(arch.attrs.js_class || view.type);
+ var params = _.extend({}, view_options);
+ if (view.type === "form" && ((this.action.target === 'new' || this.action.target === 'inline') ||
+ (view_options && view_options.mode === 'edit'))) {
+ params.mode = params.initial_mode || 'edit';
+ }
+
+ view = new View(arch, view.fields_view.fields, params);
+ return view.createController(this).then(function(controller) {
+ controller.on('history_back', this, function() {
+ if (self.action_manager) self.action_manager.trigger('history_back');
+ });
+ controller.on("change:title", this, function() {
+ if (self.action_manager && !self.flags.headless) {
+ var breadcrumbs = self.action_manager.get_breadcrumbs();
+ self.update_control_panel({breadcrumbs: breadcrumbs}, {clear: false});
+ }
+ });
+ return controller;
+ });
+ },
+ select_view: function (index) {
+ var view_type = this.view_stack[index].type;
+ return this.switch_mode(view_type);
+ },
+ /**
+ * Renders the switch buttons for multi- and mono-record views and adds
+ * listeners on them, but does not append them to the DOM
+ * Sets switch_buttons.$mono and switch_buttons.$multi to send to the ControlPanel
+ */
+ render_switch_buttons: function() {
+ var self = this;
+
+ // Partition the views according to their multi-/mono-record status
+ var views = _.partition(this.view_order, function(view) {
+ return view.multi_record === true;
+ });
+ var multi_record_views = views[0];
+ var mono_record_views = views[1];
+
+ // Inner function to render and prepare switch_buttons
+ var _render_switch_buttons = function(views) {
+ if (views.length > 1) {
+ var $switch_buttons = $(QWeb.render('ViewManager.switch-buttons', {views: views}));
+ // Create bootstrap tooltips
+ _.each(views, function(view) {
+ $switch_buttons.filter('.o_cp_switch_' + view.type).tooltip();
+ });
+ // Add onclick event listener
+ $switch_buttons.filter('button').click(_.debounce(function(event) {
+ var view_type = $(event.target).data('view-type');
+ self.switch_mode(view_type);
+ }, 200, true));
+ return $switch_buttons;
+ }
+ };
+
+ // Render switch buttons but do not append them to the DOM as this will
+ // be done later, simultaneously to all other ControlPanel elements
+ this.switch_buttons = {};
+ this.switch_buttons.$multi = _render_switch_buttons(multi_record_views);
+ this.switch_buttons.$mono = _render_switch_buttons(mono_record_views);
+ },
+ /**
+ * Renders the control elements (buttons, sidebar, pager) of the current view.
+ * Fills this.active_view.control_elements dictionnary with the rendered
+ * elements and the adequate view switcher, to send to the ControlPanel.
+ * Warning: it should be called before calling do_show on the view as the
+ * sidebar is extended to listen on the load_record event triggered as soon
+ * as do_show is done (the sidebar should thus be instantiated before).
+ */
+ render_view_control_elements: function() {
+ if (!this.active_view.control_elements) {
+ var view_controller = this.active_view.controller;
+ var $buttons = this.flags.$buttons;
+ var elements = {};
+ if (!this.flags.headless) {
+ elements = {
+ $buttons: $("<div>"),
+ $sidebar: $("<div>"),
+ $pager: $("<div>"),
+ };
+ }
+ view_controller.renderButtons($buttons ? $buttons.empty() : elements.$buttons);
+ view_controller.renderSidebar(elements.$sidebar);
+ view_controller.renderPager(elements.$pager);
+ // Remove the unnecessary outer div
+ elements = _.mapObject(elements, function($node) {
+ return $node && $node.contents();
+ });
+ // Use the adequate view switcher (mono- or multi-record)
+ if (this.switch_buttons) {
+ if (this.active_view.multi_record) {
+ elements.$switch_buttons = this.switch_buttons.$multi;
+ } else {
+ elements.$switch_buttons = this.switch_buttons.$mono;
+ }
+ }
+
+ // Store the rendered elements in the active_view to allow restoring them later
+ this.active_view.control_elements = elements;
+ }
+ return this.active_view.control_elements;
+ },
+ /**
+ * Sets up the current viewmanager's search view.
+ * Sets $searchview and $searchview_buttons in searchview_elements to send to the ControlPanel
+ */
+ setup_search_view: function() {
+ var self = this;
+ var search_defaults = {};
+ var context = this.action.context || [];
+ _.each(context, function (value, key) {
+ var match = /^search_default_(.*)$/.exec(key);
+ if (match) {
+ search_defaults[match[1]] = value;
+ }
+ });
+
+ var options = {
+ hidden: this.flags.search_view === false,
+ disable_custom_filters: this.flags.search_disable_custom_filters,
+ $buttons: $("<div>"),
+ action: this.action,
+ search_defaults: search_defaults,
+ };
+ // Instantiate the SearchView, but do not append it nor its buttons to the DOM as this will
+ // be done later, simultaneously to all other ControlPanel elements
+ this.searchview = new SearchView(this, this.dataset, this.search_fields_view, options);
+
+ return $.when(this.searchview.appendTo($("<div>"))).done(function() {
+ self.searchview_elements = {};
+ self.searchview_elements.$searchview = self.searchview.$el;
+ self.searchview_elements.$searchview_buttons = self.searchview.$buttons.contents();
+ });
+ },
+ _process_search_data: function(domains, contexts, groupbys) {
+ // var controller = this.active_view.controller; // the correct view must be loaded here
+ var action_context = this.action.context || {};
+ var view_context = {}; //controller.get_context();
+ var results = pyeval.eval_domains_and_contexts({
+ domains: [this.action.domain || []].concat(domains || []),
+ contexts: [action_context, view_context].concat(contexts || []),
+ group_by_seq: groupbys || [],
+ eval_context: this.getSession().user_context,
+ });
+ if (results.error) {
+ throw new Error(_.str.sprintf(_t("Failed to evaluate search criterions")+": \n%s",
+ JSON.stringify(results.error)));
+ }
+ // this.dataset._model = new Model(this.dataset.model, results.context, results.domain);
+ // var groupby = results.group_by.length ? results.group_by : action_context.group_by;
+ // if (_.isString(groupby)) {
+ // groupby = [groupby];
+ // }
+ // if (!controller.grouped && !_.isEmpty(groupby)){
+ // this.dataset.set_sort([]);
+ // }
+ return {
+ context: results.context,
+ domain: results.domain,
+ groupBy: results.group_by,
+ };
+ },
+ do_push_state: function(state) {
+ if (this.action_manager) {
+ state.view_type = this.active_view.type;
+ this.action_manager.do_push_state(state);
+ }
+ },
+ do_load_state: function(state) {
+ var stateChanged = false;
+ if ('id' in state && state.id !== this.env.currentId) {
+ this.env.currentId = state.id;
+ stateChanged = true;
+ }
+ if (state.view_type && state.view_type !== this.active_view.type) {
+ stateChanged = true;
+ }
+ if (stateChanged) {
+ this.switch_mode(state.view_type);
+ }
+ },
+ /**
+ * Fetches and executes the action identified by ``action_data``.
+ *
+ * @param {Object} action_data the action descriptor data
+ * @param {String} action_data.name the action name, used to uniquely identify the action to find and execute it
+ * @param {String} [action_data.special=null] special action handlers (currently: only ``'cancel'``)
+ * @param {String} [action_data.type='workflow'] the action type, if present, one of ``'object'``, ``'action'`` or ``'workflow'``
+ * @param {Object} [action_data.context=null] additional action context, to add to the current context
+ * @param {DataSet} dataset a dataset object used to communicate with the server
+ * @param {Object} [record_id] the identifier of the object on which the action is to be applied
+ * @param {Function} on_closed callback to execute when dialog is closed or when the action does not generate any result (no new action)
+ */
+ do_execute_action: function (action_data, model, record_id, on_closed) {
+ var self = this;
+ var result_handler = on_closed || function () {};
+ var context = new data.CompoundContext(this.env.context, action_data.context || {});
+
+ // response handler
+ var handler = function (action) {
+ if (action && action.constructor === Object) {
+ // filter out context keys that are specific to the current action.
+ // Wrong default_* and search_default_* values will no give the expected result
+ // Wrong group_by values will simply fail and forbid rendering of the destination view
+ var ncontext = new data.CompoundContext(
+ _.object(_.reject(_.pairs(self.env.context), function(pair) {
+ return pair[0].match('^(?:(?:default_|search_default_|show_).+|.+_view_ref|group_by|group_by_no_leaf|active_id|active_ids)$') !== null;
+ }))
+ );
+ ncontext.add(action_data.context || {});
+ ncontext.add({active_model: self.env.modelName});
+ if (record_id) {
+ ncontext.add({
+ active_id: record_id,
+ active_ids: [record_id],
+ });
+ }
+ ncontext.add(action.context || {});
+ action.context = ncontext;
+ return self.do_action(action, {
+ on_close: result_handler,
+ });
+ } else {
+ self.do_action({"type":"ir.actions.act_window_close"});
+ return result_handler();
+ }
+ };
+
+ if (action_data.special === 'cancel') {
+ return handler({"type":"ir.actions.act_window_close"});
+ } else if (action_data.type === "object") {
+ var args = record_id ? [[record_id]] : [this.env.ids];
+ if (action_data.args) {
+ try {
+ // Warning: quotes and double quotes problem due to json and xml clash
+ // Maybe we should force escaping in xml or do a better parse of the args array
+ var additional_args = JSON.parse(action_data.args.replace(/'/g, '"'));
+ args = args.concat(additional_args);
+ } catch(e) {
+ console.error("Could not JSON.parse arguments", action_data.args);
+ }
+ }
+ args.push(context);
+ var dataset = new data.DataSet(this, model, this.env.context);
+ return dataset.call_button(action_data.name, args).then(handler);
+ } else if (action_data.type === "action") {
+ return data_manager.load_action(action_data.name, _.extend(pyeval.eval('context', context), {
+ active_model: this.env.modelName,
+ active_ids: this.env.ids,
+ active_id: record_id
+ })).then(handler);
+ }
+ },
+ destroy: function () {
+ if (this.control_elements) {
+ if (this.control_elements.$switch_buttons) {
+ this.control_elements.$switch_buttons.off();
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+});
+
+return ViewManager;
+
+});
diff --git a/addons/web/static/src/js/chrome/web_client.js b/addons/web/static/src/js/chrome/web_client.js
new file mode 100644
index 00000000000..17993c73638
--- /dev/null
+++ b/addons/web/static/src/js/chrome/web_client.js
@@ -0,0 +1,178 @@
+odoo.define('web.WebClient', function (require) {
+"use strict";
+
+var AbstractWebClient = require('web.AbstractWebClient');
+var core = require('web.core');
+var data_manager = require('web.data_manager');
+var framework = require('web.framework');
+var Menu = require('web.Menu');
+var Model = require('web.DataModel');
+var session = require('web.session');
+var SystrayMenu = require('web.SystrayMenu');
+var UserMenu = require('web.UserMenu');
+
+return AbstractWebClient.extend({
+ events: {
+ 'click .oe_logo_edit_admin': 'logo_edit',
+ 'click .oe_logo img': function(ev) {
+ ev.preventDefault();
+ return this.clear_uncommitted_changes().then(function() {
+ framework.redirect("/web" + (core.debug ? "?debug" : ""));
+ });
+ },
+ },
+ show_application: function() {
+ var self = this;
+
+ // Allow to call `on_attach_callback` and `on_detach_callback` when needed
+ this.action_manager.is_in_DOM = true;
+
+ this.toggle_bars(true);
+ this.set_title();
+ this.update_logo();
+
+ // Menu is rendered server-side thus we don't want the widget to create any dom
+ this.menu = new Menu(this);
+ this.menu.setElement(this.$el.parents().find('.oe_application_menu_placeholder'));
+ this.menu.on('menu_click', this, this.on_menu_action);
+
+ // Create the user menu (rendered client-side)
+ this.user_menu = new UserMenu(this);
+ var $user_menu_placeholder = $('body').find('.oe_user_menu_placeholder').show();
+ var user_menu_loaded = this.user_menu.appendTo($user_menu_placeholder);
+
+ // Create the systray menu (rendered server-side)
+ this.systray_menu = new SystrayMenu(this);
+ this.systray_menu.setElement(this.$el.parents().find('.oe_systray'));
+ var systray_menu_loaded = this.systray_menu.start();
+
+ // Start the menu once both systray and user menus are rendered
+ // to prevent overflows while loading
+ return $.when(systray_menu_loaded, user_menu_loaded).then(function() {
+ self.menu.start();
+ self.bind_hashchange();
+ });
+
+ },
+ toggle_bars: function(value) {
+ this.$('tr:has(td.navbar),.oe_leftbar').toggle(value);
+ },
+ update_logo: function(reload) {
+ var company = session.company_id;
+ var img = session.url('/web/binary/company_logo' + '?db=' + session.db + (company ? '&company=' + company : ''));
+ this.$('.o_sub_menu_logo img').attr('src', '').attr('src', img + (reload ? "#" + Date.now() : ''));
+ this.$('.oe_logo_edit').toggleClass('oe_logo_edit_admin', session.is_superuser);
+ },
+ logo_edit: function(ev) {
+ var self = this;
+ ev.preventDefault();
+ self.alive(new Model("res.users").call("read", [[session.uid], ["company_id"]])).then(function(data) {
+ self.rpc("/web/action/load", { action_id: "base.action_res_company_form" }).done(function(result) {
+ result.res_id = data[0].company_id[0];
+ result.target = "new";
+ result.views = [[false, 'form']];
+ result.flags = {
+ action_buttons: true,
+ headless: true,
+ };
+ self.action_manager.do_action(result).then(function () {
+ var form = self.action_manager.dialog_widget.views.form.controller;
+ form.on("on_button_cancel", self.action_manager, self.action_manager.dialog_stop);
+ form.on('record_saved', self, function() {
+ self.action_manager.dialog_stop();
+ self.update_logo();
+ });
+ });
+ });
+ });
+ return false;
+ },
+ bind_hashchange: function() {
+ var self = this;
+ $(window).bind('hashchange', this.on_hashchange);
+
+ var state = $.bbq.getState(true);
+ if (_.isEmpty(state) || state.action === "login") {
+ self.menu.is_bound.done(function() {
+ new Model("res.users").call("read", [[session.uid], ["action_id"]]).done(function(result) {
+ var data = result[0];
+ if(data.action_id) {
+ self.action_manager.do_action(data.action_id[0]);
+ self.menu.open_action(data.action_id[0]);
+ } else {
+ var first_menu_id = self.menu.$el.find("a:first").data("menu");
+ if(first_menu_id) {
+ self.menu.menu_click(first_menu_id);
+ }
+ }
+ });
+ });
+ } else {
+ $(window).trigger('hashchange');
+ }
+ },
+ on_hashchange: function(event) {
+ if (this._ignore_hashchange) {
+ this._ignore_hashchange = false;
+ return;
+ }
+
+ var self = this;
+ this.clear_uncommitted_changes().then(function () {
+ var stringstate = event.getState(false);
+ if (!_.isEqual(self._current_state, stringstate)) {
+ var state = event.getState(true);
+ if(!state.action && state.menu_id) {
+ self.menu.is_bound.done(function() {
+ self.menu.menu_click(state.menu_id);
+ });
+ } else {
+ state._push_me = false; // no need to push state back...
+ self.action_manager.do_load_state(state, !!self._current_state).then(function () {
+ var action = self.action_manager.get_inner_action();
+ if (action) {
+ self.menu.open_action(action.action_descr.id);
+ }
+ });
+ }
+ }
+ self._current_state = stringstate;
+ }, function () {
+ if (event) {
+ self._ignore_hashchange = true;
+ window.location = event.originalEvent.oldURL;
+ }
+ });
+ },
+ on_menu_action: function(options) {
+ var self = this;
+ return this.menu_dm.add(data_manager.load_action(options.action_id))
+ .then(function (result) {
+ return self.action_mutex.exec(function() {
+ var completed = $.Deferred();
+ $.when(self.action_manager.do_action(result, {
+ clear_breadcrumbs: true,
+ action_menu_id: self.menu.current_menu,
+ })).fail(function() {
+ self.menu.open_menu(options.previous_menu_id);
+ }).always(function() {
+ completed.resolve();
+ });
+ setTimeout(function() {
+ completed.resolve();
+ }, 2000);
+ // We block the menu when clicking on an element until the action has correctly finished
+ // loading. If something crash, there is a 2 seconds timeout before it's unblocked.
+ return completed;
+ });
+ });
+ },
+ toggle_fullscreen: function(fullscreen) {
+ this._super(fullscreen);
+ if (!fullscreen) {
+ this.menu.reflow();
+ }
+ },
+});
+
+});