diff options
| author | Géry Debongnie <ged@odoo.com> | 2017-03-05 10:05:56 +0100 |
|---|---|---|
| committer | Aaron Bohy <aab@odoo.com> | 2017-04-11 19:44:38 +0200 |
| commit | 905e01921f3c3ef43ace6fc537d1d8c0d280002c (patch) | |
| tree | 265c43ab21c5401a5d9f0f3d64d86f1ac417451c /addons/web/static/src/js/chrome | |
| parent | 54d4c73af4ad05c7640d28c1892247c9538b8017 (diff) | |
[REF] web, *: redesign all JS views
This commit introduce a full redesign of all JS views. We started
basically from scratch. The goal was to unify all the various views
under a common framework, to make them testable, to make then usable in
different conditions (in studio, or in the frontend), and to make our
lives easier.
Some important points are:
- we introduced new coding guidelines (camelCase, 80 chars width, ...)
- we have a brand new testing framework (still QUnit based)
- kanban view moved to the web addon
- calendar view (formerly web_calender) moved to web as well
- the tree view was removed
- all new code should be documented
We hope that this code is the start of a new era for the Odoo web
client, we want to have a high quality codebase, well documented, well
tested, well designed.
Work done by the framework team: mostly aab, ged, chm, dmo, qsm
Diffstat (limited to 'addons/web/static/src/js/chrome')
| -rw-r--r-- | addons/web/static/src/js/chrome/abstract_web_client.js | 291 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/action_manager.js | 898 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/control_panel.js | 248 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/loading.js | 78 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/menu.js | 215 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/pager.js | 222 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/search_filters.js | 366 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/search_inputs.js | 762 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/search_menus.js | 455 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/search_view.js | 615 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/sidebar.js | 225 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/systray_menu.js | 60 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/user_menu.js | 112 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/view_manager.js | 610 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/web_client.js | 178 |
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(); + } + }, +}); + +}); |
