[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.
This commit is contained in:
Aaron Bohy
2018-01-16 20:49:19 +01:00
parent 9bee25a9df
commit 32b8cec536
52 changed files with 3883 additions and 1857 deletions
@@ -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,
});
});
}
},
@@ -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);
@@ -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);
},
});
});
+16 -4
View File
@@ -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',
@@ -339,6 +341,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
+9 -8
View File
@@ -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,
},
})
+1
View File
@@ -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>
@@ -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);
@@ -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');
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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,
@@ -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,
@@ -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);
@@ -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.
@@ -346,6 +348,27 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, {
this.notification_manager.warn(e.data.title, e.data.message, e.data.sticky);
}
},
/**
* 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
*
File diff suppressed because it is too large Load Diff
@@ -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);
}
},
});
});
@@ -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,22 +233,11 @@ 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
+15
View File
@@ -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;
@@ -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 || [])),
@@ -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));
}
}
@@ -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;
});
@@ -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();
+2 -2
View File
@@ -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()) {
-11
View File
@@ -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');
}
@@ -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,
},
@@ -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).
*
@@ -95,24 +134,16 @@ var AbstractController = Widget.extend({
getContext: function () {
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,20 +189,10 @@ 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
@@ -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;
},
});
@@ -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) {
@@ -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
@@ -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);
}
@@ -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;
@@ -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)
@@ -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;
@@ -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);
@@ -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);
},
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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%;
@@ -1,6 +1,4 @@
.o_graph {
height: 100%;
.o_graph_svg_container {
height: 100%;
overflow: auto;
-4
View File
@@ -23,10 +23,6 @@
float: right;
}
}
.o_view_manager_content {
position: static; // Reset position for view managers in modals (default value: absolute)
}
}
.modal-footer {
@@ -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
-3
View File
@@ -4,9 +4,6 @@
*/
// No content helper
.o_view_manager_content > div {
height: 100%;
}
.oe_view_nocontent {
a {
cursor: pointer;
@@ -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
}
}
}
}
+5 -7
View File
@@ -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>
@@ -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();
});
});
});
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -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 || {};
},
@@ -431,6 +433,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
*
@@ -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));
+72 -17
View File
@@ -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');
@@ -58,6 +60,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
@@ -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,
+20 -21
View File
@@ -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
@@ -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");
},
},
+15 -16
View File
@@ -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,
},
});
@@ -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");
});
@@ -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') {
+3 -1
View File
@@ -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>
@@ -303,7 +303,6 @@ var Dashboard = Widget.extend(ControlPanelMixin, {
$searchview: this.$searchview,
$buttons: QWeb.render("website.GoToButtons"),
},
breadcrumbs: this.getParent().get_breadcrumbs(),
});
},