diff options
Diffstat (limited to 'addons/web/static/src/js/chrome')
| -rw-r--r-- | addons/web/static/src/js/chrome/abstract_web_client.js | 55 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/action_manager.js | 1561 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/action_manager_act_window.js | 756 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/control_panel.js | 27 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/menu.js | 15 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/search_menus.js | 11 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/search_view.js | 6 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/view_manager.js | 671 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/web_client.js | 15 |
9 files changed, 1574 insertions, 1543 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 index 45a2b0b1e8c..c491297abfc 100644 --- a/addons/web/static/src/js/chrome/abstract_web_client.js +++ b/addons/web/static/src/js/chrome/abstract_web_client.js @@ -36,8 +36,8 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { toggle_fullscreen: function (event) { this.toggle_fullscreen(event.data.fullscreen); }, - current_action_updated: function (e) { - this.current_action_updated(e.data.action); + current_action_updated: function (ev) { + this.current_action_updated(ev.data.action, ev.data.controller); }, // GENERIC SERVICES // the next events are dedicated to generic services required by @@ -51,6 +51,7 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { } }, warning: '_onDisplayWarning', + load_action: '_onLoadAction', load_views: function (event) { var params = { model: event.data.modelName, @@ -66,6 +67,7 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { .load_filters(event.data.dataset, event.data.action_id) .then(event.data.on_success); }, + push_state: '_onPushState', show_effect: '_onShowEffect', // session get_session: function (event) { @@ -199,7 +201,7 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { }; }, set_action_manager: function () { - this.action_manager = new ActionManager(this, {webclient: this}); + this.action_manager = new ActionManager(this, session.user_context); return this.action_manager.appendTo(this.$('.o_main_content')); }, set_notification_manager: function () { @@ -213,11 +215,7 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { 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; + return this.action_manager.clearUncommittedChanges(); }, destroy_content: function () { _.each(_.clone(this.getChildren()), function (el) { @@ -264,7 +262,7 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { * 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); + return this.action_manager.doAction.apply(this.action_manager, arguments); }, do_reload: function () { var self = this; @@ -274,6 +272,9 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { }); }, do_push_state: function (state) { + if (!state.menu_id && this.menu) { // this.menu doesn't exist in the POS + state.menu_id = this.menu.getCurrentPrimaryMenu(); + } if ('title' in state) { this.set_title(state.title); delete state.title; @@ -304,13 +305,14 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { this.connection_notification = false; } }, - // Handler to be overwritten - current_action_updated: function () { - }, - // -------------------------------------------------------------- - // Scrolltop handling - // -------------------------------------------------------------- - getScrollTop: function () { + /** + * Handler to be overridden, called each time the UI is updated by the + * ActionManager. + * + * @param {Object} action the action of the currently displayed controller + * @param {Object} controller the currently displayed controller + */ + current_action_updated: function (action, controller) { }, //-------------------------------------------------------------- // Misc. @@ -347,6 +349,27 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { } }, /** + * Loads an action from the database given its ID. + * + * @private + * @param {OdooEvent} event + * @param {integer} event.data.actionID + * @param {Object} event.data.context + * @param {function} event.data.on_success + */ + _onLoadAction: function (event) { + data_manager + .load_action(event.data.actionID, event.data.context) + .then(event.data.on_success); + }, + /** + * @private + * @param {OdooEvent} e + */ + _onPushState: function (e) { + this.do_push_state(e.data.state); + }, + /** * Displays a visual effect (for example, a rainbowman0 * * @private diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js index e72db2c3a0c..ef2baaaad38 100644 --- a/addons/web/static/src/js/chrome/action_manager.js +++ b/addons/web/static/src/js/chrome/action_manager.js @@ -4,957 +4,886 @@ odoo.define('web.ActionManager', function (require) { /** * 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. + * The ActionManager is one of the centrepieces in the WebClient architecture. + * Its role is to makes sure that Odoo actions are properly started and + * coordinated. */ var Bus = require('web.Bus'); -var ControlPanel = require('web.ControlPanel'); +var concurrency = require('web.concurrency'); var Context = require('web.Context'); +var ControlPanel = require('web.ControlPanel'); +var config = require('web.config'); 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; +var ActionManager = Widget.extend({ + className: 'o_content', + custom_events: { + breadcrumb_clicked: '_onBreadcrumbClicked', + history_back: '_onHistoryBack', + push_state: '_onPushState', }, + /** - * @return {Object} dictionnary that will be interpreted to display the breadcrumbs + * @override + * @param {Object} [userContext={}] */ - get_breadcrumbs: function() { - return { title: this.title, action: this }; + init: function (parent, userContext) { + this._super.apply(this, arguments); + this.userContext = userContext || {}; + + // use a DropPrevious to drop previous actions when multiple actions are + // run simultaneously + this.dp = new concurrency.DropPrevious(); + + // 'actions' is an Object that registers the actions that are currently + // handled by the ActionManager (either stacked in the current window, + // or opened in dialogs) + this.actions = {}; + + // 'controllers' is an Object that registers the alive controllers + // linked registered actions, a controller being Object with keys + // (amongst others) 'jsID' (a local identifier) and 'widget' (the + // instance of the controller's widget) + this.controllers = {}; + + // 'controllerStack' is the stack of ids of the controllers currently + // displayed in the current window + this.controllerStack = []; + + // 'currentDialogController' is the current controller opened in a + // dialog (i.e. coming from an action with target='new') + this.currentDialogController = null; }, + /** - * @return {int} the number of views stacked, i.e. 0 for client functions + * @override */ - get_nb_views: function() { - return 0; + start: function () { + // AAB: temporarily instantiate a unique main ControlPanel used by + // controllers in the controllerStack + this.controlPanel = new ControlPanel(this); + var def = this.controlPanel.insertBefore(this.$el); + + return $.when(def, this._super.apply(this, arguments)); }, /** - * @return {jQuery} the DOM fragment of the action + * Called each time the action manager is attached into the DOM. */ - get_fragment: function() { - return this.$fragment; + on_attach_callback: function() { + this.isInDOM = true; + var currentController = this.getCurrentController(); + if (currentController && currentController.widget.on_attach_callback) { + currentController.widget.on_attach_callback(); + } }, /** - * @return {string} the active view, i.e. empty for client actions + * Called each time the action manager is detached from the DOM. */ - get_active_view: function() { - return ''; + on_detach_callback: function() { + this.isInDOM = false; + var currentController = this.getCurrentController(); + if (currentController && currentController.widget.on_detach_callback) { + currentController.widget.on_detach_callback(); + } }, -}); -/** - * Specialization of Action for client actions that are Widgets - */ -var WidgetAction = Action.extend({ + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** - * Initializes the WidgetAction - * Sets the title of the widget + * This function is called when the current controller is about to be + * removed from the DOM, because a new one will be pushed, or an old one + * will be restored. It ensures that the current controller can be left (for + * instance, that it has no unsaved changes). + * + * @returns {Deferred} resolved if the current controller can be left, + * rejected otherwise. */ - init: function(action, widget) { - this._super(action); - - this.widget = widget; - if (!this.widget.get('title')) { - this.widget.set('title', this.title); + clearUncommittedChanges: function () { + var currentController = this.getCurrentController(); + // AAB: with AbstractAction, the second part of the condition won't be + // necessary anymore, as there will be such a function it its API + if (currentController && currentController.widget.discardChanges) { + return currentController.widget.discardChanges(undefined, { + // AAB: get rid of this option when on_hashchange mechanism is improved + readonlyIfRealDiscard: true, + }); } - this.widget.on('change:title', this, function(widget) { - this.title = widget.get('title'); - }); + return $.when(); }, /** - * Restores WidgetAction by calling do_show on its widget - */ - restore: function() { + * This is the entry point to execute Odoo actions, given as an ID in + * database, an xml ID, a client action tag or an action descriptor. + * + * @param {number|string|Object} action the action to execute + * @param {Object} [options] + * @param {Object} [options.additional_context] additional context to be + * merged with the action's context. + * @param {boolean} [options.clear_breadcrumbs=false] set to true to clear + * the breadcrumbs history list + * @param {Function} [options.on_close] callback to be executed when the + * current action is active again (typically, if the new action is + * executed in target="new", on_close will be executed when the dialog is + * closed, if the current controller is still active) + * @param {Function} [options.on_reverse_breadcrumb] callback to be executed + * whenever an anterior breadcrumb item is clicked on + * @param {boolean} [options.pushState=true] set to false to prevent the + * ActionManager from pushing the state when the action is executed (this + * is useful when we come from a loadState()) + * @param {boolean} [options.replace_last_action=false] set to true to + * replace last part of the breadcrumbs with the action + * @return {Deferred} resolved when the action is loaded and appended to the + * DOM ; rejected if the action can't be executed (e.g. if doAction has + * been called to execute another action before this one was complete). + */ + doAction: function (action, options) { var self = this; - return $.when(this._super()).then(function() { - return self.widget.do_show(); + options = _.defaults({}, options, { + additional_context: {}, + clear_breadcrumbs: false, + on_close: function () {}, + on_reverse_breadcrumb: function () {}, + pushState: true, + replace_last_action: false, }); - }, - /** - * 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(); + // build or load an action descriptor for the given action + var def; + if (_.isString(action) && core.action_registry.contains(action)) { + // action is a tag of a client action + action = { type: 'ir.actions.client', tag: action }; + } else if (_.isNumber(action) || _.isString(action)) { + // action is an id or xml id + def = this._loadAction(action, { + active_id: options.additional_context.active_id, + active_ids: options.additional_context.active_ids, + active_model: options.additional_context.active_model, + }).then(function (result) { + action = result; + }); + } + + return this.dp.add($.when(def)).then(function () { + action.jsID = _.uniqueId('action_'); + action.pushState = options.pushState; + + // action.target 'main' is equivalent to 'current' except that it + // also clears the breadcrumbs + options.clear_breadcrumbs = action.target === 'main' || + options.clear_breadcrumbs; + + // ensure that the context and domain are evaluated + var context = new Context(self.userContext, options.additional_context, action.context); + action.context = pyeval.eval('context', context); + if (action.domain) { + action.domain = pyeval.eval('domain', action.domain, action.context); + } + + return self._handleAction(action, options); }); }, /** - * 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 + * Compatibility with client actions that are still using do_push_state. + * + * @todo: convert all of them to trigger_up('push_state') instead. + * @param {Object} state */ - set_on_reverse_breadcrumb: function(callback, scrollTop) { - this._super(callback); - this.setScrollTop(scrollTop); + do_push_state: function (state) { + this.trigger_up('push_state', {state: state}); }, /** - * Sets the scroll position of the widgets's active_view - * @todo: replace this with a generic get/set local state mechanism. - * @see getScrollTop + * Returns the action of the last controller in the controllerStack, i.e. + * the action of the currently displayed controller in the main window (not + * in a dialog), and null if there is no controller in the stack. * - * @override - * @param {integer} [scrollTop] the number of pixels to scroll + * @returns {Object|null} */ - setScrollTop: function (scrollTop) { - var activeView = this.widget.active_view; - var viewController = activeView && activeView.controller; - if (viewController) { - viewController.setScrollTop(scrollTop); - } + getCurrentAction: function () { + var controller = this.getCurrentController(); + return controller ? this.actions[controller.actionID] : null; }, /** - * Returns the current scrolling offset for the current action. We have to - * ask nicely the question to the active view, because the answer depends - * on the view. + * Returns the last controller in the controllerStack, i.e. the currently + * displayed controller in the main window (not in a dialog), and + * null if there is no controller in the stack. * - * @todo: replace this mechanism with a generic getLocalState and - * getLocalState. Scrolling behaviour is only a part of what we might want - * to restore. - * - * @override - * @returns {integer} the number of pixels the webclient is currently - * scrolled + * @returns {Object|null} */ - getScrollTop: function () { - var activeView = this.widget.active_view; - var viewController = activeView && activeView.controller; - return viewController ? viewController.getScrollTop() : 0; + getCurrentController: function () { + var currentControllerID = _.last(this.controllerStack); + return currentControllerID ? this.controllers[currentControllerID] : null; }, /** - * @return {Array} array of Objects that will be interpreted to display the breadcrumbs + * Updates the UI according to the given state, for instance, executes a new + * action, or updates the state of the current action. + * + * @param {Object} state + * @param {integer|string} [state.action] the action to execute (given its + * id or tag for client actions) + * @returns {Deferred} resolved when the UI has been updated */ - 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, + loadState: function (state) { + var action; + if (!state.action) { + return $.when(); + } + if (_.isString(state.action) && core.action_registry.contains(state.action)) { + action = { + params: state, + tag: state.action, + type: 'ir.actions.client', }; + } else { + action = state.action; + } + return this.doAction(action, { + clear_breadcrumbs: true, + pushState: false, }); }, - /** - * @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', + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + /** - * Called each time the action manager is attached into the DOM + * Appends the given controller to the DOM and restores its scroll position. + * Also updates the control panel. + * + * @private + * @param {Object} controller */ - 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(); + _appendController: function (controller) { + dom.append(this.$el, controller.widget.$el, { + in_DOM: this.isInDOM, + callbacks: [{widget: controller.widget}], + }); + + this.trigger_up('scrollTo', {offset: controller.scrollTop || 0}); + + if (!controller.widget.need_control_panel) { + this.controlPanel.do_hide(); + } else { + this.controlPanel.update({ + breadcrumbs: this._getBreadcrumbs(), + }, {clear: false}); } }, /** - * Called each time the action manager is detached from the DOM + * Closes the current dialog, if any. Because we listen to the 'closed' + * event triggered by the dialog when it is closed, this also destroys the + * embedded controller and removes the reference to the corresponding action. + * This also executes the 'on_close' handler in some cases. + * + * @private + * @param {boolean} [silent=false] if true, the 'on_close' handler won't be + * called ; this is in general the case when the current dialog is closed + * because another action is opened, so we don't want the former action + * to execute its handler as it won't be displayed anyway */ - 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(); + _closeDialog: function (silent) { + if (this.currentDialogController) { + this.currentDialogController.dialog.destroy(silent); } }, - 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); + /** + * Detaches the current controller from the DOM and stores its scroll + * position, in case we'd come back to that controller later. + * + * @private + */ + _detachCurrentController: function () { + var currentController = this.getCurrentController(); + if (currentController) { + currentController.scrollTop = this._getScrollTop(); + dom.detach([{widget: currentController.widget}]); } - this.dialog = null; }, /** - * Add a new action to the action manager + * Executes actions for which a controller has to be appended to the DOM, + * either in the main content (target="current", by default), or in a dialog + * (target="new"). * - * @param {Widget} widget typically, widgets added are openerp.web.ViewManager. The action manager uses the stack of actions to handle the breadcrumbs. - * @param {Object} action_descr new action description - * @param {Object} options - * @param options.on_reverse_breadcrumb will be called when breadcrumb is clicked on - * @param options.clear_breadcrumbs: boolean, if true, action stack is destroyed + * @private + * @param {Object} action + * @param {widget} action.controller a Widget instance to append to the DOM + * @param {string} [action.target="current"] set to "new" to render the + * controller in a dialog + * @param {Object} options @see doAction for details + * @returns {Deferred} resolved when the controller is started and appended */ - push_action: function(widget, action_descr, options) { + _executeAction: function (action, 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 || {}; + this.actions[action.jsID] = action; - // 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()]; + if (action.target === 'new') { + return this._executeActionInDialog(action, options); } - // 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); - } + return this.clearUncommittedChanges() + .then(function () { + var controller = self.controllers[action.controllerID]; + var widget = controller.widget; + // AAB: this will be moved to the Controller + if (widget.need_control_panel) { + // set the ControlPanel bus on the controller to allow it to + // communicate its status + widget.set_cp_bus(self.controlPanel.get_bus()); + } + return self._startController(controller); + }) + .then(function (controller) { + if (self.currentDialogController) { + self._closeDialog(true); + } - // 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()); - } + // store the optional 'on_reverse_breadcrumb' handler + // AAB: store it on the AbstractAction instance, and call it + // automatically when the action is restored + if (options.on_reverse_breadcrumb) { + var currentAction = self.getCurrentAction(); + if (currentAction) { + currentAction.on_reverse_breadcrumb = options.on_reverse_breadcrumb; + } + } + + // update the internal state and the DOM + self._pushController(controller, options); - // 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; + // toggle the fullscreen mode for actions in target='fullscreen' + self._toggleFullscreen(); + return action; + }) + .fail(function () { + self._removeAction(action.jsID); + }); + }, + /** + * Executes actions with attribute target='new'. Such actions are rendered + * in a dialog. + * + * @private + * @param {Object} action + * @param {Object} options @see doAction for details + * @returns {Deferred} resolved when the controller is rendered inside a + * dialog appended to the DOM + */ + _executeActionInDialog: function (action, options) { + var self = this; + var controller = this.controllers[action.controllerID]; + var widget = controller.widget; + // AAB: this will be moved to the Controller 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()); + // set the ControlPanel bus on the controller to allow it to + // communicate its status + widget.set_cp_bus(new 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(); + return this._startController(controller).then(function (controller) { + if (self.currentDialogController) { + self._closeDialog(true); } - // most of the time, the self.$el element should already be empty, - // because we detached the old action just a few line up. However, - // it may happen that it is not empty, for example when a view - // manager was unable to load a view because of a crash. In any - // case, this is done as a safety measure to avoid the 'double view' - // situation that we had when the web client was unable to recover - // from a crash. - self.$el.empty(); - - dom.append(self.$el, new_widget_fragment, { - in_DOM: self.is_in_DOM, - callbacks: [{widget: self.inner_widget}], + var dialog = new Dialog(self, _.defaults({}, options, { + buttons: [], + dialogClass: controller.className, + title: action.name, + size: action.context.dialog_size, + })); + dialog.on('closed', self, function (silent) { + self._removeAction(action.jsID); + self.currentDialogController = null; + if (silent !== true) { + options.on_close(); + } + }); + controller.dialog = dialog; + + return dialog.open().opened(function () { + dom.append(dialog.$el, widget.$el, { + in_DOM: true, + callbacks: [{widget: dialog}], + }); + // AAB: renderButtons will be a function of AbstractAction, so this + // test won't be necessary anymore + if (widget.renderButtons) { + widget.renderButtons(dialog.$footer); + } + + self.currentDialogController = controller; + + return action; }); - 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; + self._removeAction(action.jsID); }); }, - setScrollTop: function(scrollTop) { - if (this.inner_action) { - this.inner_action.setScrollTop(scrollTop); + /** + * Executes actions of type 'ir.actions.client'. + * + * @private + * @param {Object} action the description of the action to execute + * @param {string} action.tag the key of the action in the action_registry + * @param {Object} options @see doAction for details + * @returns {Deferred} resolved when the client action has been executed + */ + _executeClientAction: function (action, options) { + var self = this; + var ClientAction = core.action_registry.get(action.tag); + if (!ClientAction) { + console.error("Could not find client action " + action.tag, action); + return $.Deferred().reject(); } - }, - 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; + if (!(ClientAction.prototype instanceof Widget)) { + // the client action might be a function, which is executed and + // whose returned value might be another action to execute + var next = ClientAction(this, action); + if (next) { + return this.doAction(next, options); } + return $.when(); } - 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; + + var controllerID = _.uniqueId('controller_'); + var controller = { + actionID: action.jsID, + jsID: controllerID, + widget: new ClientAction(this, action, options), + }; + // AAB: TODO: simplify this with AbstractAction (implement a getTitle + // function that returns action.name by default, and that can be + // overriden in client actions and view controllers) + Object.defineProperty(controller, 'title', { + get: function () { + return controller.widget.get('title') || action.display_name || action.name; + }, + }); + this.controllers[controllerID] = controller; + action.controllerID = controllerID; + return this._executeAction(action, options).done(function () { + // AAB: this should be done automatically in AbstractAction, so that + // it can be overriden by actions that have specific stuff to push + // (e.g. Discuss, Views) + self._pushState(controllerID, {}); + }); }, - history_back: function() { - if (this.dialog) { - this.dialog.close(); - return; - } - var nbViews = this.inner_action.get_nb_views(); - if (nbViews > 1) { - // Stay on this action, but select the previous view - return this.select_action(this.inner_action, nbViews - 2); - } - var nbActions = this.action_stack.length; - if (nbActions > 1) { - // Select the previous action - var action = this.action_stack[nbActions - 2]; - nbViews = action.get_nb_views(); - return this.select_action(action, nbViews - 1); + /** + * Executes actions of type 'ir.actions.act_window_close', i.e. closes the + * last opened dialog. + * + * @private + * @param {Object} action + * @returns {Deferred} resolved immediately + */ + _executeCloseAction: function (action, options) { + if (!this.currentDialogController) { + options.on_close(); } - else if (nbActions === 1 && nbViews === 1) { - return this.select_action(this.action_stack[0], 0); + + this._closeDialog(); + + // display some effect (like rainbowman) on appropriate actions + if (action.effect) { + this.trigger_up('show_effect', action.effect); } - return $.Deferred().reject(); + + return $.when(); }, - select_action: function(action, index) { + /** + * Executes actions of type 'ir.actions.server'. + * + * @private + * @param {Object} action the description of the action to execute + * @param {integer} action.id the db ID of the action to execute + * @param {Object} [action.context] + * @param {Object} options @see doAction for details + * @returns {Deferred} resolved when the action has been executed + */ + _executeServerAction: function (action, options) { 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(); + var runDef = this._rpc({ + route: '/web/action/run', + params: { + action_id: action.id, + context: action.context || {}, + }, }); - }, - clear_action_stack: function(action_stack) { - _.map(action_stack || this.action_stack, function(action) { - action.destroy(); + return this.dp.add(runDef).then(function (action) { + return self.doAction(action, options); }); - if (!action_stack) { - this.action_stack = []; - this.inner_action = null; - this.inner_widget = null; + }, + /** + * Executes actions of type 'ir.actions.act_url', i.e. redirects to the + * given url. + * + * @private + * @param {Object} action the description of the action to execute + * @param {string} action.url + * @param {string} [action.target] set to 'self' to redirect in the current page, + * redirects to a new page by default + * @param {Object} options @see doAction for details + * @returns {Deferred} resolved when the redirection is done (immediately + * when redirecting to a new page) + */ + _executeURLAction: function (action, options) { + var url = action.url; + if (config.debug && url && url.length && url[0] === '/') { + url = $.param.querystring(url, {debug: config.debug}); + } + + if (action.target === 'self') { + framework.redirect(url); + return $.Deferred(); + } else { + window.open(url, '_blank'); } - this.toggle_fullscreen(); + + options.on_close(); + + return $.when(); }, - toggle_fullscreen: function() { - var is_fullscreen = _.some(this.action_stack, function(action) { - return action.action_descr.target === "fullscreen"; + /** + * Returns a description of the current stack of controllers, used to render + * the breadcrumbs. It is an array of Objects with keys 'title' (what to + * display in the breadcrumbs) and 'controllerID' (the ID of the + * corresponding controller, used to restore it when this part of the + * breadcrumbs is clicked). + * Ignores the content of the stack of controllers if the action of the + * last controller of the stack is flagged with 'no_breadcrumbs', indicating + * that the breadcrumbs should not be displayed for that action. + * + * @private + * @returns {Object[]} + */ + _getBreadcrumbs: function () { + var self = this; + var currentController = this.getCurrentController(); + var noBreadcrumbs = !currentController || + this.actions[currentController.actionID].context.no_breadcrumbs; + if (noBreadcrumbs) { + return []; + } + return _.map(this.controllerStack, function (controllerID) { + return { + title: self.controllers[controllerID].title, + controllerID: controllerID, + }; }); - this.trigger_up("toggle_fullscreen", {fullscreen: is_fullscreen}); }, - do_push_state: function(state) { - if (!this.webclient || this.dialog) { - return; + /** + * Returns an object containing information about the given controller, like + * its title, its action's id, the active_id and active_ids of the action... + * + * @private + * @param {string} controllerID + * @returns {Object} + */ + _getControllerState: function (controllerID) { + var controller = this.controllers[controllerID]; + var action = this.actions[controller.actionID]; + var state = { + title: controller.title, + }; + if (action.id) { + state.action = action.id; + } else if (action.type === 'ir.actions.client') { + state.action = action.tag; + var params = _.pick(action.params, function (v) { + return _.isString(v) || _.isNumber(v); + }); + state = _.extend(params || {}, state); } - 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 (action.context) { + var active_id = action.context.active_id; + if (active_id) { + state.active_id = active_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(','); - } + var active_ids = action.context.active_ids; + // we don't push active_ids if it's a single element array containing the active_id + // to make the url shorter in most cases + if (active_ids && !(active_ids.length === 1 && active_ids[0] === active_id)) { + state.active_ids = action.context.active_ids.join(','); } } - this.webclient.do_push_state(state); + return 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({ - route: '/web/session/get_session_action', - params: {key: state.sa}, - }) - .then(function(action) { - if (action) { - return self.do_action(action); - } - }); - } - - return $.when(action_loaded || null).then(function() { - if (self.inner_widget && self.inner_widget.do_load_state) { - return self.inner_widget.do_load_state(state, warm); + /** + * Returns the current vertical scroll position. + * + * @private + * @returns {integer} + */ + _getScrollTop: function () { + var scrollTop; + this.trigger_up('getScrollTop', { + callback: function (value) { + scrollTop = value; } }); + return scrollTop; }, /** - * Execute an OpenERP action + * Dispatches the given action to the corresponding handler to execute it, + * according to its type. This function can be overriden to extend the + * range of supported action types. * - * @param {Number|String|String|Object} action 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 Context(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 || {}); - } - + * @private + * @param {Object} action + * @param {string} action.type + * @param {Object} options + * @returns {Deferred} resolved when the action has been executed ; rejected + * if the type of action isn't supported, or if the action can't be + * executed + */ + _handleAction: function (action, options) { 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(); + switch (action.type) { + case 'ir.actions.act_url': + return this._executeURLAction(action, options); + case 'ir.actions.act_window_close': + return this._executeCloseAction(action, options); + case 'ir.actions.client': + return this._executeClientAction(action, options); + case 'ir.actions.server': + return this._executeServerAction(action, options); + default: + console.error("The ActionManager can't handle actions of type " + + action.type, action); + return $.Deferred().reject(); } + }, + /** + * Updates the internal state and the DOM with the given controller as + * current controller. + * + * @private + * @param {Object} controller + * @param {string} controller.jsID + * @param {Widget} controller.widget + * @param {Object} [options] + * @param {Object} [options.clear_breadcrumbs=false] if true, destroys all + * controllers from the controller stack before adding the given one + * @param {Object} [options.replace_last_action=false] if true, replaces the + * last controller of the controller stack by the given one + * @param {integer} [options.index] if given, pushes the controller at that + * position in the controller stack, and destroys the controllers with an + * higher index + */ + _pushController: function (controller, options) { + options = options || {}; + var self = this; - // 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, + // detach the current controller + this._detachCurrentController(); + + // empty the controller stack or replace the last controller as requested, + // destroy the removed controllers and push the new controller to the stack + var toDestroy; + if (options.clear_breadcrumbs) { + toDestroy = this.controllerStack; + this.controllerStack = []; + } else if (options.replace_last_action && this.controllerStack.length > 0) { + toDestroy = [this.controllerStack.pop()]; + } else if (options.index !== undefined) { + toDestroy = this.controllerStack.splice(options.index); + // reject from the list of controllers to destroy the one that we are + // currently pushing, or those linked to the same action as the one + // linked to the controller that we are pushing + toDestroy = _.reject(toDestroy, function (controllerID) { + return controllerID === controller.jsID || + self.controllers[controllerID].actionID === controller.actionID; }); } + this._removeControllers(toDestroy); + this.controllerStack.push(controller.jsID); - return $.when(this[type](action, options)).then(function (executor_action) { - options.on_load(executor_action); - return action; + // append the new controller to the DOM + this._appendController(controller); + + // notify the environment of the new action + this.trigger_up('current_action_updated', { + action: this.getCurrentAction(), + controller: controller, }); }, - null_action: function() { - this.dialog_stop(); - this.clear_action_stack(); - }, /** + * Pushes the given state, with additional information about the given + * controller, like the action's id and the controller's title. * - * @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 {Deferred<*>} + * @private + * @param {string} controllerID + * @param {Object} [state={}] */ - 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(); - var $dialogFooter; - if (this.dialog_widget instanceof ViewManager) { - executor.action.viewManager = this.dialog_widget; - $dialogFooter = $('<div/>'); // fake dialog footer in which view - // manager buttons will be put - _.defaults(this.dialog_widget.flags, { - $buttons: $dialogFooter, - 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()); + _pushState: function (controllerID, state) { + var controller = this.controllers[controllerID]; + if (controller) { + var action = this.actions[controller.actionID]; + if (action.target === 'new' || action.pushState === false) { + // do not push state for actions in target="new" or for actions + // that have been explicitly marked as not pushable + return; } - this.dialog_widget.setParent(this.dialog); - - var fragment = document.createDocumentFragment(); - return this.dialog_widget.appendTo(fragment).then(function () { - var def = $.Deferred(); - self.dialog.opened().then(function () { - dom.append(self.dialog.$el, fragment, { - in_DOM: true, - callbacks: [{widget: self.dialog_widget}], - }); - if ($dialogFooter) { - self.dialog.$footer.empty().append($dialogFooter.contents()); - } - if (options.state && self.dialog_widget.do_load_state) { - return self.dialog_widget.do_load_state(options.state); - } - }) - .done(def.resolve.bind(def)) - .fail(def.reject.bind(def)); - self.dialog.open(); - return def; - }).then(function () { - return executor.action; - }); + state = _.extend({}, state, this._getControllerState(controller.jsID)); + this.trigger_up('push_state', {state: state}); } - 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(); + }, + /** + * Loads an action from the database given its ID. + * + * @todo: turn this in a service (DataManager) + * @private + * @param {integer|string} action's ID or xml ID + * @param {Object} context + * @returns {Deferred<Object>} resolved with the description of the action + */ + _loadAction: function (actionID, context) { + var def = $.Deferred(); + this.trigger_up('load_action', { + actionID: actionID, + context: context, + on_success: def.resolve.bind(def), }); + return def; }, - 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); + /** + * Unlinks the given action and its controller from the internal structures + * and destroys its controllers. + * + * @private + * @param {string} actionID the id of the action to remove + */ + _removeAction: function (actionID) { + var action = this.actions[actionID]; + var controller = this.controllers[action.controllerID]; + delete this.actions[action.jsID]; + delete this.controllers[action.controllerID]; + controller.widget.destroy(); }, - ir_actions_client: function (action, options) { + /** + * Removes the given controllers and their corresponding actions. + * + * @see _removeAction + * @private + * @param {string[]} controllerIDs + */ + _removeControllers: function (controllerIDs) { 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({});} + var actionsToRemove = _.map(controllerIDs, function (controllerID) { + return self.controllers[controllerID].actionID; }); + _.each(_.uniq(actionsToRemove), this._removeAction.bind(this)); }, - ir_actions_act_window_close: function (action, options) { - if (!this.dialog) { - options.on_close(); - } - this.dialog_stop(); - // Display rainbowman on appropriate actions - if (action.effect) { - this.trigger_up('show_effect', action.effect); - } - - return $.when(); - }, - ir_actions_server: function (action, options) { + /** + * Restores a controller from the controllerStack and destroys all + * controllers stacked over the given controller (called when coming back + * using the breadcrumbs). + * + * @private + * @param {string} controllerID + * @returns {Deferred} resolved when the controller has been restored + */ + _restoreController: function (controllerID) { var self = this; - return this._rpc({ - route: '/web/action/run', - params: { - action_id: action.id, - context: action.context || {} - }, - }) - .then(function (action) { - return self.do_action(action, options); + var controller = this.controllers[controllerID]; + // AAB: AbstractAction should define a proper hook to execute code when + // it is restored (other than do_show), and it should return a deferred + var action = this.actions[controller.actionID]; + var def; + if (action.on_reverse_breadcrumb) { + def = action.on_reverse_breadcrumb(); + } + return $.when(def).then(function () { + return $.when(controller.widget.do_show()).then(function () { + var index = _.indexOf(self.controllerStack, controllerID); + self._pushController(controller, {index: index}); }); + }); + }, + /** + * Starts the controller by appending it in a document fragment, so that it + * is ready when it will be appended to the DOM. This allows to prevent + * flickering for widgets doing async stuff in willStart() or start(). + * + * @private + * @param {Object} controller + * @returns {Deferred<Object>} resolved with the controller when it is ready + */ + _startController: function (controller) { + var fragment = document.createDocumentFragment(); + return controller.widget.appendTo(fragment).then(function () { + return controller; + }); }, - ir_actions_report: function(action, options) { + /** + * Toggles the fullscreen mode if there is an action in target='fullscreen' + * in the current stack. + * + * @private + */ + _toggleFullscreen: function () { 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 = session.url('/web/report', params); - framework.unblockUI(); - $('<a href="'+url+'" target="_blank"></a>')[0].click(); - return; - } - var c = crash_manager; - return $.Deferred(function (d) { - 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(); - } - }); + var fullscreen = _.some(this.controllerStack, function (controllerID) { + var controller = self.controllers[controllerID]; + return self.actions[controller.actionID].target === 'fullscreen'; }); + this.trigger_up('toggle_fullscreen', {fullscreen: fullscreen}); }, - ir_actions_act_url: function (action, options) { - 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 + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + * @param {OdooEvent} ev.data.controllerID + */ + _onBreadcrumbClicked: function (ev) { + ev.stopPropagation(); + this._restoreController(ev.data.controllerID); + }, + /** + * Goes back in the history: if a controller is opened in a dialog, closes + * the dialog, otherwise, restores the second to last controller from the + * stack. + * + * @private + */ + _onHistoryBack: function () { + if (this.currentDialogController) { + this._closeDialog(); } else { - window.open(url, '_blank'); - options.on_close(); + var length = this.controllerStack.length; + if (length > 1) { + this._restoreController(this.controllerStack[length - 2]); + } + } + }, + /** + * Intercepts and triggers a new push_state event, with additional + * information about the given controller. + * + * @private + * @param {OdooEvent} ev + * @param {string} ev.controllerID + * @param {Object} [ev.state={}] + */ + _onPushState: function (ev) { + if (ev.target !== this) { + ev.stopPropagation(); + this._pushState(ev.data.controllerID, ev.data.state); } - return $.when(); }, }); diff --git a/addons/web/static/src/js/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js new file mode 100644 index 00000000000..a37b37fca73 --- /dev/null +++ b/addons/web/static/src/js/chrome/action_manager_act_window.js @@ -0,0 +1,756 @@ +odoo.define('web.ActWindowActionManager', function (require) { +"use strict"; + +/** + * The purpose of this file is to add the support of Odoo actions of type + * 'ir.actions.act_window' to the ActionManager. + */ + +var ActionManager = require('web.ActionManager'); +var config = require('web.config'); +var Context = require('web.Context'); +var core = require('web.core'); +var data = require('web.data'); // this will be removed at some point +var pyeval = require('web.pyeval'); +var SearchView = require('web.SearchView'); +var view_registry = require('web.view_registry'); + +var _t = core._t; + +ActionManager.include({ + custom_events: _.extend({}, ActionManager.prototype.custom_events, { + env_updated: '_onEnvUpdated', + execute_action: '_onExecuteAction', + search: '_onSearch', + switch_view: '_onSwitchView', + }), + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Overrides to handle the case where an 'ir.actions.act_window' has to be + * loaded. + * + * @override + * @param {Object} state + * @param {integer|string} [state.action] the ID or xml ID of the action to + * execute + * @param {integer} [state.active_id] + * @param {string} [state.active_ids] + * @param {integer} [state.id] + * @param {integer} [state.view_id=false] + * @param {string} [state.view_type] + */ + loadState: function (state) { + var action; + var options = {pushState: false}; + if (state.action) { + var currentController = this.getCurrentController(); + var currentAction = currentController && this.actions[currentController.actionID]; + if (currentAction && currentAction.id === state.action) { + // the action to load is already the current one, so update it + this._closeDialog(true); // there may be a currently opened dialog, close it + currentAction.env.currentId = state.id; + var viewType = state.view_type || currentController.viewType; + return this._switchController(currentAction, viewType); + } else if (!core.action_registry.contains(state.action)) { + // the action to load isn't the current one, so execute it + var context = {}; + if (state.active_id) { + context.active_id = state.active_id; + } + if (state.active_ids) { + // jQuery's BBQ plugin does some parsing on values that are valid integers + // which means that if there's only one item, it will do parseInt() on it, + // otherwise it will keep the comma seperated list as string + context.active_ids = state.active_ids.split(',').map(function (id) { + return parseInt(id, 10) || id; + }); + } else if (state.active_id) { + context.active_ids = [state.active_id]; + } + context.params = state; + action = state.action; + options = _.extend(options, { + additional_context: context, + resID: state.id, + viewType: state.view_type, + }); + } + } else if (state.model && state.id) { + action = { + res_model: state.model, + res_id: state.id, + type: 'ir.actions.act_window', + views: [[state.view_id || false, 'form']], + }; + } + if (action) { + return this.doAction(action, options); + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Instantiates a search view for a given action, and starts it so that it + * is ready to be appended to the DOM. + * + * @private + * @param {Object} action + * @returns {Deferred} resolved with the search view when it is ready + */ + _createSearchView: function (action) { + // AAB: temporarily create a dataset, until the SearchView is refactored + // and stops using it + 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; + } + + // find 'search_default_*' keys in actions's context + var searchDefaults = {}; + _.each(action.context, function (value, key) { + var match = /^search_default_(.*)$/.exec(key); + if (match) { + searchDefaults[match[1]] = value; + } + }); + var searchView = new SearchView(this, dataset, action.searchFieldsView, { + $buttons: $('<div>'), + action: action, + disable_custom_filters: action.flags.disableCustomFilters, + search_defaults: searchDefaults, + }); + + return searchView.appendTo(document.createDocumentFragment()).then(function () { + action.searchView = searchView; + return searchView; + }); + }, + /** + * Instantiates the controller for a given action and view type, and adds it + * to the list of controllers in the action. + * + * @private + * @param {Object} action + * @param {AbstractController[]} action.controllers the already created + * controllers for this action + * @param {Object[]} action.views the views available for the action, each + * one containing its fieldsView + * @param {Object} action.env + * @param {string} viewType + * @param {Object} [viewOptions] dict of options passed to the initialization + * of the controller's widget + * @param {Object} [options] + * @param {boolean} [options.lazy=false] set to true to differ the + * initialization of the controller's widget + * @returns {Deferred<Object>} resolved with the created controller + */ + _createViewController: function (action, viewType, viewOptions, options) { + var self = this; + var controllerID = _.uniqueId('controller_'); + var controller = { + actionID: action.jsID, + className: 'o_act_window', // used to remove the padding in dialogs + jsID: controllerID, + viewType: viewType, + }; + Object.defineProperty(controller, 'title', { + get: function () { + // handle the case where the widget is lazy loaded + return controller.widget ? controller.widget.getTitle() : (action.display_name || action.name); + }, + }); + this.controllers[controllerID] = controller; + + if (!options || !options.lazy) { + // build the view options from different sources + viewOptions = _.extend({ + action: action, + limit: action.limit, + }, action.flags, action.flags[viewType], viewOptions, action.env); + // pass the controllerID to the views as an hook for further + // communication with trigger_up (e.g. for 'env_updated' event) + viewOptions = _.extend(viewOptions, { controllerID: controllerID }); + + var viewDescr = _.findWhere(action.views, {type: viewType}); + var view = new viewDescr.Widget(viewDescr.fieldsView, viewOptions); + var def = $.Deferred(); + action.controllers[viewType] = def; + view.getController(this).then(function (widget) { + if (def.state() === 'rejected') { + // the deferred has been rejected meanwhile, meaning that + // the action has been removed, so simply destroy the widget + widget.destroy(); + } else { + // AAB: change this logic to stop using the properties mixin + widget.on("change:title", this, function () { + if (!action.flags.headless) { + var breadcrumbs = self._getBreadcrumbs(); + self.controlPanel.update({breadcrumbs: breadcrumbs}, {clear: false}); + } + }); + controller.widget = widget; + + def.resolve(controller); + } + }).fail(def.reject.bind(def)); + def.fail(function () { + delete self.controllers[controllerID]; + }); + } else { + action.controllers[viewType] = $.Deferred().resolve(controller); + } + + return action.controllers[viewType]; + }, + /** + * Executes actions of type 'ir.actions.act_window'. + * + * @private + * @param {Object} action the description of the action to execute + * @param {Array} action.views list of tuples [viewID, viewType] + * @param {Object} options @see doAction for details + * @param {integer} [options.resID] the current res ID + * @param {string} [options.viewType] the view to open + * @returns {Deferred} resolved when the action is appended to the DOM + */ + _executeWindowAction: function (action, options) { + var self = this; + + if (action.context.active_id || action.context.active_ids) { + // we assume that when '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; + } + action.flags = this._generateActionFlags(action); + + return this._loadViews(action).then(function (fieldsViews) { + var views = self._generateActionViews(action, fieldsViews); + action._views = action.views; // save the initial attribute + action.views = views; + if (fieldsViews.search) { + action.searchFieldsView = fieldsViews.search; + } + action.env = self._generateActionEnv(action, options); + action.controllers = {}; + + // select the first view to display + var lazyLoadFirstView = false; + var firstView = options.viewType && _.findWhere(views, {type: options.viewType}); + if (firstView) { + if (!firstView.multiRecord && views[0].multiRecord) { + lazyLoadFirstView = true; + } + } else { + firstView = views[0]; + } + if (config.device.isMobile && !firstView.isMobileFriendly) { + firstView = _.findWhere(action.views, {isMobileFriendly: true}) || firstView; + } + + var def; + if (action.flags.hasSearchView) { + def = self._createSearchView(action).then(function (searchView) { + // udpate domain, context and groupby in the env + var searchData = searchView.build_search_data(); + _.extend(action.env, self._processSearchData(action, searchData)); + }); + } + return $.when(def).then(function () { + var defs = []; + defs.push(self._createViewController(action, firstView.type)); + if (lazyLoadFirstView) { + defs.push(self._createViewController(action, views[0].type, {}, {lazy: true})); + } + return $.when.apply($, defs); + }).then(function (controller, lazyLoadedController) { + action.controllerID = controller.jsID; + return self._executeAction(action, options).done(function () { + if (lazyLoadedController) { + self.controllerStack.unshift(lazyLoadedController.jsID); + self.controlPanel.update({ + breadcrumbs: self._getBreadcrumbs(), + }, {clear: false}); + } + }); + }); + }); + }, + /** + * Generates the initial environment of a given action, which is a dict + * containing information like the model name, the domain, the context... + * That information is shared between the controllers of the corresponding + * action. + * + * @private + * @param {Object} action + * @param {Object} options + * @param {integer} [options.resID] + * @returns {Object} + */ + _generateActionEnv: function (action, options) { + var actionGroupBy = action.context.group_by || []; + if (typeof actionGroupBy === 'string') { + actionGroupBy = [actionGroupBy]; + } + var resID = options.resID || action.res_id; + return { + modelName: action.res_model, + ids: resID ? [resID] : undefined, + currentId: resID || undefined, + domain: [], + context: action.context || {}, + groupBy: actionGroupBy, + }; + }, + /** + * Generates the flags of a given action. + * + * @private + * @param {Object} action + * @returns {Object} + */ + _generateActionFlags: function (action) { + var popup = action.target === 'new'; + var inline = action.target === 'inline'; + var form = _.str.startsWith(action.view_mode, 'form'); + return _.defaults({}, action.flags, { + disableCustomFilters: action.context && action.context.search_disable_custom_filters, + footerToButtons: popup, + hasSearchView: !(popup && form) && !inline, + hasSidebar: !popup && !inline, + headless: (popup || inline) && form, + mode: (popup || inline || action.target === 'fullscreen') && 'edit', + }); + }, + /** + * Generate the description of the views of a given action. For each view, + * it generates a dict with information like the fieldsView, the view type, + * the Widget to use... + * + * @private + * @param {Object} action + * @param {Object} fieldsViews + * @returns {Object} + */ + _generateActionViews: function (action, fieldsViews) { + var views = []; + _.each(action.views, function (view) { + var viewType = view[1]; + var fieldsView = fieldsViews[viewType]; + var View = view_registry.get(fieldsView.arch.attrs.js_class || viewType); + if (View) { + views.push({ + accessKey: View.prototype.accessKey, + fieldsView: fieldsViews[viewType], + icon: View.prototype.icon, + isMobileFriendly: View.prototype.mobile_friendly, + multiRecord: View.prototype.multi_record, + type: viewType, + viewID: view[0], + Widget: View, + }); + } else { + console.error("View type '" + viewType + "' is not present in the view registry."); + } + }); + return views; + }, + /** + * Overrides to add specific information for controllers from actions of + * type 'ir.actions.act_window', like the res_model and the view_type. + * + * @override + * @private + */ + _getControllerState: function (controllerID) { + var state = this._super.apply(this, arguments); + var controller = this.controllers[controllerID]; + var action = this.actions[controller.actionID]; + if (action.type === 'ir.actions.act_window') { + state.model = action.res_model; + state.view_type = controller.viewType; + } + return state; + }, + /** + * Overrides to handle the 'ir.actions.act_window' actions. + * + * @override + * @private + */ + _handleAction: function (action, options) { + if (action.type === 'ir.actions.act_window') { + return this._executeWindowAction(action, options); + } + return this._super.apply(this, arguments); + }, + /** + * Loads the fields_views and fields for the given action. + * + * @private + * @param {Object} action + * @returns {Deferred} + */ + _loadViews: function (action) { + var options = { + action_id: action.id, + toolbar: action.flags.hasSidebar, + }; + var views = action.views.slice(); + if (action.flags.hasSearchView) { + options.load_filters = true; + var searchviewID = action.search_view_id && action.search_view_id[0]; + views.push([searchviewID || false, 'search']); + } + return this.loadViews(action.res_model, action.context, views, options); + }, + /** + * Processes the search data sent by the search view. + * + * @private + * @param {Object} action + * @param {Object} searchData + * @param {Object} [searchData.contexts=[]] + * @param {Object} [searchData.domains=[]] + * @param {Object} [searchData.groupbys=[]] + * @returns {Object} an object with keys 'context', 'domain', 'groupBy' + */ + _processSearchData: function (action, searchData) { + var contexts = searchData.contexts; + var domains = searchData.domains; + var groupbys = searchData.groupbys; + var action_context = action.context || {}; + var results = pyeval.eval_domains_and_contexts({ + domains: [action.domain || []].concat(domains || []), + contexts: [action_context].concat(contexts || []), + group_by_seq: groupbys || [], + eval_context: this.userContext, + }); + if (results.error) { + throw new Error(_.str.sprintf(_t("Failed to evaluate search criterions")+": \n%s", + JSON.stringify(results.error))); + } + return { + context: results.context, + domain: results.domain, + groupBy: results.group_by, + }; + }, + /** + * Overrides to handle the case of 'ir.actions.act_window' actions, i.e. + * destroys all controllers associated to the given action, and its search + * view. + * + * @override + * @private + */ + _removeAction: function (actionID) { + var self = this; + var action = this.actions[actionID]; + if (action.type === 'ir.actions.act_window') { + delete this.actions[action.jsID]; + _.each(action.controllers, function (controllerDef) { + controllerDef.then(function (controller) { + delete self.controllers[controller.jsID]; + if (controller.widget) { + controller.widget.destroy(); + } + }); + // reject the deferred if it is not yet resolved, so that the + // controller is correctly destroyed as soon as it is ready, and + // its reference is removed + controllerDef.reject(); + }); + if (action.searchView) { + action.searchView.destroy(); + } + } else { + this._super.apply(this, arguments); + } + }, + /** + * Overrides to handle the case where the controller to restore is from an + * 'ir.actions.act_window' action. In this case, only the controllers + * stacked over the one to restore *that are not from the same action* are + * destroyed. + * For instance, when going back to the list controller from a form + * controller of the same action using the breadcrumbs, the form controller + * isn't destroyed, as it might be reused in the future. + * + * @override + * @private + */ + _restoreController: function (controllerID) { + var self = this; + var controller = this.controllers[controllerID]; + var action = this.actions[controller.actionID]; + if (action.type === 'ir.actions.act_window') { + return this.clearUncommittedChanges().then(function () { + // AAB: this will be done directly in AbstractAction's restore + // function + var def; + if (action.on_reverse_breadcrumb) { + def = action.on_reverse_breadcrumb(); + } + return $.when(def).then(function () { + return self._switchController(action, controller.viewType); + }); + }); + } + return this._super.apply(this, arguments); + }, + /** + * Handles the switch from a controller to another inside the same window + * action. + * + * @private + * @param {Object} controller the controller to switch to + * @param {Object} [viewOptions] + * @return {Deferred} resolved when the new controller is in the DOM + */ + _switchController: function (action, viewType, viewOptions) { + var self = this; + + var newController = function () { + return self + ._createViewController(action, viewType, viewOptions) + .then(function (controller) { + // AAB: this will be moved to the Controller + var widget = controller.widget; + if (widget.need_control_panel) { + // set the ControlPanel bus on the controller to allow it to + // communicate its status + widget.set_cp_bus(self.controlPanel.get_bus()); + } + return self._startController(controller); + }); + }; + + var controllerDef = action.controllers[viewType]; + if (!controllerDef) { + controllerDef = newController(); + } else { + controllerDef = controllerDef.then(function (controller) { + if (!controller.widget) { + // lazy loaded -> load it now + return newController().done(function (newController) { + // replace the old controller (without widget) by the new one + var index = self.controllerStack.indexOf(controller.jsID); + self.controllerStack[index] = newController.jsID; + delete self.controllers[controller.jsID]; + }); + } else { + viewOptions = _.extend(viewOptions || {}, action.env); + return controller.widget.reload(viewOptions).then(function () { + return controller; + }); + } + }); + } + + return this.dp.add(controllerDef).then(function (controller) { + var view = _.findWhere(action.views, {type: viewType}); + var currentController = self.getCurrentController(); + var index; + if (currentController.actionID !== action.jsID) { + index = _.indexOf(self.controllerStack, controller.jsID); + } else if (view.multiRecord) { + // remove other controllers linked to the same action from the stack + index = _.findIndex(self.controllerStack, function (controllerID) { + return self.controllers[controllerID].actionID === action.jsID; + }); + } else if (!_.findWhere(action.views, {type: currentController.viewType}).multiRecord) { + // replace the last controller by the new one if they are from the + // same action and if they both are mono record + index = self.controllerStack.length - 1; + } + return self._pushController(controller, {index: index}); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + * @param {string} ev.data.controllerID + * @param {Object} ev.data.env + */ + _onEnvUpdated: function (ev) { + ev.stopPropagation(); + var controller = this.controllers[ev.data.controllerID]; + var action = this.actions[controller.actionID]; + _.extend(action.env, ev.data.env); + }, + /** + * Handler for event 'execute_action', which is typically called when a + * button is clicked. The button may be of type 'object' (call a given + * method of a given model) or 'action' (execute a given action). + * Alternatively, the button may have the attribute 'special', and in this + * case an 'ir.actions.act_window_close' is executed. + * + * @private + * @param {OdooEvent} ev + * @param {Object} ev.data.action_data typically, the html attributes of the + * button extended with additional information like the context + * @param {Object} [ev.data.action_data.special=false] + * @param {Object} [ev.data.action_data.type] 'object' or 'action', if set + * @param {Object} ev.data.env + * @param {function} [ev.data.on_closed] + * @param {function} [ev.data.on_fail] + * @param {function} [ev.data.on_success] + */ + _onExecuteAction: function (ev) { + ev.stopPropagation(); + var self = this; + var actionData = ev.data.action_data; + var env = ev.data.env; + var context = new Context(env.context, actionData.context || {}); + var recordID = env.currentID || null; // pyeval handles null value, not undefined + var def; + + // determine the action to execute according to the actionData + if (actionData.special) { + def = $.when({type: 'ir.actions.act_window_close'}); + } else if (actionData.type === 'object') { + // call a Python Object method, which may return an action to execute + var args = recordID ? [[recordID]] : [env.resIDs]; + if (actionData.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 additionalArgs = JSON.parse(actionData.args.replace(/'/g, '"')); + args = args.concat(additionalArgs); + } catch (e) { + console.error("Could not JSON.parse arguments", actionData.args); + } + } + args.push(context.eval()); + def = this._rpc({ + route: '/web/dataset/call_button', + params: { + args: args, + method: actionData.name, + model: env.model, + }, + }); + } else if (actionData.type === 'action') { + // execute a given action, so load it first + def = this._loadAction(actionData.name, _.extend(pyeval.eval('context', context), { + active_model: env.model, + active_ids: env.resIDs, + active_id: recordID, + })); + } + + // use the DropPrevious to prevent from executing the handler if another + // request (doAction, switchView...) has been done meanwhile ; execute + // the fail handler if the 'call_button' or 'loadAction' failed but not + // if the request failed due to the DropPrevious, + def.fail(ev.data.on_fail); + this.dp.add(def).then(function (action) { + // show effect if button have effect attribute + // rainbowman can be displayed from two places: from attribute on a button or from python + // code below handles the first case i.e 'effect' attribute on button. + var effect = false; + if (actionData.effect) { + effect = pyeval.py_eval(actionData.effect); + } + + if (action && action.constructor === Object) { + // filter out context keys that are specific to the current action, because: + // - wrong default_* and search_default_* values won't give the expected result + // - wrong group_by values will fail and forbid rendering of the destination view + var ctx = new Context( + _.object(_.reject(_.pairs(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; + })) + ); + ctx.add(actionData.context || {}); + ctx.add({active_model: env.model}); + if (recordID) { + ctx.add({ + active_id: recordID, + active_ids: [recordID], + }); + } + ctx.add(action.context || {}); + action.context = ctx; + // in case an effect is returned from python and there is already an effect + // attribute on the button, the priority is given to the button attribute + action.effect = effect || action.effect; + } else { + // if action doesn't return anything, but there is an effect + // attribute on the button, display rainbowman + action = { + effect: effect, + type: 'ir.actions.act_window_close', + }; + } + var options = {on_close: ev.data.on_closed}; + return self.doAction(action, options).then(ev.data.on_success, ev.data.on_fail); + }); + }, + /** + * Called when there is a change in the search view, so the current action's + * environment needs to be updated with the new domain, context and groupby. + * + * @private + * @param {OdooEvent} ev + */ + _onSearch: function (ev) { + ev.stopPropagation(); + // AAB: the id of the correct controller should be given in data + var currentController = this.getCurrentController(); + var action = this.actions[currentController.actionID]; + _.extend(action.env, this._processSearchData(action, ev.data)); + currentController.widget.reload(_.extend({offset: 0}, action.env)); + }, + /** + * @private + * @param {OdooEvent} ev + * @param {string} ev.data.controllerID the id of the controller that + * triggered the event + * @param {string} ev.data.viewType the type of view to switch to + * @param {integer} [ev.data.res_id] the id of the record to open (for + * mono-record views) + * @param {mode} [ev.data.mode] the mode to open, i.e. 'edit' or 'readonly' + * (only relevant for form views) + */ + _onSwitchView: function (ev) { + ev.stopPropagation(); + var viewType = ev.data.view_type; + var currentController = this.getCurrentController(); + if (currentController.jsID === ev.data.controllerID) { + // only switch to the requested view if the controller that + // triggered the request is the current controller + var action = this.actions[currentController.actionID]; + if ('res_id' in ev.data) { + action.env.currentId = ev.data.res_id; + } + var options = {}; + if (viewType === 'form' && !action.env.currentId) { + options.mode = 'edit'; + } else if (ev.data.mode) { + options.mode = ev.data.mode; + } + this._switchController(action, viewType, options); + } + }, +}); + +}); diff --git a/addons/web/static/src/js/chrome/control_panel.js b/addons/web/static/src/js/chrome/control_panel.js index 98ce7814b7a..3f85d32d2b6 100644 --- a/addons/web/static/src/js/chrome/control_panel.js +++ b/addons/web/static/src/js/chrome/control_panel.js @@ -31,7 +31,6 @@ odoo.define('web.ControlPanelMixin', function (require) { * }, * _updateControlPanel: function () { * this.update_control_panel({ - * breadcrumbs: this.action_manager.get_breadcrumbs(), * cp_content: { * $buttons: this.$buttons, * }, @@ -51,7 +50,9 @@ var ControlPanelMixin = { * @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 || {}); + if (this.cp_bus) { + this.cp_bus.trigger("update", cp_status || {}, options || {}); + } }, }; @@ -88,7 +89,6 @@ var ControlPanel = Widget.extend({ 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'), @@ -107,10 +107,6 @@ var ControlPanel = Widget.extend({ return this._super(); }, - destroy: function() { - this._clear_breadcrumbs_handlers(); - return this._super.apply(this, arguments); - }, /** * @return {Object} the Bus the ControlPanel is listening on */ @@ -143,9 +139,7 @@ var ControlPanel = Widget.extend({ // Render the breadcrumbs if (status.breadcrumbs) { - this._clear_breadcrumbs_handlers(); - this.$breadcrumbs = this._render_breadcrumbs(status.breadcrumbs); - new_cp_content.$breadcrumbs = this.$breadcrumbs; + this.$('.breadcrumb').html(this._render_breadcrumbs(status.breadcrumbs)); } // Detach control_panel old content and attach new elements @@ -239,23 +233,12 @@ var ControlPanel = Widget.extend({ .toggleClass('active', is_last); if (!is_last) { $bc.click(function () { - self.trigger("on_breadcrumb_click", bc.action, bc.index); + self.trigger_up('breadcrumb_clicked', {controllerID: bc.controllerID}); }); } 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 diff --git a/addons/web/static/src/js/chrome/menu.js b/addons/web/static/src/js/chrome/menu.js index c9d58d89c81..1e5e2922186 100644 --- a/addons/web/static/src/js/chrome/menu.js +++ b/addons/web/static/src/js/chrome/menu.js @@ -118,6 +118,8 @@ var Menu = Widget.extend({ $main_menu = $clicked_menu; } + this.current_primary_menu = $main_menu.data('menu'); + // Activate current main menu this.$el.find('.active').removeClass('active'); $main_menu.parent().addClass('active'); @@ -206,6 +208,19 @@ var Menu = Widget.extend({ ev.preventDefault(); this.menu_click($(ev.currentTarget).data('menu')); }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the id of the current primary (first level) menu. + * + * @returns {integer} + */ + getCurrentPrimaryMenu: function () { + return this.current_primary_menu; + }, }); return Menu; diff --git a/addons/web/static/src/js/chrome/search_menus.js b/addons/web/static/src/js/chrome/search_menus.js index fe2ce5f3c32..38f2bb7374c 100644 --- a/addons/web/static/src/js/chrome/search_menus.js +++ b/addons/web/static/src/js/chrome/search_menus.js @@ -30,12 +30,13 @@ return Widget.extend({ } }, }, - init: function (parent, query, target_model, action_id, filters) { + init: function (parent, query, target_model, action, filters) { this._super.apply(this,arguments); this.searchview = parent; this.query = query; this.target_model = target_model; - this.action_id = action_id; + this.action = action; + this.action_id = action.id; this.filters = {}; _.each(filters, this.add_filter.bind(this)); }, @@ -96,11 +97,7 @@ return Widget.extend({ } 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.getContext() : {}; + var view_context = this.action.env.context; var results = pyeval.eval_domains_and_contexts({ domains: search.domains, contexts: [user_context].concat(search.contexts.concat(view_context || [])), diff --git a/addons/web/static/src/js/chrome/search_view.js b/addons/web/static/src/js/chrome/search_view.js index 1e199e76dff..6fbda215814 100644 --- a/addons/web/static/src/js/chrome/search_view.js +++ b/addons/web/static/src/js/chrome/search_view.js @@ -278,7 +278,7 @@ var SearchView = Widget.extend({ 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.action = this.options.action || {}; this.search_fields = []; this.filters = []; this.groupbys = []; @@ -297,7 +297,7 @@ var SearchView = Widget.extend({ var self = this; var def; if (!this.options.disable_favorites) { - def = this.loadFilters(this.dataset, this.action_id).then(function (filters) { + def = this.loadFilters(this.dataset, this.action.id).then(function (filters) { self.favorite_filters = filters; }); } @@ -328,7 +328,7 @@ var SearchView = Widget.extend({ 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); + this.favorite_menu = new FavoriteMenu(this, this.query, this.dataset.model, this.action, this.favorite_filters); menu_defs.push(this.favorite_menu.appendTo(this.$buttons)); } } diff --git a/addons/web/static/src/js/chrome/view_manager.js b/addons/web/static/src/js/chrome/view_manager.js deleted file mode 100644 index 75029d4cc27..00000000000 --- a/addons/web/static/src/js/chrome/view_manager.js +++ /dev/null @@ -1,671 +0,0 @@ -odoo.define('web.ViewManager', function (require) { -"use strict"; - -var Context = require('web.Context'); -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.env, 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(_.extend({offset: 0}, this.env)); - }, - switch_view: function(event) { - if ('res_id' in event.data) { - this.env.currentId = event.data.res_id; - } - var options = {}; - if (event.data.view_type === 'form' && !this.env.currentId) { - options.mode = 'edit'; - } else 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); - }, - switch_to_previous_view: '_onSwitchToPreviousView', - }, - /** - * Called each time the view manager is attached into the DOM - */ - on_attach_callback: function() { - this.is_in_DOM = true; - var controller = this.active_view && this.active_view.controller; - if (controller && 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; - var controller = this.active_view && this.active_view.controller; - if (controller && 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); - } - core.bus.on('clear_uncommitted_changes', self, function(chain_callbacks) { - chain_callbacks(function() { - if (self.active_view.controller) { - return self.active_view.controller.discardChanges(undefined, { - readonlyIfRealDiscard: true, - }); - } - }); - }); - }); - }, - /** - * 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; - - // 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_options = _.extend({}, view.options, view_options, self.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; - } - 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(); - }); - }).fail(view.loaded.reject.bind(view.loaded)); - } else { - view.loaded = view.loaded.then(function() { - view_options = _.extend({}, view_options, self.env); - 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.label + _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.active_view.searchview_hidden, - }; - 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_descr, view_options) { - var self = this; - var arch = view_descr.fields_view.arch; - var View = this.registry.get(arch.attrs.js_class || view_descr.type); - var params = _.extend({}, view_options, {userContext: this.getSession().user_context}); - if (view_descr.type === "form" && ((this.action.target === 'new' || this.action.target === 'inline' || this.action.target === 'fullscreen') || - (view_options && (view_options.mode === 'edit' || view_options.context.form_view_initial_mode)))) { - params.mode = params.initial_mode || 'edit'; - } - view_descr.searchview_hidden = View.prototype.searchview_hidden; - var view = new View(view_descr.fields_view, params); - return view.getController(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 viewType = this.view_stack[index].type; - var viewOptions = {}; - if (viewType === 'form') { - // reload form views in readonly, except for inline actions (i.e. - // settings views) that stay in edit - viewOptions.mode = this.action.target === 'inline' ? 'edit' : 'readonly'; - } - return this.switch_mode(viewType, viewOptions); - }, - /** - * 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 !== '' && 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) { - return 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, closes the dialog if set - * @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 {string} [action_data.effect] if given, a visual effect (a - * rainbowman by default) will be displayed when the action is complete, - * with the string (evaluated) given as options. - * @param {Object} env - * @param {string} env.model the model of the record(s) triggering the action - * @param {integer[]} [env.resIDs] the current ids in the environment where the action is triggered - * @param {integer} [env.currentID] the id of the record triggering the action - * @param {Object} [env.context] a context to pass to the action - * @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, env, on_closed) { - var self = this; - var result_handler = on_closed || function () {}; - var context = new Context(env.context, action_data.context || {}); - // OR NULL hereunder: pyeval waits specifically for a null value, different from undefined - var recordID = env.currentID || null; - - // response handler - var handler = function (action) { - // show effect if button have effect attribute - // Rainbowman can be displayed from two places : from attribute on a button, or from python. - // Code below handles the first case i.e 'effect' attribute on button. - var effect = false; - if (action_data.effect) { - effect = pyeval.py_eval(action_data.effect); - }; - - 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 Context( - _.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: env.model}); - if (recordID) { - ncontext.add({ - active_id: recordID, - active_ids: [recordID], - }); - } - ncontext.add(action.context || {}); - action.context = ncontext; - // In case effect data is returned from python and also there is rainbow - // attribute on button, priority is given to button attribute - action.effect = effect || action.effect; - return self.do_action(action, { - on_close: result_handler, - }); - } else { - // If action doesn't return anything, but have effect - // attribute on button, display rainbowman - self.do_action({"type":"ir.actions.act_window_close", 'effect': effect}); - return result_handler(); - } - }; - - if (action_data.special) { - return handler({"type":"ir.actions.act_window_close"}); - } else if (action_data.type === "object") { - var args = recordID ? [[recordID]] : [env.resIDs]; - 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, env.model, 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: env.model, - active_ids: env.resIDs, - active_id: recordID, - })).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); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * This handler is probably called by a sub form view when the user discards - * its value. The usual result of this is that we switch back to the - * previous view (the first in our action stack). We do this in a really - * stupid way: we trigger a 'history_back' event, and the action manager - * will call this very view manager to activate the previous view. - * @todo: directly switch to previous view - * - * @private - */ - _onSwitchToPreviousView: function () { - this.trigger_up('history_back'); - } -}); - -return ViewManager; - -}); diff --git a/addons/web/static/src/js/chrome/web_client.js b/addons/web/static/src/js/chrome/web_client.js index e725aaee8f6..c39917d5118 100644 --- a/addons/web/static/src/js/chrome/web_client.js +++ b/addons/web/static/src/js/chrome/web_client.js @@ -83,7 +83,7 @@ return AbstractWebClient.extend({ action_buttons: true, headless: true, }; - self.action_manager.do_action(result).then(function () { + self.action_manager.doAction(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() { @@ -110,7 +110,7 @@ return AbstractWebClient.extend({ .done(function(result) { var data = result[0]; if(data.action_id) { - self.action_manager.do_action(data.action_id[0]); + self.action_manager.doAction(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"); @@ -140,11 +140,10 @@ return AbstractWebClient.extend({ 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(); + self.action_manager.loadState(state, !!self._current_state).then(function () { + var action = self.action_manager.getCurrentAction(); if (action) { - self.menu.open_action(action.action_descr.id); + self.menu.open_action(action.id); } }); } @@ -163,10 +162,10 @@ return AbstractWebClient.extend({ .then(function (result) { return self.action_mutex.exec(function() { var completed = $.Deferred(); - $.when(self.action_manager.do_action(result, { + self.action_manager.doAction(result, { clear_breadcrumbs: true, action_menu_id: self.menu.current_menu, - })).fail(function() { + }).fail(function() { self.menu.open_menu(options.previous_menu_id); }).always(function() { completed.resolve(); |
