diff options
| author | Aaron Bohy <aab@odoo.com> | 2017-11-17 13:14:02 +0100 |
|---|---|---|
| committer | Aaron Bohy <aab@odoo.com> | 2018-01-16 20:49:19 +0100 |
| commit | 32b8cec5361174c92c45a4975d4e02f1fb448aa2 (patch) | |
| tree | 110bd6986c2da1ed5047002b7ea40c91ea9fa5ed | |
| parent | 9bee25a9df65f657fef0f2f32a7ad823fdc7d4e1 (diff) | |
[REF] web,*: ActionManager/ViewManager refactoring
This rev. is the first part (out of 3) of the refactoring of the
ActionManager and ViewManager layer of the webclient.
The main changes are:
- there is no ViewManager anymore, its work is now handled by the
ActionManager itself, but isolated in a specific file ; this
eases a lot of things, as the breadcrumbs handling for example.
- the ActionManager code is converted to the new coding principles
and guidelines ; mainly, children widgets communicate with it by
triggering events up, and not by function calls anymore.
- the ActionManager layer is now testable, and a lot of tests have
already been written.
- the code in other addons has been adapted consequently.
What's coming next:
- introduce the 'AbstractAction' Widget, and make client actions
and view controllers inherit from it ; this widget will
implement a common API that could be used uniformly by the
ActionManager (e.g. restore(), canBeLeft(), renderButtons(),
getTitle()...).
- move the ControlPanel handling from the ActionManager to the
AbstractAction.
52 files changed, 3829 insertions, 1803 deletions
diff --git a/addons/account/static/src/js/reconciliation/reconciliation_action.js b/addons/account/static/src/js/reconciliation/reconciliation_action.js index 79724846cbc..4102780c5c0 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_action.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_action.js @@ -106,8 +106,7 @@ var StatementAction = Widget.extend(ControlPanelMixin, { var self = this; this.set("title", this.title); - var breadcrumbs = this.action_manager && this.action_manager.get_breadcrumbs() || [{ title: this.title, action: this }]; - this.update_control_panel({breadcrumbs: breadcrumbs, search_view_hidden: true}, {clear: true}); + this.update_control_panel({search_view_hidden: true}, {clear: true}); this.renderer.prependTo(self.$('.o_form_sheet')); this._renderLines(); @@ -122,18 +121,11 @@ var StatementAction = Widget.extend(ControlPanelMixin, { do_show: function () { this._super.apply(this, arguments); if (this.action_manager) { - var breadcrumbs = this.action_manager && this.action_manager.get_breadcrumbs() || [{ title: this.title, action: this }]; - while (breadcrumbs.length) { - if (breadcrumbs[breadcrumbs.length-1].action.widget === this) { - break; - } - breadcrumbs.pop(); - } - this.update_control_panel({breadcrumbs: breadcrumbs, search_view_hidden: true}, {clear: true}); + this.update_control_panel({search_view_hidden: true}, {clear: true}); this.action_manager.do_push_state({ action: this.params.tag, active_id: this.params.res_id, - }); + }); } }, diff --git a/addons/base_import/static/src/js/import_action.js b/addons/base_import/static/src/js/import_action.js index ee9e95fa5a0..513a663cfc2 100644 --- a/addons/base_import/static/src/js/import_action.js +++ b/addons/base_import/static/src/js/import_action.js @@ -126,7 +126,6 @@ var DataImport = Widget.extend(ControlPanelMixin, { self.renderButtons(); self.renderImportLink(); var status = { - breadcrumbs: self.action_manager.get_breadcrumbs(), cp_content: {$buttons: self.$buttons}, }; self.update_control_panel(status); @@ -505,10 +504,7 @@ var DataImport = Widget.extend(ControlPanelMixin, { this.exit(); }, exit: function () { - this.do_action({ - type: 'ir.actions.client', - tag: 'history_back' - }); + this.trigger_up('history_back'); }, onresults: function (event, from, to, message) { var no_messages = _.isEmpty(message); diff --git a/addons/board/static/src/js/action_manager_board_action.js b/addons/board/static/src/js/action_manager_board_action.js new file mode 100644 index 00000000000..3036ac0f328 --- /dev/null +++ b/addons/board/static/src/js/action_manager_board_action.js @@ -0,0 +1,33 @@ +odoo.define('board.ActionManager', function (require) { +"use strict"; + +/** + * The purpose of this file is to patch the ActionManager to properly generate + * the flags for the 'ir.actions.act_window' of model 'board.board'. + */ + +var ActionManager = require('web.ActionManager'); + +ActionManager.include({ + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _executeWindowAction: function (action) { + if (action.res_model === 'board.board' && action.view_mode === 'form') { + action.target = 'inline'; + _.extend(action.flags, { + hasSearchView: false, + hasSidebar: false, + headless: true, + }); + } + return this._super.apply(this, arguments); + }, +}); + +}); diff --git a/addons/board/static/src/js/dashboard.js b/addons/board/static/src/js/dashboard.js index 00b66c06312..e84529be83b 100644 --- a/addons/board/static/src/js/dashboard.js +++ b/addons/board/static/src/js/dashboard.js @@ -104,10 +104,9 @@ FormController.include({ }, /** - * We need to intercept switch_view event coming from sub views, because - * there is no view manager doing the job. Also, we don't actually want to - * switch view in dashboard, we want to do a do_action (which will open the - * record in a different breadcrumb) + * We need to intercept switch_view event coming from sub views, because we + * don't actually want to switch view in dashboard, we want to do a + * do_action (which will open the record in a different breadcrumb). * * @private * @param {OdooEvent} event @@ -126,6 +125,9 @@ FormController.include({ }); FormRenderer.include({ + custom_events: _.extend({}, FormRenderer.prototype.custom_events, { + env_updated: '_onEnvUpdated', + }), events: _.extend({}, FormRenderer.prototype.events, { 'click .oe_dashboard_column .oe_fold': '_onFoldClick', 'click .oe_dashboard_link_change_layout': '_onChangeLayout', @@ -340,6 +342,16 @@ FormRenderer.include({ }); }, /** + * Stops the propagation of 'update_env' events triggered by the controllers + * instantiated by the dashboard. + * + * @override + * @private + */ + _onEnvUpdated: function (event) { + event.stopPropagation(); + }, + /** * @private * @param {MouseEvent} event */ diff --git a/addons/board/static/src/js/favorite_menu.js b/addons/board/static/src/js/favorite_menu.js index a4d7aa11022..77cd3df4967 100644 --- a/addons/board/static/src/js/favorite_menu.js +++ b/addons/board/static/src/js/favorite_menu.js @@ -7,7 +7,6 @@ var core = require('web.core'); var Domain = require('web.Domain'); var FavoriteMenu = require('web.FavoriteMenu'); var pyeval = require('web.pyeval'); -var ViewManager = require('web.ViewManager'); var _t = core._t; var QWeb = core.qweb; @@ -23,11 +22,7 @@ FavoriteMenu.include({ if(this.action_id === undefined) { return this._super(); } - var am = this.findAncestor(function (a) { - return a instanceof ActionManager; - }); - if (am && am.get_inner_widget() instanceof ViewManager) { - this.view_manager = am.get_inner_widget(); + if (this.action.type === 'ir.actions.act_window') { this.add_to_dashboard_available = true; this.$('.o_favorites_menu').append(QWeb.render('SearchView.addtodashboard')); this.$add_to_dashboard = this.$('.o_add_to_dashboard'); @@ -71,7 +66,13 @@ FavoriteMenu.include({ context.add({ group_by: pyeval.eval('groupbys', search_data.groupbys || []) }); - context.add(this.view_manager.active_view.controller.getContext()); + // AAB: trigger_up an event that will be intercepted by the controller, + // as soon as the controller is the parent of the control panel + var am = this.findAncestor(function (a) { + return a instanceof ActionManager; + }); + var controller = am.getCurrentController(); + context.add(controller.widget.getContext()); var c = pyeval.eval('context', context); for (var k in c) { if (c.hasOwnProperty(k) && /^search_default_/.test(k)) { @@ -88,7 +89,7 @@ FavoriteMenu.include({ action_id: self.action_id || false, context_to_save: c, domain: domain, - view_mode: self.view_manager.active_view.type, + view_mode: controller.viewType, name: name, }, }) diff --git a/addons/board/views/board_templates.xml b/addons/board/views/board_templates.xml index 7e8b8d9c4a1..ac87af8ef26 100644 --- a/addons/board/views/board_templates.xml +++ b/addons/board/views/board_templates.xml @@ -3,6 +3,7 @@ <template id="assets_backend" name="board assets" inherit_id="web.assets_backend"> <xpath expr="." position="inside"> <link rel="stylesheet" href="/board/static/src/less/dashboard.less"/> + <script type="text/javascript" src="/board/static/src/js/action_manager_board_action.js"></script> <script type="text/javascript" src="/board/static/src/js/dashboard.js"></script> <script type="text/javascript" src="/board/static/src/js/favorite_menu.js"></script> </xpath> diff --git a/addons/document/static/tests/document_tests.js b/addons/document/static/tests/document_tests.js index 9fd1e7d25ae..338f3e75581 100644 --- a/addons/document/static/tests/document_tests.js +++ b/addons/document/static/tests/document_tests.js @@ -56,7 +56,7 @@ odoo.define('document.tests', function (require) { '</sheet>' + '</form>', res_id: 1, - viewOptions: {sidebar: true}, + viewOptions: {hasSidebar: true}, mockRPC: function (route, args) { if (args.method === 'search_read' && args.model === 'ir.attachment') { return $.when(this.data['ir.attachment'].records); diff --git a/addons/google_spreadsheet/static/src/js/search.js b/addons/google_spreadsheet/static/src/js/search.js index a9f8e392d0a..20b7c804f33 100644 --- a/addons/google_spreadsheet/static/src/js/search.js +++ b/addons/google_spreadsheet/static/src/js/search.js @@ -7,27 +7,28 @@ var data = require('web.data'); var Domain = require('web.Domain'); var FavoriteMenu = require('web.FavoriteMenu'); var pyeval = require('web.pyeval'); -var ViewManager = require('web.ViewManager'); var QWeb = core.qweb; FavoriteMenu.include({ start: function () { this._super(); - var am = this.findAncestor(function (a) { - return a instanceof ActionManager; - }); - if (am && am.get_inner_widget() instanceof ViewManager) { - this.view_manager = am.get_inner_widget(); + if (this.action.type === 'ir.actions.act_window') { this.$('.o_favorites_menu').append(QWeb.render('SearchView.addtogooglespreadsheet')); this.$('.add-to-spreadsheet').click(this.add_to_spreadsheet.bind(this)); } }, add_to_spreadsheet: function () { + // AAB: trigger_up an event that will be intercepted by the controller, + // as soon as the controller is the parent of the control panel + var am = this.findAncestor(function (a) { + return a instanceof ActionManager; + }); + var controller = am.getCurrentController(); var sv_data = this.searchview.build_search_data(), model = this.searchview.dataset.model, - list_view = this.view_manager.views.list, - list_view_id = list_view ? list_view.view_id : false, + list_view = _.findWhere(controller.widget.actionViews, {type: 'list'}), + list_view_id = list_view ? list_view.viewID : false, domain = [], groupbys = pyeval.eval('groupbys', sv_data.groupbys).join(" "), ds = new data.DataSet(this, 'google.drive.config'); diff --git a/addons/mail/static/src/js/chat_mixin.js b/addons/mail/static/src/js/chat_mixin.js index d25426e3db7..ec1b06a805e 100644 --- a/addons/mail/static/src/js/chat_mixin.js +++ b/addons/mail/static/src/js/chat_mixin.js @@ -46,7 +46,7 @@ var ChatMixin = { options: options, callback: function(messages) { result = messages;} }); - return result ? result : $.when(); + return result ? result : $.when([]); }, _joinChannel: function(channel_id) { var result; diff --git a/addons/mail/static/src/js/client_action.js b/addons/mail/static/src/js/client_action.js index 2b5b342787b..d2299259486 100644 --- a/addons/mail/static/src/js/client_action.js +++ b/addons/mail/static/src/js/client_action.js @@ -663,7 +663,6 @@ var ChatAction = Widget.extend(ControlPanelMixin, { */ _updateControlPanel: function () { this.update_control_panel({ - breadcrumbs: this.action_manager.get_breadcrumbs(), cp_content: { $buttons: this.$buttons, $searchview: this.searchview.$el, @@ -905,6 +904,7 @@ var ChatAction = Widget.extend(ControlPanelMixin, { * @param {OdooEvent} */ _onSearch: function (event) { + event.stopPropagation(); var session = this.getSession(); var result = pyeval.eval_domains_and_contexts({ domains: event.data.domains, diff --git a/addons/sale_timesheet/static/src/js/timesheet_plan.js b/addons/sale_timesheet/static/src/js/timesheet_plan.js index f35ecae4529..3d98d97a6b7 100644 --- a/addons/sale_timesheet/static/src/js/timesheet_plan.js +++ b/addons/sale_timesheet/static/src/js/timesheet_plan.js @@ -86,7 +86,6 @@ var PlanAction = Widget.extend(ControlPanelMixin, { }, update_cp: function () { this.update_control_panel({ - breadcrumbs: this.action_manager.get_breadcrumbs(), cp_content: { $buttons: this.$buttons, $searchview: this.searchview.$el, diff --git a/addons/stock/static/src/js/stock_traceability_report_backend.js b/addons/stock/static/src/js/stock_traceability_report_backend.js index dfe23c9036a..d342742122c 100644 --- a/addons/stock/static/src/js/stock_traceability_report_backend.js +++ b/addons/stock/static/src/js/stock_traceability_report_backend.js @@ -68,7 +68,6 @@ var stock_report_generic = Widget.extend(ControlPanelMixin, { this.renderButtons(); } var status = { - breadcrumbs: this.actionManager.get_breadcrumbs(), cp_content: {$buttons: this.$buttons}, }; return this.update_control_panel(status); 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(); diff --git a/addons/web/static/src/js/core/dialog.js b/addons/web/static/src/js/core/dialog.js index 0bc370f782a..d5be248b16e 100644 --- a/addons/web/static/src/js/core/dialog.js +++ b/addons/web/static/src/js/core/dialog.js @@ -165,12 +165,12 @@ var Dialog = Widget.extend({ this.destroy(); }, - destroy: function (reason) { + destroy: function (arg) { // Need to trigger before real destroy but if 'closed' handler destroys // the widget again, we want to avoid infinite recursion if (!this.__closed) { this.__closed = true; - this.trigger("closed", reason); + this.trigger("closed", arg); } if (this.isDestroyed()) { diff --git a/addons/web/static/src/js/core/misc.js b/addons/web/static/src/js/core/misc.js index 99c21c14038..be0f012752c 100644 --- a/addons/web/static/src/js/core/misc.js +++ b/addons/web/static/src/js/core/misc.js @@ -140,17 +140,6 @@ function Home (parent, action) { } core.action_registry.add("home", Home); -/** - * Client action to go back in breadcrumb history. - * If can't go back in history stack, will go back to home. - */ -function HistoryBack (parent) { - parent.history_back().fail(function () { - Home(parent); - }); -} -core.action_registry.add("history_back", HistoryBack); - function login() { redirect('/web/login'); } diff --git a/addons/web/static/src/js/report/client_action.js b/addons/web/static/src/js/report/client_action.js index cf6f4c94dfe..2fd2ccd44b3 100644 --- a/addons/web/static/src/js/report/client_action.js +++ b/addons/web/static/src/js/report/client_action.js @@ -94,7 +94,6 @@ var ReportAction = Widget.extend(ControlPanelMixin, { _update_control_panel: function () { this.update_control_panel({ - breadcrumbs: this.action_manager.get_breadcrumbs(), cp_content: { $buttons: this.$buttons, }, diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index 3bdf8ab08ca..150b4803a7a 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -12,12 +12,16 @@ odoo.define('web.AbstractController', function (require) { * reading localstorage, ...) has to go through the controller. */ +var ControlPanelMixin = require('web.ControlPanelMixin'); +var core = require('web.core'); var Widget = require('web.Widget'); +var QWeb = core.qweb; -var AbstractController = Widget.extend({ +var AbstractController = Widget.extend(ControlPanelMixin, { custom_events: { open_record: '_onOpenRecord', + switch_view: '_onSwitchView', }, events: { 'click a[type="action"]': '_onActionClicked', @@ -30,8 +34,15 @@ var AbstractController = Widget.extend({ * @param {AbstractRenderer} renderer * @param {object} params * @param {string} params.modelName + * @param {string} [params.controllerID] an id to ease the communication + * with upstream components * @param {any} [params.handle] a handle that will be given to the model (some id) * @param {any} params.initialState the initialState + * @param {boolean} params.isMultiRecord + * @param {Object[]} params.actionViews + * @param {string} params.viewType + * @param {boolean} params.withControlPanel set to false to hide the + * ControlPanel */ init: function (parent, model, renderer, params) { this._super.apply(this, arguments); @@ -40,7 +51,23 @@ var AbstractController = Widget.extend({ this.modelName = params.modelName; this.handle = params.handle; this.activeActions = params.activeActions; + this.controllerID = params.controllerID; this.initialState = params.initialState; + + // those arguments are temporary, they won't be necessary as soon as the + // ControlPanel will be handled by the View + this.displayName = params.displayName; + this.isMultiRecord = params.isMultiRecord; + this.searchable = params.searchable; + this.searchView = params.searchView; + this.searchViewHidden = params.searchViewHidden; + this.actionViews = params.actionViews; + this.viewType = params.viewType; + this.withControlPanel = params.withControlPanel !== false; + // override this.need_control_panel so that the ActionManager doesn't + // update the control panel when it isn't visible (this is a temporary + // hack that can be removed as soon as the CP'll be handled by the view) + this.need_control_panel = this.withControlPanel; }, /** * Simply renders and updates the url. @@ -48,10 +75,19 @@ var AbstractController = Widget.extend({ * @returns {Deferred} */ start: function () { + var self = this; + + this.$el.addClass('o_view_controller'); + + // render the ControlPanel elements (buttons, pager, sidebar...) + this.controlPanelElements = this._renderControlPanelElements(); + return $.when( this._super.apply(this, arguments), this.renderer.appendTo(this.$el) - ).then(this._update.bind(this, this.initialState)); + ).then(function () { + self._update(self.initialState); + }); }, /** * @override @@ -60,6 +96,9 @@ var AbstractController = Widget.extend({ if (this.$buttons) { this.$buttons.off(); } + if (this.controlPanelElements && this.controlPanelElements.$switch_buttons) { + this.controlPanelElements.$switch_buttons.off(); + } return this._super.apply(this, arguments); }, @@ -70,7 +109,7 @@ var AbstractController = Widget.extend({ /** * Discards the changes made on the record associated to the given ID, or * all changes made by the current controller if no recordID is given. For - * example, when the user open the 'home' screen, the view manager will call + * example, when the user opens the 'home' screen, the action manager calls * this method on the active view to make sure it is ok to open the home * screen (and lose all current state). * @@ -96,23 +135,15 @@ var AbstractController = Widget.extend({ return {}; }, /** - * @see setScrollTop - * @returns {number} - */ - getScrollTop: function () { - return this.scrollTop; - }, - /** * Returns a title that may be displayed in the breadcrumb area. For * example, the name of the record. * - * Note: this seems wrong right now, it should not be implemented, we have - * no guarantee that there is a display_name variable in a controller. + * note: this will be moved to AbstractAction * * @returns {string} */ getTitle: function () { - return this.display_name; + return this.displayName; }, /** * The use of this method is discouraged. It is still snakecased, because @@ -136,12 +167,9 @@ var AbstractController = Widget.extend({ return this.update(params || {}); }, /** - * Most likely called by the view manager, this method is responsible for - * adding buttons in the control panel (buttons such as save/discard/...) - * - * Note that there is no guarantee that this method will be called. The - * controller is supposed to work even without a view manager, for example - * in the frontend (odoo frontend = public website) + * Renders the buttons to append, in most cases, to the control panel (in + * the bottom left corner). When the view is rendered in a dialog, those + * buttons might be moved to the dialog's footer. * * @param {jQuery Node} $node */ @@ -153,7 +181,7 @@ var AbstractController = Widget.extend({ * controller can actually render whatever he wants in the pager zone. If * your view does not want a pager, just let this method empty. * - * @param {Query Node} $node + * @param {jQuery Node} $node */ renderPager: function ($node) { }, @@ -161,21 +189,11 @@ var AbstractController = Widget.extend({ * Same as renderPager, but for the 'sidebar' zone (the zone with the menu * dropdown in the control panel next to the buttons) * - * @param {Query Node} $node + * @param {jQuery Node} $node */ renderSidebar: function ($node) { }, /** - * Not sure about this one, it probably needs to be reworked, maybe merged - * in get/set local state methods. - * - * @see getScrollTop - * @param {number} scrollTop - */ - setScrollTop: function (scrollTop) { - this.scrollTop = scrollTop; - }, - /** * This is the main entry point for the controller. Changes from the search * view arrive in this method, and internal changes can sometimes also call * this method. It is basically the way everything notifies the controller @@ -219,7 +237,68 @@ var AbstractController = Widget.extend({ * world */ _pushState: function (state) { - this.trigger_up('push_state', state || {}); + this.trigger_up('push_state', { + controllerID: this.controllerID, + state: state || {}, + }); + }, + /** + * Renders the control elements (buttons, pager and sidebar) of the current + * view. + * + * @private + * @returns {Object} an object containing the control panel jQuery elements + */ + _renderControlPanelElements: function () { + var elements = {}; + + if (this.withControlPanel) { + elements = { + $buttons: $('<div>'), + $sidebar: $('<div>'), + $pager: $('<div>'), + }; + + this.renderButtons(elements.$buttons); + this.renderSidebar(elements.$sidebar); + this.renderPager(elements.$pager); + // remove the unnecessary outer div + elements = _.mapObject(elements, function($node) { + return $node && $node.contents(); + }); + elements.$switch_buttons = this._renderSwitchButtons(); + } + + return elements; + }, + /** + * Renders the switch buttons and binds listeners on them. + * + * @private + * @returns {jQuery} + */ + _renderSwitchButtons: function () { + var self = this; + var views = _.filter(this.actionViews, {multiRecord: this.isMultiRecord}); + + if (views.length <= 1) { + return $(); + } + + var $switchButtons = $(QWeb.render('ControlPanel.SwitchButtons', { + views: views, + })); + // create bootstrap tooltips + _.each(views, function (view) { + $switchButtons.filter('.o_cp_switch_' + view.type).tooltip(); + }); + // add onclick event listener + $switchButtons.filter('button').click(_.debounce(function (event) { + var viewType = $(event.target).data('view-type'); + self.trigger_up('switch_view', {view_type: viewType}); + }, 200, true)); + + return $switchButtons; }, /** * This method is called after each update or when the start method is @@ -235,6 +314,22 @@ var AbstractController = Widget.extend({ * @returns {Deferred} */ _update: function (state) { + // AAB: update the control panel -> this will be moved elsewhere at some point + var cpContent = _.extend({}, this.controlPanelElements); + if (this.searchView) { + _.extend(cpContent, { + $searchview: this.searchView.$el, + $searchview_buttons: this.searchView.$buttons.contents(), + }); + } + this.update_control_panel({ + active_view_selector: '.o_cp_switch_' + this.viewType, + cp_content: cpContent, + hidden: !this.withControlPanel, + searchview: this.searchView, + search_view_hidden: !this.searchable || this.searchviewHidden, + }); + this._pushState(); return $.when(); }, @@ -249,7 +344,7 @@ var AbstractController = Widget.extend({ * * Note: this method seems wrong, it relies on the model being a basic model, * to get the res_id. It should receive the res_id in the event data - * @todo move this to basic controller? or view manager + * @todo move this to basic controller? * * @private * @param {OdooEvent} event @@ -278,6 +373,15 @@ var AbstractController = Widget.extend({ event.preventDefault(); this.do_action(event.target.name); }, + /** + * Intercepts the 'switch_view' event to add the controllerID into the data, + * and lets the event bubble up. + * + * @param {OdooEvent} event + */ + _onSwitchView: function (event) { + event.data.controllerID = this.controllerID; + }, }); diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js index 2a1ba16a33e..58b2c85a670 100644 --- a/addons/web/static/src/js/views/abstract_view.js +++ b/addons/web/static/src/js/views/abstract_view.js @@ -67,11 +67,18 @@ var AbstractView = Class.extend({ * @param {string} params.modelName The actual model name * @param {Object} params.context * @param {number} [params.count] + * @param {string} [params.controllerID] * @param {string[]} params.domain * @param {string[]} params.groupBy * @param {number} [params.currentId] * @param {number[]} [params.ids] + * @param {boolean} [params.withControlPanel] + * @param {boolean} [params.action.flags.headless] + * @param {string} [params.action.display_name] + * @param {string} [params.action.name] * @param {string} [params.action.help] + * @param {string} [params.action.jsID] + * @param {boolean} [params.action.views] */ init: function (viewInfo, params) { this.rendererParams = { @@ -87,7 +94,23 @@ var AbstractView = Class.extend({ delete: viewInfo.arch.attrs.delete ? JSON.parse(viewInfo.arch.attrs.delete) : true, duplicate: viewInfo.arch.attrs.duplicate ? JSON.parse(viewInfo.arch.attrs.duplicate) : true, }, + controllerID: params.controllerID, }; + // AAB: these params won't be necessary as soon as the ControlPanel will + // be instantiated by the View + this.controllerParams.displayName = params.action && (params.action.display_name || params.action.name); + this.controllerParams.isMultiRecord = this.multi_record; + this.controllerParams.searchable = this.searchable; + this.controllerParams.searchView = params.action && params.action.searchView; + this.controllerParams.searchViewHidden = this.searchview_hidden; // AAB: use searchable instead where it is used? + this.controllerParams.actionViews = params.action ? params.action.views : []; + this.controllerParams.viewType = this.viewType; + this.controllerParams.withControlPanel = true; + if (params.action && params.action.flags) { + this.controllerParams.withControlPanel = !params.action.flags.headless; + } else if ('withControlPanel' in params) { + this.controllerParams.withControlPanel = params.withControlPanel; + } this.loadParams = { context: params.context, @@ -133,7 +156,7 @@ var AbstractView = Class.extend({ * load itself, and the renderer needs the data in its constructor. * * @param {Widget} parent The parent of the resulting Controller (most - * likely a view manager) + * likely an action manager) * @returns {Deferred} The deferred resolves to a controller */ getController: function (parent) { diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index 1c8060a6fe1..2c7c1c40210 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -200,7 +200,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { _abandonRecord: function (recordID) { recordID = recordID || this.handle; if (recordID === this.handle) { - this.trigger_up('switch_to_previous_view'); + this.trigger_up('history_back'); } else { this.model.removeLine(recordID); } @@ -244,17 +244,11 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { model: record.model, resIDs: record.res_ids, }, - on_closed: function (reason) { - if (!_.isObject(reason)) { - reload(reason); - } - }, - on_fail: function (reason) { - reload().always(function() { - def.reject(reason); - }); - }, on_success: def.resolve.bind(def), + on_fail: function () { + reload().always(def.reject.bind(def)); + }, + on_closed: reload, }); return this.alive(def); }, @@ -313,13 +307,13 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { * @param {Object} [options] * @param {boolean} [options.readonlyIfRealDiscard=false] * After discarding record changes, the usual option is to make the - * record readonly. However, the view manager calls this function + * record readonly. However, the action manager calls this function * at inappropriate times in the current code and in that case, we * don't want to go back to readonly if there is nothing to discard * (e.g. when switching record in edit mode in form view, we expect * the new record to be in edit mode too, but the view manager calls * this function as the URL changes...) @todo get rid of this when - * the view manager is improved. + * the webclient/action_manager's hashchange mechanism is improved. * @returns {Deferred} */ _discardChanges: function (recordID, options) { @@ -467,7 +461,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { var sidebarEnv = this._getSidebarEnv(); this.sidebar.updateEnv(sidebarEnv); } - this.trigger_up('env_updated', env); + this.trigger_up('env_updated', {controllerID: this.controllerID, env: env}); }, /** * Helper method, to make sure the information displayed by the pager is up diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js index 2820dad448f..7b279d44bcd 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -149,7 +149,7 @@ var FormController = BasicController.extend({ * inserted **/ renderSidebar: function ($node) { - if (!this.sidebar && this.hasSidebar) { + if (this.hasSidebar) { var otherItems = []; if (this.is_action_enabled('delete')) { otherItems.push({ @@ -280,7 +280,7 @@ var FormController = BasicController.extend({ _onDeletedRecords: function () { var state = this.model.get(this.handle, {raw: true}); if (!state.res_ids.length) { - this.do_action('history_back'); + this.trigger_up('history_back'); } else { this._super.apply(this, arguments); } diff --git a/addons/web/static/src/js/views/form/form_view.js b/addons/web/static/src/js/views/form/form_view.js index 42cf5de12e4..2851d3ac72a 100644 --- a/addons/web/static/src/js/views/form/form_view.js +++ b/addons/web/static/src/js/views/form/form_view.js @@ -2,7 +2,6 @@ odoo.define('web.FormView', function (require) { "use strict"; var BasicView = require('web.BasicView'); -var Context = require('web.Context'); var core = require('web.core'); var FormController = require('web.FormController'); var FormRenderer = require('web.FormRenderer'); @@ -25,15 +24,16 @@ var FormView = BasicView.extend({ init: function (viewInfo, params) { this._super.apply(this, arguments); - var mode = params.mode || (params.currentId ? 'readonly' : 'edit'); + var mode = params.mode || params.context.form_view_initial_mode || + (params.currentId ? 'readonly' : 'edit'); this.loadParams.type = 'record'; this.controllerParams.disableAutofocus = params.disable_autofocus; - this.controllerParams.hasSidebar = params.sidebar; + this.controllerParams.hasSidebar = params.hasSidebar; this.controllerParams.toolbarActions = viewInfo.toolbar; - this.controllerParams.footerToButtons = params.footer_to_buttons; + this.controllerParams.footerToButtons = params.footerToButtons; if ('action' in params && 'flags' in params.action) { - this.controllerParams.footerToButtons = params.action.flags.footer_to_buttons; + this.controllerParams.footerToButtons = params.action.flags.footerToButtons; } var defaultButtons = 'default_buttons' in params ? params.default_buttons : true; this.controllerParams.defaultButtons = defaultButtons; diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js index 982d5456bca..33c1d234c7a 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -128,7 +128,7 @@ var ListController = BasicController.extend({ * @param {jQuery Node} $node */ renderSidebar: function ($node) { - if (this.hasSidebar && !this.sidebar) { + if (this.hasSidebar) { var other = [{ label: _t("Export"), callback: this._onExportData.bind(this) diff --git a/addons/web/static/src/js/views/list/list_view.js b/addons/web/static/src/js/views/list/list_view.js index 8a989dad35e..e970f825846 100644 --- a/addons/web/static/src/js/views/list/list_view.js +++ b/addons/web/static/src/js/views/list/list_view.js @@ -30,7 +30,7 @@ var ListView = BasicView.extend({ * * @param {Object} viewInfo * @param {Object} params - * @param {boolean} params.sidebar + * @param {boolean} params.hasSidebar * @param {boolean} [params.hasSelectors=true] */ init: function (viewInfo, params) { @@ -40,7 +40,7 @@ var ListView = BasicView.extend({ var mode = arch.attrs.editable && !params.readonly ? "edit" : "readonly"; this.controllerParams.editable = arch.attrs.editable; - this.controllerParams.hasSidebar = params.sidebar; + this.controllerParams.hasSidebar = params.hasSidebar; this.controllerParams.toolbarActions = viewInfo.toolbar; this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf; this.controllerParams.mode = mode; diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js index aea29d96ee2..72e215f9c24 100644 --- a/addons/web/static/src/js/views/view_dialogs.js +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -175,8 +175,9 @@ var FormViewDialog = ViewDialog.extend({ currentId: self.res_id || undefined, index: 0, mode: self.res_id && self.options.readonly ? 'readonly' : 'edit', - footer_to_buttons: true, + footerToButtons: true, default_buttons: false, + withControlPanel: false, model: self.model, parentID: self.parentID, recordID: self.recordID, @@ -190,9 +191,9 @@ var FormViewDialog = ViewDialog.extend({ } self.form_view.appendTo(fragment) .then(function () { - var $buttons = $('<div>'); - self.form_view.renderButtons($buttons); self.opened().always(function () { + var $buttons = $('<div>'); + self.form_view.renderButtons($buttons); if ($buttons.children().length) { self.$footer.empty().append($buttons.contents()); } @@ -257,7 +258,7 @@ var SelectCreateDialog = ViewDialog.extend({ this.$footer.find(".o_select_button").prop('disabled', !event.data.selection.length); }, search: function (event) { - event.stopPropagation(); // prevent this event from bubbling up to the view manager + event.stopPropagation(); // prevent this event from bubbling up to the action manager var d = event.data; var searchData = this._process_search_data(d.domains, d.contexts, d.groupbys); this.list_controller.reload(searchData); diff --git a/addons/web/static/src/js/widgets/debug_manager.js b/addons/web/static/src/js/widgets/debug_manager.js index 2c9cf3d4457..50e0497b91b 100644 --- a/addons/web/static/src/js/widgets/debug_manager.js +++ b/addons/web/static/src/js/widgets/debug_manager.js @@ -10,7 +10,6 @@ var field_utils = require('web.field_utils'); var session = require('web.session'); var SystrayMenu = require('web.SystrayMenu'); var utils = require('web.utils'); -var ViewManager = require('web.ViewManager'); var WebClient = require('web.WebClient'); var Widget = require('web.Widget'); @@ -332,42 +331,35 @@ DebugManager.include({ ); }, update: function (tag, descriptor, widget) { - switch (tag) { - case 'action': - if (this._view_manager) { - this._view_manager.off('switch_mode', this); - } - if (!(widget instanceof ViewManager)) { - this._active_view = null; - this._view_manager = null; - break; - } - this._view_manager = widget; - widget.on('switch_mode', this, function () { - this.update('view', null, widget); - }); - case 'view': - this._active_view = widget.active_view; + if (tag === 'action' || tag === 'view') { + this._controller = widget; } - return this._super(tag, descriptor).then(function () { this.$dropdown.find(".o_debug_leave_section").before(QWeb.render('WebClient.DebugManager.View', { - manager: this, action: this._action, - view: this._active_view, can_edit: this._can_edit_views, - searchview: this._view_manager && this._view_manager.searchview, + controller: this._controller, + manager: this, + searchview: this._controller && this._controller.searchView, + view: this._controller && _.findWhere(this._action.views, { + type: this._controller.viewType, + }), })); }.bind(this)); }, get_metadata: function() { - var ds = this._view_manager.dataset; - if (!this._active_view.controller.getSelectedIds().length) { + var self = this; + var selectedIDs = this._controller.getSelectedIds(); + if (!selectedIDs.length) { console.warn(_t("No metadata available")); return; } - ds.call('get_metadata', [this._active_view.controller.getSelectedIds()]).done(function(result) { + this._rpc({ + model: this._action.res_model, + method: 'get_metadata', + args: [selectedIDs], + }).done(function(result) { var metadata = result[0]; metadata.creator = field_utils.format.many2one(metadata.create_uid); metadata.lastModifiedBy = field_utils.format.many2one(metadata.write_uid); @@ -376,7 +368,7 @@ DebugManager.include({ var modificationDate = field_utils.parse.datetime(metadata.write_date); metadata.write_date = field_utils.format.datetime(modificationDate); new Dialog(this, { - title: _.str.sprintf(_t("Metadata (%s)"), ds.model), + title: _.str.sprintf(_t("Metadata (%s)"), self._action.res_model), size: 'medium', $content: QWeb.render('WebClient.DebugViewLog', { perm : metadata, @@ -400,11 +392,12 @@ DebugManager.include({ return [value, displayed]; }; - var renderer = this._active_view.controller.renderer; - var fields = self._active_view.fields_view.fields; - var fieldsInfo = self._active_view.fields_view.fieldsInfo.form; - var fieldNamesInView = renderer.state.getFieldNames(); - var fieldsValues = renderer.state.data; + var renderer = this._controller.renderer; + var state = renderer.state; + var fields = state.fields; + var fieldsInfo = state.fieldsInfo.form; + var fieldNamesInView = state.getFieldNames(); + var fieldsValues = state.data; var modifierDatas = {}; _.each(fieldNamesInView, function (fieldName) { modifierDatas[fieldName] = _.find(renderer.allModifiersData, function (modifierdata) { @@ -416,14 +409,14 @@ DebugManager.include({ var modifierData = modifierDatas[fieldName]; var invisibleOrReadOnly; if (modifierData) { - var evaluatedModifiers = modifierData.evaluatedModifiers[renderer.state.id]; + var evaluatedModifiers = modifierData.evaluatedModifiers[state.id]; invisibleOrReadOnly = evaluatedModifiers.invisible || evaluatedModifiers.readonly; } var fieldInfo = fields[fieldName]; var valueDisplayed = display(fieldInfo, fieldsValues[fieldName]); var value = valueDisplayed[0]; var displayed = valueDisplayed[1]; - // ignore fields which are empty, invisible, readonly, o2m + // ignore fields which are empty, invisible, readonly, o2m // or m2m if (!value || invisibleOrReadOnly || fieldInfo.type === 'one2many' || fieldInfo.type === 'many2many' || fieldInfo.type === 'binary' || @@ -479,7 +472,7 @@ DebugManager.include({ model: 'ir.default', method: 'set', args: [ - self._active_view.fields_view.model, + self._controller.fields_view.model, fieldToSet, value, allUsers, @@ -502,7 +495,7 @@ DebugManager.include({ var dialog = new Dialog(this, { title: _t("Fields View Get") }); dialog.opened().then(function () { $('<pre>').text(utils.json_node_to_xml( - self._active_view.controller.renderer.arch, true) + self._controller.renderer.arch, true) ).appendTo(dialog.$el); }); dialog.open(); @@ -788,30 +781,80 @@ if (config.debug) { SystrayMenu.Items.push(DebugManager); WebClient.include({ - current_action_updated: function(action) { + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @override + */ + current_action_updated: function (action, controller) { this._super.apply(this, arguments); - var action_descr = action && action.action_descr; - var action_widget = action && action.widget; - var debug_manager = _.find(this.systray_menu.widgets, function(item) {return item instanceof DebugManager; }); - debug_manager.update('action', action_descr, action_widget); + var debugManager = _.find(this.systray_menu.widgets, function(item) { + return item instanceof DebugManager; + }); + debugManager.update('action', action, controller && controller.widget); + }, + }); + + ActionManager.include({ + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Returns the action of the controller currently opened in a dialog, + * i.e. a target='new' action, if any. + * + * @returns {Object|null} + */ + getCurrentActionInDialog: function () { + if (this.currentDialogController) { + return this.actions[this.currentDialogController.actionID]; + } + return null; + }, + /** + * Returns the controller currently opened in a dialog, if any. + * + * @returns {Object|null} + */ + getCurrentControllerInDialog: function () { + return this.currentDialogController; }, }); Dialog.include({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ open: function() { var self = this; - - var parent = self.getParent(); - if (parent instanceof ActionManager && parent.dialog_widget) { - // Instantiate the DebugManager and insert it into the DOM once dialog is opened - this.opened(function() { - self.debug_manager = new DebugManager(self); - var $header = self.$modal.find('.modal-header:first'); - return self.debug_manager.prependTo($header).then(function() { - self.debug_manager.update('action', parent.dialog_widget.action, parent.dialog_widget); - }); - }); - } + // if the dialog is opened by the ActionManager, instantiate a + // DebugManager and insert it into the DOM once the dialog is opened + // (delay this with a setTimeout(0) to ensure that the internal + // state, i.e. the current action and controller, of the + // ActionManager is set to properly update the DebugManager) + this.opened(function() { + setTimeout(function () { + var parent = self.getParent(); + if (parent instanceof ActionManager) { + var action = parent.getCurrentActionInDialog(); + if (action) { + var controller = parent.getCurrentControllerInDialog(); + self.debugManager = new DebugManager(self); + var $header = self.$modal.find('.modal-header:first'); + return self.debugManager.prependTo($header).then(function () { + self.debugManager.update('action', action, controller.widget); + }); + } + } + }, 0); + }); return this._super.apply(this, arguments); }, diff --git a/addons/web/static/src/less/fields.less b/addons/web/static/src/less/fields.less index 3c6428e5c75..e8bf81c9448 100644 --- a/addons/web/static/src/less/fields.less +++ b/addons/web/static/src/less/fields.less @@ -33,7 +33,7 @@ } // Block fields - &.note-editor, &.oe_form_field_html, &.oe_form_field_html_text, &.oe_form_field_text, &.o_view_manager_content, &.o_field_domain, &.o_graph_linechart, &.o_graph_barchart { + &.note-editor, &.oe_form_field_html, &.oe_form_field_html_text, &.oe_form_field_text, &.o_field_domain, &.o_graph_linechart, &.o_graph_barchart { display: block; } diff --git a/addons/web/static/src/less/form_view.less b/addons/web/static/src/less/form_view.less index 36002ace912..8a053a8cca3 100644 --- a/addons/web/static/src/less/form_view.less +++ b/addons/web/static/src/less/form_view.less @@ -431,7 +431,7 @@ // One2Many, Many2Many outside of group .o_field_widget { - &.o_view_manager_content, &.o_field_one2many, &.o_field_many2many { + &.o_field_one2many, &.o_field_many2many { width: 100%; > div { width: 100%; diff --git a/addons/web/static/src/less/graph_view.less b/addons/web/static/src/less/graph_view.less index f77b4faa9d0..fc8fc323582 100644 --- a/addons/web/static/src/less/graph_view.less +++ b/addons/web/static/src/less/graph_view.less @@ -1,6 +1,4 @@ .o_graph { - height: 100%; - .o_graph_svg_container { height: 100%; overflow: auto; diff --git a/addons/web/static/src/less/modal.less b/addons/web/static/src/less/modal.less index a223deb55ef..5b74583ee41 100644 --- a/addons/web/static/src/less/modal.less +++ b/addons/web/static/src/less/modal.less @@ -23,10 +23,6 @@ float: right; } } - - .o_view_manager_content { - position: static; // Reset position for view managers in modals (default value: absolute) - } } .modal-footer { diff --git a/addons/web/static/src/less/pivot_view.less b/addons/web/static/src/less/pivot_view.less index 2fa6e1a6f44..d7dfdd45de5 100644 --- a/addons/web/static/src/less/pivot_view.less +++ b/addons/web/static/src/less/pivot_view.less @@ -1,5 +1,4 @@ .o_pivot { - height: 100%; overflow: auto; // Inform the user that he may click on a cell to be redirected to a list view of the diff --git a/addons/web/static/src/less/views.less b/addons/web/static/src/less/views.less index ee82aa5d9e5..48117cb1b75 100644 --- a/addons/web/static/src/less/views.less +++ b/addons/web/static/src/less/views.less @@ -4,9 +4,6 @@ */ // No content helper -.o_view_manager_content > div { - height: 100%; -} .oe_view_nocontent { a { cursor: pointer; diff --git a/addons/web/static/src/less/webclient_layout.less b/addons/web/static/src/less/webclient_layout.less index b389a919a1e..f876d8b3527 100644 --- a/addons/web/static/src/less/webclient_layout.less +++ b/addons/web/static/src/less/webclient_layout.less @@ -155,14 +155,9 @@ overflow: auto; position: relative; - .o_view_manager_content { + > .o_view_controller { .o-position-absolute(0, 0, 0, 0); // Get the 100% height of its flex parent height: 100%; - - .o_view_manager_content { - position: static; // Reset position for nested view managers - height: auto; // reset height inheritance for nested view managers - } } } } diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 509c03fdf4b..ea4f554b183 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -177,8 +177,6 @@ </div> </form> -<div t-name="ActionManager" class="o_content"/> - <t t-name="ControlPanel"> <div class="o_control_panel"> <ol class="breadcrumb"/> @@ -201,9 +199,9 @@ </div> </t> -<t t-name="ViewManager.switch-buttons"> +<t t-name="ControlPanel.SwitchButtons"> <t t-foreach="views" t-as="view"> - <button type="button" t-att-accesskey="view.accesskey" + <button type="button" t-att-accesskey="view.accessKey" t-attf-class="btn btn-icon fa fa-lg #{view.icon} o_cp_switch_#{view.type}" t-att-aria-label="view.type" t-att-data-view-type="view.type" t-att-title="view.label"/> @@ -259,14 +257,14 @@ <li class="divider"/> <t t-if="view.type === 'form'"> <li><a href="#" data-action="set_defaults">Set Defaults</a></li> - <t t-if="view.controller.getSelectedIds().length === 1"> + <t t-if="controller.getSelectedIds().length === 1"> <li><a href="#" data-action="get_metadata">View Metadata</a></li> </t> </t> <li><a href="#" data-action="fvg">Fields View Get</a></li> <t t-if="can_edit"> <li> - <a href="#" data-action="edit" data-model="ir.ui.view" t-att-data-id="view.fields_view.view_id"> + <a href="#" data-action="edit" data-model="ir.ui.view" t-att-data-id="view.fieldsView.view_id"> <t t-if="view.type == 'form'">Edit Form view</t> <t t-elif="view.type == 'list'">Edit List view</t> <t t-elif="view.type == 'kanban'">Edit Kanban view</t> @@ -277,7 +275,7 @@ <t t-else="1">Edit view, type: <t t-esc="_.str.capitalize(view.type)"/></t> </a> </li> - <li t-if="searchview and searchview.$el.is(':visible')"><a href="#" data-action="edit" data-model="ir.ui.view" t-att-data-id="searchview.fields_view.view_id">Edit Search view</a></li> + <li t-if="searchview and searchview.$el.is(':visible')"><a href="#" data-action="edit" data-model="ir.ui.view" t-att-data-id="action.searchFieldsView.view_id">Edit SearchView</a></li> </t> </t> </t> diff --git a/addons/web/static/tests/chrome/action_manager_mobile_tests.js b/addons/web/static/tests/chrome/action_manager_mobile_tests.js new file mode 100644 index 00000000000..c23d7be19f8 --- /dev/null +++ b/addons/web/static/tests/chrome/action_manager_mobile_tests.js @@ -0,0 +1,85 @@ +odoo.define('web.action_manager_mobile_tests', function (require) { +"use strict"; + +var testUtils = require('web.test_utils'); + +var createActionManager = testUtils.createActionManager; + +QUnit.module('ActionManager', { + beforeEach: function () { + this.data = { + partner: { + fields: { + foo: {string: "Foo", type: "char"}, + }, + records: [ + {id: 1, display_name: "First record", foo: "yop"}, + ], + }, + }; + + this.actions = [{ + id: 1, + name: 'Partners Action 1', + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'list'], [false, 'kanban'], [false, 'form']], + }, { + id: 2, + name: 'Partners Action 2', + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'list'], [false, 'form']], + }]; + + this.archs = { + // kanban views + 'partner,false,kanban': '<kanban><templates><t t-name="kanban-box">' + + '<div class="oe_kanban_global_click"><field name="foo"/></div>' + + '</t></templates></kanban>', + + // list views + 'partner,false,list': '<tree><field name="foo"/></tree>', + + // form views + 'partner,false,form': '<form>' + + '<group>' + + '<field name="display_name"/>' + + '</group>' + + '</form>', + + // search views + 'partner,false,search': '<search><field name="foo" string="Foo"/></search>', + }; + }, +}, function () { + QUnit.test('uses a mobile-friendly view by default (if possible)', function (assert) { + assert.expect(4); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + + // should default on a mobile-friendly view (kanban) for action 1 + actionManager.doAction(1); + + assert.strictEqual(actionManager.$('.o_list_view').length, 0, + "should not have rendered the list view"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 1, + "should have rendered the kanban view"); + + // there is no mobile-friendly view for action 2, should use the first one (list) + actionManager.doAction(2); + + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should have rendered the list view"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 0, + "there should be no kanban view in the DOM"); + + actionManager.destroy(); + }); +}); + +}); diff --git a/addons/web/static/tests/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js new file mode 100644 index 00000000000..848d7eb1570 --- /dev/null +++ b/addons/web/static/tests/chrome/action_manager_tests.js @@ -0,0 +1,1667 @@ +odoo.define('web.action_manager_tests', function (require) { +"use strict"; + +var ControlPanelMixin = require('web.ControlPanelMixin'); +var core = require('web.core'); +var testUtils = require('web.test_utils'); +var Widget = require('web.Widget'); + +var createActionManager = testUtils.createActionManager; + +QUnit.module('ActionManager', { + beforeEach: function () { + this.data = { + partner: { + fields: { + foo: {string: "Foo", type: "char"}, + }, + records: [ + {id: 1, display_name: "First record", foo: "yop"}, + {id: 2, display_name: "Second record", foo: "blip"}, + {id: 3, display_name: "Third record", foo: "gnap"}, + {id: 4, display_name: "Fourth record", foo: "plop"}, + {id: 5, display_name: "Fifth record", foo: "zoup"}, + ], + }, + }; + + this.actions = [{ + id: 1, + name: 'Partners Action 1', + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[1, 'kanban']], + }, { + id: 2, + type: 'ir.actions.server', + }, { + id: 3, + name: 'Partners', + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'list'], [1, 'kanban'], [false, 'form']], + }, { + id: 4, + name: 'Partners Action 4', + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[1, 'kanban'], [2, 'list'], [false, 'form']], + }, { + id: 5, + name: 'Create a Partner', + res_model: 'partner', + target: 'new', + type: 'ir.actions.act_window', + view_mode: 'form', + views: [[false, 'form']], + }, { + id: 6, + name: 'Partner', + res_id: 2, + res_model: 'partner', + target: 'inline', + type: 'ir.actions.act_window', + view_mode: 'form', + views: [[false, 'form']], + }]; + this.archs = { + // kanban views + 'partner,1,kanban': '<kanban><templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + + // list views + 'partner,false,list': '<tree><field name="foo"/></tree>', + 'partner,2,list': '<tree limit="3"><field name="foo"/></tree>', + + // form views + 'partner,false,form': '<form>' + + '<header>' + + '<button name="object" string="Call method" type="object"/>' + + '<button name="4" string="Execute action" type="action"/>' + + '</header>' + + '<group>' + + '<field name="display_name"/>' + + '<field name="foo"/>' + + '</group>' + + '</form>', + + // search views + 'partner,false,search': '<search><field name="foo" string="Foo"/></search>', + }; + }, +}, function () { + QUnit.module('Misc'); + + QUnit.test('no widget memory leaks when executing actions in dialog', function (assert) { + assert.expect(1); + + var delta = 0; + testUtils.patch(Widget, { + init: function () { + delta++; + this._super.apply(this, arguments); + }, + destroy: function () { + if (!this.isDestroyed()) { + delta--; + } + this._super.apply(this, arguments); + }, + }); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + var n = delta; + + actionManager.doAction(5); + actionManager.doAction({type: 'ir.actions.act_window_close'}); + + assert.strictEqual(delta, n, + "should have properly destroyed all widgets"); + + actionManager.destroy(); + testUtils.unpatch(Widget); + }); + + QUnit.test('no memory leaks when executing an action while switching view', function (assert) { + assert.expect(1); + + var def; + var delta = 0; + testUtils.patch(Widget, { + init: function () { + delta += 1; + this._super.apply(this, arguments); + }, + destroy: function () { + delta -= 1; + this._super.apply(this, arguments); + }, + }); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'read') { + return $.when(def).then(_.constant(result)); + } + return result; + }, + }); + + actionManager.doAction(4); + var n = delta; + + actionManager.doAction(3, {clear_breadcrumbs: true}); + + // switch to the form view (this request is blocked) + def = $.Deferred(); + actionManager.$('.o_list_view .o_data_row:first').click(); + + // execute another action meanwhile (don't block this request) + actionManager.doAction(4, {clear_breadcrumbs: true}); + + // unblock the switch to the form view in action 3 + def.resolve(); + + assert.strictEqual(n, delta, + "all widgets of action 3 should have been destroyed"); + + actionManager.destroy(); + testUtils.unpatch(Widget); + }); + + QUnit.test('action with "no_breadcrumbs" set to true', function (assert) { + assert.expect(2); + + _.findWhere(this.actions, {id: 4}).context = {no_breadcrumbs: true}; + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + actionManager.doAction(3); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 1, + "there should be one controller in the breadcrumbs"); + + // push another action flagged with 'no_breadcrumbs=true' + actionManager.doAction(4); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 0, + "the breadcrumbs should be empty"); + + actionManager.destroy(); + }); + + QUnit.test('on_reverse_breadcrumb handler is correctly called', function (assert) { + assert.expect(3); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + + // execute action 3 and open a record in form view + actionManager.doAction(3); + actionManager.$('.o_list_view .o_data_row:first').click(); + + // execute action 4 without 'on_reverse_breadcrumb' handler, then go back + actionManager.doAction(4); + $('.o_control_panel .breadcrumb a:first').click(); + assert.verifySteps([]); + + // execute action 4 with an 'on_reverse_breadcrumb' handler, then go back + actionManager.doAction(4, { + on_reverse_breadcrumb: function () { + assert.step('on_reverse_breadcrumb'); + } + }); + $('.o_control_panel .breadcrumb a:first').click(); + assert.verifySteps(['on_reverse_breadcrumb']); + + actionManager.destroy(); + }); + + QUnit.test('handles "history_back" event', function (assert) { + assert.expect(2); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + + actionManager.doAction(4); + actionManager.doAction(3); + actionManager.trigger_up('history_back'); + + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 1, + "there should be one controller in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li').text(), 'Partners Action 4', + "breadcrumbs should display the display_name of the action"); + + actionManager.destroy(); + }); + + QUnit.module('Load State'); + + QUnit.test('should not crash on invalid state', function (assert) { + assert.expect(2); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + actionManager.loadState({ + res_model: 'partner', // the valid key for the model is 'model', not 'res_model' + }); + + assert.strictEqual(actionManager.$el.text(), '', "should display nothing"); + assert.verifySteps([]); + + actionManager.destroy(); + }); + + QUnit.test('properly load client actions', function (assert) { + assert.expect(2); + + var ClientAction = Widget.extend({ + className: 'o_client_action_test', + start: function () { + this.$el.text('Hello World'); + }, + }); + core.action_registry.add('HelloWorldTest', ClientAction); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + actionManager.loadState({ + action: 'HelloWorldTest', + }); + + assert.strictEqual(actionManager.$('.o_client_action_test').text(), + 'Hello World', "should have correctly rendered the client action"); + + assert.verifySteps([]); + + actionManager.destroy(); + delete core.action_registry.map.HelloWorldTest; + }); + + QUnit.test('properly load act window actions', function (assert) { + assert.expect(6); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + actionManager.loadState({ + action: 1, + }); + + assert.strictEqual($('.o_control_panel').length, 1, + "should have rendered a control panel"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 1, + "should have rendered a kanban view"); + + assert.verifySteps([ + '/web/action/load', + 'load_views', + '/web/dataset/search_read', + ]); + + actionManager.destroy(); + }); + + QUnit.test('properly load records', function (assert) { + assert.expect(5); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + actionManager.loadState({ + id: 2, + model: 'partner', + }); + + assert.strictEqual(actionManager.$('.o_form_view').length, 1, + "should have rendered a form view"); + assert.strictEqual($('.o_control_panel .breadcrumb li').text(), 'Second record', + "should have opened the second record"); + + assert.verifySteps([ + 'load_views', + 'read', + ]); + + actionManager.destroy(); + }); + + QUnit.test('load requested view for act window actions', function (assert) { + assert.expect(6); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + actionManager.loadState({ + action: 3, + view_type: 'kanban', + }); + + assert.strictEqual(actionManager.$('.o_list_view').length, 0, + "should not have rendered a list view"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 1, + "should have rendered a kanban view"); + + assert.verifySteps([ + '/web/action/load', + 'load_views', + '/web/dataset/search_read', + ]); + + actionManager.destroy(); + }); + + QUnit.test('lazy load multi record view if mono record one is requested', function (assert) { + assert.expect(11); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + actionManager.loadState({ + action: 3, + id: 2, + view_type: 'form', + }); + + assert.strictEqual(actionManager.$('.o_list_view').length, 0, + "should not have rendered a list view"); + assert.strictEqual(actionManager.$('.o_form_view').length, 1, + "should have rendered a form view"); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 2, + "there should be two controllers in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li:last').text(), 'Second record', + "breadcrumbs should contain the display_name of the opened record"); + + // go back to Lst + $('.o_control_panel .breadcrumb a').click(); + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should now display the list view"); + assert.strictEqual(actionManager.$('.o_form_view').length, 0, + "should not display the form view anymore"); + + assert.verifySteps([ + '/web/action/load', + 'load_views', + 'read', // read the opened record + '/web/dataset/search_read', // search read when coming back to List + ]); + + actionManager.destroy(); + }); + + QUnit.test('change the viewType of the current action', function (assert) { + assert.expect(13); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + actionManager.doAction(3); + + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should have rendered a list view"); + + // switch to kanban view + actionManager.loadState({ + action: 3, + view_type: 'kanban', + }); + + assert.strictEqual(actionManager.$('.o_list_view').length, 0, + "should not display the list view anymore"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 1, + "should have switched to the kanban view"); + + // switch to form view, open record 4 + actionManager.loadState({ + action: 3, + id: 4, + view_type: 'form', + }); + + assert.strictEqual(actionManager.$('.o_kanban_view').length, 0, + "should not display the kanban view anymore"); + assert.strictEqual(actionManager.$('.o_form_view').length, 1, + "should have switched to the form view"); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 2, + "there should be two controllers in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li:last').text(), 'Fourth record', + "should have opened the requested record"); + + // verify steps to ensure that the whole action hasn't been re-executed + // (if it would have been, /web/action/load and load_views would appear + // several times) + assert.verifySteps([ + '/web/action/load', + 'load_views', + '/web/dataset/search_read', // list view + '/web/dataset/search_read', // kanban view + 'read', // form view + ]); + + actionManager.destroy(); + }); + + QUnit.test('change the id of the current action', function (assert) { + assert.expect(11); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + + // execute action 3 and open the first record in a form view + actionManager.doAction(3); + actionManager.$('.o_list_view .o_data_row:first').click(); + + assert.strictEqual(actionManager.$('.o_form_view').length, 1, + "should have rendered a form view"); + assert.strictEqual($('.o_control_panel .breadcrumb li:last').text(), 'First record', + "should have opened the first record"); + + // switch to record 4 + actionManager.loadState({ + action: 3, + id: 4, + view_type: 'form', + }); + + assert.strictEqual(actionManager.$('.o_form_view').length, 1, + "should still display the form view"); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 2, + "there should be two controllers in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li:last').text(), 'Fourth record', + "should have switched to the requested record"); + + // verify steps to ensure that the whole action hasn't been re-executed + // (if it would have been, /web/action/load and load_views would appear + // twice) + assert.verifySteps([ + '/web/action/load', + 'load_views', + '/web/dataset/search_read', // list view + 'read', // form view, record 1 + 'read', // form view, record 4 + ]); + + actionManager.destroy(); + }); + + QUnit.test('should not push a loaded state', function (assert) { + assert.expect(1); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + intercepts: { + push_state: function () { + assert.step('push_state'); + }, + }, + }); + actionManager.loadState({action: 1}); + + assert.verifySteps([]); + + actionManager.destroy(); + }); + + QUnit.module('Concurrency management'); + + QUnit.test('execute a new action while loading a lazy-loaded controller', function (assert) { + assert.expect(15); + + var def; + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + assert.step(args.method || route); + if (route === '/web/dataset/search_read' && args.model === 'partner') { + return $.when(def).then(_.constant(result)); + } + return result; + }, + }); + actionManager.loadState({ + action: 4, + id: 2, + view_type: 'form', + }); + + assert.strictEqual(actionManager.$('.o_form_view').length, 1, + "should display the form view of action 4"); + + // click to go back to Kanban (this request is blocked) + def = $.Deferred(); + $('.o_control_panel .breadcrumb a').click(); + + assert.strictEqual(actionManager.$('.o_form_view').length, 1, + "should still display the form view of action 4"); + + // execute another action meanwhile (don't block this request) + actionManager.doAction(8, {clear_breadcrumbs: true}); + + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should display action 8"); + assert.strictEqual(actionManager.$('.o_form_view').length, 0, + "should no longer display the form view"); + + assert.verifySteps([ + '/web/action/load', // load state action 4 + 'load_views', // load state action 4 + 'read', // read the opened record (action 4) + '/web/dataset/search_read', // blocked search read when coming back to Kanban (action 4) + '/web/action/load', // action 8 + 'load_views', // action 8 + '/web/dataset/search_read', // search read action 8 + ]); + + // unblock the switch to Kanban in action 4 + def.resolve(); + + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should still display action 8"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 0, + "should not display the kanban view of action 4"); + + assert.verifySteps([ + '/web/action/load', // load state action 4 + 'load_views', // load state action 4 + 'read', // read the opened record (action 4) + '/web/dataset/search_read', // blocked search read when coming back to Kanban (action 4) + '/web/action/load', // action 8 + 'load_views', // action 8 + '/web/dataset/search_read', // search read action 8 + ]); + + actionManager.destroy(); + }); + + QUnit.test('execute a new action while handling a call_button', function (assert) { + assert.expect(16); + + var self = this; + var def = $.Deferred(); + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + if (route === '/web/dataset/call_button') { + return def.then(_.constant(self.actions[0])); + } + return this._super.apply(this, arguments); + }, + }); + + // execute action 3 and open a record in form view + actionManager.doAction(3); + actionManager.$('.o_list_view .o_data_row:first').click(); + + assert.strictEqual(actionManager.$('.o_form_view').length, 1, + "should display the form view of action 3"); + + // click on 'Call method' button (this request is blocked) + actionManager.$('.o_form_view button:contains(Call method)').click(); + + assert.strictEqual(actionManager.$('.o_form_view').length, 1, + "should still display the form view of action 3"); + + // execute another action + actionManager.doAction(8, {clear_breadcrumbs: true}); + + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should display the list view of action 8"); + assert.strictEqual(actionManager.$('.o_form_view').length, 0, + "should no longer display the form view"); + + assert.verifySteps([ + '/web/action/load', // action 3 + 'load_views', // action 3 + '/web/dataset/search_read', // list for action 3 + 'read', // form for action 3 + 'object', // click on 'Call method' button (this request is blocked) + '/web/action/load', // action 8 + 'load_views', // action 8 + '/web/dataset/search_read', // list for action 8 + ]); + + // unblock the call_button request + def.resolve(); + + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should still display the list view of action 8"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 0, + "should not display action 1"); + + assert.verifySteps([ + '/web/action/load', // action 3 + 'load_views', // action 3 + '/web/dataset/search_read', // list for action 3 + 'read', // form for action 3 + 'object', // click on 'Call method' button (this request is blocked) + '/web/action/load', // action 8 + 'load_views', // action 8 + '/web/dataset/search_read', // list for action 8 + ]); + + actionManager.destroy(); + }); + + QUnit.test('execute a new action while switching to another controller', function (assert) { + assert.expect(15); + + var def; + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + assert.step(args.method || route); + if (args.method === 'read') { + return $.when(def).then(_.constant(result)); + } + return result; + }, + }); + + actionManager.doAction(3); + + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should display the list view of action 3"); + + // switch to the form view (this request is blocked) + def = $.Deferred(); + actionManager.$('.o_list_view .o_data_row:first').click(); + + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should still display the list view of action 3"); + + // execute another action meanwhile (don't block this request) + actionManager.doAction(4, {clear_breadcrumbs: true}); + + assert.strictEqual(actionManager.$('.o_kanban_view').length, 1, + "should display the kanban view of action 8"); + assert.strictEqual(actionManager.$('.o_list_view').length, 0, + "should no longer display the list view"); + + assert.verifySteps([ + '/web/action/load', // action 3 + 'load_views', // action 3 + '/web/dataset/search_read', // search read of list view of action 3 + 'read', // read the opened record of action 3 (this request is blocked) + '/web/action/load', // action 4 + 'load_views', // action 4 + '/web/dataset/search_read', // search read action 4 + ]); + + // unblock the switch to the form view in action 3 + def.resolve(); + + assert.strictEqual(actionManager.$('.o_kanban_view').length, 1, + "should still display the kanban view of action 8"); + assert.strictEqual(actionManager.$('.o_form_view').length, 0, + "should not display the form view of action 3"); + + assert.verifySteps([ + '/web/action/load', // action 3 + 'load_views', // action 3 + '/web/dataset/search_read', // search read of list view of action 3 + 'read', // read the opened record of action 3 (this request is blocked) + '/web/action/load', // action 4 + 'load_views', // action 4 + '/web/dataset/search_read', // search read action 4 + ]); + + actionManager.destroy(); + }); + + QUnit.module('Client Actions'); + + QUnit.test('can execute client actions from tag name', function (assert) { + assert.expect(3); + + var ClientAction = Widget.extend({ + className: 'o_client_action_test', + start: function () { + this.$el.text('Hello World'); + }, + }); + core.action_registry.add('HelloWorldTest', ClientAction); + + var actionManager = createActionManager({ + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + } + }); + actionManager.doAction('HelloWorldTest'); + + assert.strictEqual($('.o_control_panel:visible').length, 0, // AAB: global selector until the ControlPanel is moved from ActionManager to the Views + "shouldn't have rendered a control panel"); + assert.strictEqual(actionManager.$('.o_client_action_test').text(), + 'Hello World', "should have correctly rendered the client action"); + assert.verifySteps([]); + + actionManager.destroy(); + delete core.action_registry.map.HelloWorldTest; + }); + + QUnit.test('client action with control panel', function (assert) { + assert.expect(4); + + var ClientAction = Widget.extend(ControlPanelMixin, { + className: 'o_client_action_test', + start: function () { + this.$el.text('Hello World'); + this.set('title', 'Hello'); // AAB: drop this and replace by getTitle() + }, + }); + core.action_registry.add('HelloWorldTest', ClientAction); + + var actionManager = createActionManager(); + actionManager.doAction('HelloWorldTest'); + + assert.strictEqual($('.o_control_panel:visible').length, 1, + "should have rendered a control panel"); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 1, + "there should be one controller in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li').text(), 'Hello', + "breadcrumbs should still display the title of the controller"); + assert.strictEqual(actionManager.$('.o_client_action_test').text(), + 'Hello World', "should have correctly rendered the client action"); + + actionManager.destroy(); + delete core.action_registry.map.HelloWorldTest; + }); + + QUnit.test('state is pushed for client actions', function (assert) { + assert.expect(2); + + var ClientAction = Widget.extend(ControlPanelMixin, { + className: 'o_client_action_test', + start: function () { + this.$el.text('Hello World'); + }, + }); + var actionManager = createActionManager({ + intercepts: { + push_state: function () { + assert.step('push state'); + }, + }, + }); + core.action_registry.add('HelloWorldTest', ClientAction); + + actionManager.doAction('HelloWorldTest'); + + assert.verifySteps(['push state']); + + actionManager.destroy(); + delete core.action_registry.map.HelloWorldTest; + }); + + QUnit.module('Server actions'); + + QUnit.test('can execute server actions from db ID', function (assert) { + assert.expect(9); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + if (route === '/web/action/run') { + assert.strictEqual(args.action_id, 2, + "should call the correct server action"); + return $.when(1); // execute action 1 + } + return this._super.apply(this, arguments); + }, + }); + actionManager.doAction(2); + + assert.strictEqual($('.o_control_panel:visible').length, 1, + "should have rendered a control panel"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 1, + "should have rendered a kanban view"); + assert.verifySteps([ + '/web/action/load', + '/web/action/run', + '/web/action/load', + 'load_views', + '/web/dataset/search_read', + ]); + + actionManager.destroy(); + }); + + QUnit.module('Window Actions'); + + QUnit.test('can execute act_window actions from db ID', function (assert) { + assert.expect(6); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + actionManager.doAction(1); + + assert.strictEqual($('.o_control_panel').length, 1, + "should have rendered a control panel"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 1, + "should have rendered a kanban view"); + assert.verifySteps([ + '/web/action/load', + 'load_views', + '/web/dataset/search_read', + ]); + + actionManager.destroy(); + }); + + QUnit.test('can switch between views', function (assert) { + assert.expect(18); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + actionManager.doAction(3); + + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should display the list view"); + + // switch to kanban view + $('.o_control_panel .o_cp_switch_kanban').click(); + assert.strictEqual(actionManager.$('.o_list_view').length, 0, + "should no longer display the list view"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 1, + "should display the kanban view"); + + // switch back to list view + $('.o_control_panel .o_cp_switch_list').click(); + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should display the list view"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 0, + "should no longer display the kanban view"); + + // open a record in form view + actionManager.$('.o_list_view .o_data_row:first').click(); + assert.strictEqual(actionManager.$('.o_list_view').length, 0, + "should no longer display the list view"); + assert.strictEqual(actionManager.$('.o_form_view').length, 1, + "should display the form view"); + assert.strictEqual(actionManager.$('.o_field_widget[name=foo]').text(), 'yop', + "should have opened the correct record"); + + // go back to list view using the breadcrumbs + $('.o_control_panel .breadcrumb a').click(); + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should display the list view"); + assert.strictEqual(actionManager.$('.o_form_view').length, 0, + "should no longer display the form view"); + + assert.verifySteps([ + '/web/action/load', + 'load_views', + '/web/dataset/search_read', // list + '/web/dataset/search_read', // kanban + '/web/dataset/search_read', // list + 'read', // form + '/web/dataset/search_read', // list + ]); + + actionManager.destroy(); + }); + + QUnit.test('breadcrumbs are updated when switching between views', function (assert) { + assert.expect(10); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + actionManager.doAction(3); + + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 1, + "there should be one controller in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li').text(), 'Partners', + "breadcrumbs should display the display_name of the action"); + + // switch to kanban view + $('.o_control_panel .o_cp_switch_kanban').click(); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 1, + "there should still be one controller in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li').text(), 'Partners', + "breadcrumbs should still display the display_name of the action"); + + // switch back to list view + $('.o_control_panel .o_cp_switch_list').click(); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 1, + "there should still be one controller in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li').text(), 'Partners', + "breadcrumbs should still display the display_name of the action"); + + // open a record in form view + actionManager.$('.o_list_view .o_data_row:first').click(); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 2, + "there should be two controllers in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li:last').text(), 'First record', + "breadcrumbs should contain the display_name of the opened record"); + + // go back to list view using the breadcrumbs + $('.o_control_panel .breadcrumb a').click(); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 1, + "there should be one controller in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li').text(), 'Partners', + "breadcrumbs should display the display_name of the action"); + + actionManager.destroy(); + }); + + QUnit.test('switch buttons are updated when switching between views', function (assert) { + assert.expect(13); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + actionManager.doAction(3); + + assert.strictEqual($('.o_control_panel .o_cp_switch_buttons button').length, 2, + "should have two switch buttons (list and kanban)"); + assert.strictEqual($('.o_control_panel .o_cp_switch_buttons button.active').length, 1, + "should have only one active button"); + assert.ok($('.o_control_panel .o_cp_switch_buttons button:first').hasClass('o_cp_switch_list'), + "list switch button should be the first one"); + assert.ok($('.o_control_panel .o_cp_switch_list').hasClass('active'), + "list should be the active view"); + + // switch to kanban view + $('.o_control_panel .o_cp_switch_kanban').click(); + assert.strictEqual($('.o_control_panel .o_cp_switch_buttons button').length, 2, + "should still have two switch buttons (list and kanban)"); + assert.strictEqual($('.o_control_panel .o_cp_switch_buttons button.active').length, 1, + "should still have only one active button"); + assert.ok($('.o_control_panel .o_cp_switch_buttons button:first').hasClass('o_cp_switch_list'), + "list switch button should still be the first one"); + assert.ok($('.o_control_panel .o_cp_switch_kanban').hasClass('active'), + "kanban should now be the active view"); + + // switch back to list view + $('.o_control_panel .o_cp_switch_list').click(); + assert.strictEqual($('.o_control_panel .o_cp_switch_buttons button').length, 2, + "should still have two switch buttons (list and kanban)"); + assert.ok($('.o_control_panel .o_cp_switch_list').hasClass('active'), + "list should now be the active view"); + + // open a record in form view + actionManager.$('.o_list_view .o_data_row:first').click(); + assert.strictEqual($('.o_control_panel .o_cp_switch_buttons button').length, 0, + "should not have any switch buttons"); + + // go back to list view using the breadcrumbs + $('.o_control_panel .breadcrumb a').click(); + assert.strictEqual($('.o_control_panel .o_cp_switch_buttons button').length, 2, + "should have two switch buttons (list and kanban)"); + assert.ok($('.o_control_panel .o_cp_switch_list').hasClass('active'), + "list should be the active view"); + + actionManager.destroy(); + }); + + QUnit.test('pager is updated when switching between views', function (assert) { + assert.expect(10); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + actionManager.doAction(4); + + assert.strictEqual($('.o_control_panel .o_pager_value').text(), '1-5', + "value should be correct for kanban"); + assert.strictEqual($('.o_control_panel .o_pager_limit').text(), '5', + "limit should be correct for kanban"); + + // switch to list view + $('.o_control_panel .o_cp_switch_list').click(); + assert.strictEqual($('.o_control_panel .o_pager_value').text(), '1-3', + "value should be correct for list"); + assert.strictEqual($('.o_control_panel .o_pager_limit').text(), '5', + "limit should be correct for list"); + + // open a record in form view + actionManager.$('.o_list_view .o_data_row:first').click(); + assert.strictEqual($('.o_control_panel .o_pager_value').text(), '1', + "value should be correct for form"); + assert.strictEqual($('.o_control_panel .o_pager_limit').text(), '3', + "limit should be correct for form"); + + // go back to list view using the breadcrumbs + $('.o_control_panel .breadcrumb a').click(); + assert.strictEqual($('.o_control_panel .o_pager_value').text(), '1-3', + "value should be correct for list"); + assert.strictEqual($('.o_control_panel .o_pager_limit').text(), '5', + "limit should be correct for list"); + + // switch back to kanban view + $('.o_control_panel .o_cp_switch_kanban').click(); + assert.strictEqual($('.o_control_panel .o_pager_value').text(), '1-5', + "value should be correct for kanban"); + assert.strictEqual($('.o_control_panel .o_pager_limit').text(), '5', + "limit should be correct for kanban"); + + actionManager.destroy(); + }); + + QUnit.test('there is no flickering when switching between views', function (assert) { + assert.expect(20); + + var def; + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function () { + var result = this._super.apply(this, arguments); + return $.when(def).then(_.constant(result)); + }, + }); + actionManager.doAction(3); + + // switch to kanban view + def = $.Deferred(); + $('.o_control_panel .o_cp_switch_kanban').click(); + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should still display the list view"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 0, + "shouldn't display the kanban view yet"); + def.resolve(); + assert.strictEqual(actionManager.$('.o_list_view').length, 0, + "shouldn't display the list view anymore"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 1, + "should now display the kanban view"); + + // switch back to list view + def = $.Deferred(); + $('.o_control_panel .o_cp_switch_list').click(); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 1, + "should still display the kanban view"); + assert.strictEqual(actionManager.$('.o_list_view').length, 0, + "shouldn't display the list view yet"); + def.resolve(); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 0, + "shouldn't display the kanban view anymore"); + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should now display the list view"); + + // open a record in form view + def = $.Deferred(); + actionManager.$('.o_list_view .o_data_row:first').click(); + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should still display the list view"); + assert.strictEqual(actionManager.$('.o_form_view').length, 0, + "shouldn't display the form view yet"); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 1, + "there should still be one controller in the breadcrumbs"); + def.resolve(); + assert.strictEqual(actionManager.$('.o_list_view').length, 0, + "should no longer display the list view"); + assert.strictEqual(actionManager.$('.o_form_view').length, 1, + "should display the form view"); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 2, + "there should be two controllers in the breadcrumbs"); + + // go back to list view using the breadcrumbs + def = $.Deferred(); + $('.o_control_panel .breadcrumb a').click(); + assert.strictEqual(actionManager.$('.o_form_view').length, 1, + "should still display the form view"); + assert.strictEqual(actionManager.$('.o_list_view').length, 0, + "shouldn't display the list view yet"); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 2, + "there should still be two controllers in the breadcrumbs"); + def.resolve(); + assert.strictEqual(actionManager.$('.o_form_view').length, 0, + "should no longer display the form view"); + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should display the list view"); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 1, + "there should be one controller in the breadcrumbs"); + + actionManager.destroy(); + }); + + QUnit.test('breadcrumbs are updated when display_name changes', function (assert) { + assert.expect(4); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + actionManager.doAction(3); + + // open a record in form view + actionManager.$('.o_list_view .o_data_row:first').click(); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 2, + "there should be two controllers in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li:last').text(), 'First record', + "breadcrumbs should contain the display_name of the opened record"); + + // switch to edit mode and change the display_name + $('.o_control_panel .o_form_button_edit').click(); + actionManager.$('.o_field_widget[name=display_name]').val('New name').trigger('input'); + $('.o_control_panel .o_form_button_save').click(); + + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 2, + "there should still be two controllers in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li:last').text(), 'New name', + "breadcrumbs should contain the display_name of the opened record"); + + actionManager.destroy(); + }); + + QUnit.test('reload previous controller when discarding a new record', function (assert) { + assert.expect(8); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + actionManager.doAction(3); + + // create a new record + $('.o_control_panel .o_list_button_add').click(); + assert.strictEqual(actionManager.$('.o_form_view.o_form_editable').length, 1, + "should have opened the form view in edit mode"); + + // discard + $('.o_control_panel .o_form_button_cancel').click(); + assert.strictEqual(actionManager.$('.o_list_view').length, 1, + "should have switched back to the list view"); + + assert.verifySteps([ + '/web/action/load', + 'load_views', + '/web/dataset/search_read', // list + 'default_get', // form + '/web/dataset/search_read', // list + ]); + + actionManager.destroy(); + }); + + QUnit.test('requests for execute_action of type object are handled', function (assert) { + assert.expect(10); + + var self = this; + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + if (route === '/web/dataset/call_button') { + assert.deepEqual(args, { + args: [[1], {some_key: 2}], + method: 'object', + model: 'partner', + }, "should call route with correct arguments"); + var record = _.findWhere(self.data.partner.records, {id: args.args[0][0]}); + record.foo = 'value changed'; + return $.when(false); + } + return this._super.apply(this, arguments); + }, + session: {user_context: { + some_key: 2, + }}, + }); + actionManager.doAction(3); + + // open a record in form view + actionManager.$('.o_list_view .o_data_row:first').click(); + assert.strictEqual(actionManager.$('.o_field_widget[name=foo]').text(), 'yop', + "check initial value of 'yop' field"); + + // click on 'Call method' button (should call an Object method) + actionManager.$('.o_form_view button:contains(Call method)').click(); + assert.strictEqual(actionManager.$('.o_field_widget[name=foo]').text(), 'value changed', + "'yop' has been changed by the server, and should be updated in the UI"); + + assert.verifySteps([ + '/web/action/load', + 'load_views', + '/web/dataset/search_read', // list for action 3 + 'read', // form for action 3 + 'object', // click on 'Call method' button + 'read', // re-read form view + ]); + + actionManager.destroy(); + }); + + QUnit.test('requests for execute_action of type action are handled', function (assert) { + assert.expect(11); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + actionManager.doAction(3); + + // open a record in form view + actionManager.$('.o_list_view .o_data_row:first').click(); + + // click on 'Execute action' button (should execute an action) + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 2, + "there should be two parts in the breadcrumbs"); + actionManager.$('.o_form_view button:contains(Execute action)').click(); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 3, + "the returned action should have been stacked over the previous one"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 1, + "the returned action should have been executed"); + + assert.verifySteps([ + '/web/action/load', + 'load_views', + '/web/dataset/search_read', // list for action 3 + 'read', // form for action 3 + '/web/action/load', // click on 'Execute action' button + 'load_views', + '/web/dataset/search_read', // kanban for action 4 + ]); + + actionManager.destroy(); + }); + + QUnit.test('can open different records from a multi record view', function (assert) { + assert.expect(11); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + actionManager.doAction(3); + + // open the first record in form view + actionManager.$('.o_list_view .o_data_row:first').click(); + assert.strictEqual($('.o_control_panel .breadcrumb li:last').text(), 'First record', + "breadcrumbs should contain the display_name of the opened record"); + assert.strictEqual(actionManager.$('.o_field_widget[name=foo]').text(), 'yop', + "should have opened the correct record"); + + // go back to list view using the breadcrumbs + $('.o_control_panel .breadcrumb a').click(); + + // open the second record in form view + actionManager.$('.o_list_view .o_data_row:nth(1)').click(); + assert.strictEqual($('.o_control_panel .breadcrumb li:last').text(), 'Second record', + "breadcrumbs should contain the display_name of the opened record"); + assert.strictEqual(actionManager.$('.o_field_widget[name=foo]').text(), 'blip', + "should have opened the correct record"); + + assert.verifySteps([ + '/web/action/load', + 'load_views', + '/web/dataset/search_read', // list + 'read', // form + '/web/dataset/search_read', // list + 'read', // form + ]); + + actionManager.destroy(); + }); + + QUnit.test('limit set in action is passed to each created controller', function (assert) { + assert.expect(2); + + _.findWhere(this.actions, {id: 3}).limit = 2; + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + actionManager.doAction(3); + + assert.strictEqual(actionManager.$('.o_data_row').length, 2, + "should only display 2 record"); + + // switch to kanban view + $('.o_control_panel .o_cp_switch_kanban').click(); + + assert.strictEqual(actionManager.$('.o_kanban_record:not(.o_kanban_ghost)').length, 2, + "should only display 2 record"); + + actionManager.destroy(); + }); + + QUnit.test('go back to a previous action using the breadcrumbs', function (assert) { + assert.expect(10); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + actionManager.doAction(3); + + // open a record in form view + actionManager.$('.o_list_view .o_data_row:first').click(); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 2, + "there should be two controllers in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li:last').text(), 'First record', + "breadcrumbs should contain the display_name of the opened record"); + + // push another action on top of the first one, and come back to the form view + actionManager.doAction(4); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 3, + "there should be three controllers in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li:last').text(), 'Partners Action 4', + "breadcrumbs should contain the name of the current action"); + // go back using the breadcrumbs + $('.o_control_panel .breadcrumb a:nth(1)').click(); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 2, + "there should be two controllers in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li:last').text(), 'First record', + "breadcrumbs should contain the display_name of the opened record"); + + // push again the other action on top of the first one, and come back to the list view + actionManager.doAction(4); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 3, + "there should be three controllers in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li:last').text(), 'Partners Action 4', + "breadcrumbs should contain the name of the current action"); + // go back using the breadcrumbs + $('.o_control_panel .breadcrumb a:first').click(); + assert.strictEqual($('.o_control_panel .breadcrumb li').length, 1, + "there should be one controller in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb li:last').text(), 'Partners', + "breadcrumbs should contain the name of the current action"); + + actionManager.destroy(); + }); + + QUnit.module('Actions in target="new"'); + + QUnit.test('can execute act_window actions in target="new"', function (assert) { + assert.expect(7); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + actionManager.doAction(5); + + assert.strictEqual($('.o_technical_modal .o_form_view').length, 1, + "should have rendered a form view in a modal"); + assert.ok($('.o_technical_modal .modal-body').hasClass('o_act_window'), + "modal-body element should have classname 'o_act_window'"); + assert.ok($('.o_technical_modal .o_form_view').hasClass('o_form_editable'), + "form view should be in edit mode"); + + assert.verifySteps([ + '/web/action/load', + 'load_views', + 'default_get', + ]); + + actionManager.destroy(); + }); + + QUnit.test('footer buttons are moved to the dialog footer', function (assert) { + assert.expect(3); + + this.archs['partner,false,form'] = '<form>' + + '<field name="display_name"/>' + + '<footer>' + + '<button string="Create" type="object" class="infooter"/>' + + '</footer>' + + '</form>'; + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + actionManager.doAction(5); + + assert.strictEqual($('.o_technical_modal .modal-body button.infooter').length, 0, + "the button should not be in the body"); + assert.strictEqual($('.o_technical_modal .modal-footer button.infooter').length, 1, + "the button should be in the footer"); + assert.strictEqual($('.o_technical_modal .modal-footer button').length, 1, + "the modal footer should only contain one button"); + + actionManager.destroy(); + }); + + QUnit.module('Actions in target="inline"'); + + QUnit.test('form views for actions in target="inline" open in edit mode', function (assert) { + assert.expect(5); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + actionManager.doAction(6); + + assert.strictEqual(actionManager.$('.o_form_view.o_form_editable').length, 1, + "should have rendered a form view in edit mode"); + + assert.verifySteps([ + '/web/action/load', + 'load_views', + 'read', + ]); + + actionManager.destroy(); + }); + + QUnit.module('Actions in target="fullscreen"'); + + QUnit.test('correctly execute act_window actions in target="fullscreen"', function (assert) { + assert.expect(7); + + this.actions[0].target = 'fullscreen'; + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + intercepts: { + toggle_fullscreen: function () { + assert.step('toggle_fullscreen'); + }, + }, + }); + actionManager.doAction(1); + + assert.strictEqual($('.o_control_panel').length, 1, + "should have rendered a control panel"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 1, + "should have rendered a kanban view"); + assert.verifySteps([ + '/web/action/load', + 'load_views', + '/web/dataset/search_read', + 'toggle_fullscreen', + ]); + + actionManager.destroy(); + }); + + QUnit.module('"ir.actions.act_window_close" actions'); + + QUnit.test('close the currently opened dialog', function (assert) { + assert.expect(2); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + + // execute an action in target="new" + actionManager.doAction(5); + assert.strictEqual($('.o_technical_modal .o_form_view').length, 1, + "should have rendered a form view in a modal"); + + // execute an 'ir.actions.act_window_close' action + actionManager.doAction({ + type: 'ir.actions.act_window_close', + }); + assert.strictEqual($('.o_technical_modal').length, 0, + "should have closed the modal"); + + actionManager.destroy(); + }); + + QUnit.test('execute "on_close" only if there is no dialog to close', function (assert) { + assert.expect(3); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + + // execute an action in target="new" + actionManager.doAction(5); + + var options = { + on_close: assert.step.bind(assert, 'on_close'), + }; + // execute an 'ir.actions.act_window_close' action + // should not call 'on_close' as there is a dialog to close + actionManager.doAction({type: 'ir.actions.act_window_close'}, options); + + assert.verifySteps([]); + + // execute again an 'ir.actions.act_window_close' action + // should call 'on_close' as there is no dialog to close + actionManager.doAction({type: 'ir.actions.act_window_close'}, options); + + assert.verifySteps(['on_close']); + + actionManager.destroy(); + }); +}); + +}); diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index 4bc58dd097d..dbe1a2c49e0 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -12,6 +12,7 @@ var MockServer = Class.extend({ * @constructor * @param {Object} data * @param {Object} options + * @param {Object[]} [options.actions=[]] * @param {Object} [options.archs={}] dict of archs with keys being strings like * 'model,id,viewType' * @param {boolean} [options.debug=false] logs RPCs if set to true @@ -47,6 +48,7 @@ var MockServer = Class.extend({ this.currentDate = options.currentDate || moment().format("YYYY-MM-DD"); + this.actions = options.actions || []; this.archs = options.archs || {}; }, @@ -432,6 +434,23 @@ var MockServer = Class.extend({ return modelFields; }, /** + * Simulate a call to the '/web/action/load' route + * + * @private + * @param {Object} kwargs + * @param {integer} kwargs.action_id + * @returns {Object} + */ + _mockLoadAction: function (kwargs) { + var action = _.findWhere(this.actions, {id: parseInt(kwargs.action_id)}); + if (!action) { + // when the action doesn't exist, the real server doesn't crash, it + // simply returns false + console.warn("No action found for ID " + event.data.actionID); + } + return action || false; + }, + /** * Simulate a 'load_views' operation * * @param {string} model @@ -943,7 +962,7 @@ var MockServer = Class.extend({ _performRpc: function (route, args) { switch (route) { case '/web/action/load': - return $.when(this._mockLoadAction(args)); + return $.when(this._mockLoadAction(args.kwargs)); case '/web/dataset/search_read': return $.when(this._mockSearchReadController(args)); diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js index e049ba968b2..afeb579eb02 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -10,9 +10,11 @@ odoo.define('web.test_utils', function (require) { * instance of a view, appended in the dom, ready to be tested. */ +var ActionManager = require('web.ActionManager'); var ajax = require('web.ajax'); var basic_fields = require('web.basic_fields'); var config = require('web.config'); +var ControlPanel = require('web.ControlPanel'); var core = require('web.core'); var dom = require('web.dom'); var session = require('web.session'); @@ -59,6 +61,58 @@ function observe(widget) { } /** + * create and return an instance of ActionManager with all rpcs going through a + * mock method using the data, actions and archs objects as sources. + * + * @param {Object} [params] + * @param {Object} [params.actions] the actions given to the mock server + * @param {Object} [params.archs] this archs given to the mock server + * @param {Object} [params.data] the business data given to the mock server + * @param {boolean} [params.debug] + * @param {function} [params.mockRPC] + * @returns {ActionManager} + */ +var createActionManager = function (params) { + params = params || {}; + var $target = $('#qunit-fixture'); + if (params.debug) { + $target = $('body'); + } + + var widget = new Widget(); + // when 'document' addon is installed, the sidebar does a 'search_read' on + // model 'ir_attachment' each time a record is open, so we monkey-patch + // 'mockRPC' to mute those RPCs, so that the tests can be written uniformly, + // whether or not 'document' is installed + var mockRPC = params.mockRPC; + _.extend(params, { + mockRPC: function (route, args) { + if (args.model === 'ir.attachment') { + return $.when([]); + } + if (mockRPC) { + return mockRPC.apply(this, arguments); + } + return this._super.apply(this, arguments); + }, + }); + addMockEnvironment(widget, _.defaults(params, {debounce: false})); + widget.appendTo($target); + widget.$el.addClass('o_web_client'); + + var userContext = params.context && params.context.user_context || {}; + var actionManager = new ActionManager(widget, userContext); + var originalDestroy = ActionManager.prototype.destroy; + actionManager.destroy = function () { + actionManager.destroy = originalDestroy; + widget.destroy(); + }; + actionManager.appendTo(widget.$el); + + return actionManager; +}; + +/** * create a view synchronously. This method uses the createAsyncView method. * Most views are synchronous, so the deferred can be resolved immediately and * this method will work. @@ -87,8 +141,7 @@ function createView(params) { * * It returns the instance of the view, properly created, with all rpcs going * through a mock method using the data object as source, and already loaded/ - * started (with a do_search). The buttons/pager should also be created, if - * appropriate. + * started. * * Most views can be tested synchronously (@see createView), but some view have * external dependencies (like lazy loaded libraries). In that case, it is @@ -145,9 +198,9 @@ function createAsyncView(params) { // reproduce the DOM environment of views var $web_client = $('<div>').addClass('o_web_client').prependTo($target); - var $control_panel = $('<div>').addClass('o_control_panel').appendTo($web_client); + var controlPanel = new ControlPanel(widget); + controlPanel.appendTo($web_client); var $content = $('<div>').addClass('o_content').appendTo($web_client); - var $view_manager = $('<div>').addClass('o_view_manager_content').appendTo($content); return view.getController(widget).then(function (view) { // override the view's 'destroy' so that it calls 'destroy' on the widget @@ -159,29 +212,21 @@ function createAsyncView(params) { widget.destroy(); $('#qunit-fixture').off('DOMNodeInserted.removeSRC'); }; + + // link the view to the control panel + view.set_cp_bus(controlPanel.get_bus()); + // render the view in a fragment as they must be able to render correctly // without being in the DOM var fragment = document.createDocumentFragment(); return view.appendTo(fragment).then(function () { - dom.append($view_manager, fragment, { + dom.append($content, fragment, { callbacks: [{widget: view}], in_DOM: true, }); view.$el.on('click', 'a', function (ev) { ev.preventDefault(); }); - }).then(function () { - var $buttons = $('<div>'); - view.renderButtons($buttons); - $buttons.contents().appendTo($control_panel); - - var $sidebar = $('<div>'); - view.renderSidebar($sidebar); - $sidebar.contents().appendTo($control_panel); - - var $pager = $('<div>'); - view.renderPager($pager); - $pager.contents().appendTo($control_panel); return view; }); @@ -250,6 +295,7 @@ function addMockEnvironment(widget, params) { console.log('%c[debug] debug mode activated', 'color: blue; font-weight: bold;', url); } var mockServer = new Server(params.data, { + actions: params.actions, archs: params.archs, currentDate: params.currentDate, debug: params.debug, @@ -336,6 +382,14 @@ function addMockEnvironment(widget, params) { } }); + intercept(widget, 'load_action', function (event) { + mockServer.performRpc('/web/action/load', { + kwargs: {action_id: event.data.actionID}, + }).then(function (action) { + event.data.on_success(action); + }); + }); + intercept(widget, "load_views", function (event) { mockServer.performRpc('/web/dataset/call_kw/' + event.data.modelName, { args: [], @@ -617,6 +671,7 @@ return $.when( }, 0); return { addMockEnvironment: addMockEnvironment, + createActionManager: createActionManager, createAsyncView: createAsyncView, createModel: createModel, createParent: createParent, diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index 9f2a9b68698..06cc046df83 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -1451,7 +1451,7 @@ QUnit.module('Views', { '<field name="foo"/>' + '</sheet>' + '</form>', - viewOptions: {sidebar: true}, + viewOptions: {hasSidebar: true}, res_id: 1, mockRPC: function (route, args) { if (args.method === 'search_read' && args.model === 'ir.attachment') { @@ -1597,7 +1597,7 @@ QUnit.module('Views', { '<field name="foo"/>' + '</form>', res_id: 1, - viewOptions: {sidebar: true}, + viewOptions: {hasSidebar: true}, mockRPC: function (route, args) { if (args.method === 'search_read' && args.model === 'ir.attachment') { return $.when([]); @@ -1628,7 +1628,7 @@ QUnit.module('Views', { '<field name="foo"/>' + '</form>', res_id: 1, - viewOptions: {sidebar: true}, + viewOptions: {hasSidebar: true}, mockRPC: function (route, args) { if (args.method === 'search_read' && args.model === 'ir.attachment') { return $.when([]); @@ -1659,7 +1659,7 @@ QUnit.module('Views', { '</footer>' + '</form>', res_id: 1, - viewOptions: {footer_to_buttons: true}, + viewOptions: {footerToButtons: true}, }); assert.ok(form.$buttons.find('button.infooter').length, "footer button should be in footer"); @@ -2050,7 +2050,7 @@ QUnit.module('Views', { data: this.data, arch: '<form string="Partners"><field name="date"></field></form>', intercepts: { - switch_to_previous_view: function (event) { + history_back: function () { form.update({}, {reload: false}); } }, @@ -2086,9 +2086,9 @@ QUnit.module('Views', { return this._super.apply(this, arguments); }, intercepts: { - switch_to_previous_view: function (event) { + history_back: function () { assert.ok(true, "should have sent correct event"); - // simulate the response from the view manager, in the case + // simulate the response from the action manager, in the case // where we have only one active view (the form). If there // was another view, we would have switched to that view // instead @@ -2126,7 +2126,7 @@ QUnit.module('Views', { data: this.data, arch: '<form string="Partners"><field name="foo"></field></form>', intercepts: { - switch_to_previous_view: function () { + history_back: function () { assert.ok(true, "should have sent correct event"); } } @@ -2155,9 +2155,9 @@ QUnit.module('Views', { data: this.data, arch: '<form string="Partners"><field name="foo"></field></form>', intercepts: { - switch_to_previous_view: function (event) { + history_back: function () { assert.ok(true, "should have sent correct event"); - // simulate the response from the view manager, in the case + // simulate the response from the action manager, in the case // where we have only one active view (the form). If there // was another view, we would have switched to that view // instead @@ -2197,7 +2197,7 @@ QUnit.module('Views', { data: this.data, arch: '<form string="Partners"><field name="foo"></field></form>', res_id: 1, - viewOptions: {sidebar: true}, + viewOptions: {hasSidebar: true}, mockRPC: function (route, args) { if (args.method === 'search_read' && args.model === 'ir.attachment') { return $.when([]); @@ -2542,7 +2542,7 @@ QUnit.module('Views', { viewOptions: { ids: [1, 2, 4], index: 0, - sidebar: true, + hasSidebar: true, }, res_id: 1, mockRPC: function (route, args) { @@ -2586,7 +2586,7 @@ QUnit.module('Views', { viewOptions: { ids: [1], index: 0, - sidebar: true, + hasSidebar: true, }, res_id: 1, mockRPC: function (route, args) { @@ -2602,15 +2602,14 @@ QUnit.module('Views', { form.sidebar.$('button.o_dropdown_toggler_btn').click(); form.sidebar.$('a:contains(Delete)').click(); - testUtils.intercept(form, 'do_action', function (event) { - assert.strictEqual(event.data.action, 'history_back', - "should trigger an history back action"); + testUtils.intercept(form, 'history_back', function () { + assert.step('history_back'); }); assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed'); $('.modal .modal-footer button.btn-primary').click(); assert.strictEqual($('.modal').length, 0, 'no confirm modal should be displayed'); - assert.verifySteps(['read', 'unlink']); + assert.verifySteps(['read', 'unlink', 'history_back']); form.destroy(); }); @@ -4287,7 +4286,7 @@ QUnit.module('Views', { '</form>', res_id: 1, viewOptions: { - footer_to_buttons: true, + footerToButtons: true, mode: 'edit', }, }); @@ -4326,7 +4325,7 @@ QUnit.module('Views', { '</form>', res_id: 1, viewOptions: { - footer_to_buttons: true, + footerToButtons: true, }, }); @@ -4684,7 +4683,7 @@ QUnit.module('Views', { print: [], }, viewOptions: { - sidebar: true, + hasSidebar: true, }, mockRPC: function (route, args) { if (route === '/web/action/load') { @@ -5943,7 +5942,7 @@ QUnit.module('Views', { '<field name="display_name"/>' + '</form>', res_id: 1, - viewOptions: {sidebar: true}, + viewOptions: {hasSidebar: true}, mockRPC: function (route, args) { if (args.method === 'search_read' && args.model === 'ir.attachment') { // rpcs done by the sidebar diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index eed80410e7f..5b34ed413c5 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -164,7 +164,7 @@ QUnit.module('Views', { groupBy: ['bar'], intercepts: { env_updated: function (event) { - assert.deepEqual(event.data.ids, envIDs, + assert.deepEqual(event.data.env.ids, envIDs, "should notify the environment with the correct ids"); }, }, @@ -384,7 +384,7 @@ QUnit.module('Views', { groupBy: ['bar'], intercepts: { env_updated: function (event) { - assert.strictEqual(event.data.ids.length, nbRecords, + assert.strictEqual(event.data.env.ids.length, nbRecords, "should update the env with the records ids"); }, } @@ -666,7 +666,7 @@ QUnit.module('Views', { }, intercepts: { switch_view: function (event) { - assert.deepEqual(event.data, { + assert.deepEqual(_.pick(event.data, 'mode', 'model', 'res_id', 'view_type'), { mode: 'readonly', model: 'partner', res_id: 1, @@ -733,7 +733,7 @@ QUnit.module('Views', { }, intercepts: { env_updated: function (event) { - assert.deepEqual(event.data.ids, envIDs, + assert.deepEqual(event.data.env.ids, envIDs, "should notify the environment with the correct ids"); }, }, @@ -977,7 +977,7 @@ QUnit.module('Views', { groupBy: ['product_id'], intercepts: { env_updated: function (event) { - assert.deepEqual(envIDs, event.data.ids, + assert.deepEqual(event.data.env.ids, envIDs, "should notify the environment with the correct ids"); }, }, @@ -1659,7 +1659,7 @@ QUnit.module('Views', { }, intercepts: { env_updated: function (event) { - assert.deepEqual(event.data.ids, envIDs, + assert.deepEqual(event.data.env.ids, envIDs, "should notify the environment with the correct ids"); }, }, @@ -1823,7 +1823,7 @@ QUnit.module('Views', { }, intercepts:{ env_updated: function (event) { - assert.deepEqual(event.data.ids, envIDs, + assert.deepEqual(event.data.env.ids, envIDs, "should notify the environment with the correct ids"); }, }, diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 33b44948fca..b86a820196e 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -150,7 +150,7 @@ QUnit.module('Views', { View: ListView, model: 'foo', data: this.data, - viewOptions: {sidebar: true}, + viewOptions: {hasSidebar: true}, arch: '<tree delete="0"><field name="foo"/></tree>', }); @@ -261,7 +261,7 @@ QUnit.module('Views', { '<field name="foo"/>' + '<field name="m2o" invisible="1"/>' + '</tree>', - mockRPC: function (route, args) { + mockRPC: function (route) { assert.step(_.last(route.split('/'))); return this._super.apply(this, arguments); }, @@ -765,7 +765,7 @@ QUnit.module('Views', { View: ListView, model: 'foo', data: this.data, - viewOptions: {sidebar: true}, + viewOptions: {hasSidebar: true}, arch: '<tree><field name="foo"/></tree>', }); @@ -795,7 +795,7 @@ QUnit.module('Views', { View: ListView, model: 'foo', data: this.data, - viewOptions: {sidebar: true}, + viewOptions: {hasSidebar: true}, arch: '<tree><field name="foo"/></tree>', mockRPC: function (route) { if (route === '/web/dataset/call_kw/ir.attachment/search_read') { @@ -1686,7 +1686,7 @@ QUnit.module('Views', { "'switch_view' event has been triggered"); }, env_updated: function (event) { - assert.deepEqual(event.data.ids, envIDs, + assert.deepEqual(event.data.env.ids, envIDs, "should notify the environment with the correct ids"); }, }, @@ -2031,7 +2031,7 @@ QUnit.module('Views', { '<field name="bar"/>' + '</tree>', intercepts: { - warning: function (ev) { + warning: function () { warnings++; }, }, @@ -2070,14 +2070,13 @@ QUnit.module('Views', { arch: '<tree><field name="name"/></tree>', }); - testUtils.intercept(list, "switch_view", function (event) { - assert.deepEqual(event.data, { - 'model': 'event', - 'res_id': '2-20170808020000', - 'view_type': 'form', - 'mode': 'readonly' - }, - "should trigger switch to the form view for the record virtual id"); + testUtils.intercept(list, 'switch_view', function (event) { + assert.deepEqual(_.pick(event.data, 'mode', 'model', 'res_id', 'view_type'), { + mode: 'readonly', + model: 'event', + res_id: '2-20170808020000', + view_type: 'form', + }, "should trigger a switch_view event to the form view for the record virtual id"); }); list.$('td:contains(virtual)').click(); @@ -2092,7 +2091,7 @@ QUnit.module('Views', { model: 'foo', data: this.data, arch: '<tree editable="bottom"><field name="foo"/></tree>', - mockRPC: function (route, args) { + mockRPC: function (route) { assert.step(route); return this._super.apply(this, arguments); }, @@ -2220,7 +2219,7 @@ QUnit.module('Views', { print: [], }, viewOptions: { - sidebar: true, + hasSidebar: true, }, }); diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js index b2dc9c5ab0e..08ddd2c91c2 100644 --- a/addons/web/static/tests/views/view_dialogs_tests.js +++ b/addons/web/static/tests/views/view_dialogs_tests.js @@ -51,7 +51,7 @@ QUnit.module('Views', { }); testUtils.intercept(parent, 'env_updated', function () { - throw new Error("The environment should not be propagated to the view manager"); + throw new Error("The environment should not be propagated to the action manager"); }); diff --git a/addons/web/static/tests/widgets/data_export_tests.js b/addons/web/static/tests/widgets/data_export_tests.js index d251e3dea15..192944ce3b5 100644 --- a/addons/web/static/tests/widgets/data_export_tests.js +++ b/addons/web/static/tests/widgets/data_export_tests.js @@ -53,7 +53,7 @@ QUnit.module('widgets', { data: this.data, arch: '<tree><field name="foo"/></tree>', viewOptions: { - sidebar: true, + hasSidebar: true, }, mockRPC: function (route) { if (route === '/web/export/formats') { @@ -144,7 +144,7 @@ QUnit.module('widgets', { data: this.data, arch: '<tree><field name="foo"/></tree>', viewOptions: { - sidebar: true, + hasSidebar: true, }, mockRPC: function (route) { if (route === '/web/export/formats') { diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 438636a0253..295aa7f9b26 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -149,8 +149,8 @@ <script type="text/javascript" src="/web/static/src/js/core/domain.js"></script> <script type="text/javascript" src="/web/static/src/js/core/pyeval.js"></script> <script type="text/javascript" src="/web/static/src/js/chrome/action_manager.js"></script> + <script type="text/javascript" src="/web/static/src/js/chrome/action_manager_act_window.js"></script> <script type="text/javascript" src="/web/static/src/js/chrome/control_panel.js"></script> - <script type="text/javascript" src="/web/static/src/js/chrome/view_manager.js"></script> <script type="text/javascript" src="/web/static/src/js/chrome/abstract_web_client.js"></script> <script type="text/javascript" src="/web/static/src/js/chrome/web_client.js"></script> <script type="text/javascript" src="/web/static/src/js/_deprecated/data.js"></script> @@ -489,6 +489,7 @@ <script type="text/javascript" src="/web/static/tests/core/util_tests.js"></script> <script type="text/javascript" src="/web/static/tests/core/widget_tests.js"></script> + <script type="text/javascript" src="/web/static/tests/chrome/action_manager_tests.js"></script> <script type="text/javascript" src="/web/static/tests/chrome/search_tests.js"></script> <script type="text/javascript" src="/web/static/tests/chrome/pager_tests.js"></script> @@ -534,6 +535,7 @@ <t t-call="web.js_tests_assets"/> <script type="text/javascript" src="/web/static/lib/jquery.touchSwipe/jquery.touchSwipe.js"></script> + <script type="text/javascript" src="/web/static/tests/chrome/action_manager_mobile_tests.js"></script> <script type="text/javascript" src="/web/static/tests/views/kanban_mobile_tests.js"></script> </t> diff --git a/addons/website/static/src/js/backend/dashboard.js b/addons/website/static/src/js/backend/dashboard.js index 9aae072e155..6a4d7c7a609 100644 --- a/addons/website/static/src/js/backend/dashboard.js +++ b/addons/website/static/src/js/backend/dashboard.js @@ -303,7 +303,6 @@ var Dashboard = Widget.extend(ControlPanelMixin, { $searchview: this.$searchview, $buttons: QWeb.render("website.GoToButtons"), }, - breadcrumbs: this.getParent().get_breadcrumbs(), }); }, |
