[REF] web: move ControlPanel inside controllers/actions

This branch introduces a large-scale reorganization of the
component tree generated by the web client.  The short version is
that now, the control panel is a child of the view controller and
no longer a sibling. Graphically (and simplified), we go from
this:

                  webClient
                 /    |    \
               ...   ...  actionManager
                           /         \
                   controlPanel   viewController
                                    /       \

to this:

           	  webClient
                 /    |    \
               ...   ...  actionManager
                               |
                          viewController
                            /   |   \
          ControlPanelController
              /           \
         CPRenderer     CPModel

The motivation is that this work moves the code where it should be.
Before this commit, it was kind of weird to have code in the
controllers to render buttons outside of their root node (in
renderButtons).  Also, the action manager had to take care of
coordinating search view states between view transitions.

So, this work simplifies the code.  It also makes it easier to
extend. We see day after day that Odoo needs to take care of more
complex UI needs, and in many cases, these needs were quite
difficult to implement (we prefer spaghettis in our plates, not in
our code).  The changes in this branch should open the way to
implement these features.

For example,
- it will now be easy to add the possibility of views (for example,
  the search view or a new ControlPanel view) to add custom buttons
  (of type action or object) in the control panel.
- Another need will be to serialize/ restore the state of the
  search view across action boundaries (needed by the dashboard).
- Another example is the possibility for views to customize easily
  the presence/absence of sub menus (filters/groupbys/favorites/
  time range/...)
- Another need is an easier way for views/client action to customize
  their control panel.

This commit also contains a large rewrite of the search view (so it
is more inline with our architecture and easier to maintain).

Part of task 1893568

Co-authored-by: Aaron Bohy <aab@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Martin Geubelle <mge@odoo.com>

--
I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr

closes odoo/odoo#29106
This commit is contained in:
Robot Odoo
2018-12-07 23:26:47 +01:00
committed by GitHub
147 changed files with 8072 additions and 7018 deletions
@@ -4,18 +4,16 @@ odoo.define('account.ReconciliationClientAction', function (require) {
var AbstractAction = require('web.AbstractAction');
var ReconciliationModel = require('account.ReconciliationModel');
var ReconciliationRenderer = require('account.ReconciliationRenderer');
var ControlPanelMixin = require('web.ControlPanelMixin');
var Widget = require('web.Widget');
var core = require('web.core');
var _t = core._t;
/**
* Widget used as action for 'account.bank.statement' reconciliation
*/
var StatementAction = AbstractAction.extend(ControlPanelMixin, {
var StatementAction = AbstractAction.extend({
hasControlPanel: true,
title: core._t('Bank Reconciliation'),
template: 'reconciliation',
contentTemplate: 'reconciliation',
custom_events: {
change_mode: '_onAction',
change_filter: '_onAction',
@@ -34,7 +32,7 @@ var StatementAction = AbstractAction.extend(ControlPanelMixin, {
load_more: '_onLoadMore',
reload: 'reload',
},
config: {
config: _.extend({}, AbstractAction.prototype.config, {
// used to instantiate the model
Model: ReconciliationModel.StatementModel,
// used to instantiate the action interface
@@ -47,7 +45,7 @@ var StatementAction = AbstractAction.extend(ControlPanelMixin, {
defaultDisplayQty: 10,
// number of moves lines displayed in 'match' mode
limitMoveLines: 15,
},
}),
/**
* @override
@@ -65,9 +63,6 @@ var StatementAction = AbstractAction.extend(ControlPanelMixin, {
limitMoveLines: params.params && params.params.limitMoveLines || this.config.limitMoveLines,
});
this.widgets = [];
if (!this.action_manager) {
this.set_cp_bus(new Widget());
}
// Adding values from the context is necessary to put this information in the url via the action manager so that
// you can retrieve it if the person shares his url or presses f5
_.each(params.params, function (value, name) {
@@ -90,13 +85,14 @@ var StatementAction = AbstractAction.extend(ControlPanelMixin, {
var self = this;
var def = this.model.load(this.params.context).then(this._super.bind(this));
return def.then(function () {
self.title = self.model.bank_statement_id ? self.model.bank_statement_id.display_name : self.title;
var title = self.model.bank_statement_id && self.model.bank_statement_id.display_name;
self._setTitle(title);
self.renderer = new self.config.ActionRenderer(self, self.model, {
'bank_statement_id': self.model.bank_statement_id,
'valuenow': self.model.valuenow,
'valuemax': self.model.valuemax,
'defaultDisplayQty': self.model.defaultDisplayQty,
'title': self.title,
'title': title,
});
});
},
@@ -124,9 +120,6 @@ var StatementAction = AbstractAction.extend(ControlPanelMixin, {
start: function () {
var self = this;
this.set("title", this.title);
this.update_control_panel({search_view_hidden: true}, {clear: true});
this.renderer.prependTo(self.$('.o_form_sheet'));
this._renderLines();
@@ -141,6 +134,8 @@ var StatementAction = AbstractAction.extend(ControlPanelMixin, {
this.renderer._renderNotifications(this.model.statement.notifications);
this._openFirstLine();
}
return this._super.apply(this, arguments);
},
/**
@@ -151,7 +146,7 @@ var StatementAction = AbstractAction.extend(ControlPanelMixin, {
do_show: function () {
this._super.apply(this, arguments);
if (this.action_manager) {
this.update_control_panel({search_view_hidden: true}, {clear: true});
this.updateControlPanel({clear: true});
this.action_manager.do_push_state({
action: this.params.tag,
active_id: this.params.res_id,
@@ -354,14 +349,14 @@ var StatementAction = AbstractAction.extend(ControlPanelMixin, {
*/
var ManualAction = StatementAction.extend({
title: core._t('Journal Items to Reconcile'),
config: {
config: _.extend({}, StatementAction.prototype.config, {
Model: ReconciliationModel.ManualModel,
ActionRenderer: ReconciliationRenderer.ManualRenderer,
LineRenderer: ReconciliationRenderer.ManualLineRenderer,
params: ['company_ids', 'mode', 'partner_ids', 'account_ids'],
defaultDisplayQty: 30,
limitMoveLines: 15,
},
}),
//--------------------------------------------------------------------------
// Handlers
@@ -700,7 +700,7 @@ QUnit.module('account', {
assert.ok(clientAction.widgets[1].$('.match div.load-more a:visible').length, "should display the 'load more' button");
assert.equal(clientAction.widgets[1].$('.match div.load-more span').text(), 3, "should display 3 items remaining");
clientAction.widgets[1].$('.match div.load-more a').trigger('click');
assert.containsN(clientAction.widgets[1], '.mv_line', 8, "should load 3 more records"),
assert.containsN(clientAction.widgets[1], '.mv_line', 8, "should load 3 more records");
assert.notOk(clientAction.widgets[1].$('.match div.load-more a:visible').length, "should not display the 'load more' button anymore");
assert.ok(clientAction.widgets[0].$('caption button.btn-secondary:visible').length, "should display the 'validate' button");
@@ -2,7 +2,6 @@ odoo.define('base_import.import', function (require) {
"use strict";
var AbstractAction = require('web.AbstractAction');
var ControlPanelMixin = require('web.ControlPanelMixin');
var core = require('web.core');
var session = require('web.session');
var time = require('web.time');
@@ -72,8 +71,9 @@ function dataFilteredQuery(q) {
q.callback({results: suggestions});
}
var DataImport = AbstractAction.extend(ControlPanelMixin, {
template: 'ImportView',
var DataImport = AbstractAction.extend({
hasControlPanel: true,
contentTemplate: 'ImportView',
opts: [
{name: 'encoding', label: _lt("Encoding:"), value: ''},
{name: 'separator', label: _lt("Separator:"), value: ''},
@@ -137,7 +137,7 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
// import object id
this.id = null;
this.session = session;
action.display_name = _t('Import a File'); // Displayed in the breadcrumbs
this._title = _t('Import a File'); // Displayed in the breadcrumbs
this.do_not_change_match = false;
},
/**
@@ -145,13 +145,14 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
*/
willStart: function () {
var self = this;
return this._rpc({
var def = this._rpc({
model: this.res_model,
method: 'get_import_templates',
context: this.parent_context,
}).then(function (result) {
self.importTemplates = result;
});
return $.when(this._super.apply(this, arguments), def);
},
start: function () {
var self = this;
@@ -170,7 +171,7 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
var status = {
cp_content: {$buttons: self.$buttons},
};
self.update_control_panel(status);
self.updateControlPanel(status);
})
);
},
@@ -189,7 +190,7 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
this.$buttons.filter('.o_import_import').on('click', this.import.bind(this));
this.$buttons.filter('.o_import_file_reload').on('click', this.loaded_file.bind(this));
this.$buttons.filter('.oe_import_file').on('click', function () {
self.$('.oe_import_file').click();
self.$('.o_content .oe_import_file').click();
});
this.$buttons.filter('.o_import_cancel').on('click', function(e) {
e.preventDefault();
@@ -0,0 +1,205 @@
odoo.define('board.AddToBoardMenu', function (require) {
"use strict";
var ActionManager = require('web.ActionManager');
var Context = require('web.Context');
var core = require('web.core');
var Domain = require('web.Domain');
var favorites_submenus_registry = require('web.favorites_submenus_registry');
var pyUtils = require('web.py_utils');
var Widget = require('web.Widget');
var _t = core._t;
var QWeb = core.qweb;
var AddToBoardMenu = Widget.extend({
events: _.extend({}, Widget.prototype.events, {
'click .o_add_to_board.o_menu_header': '_onMenuHeaderClick',
'click .o_add_to_board_confirm_button': '_onAddToBoardConfirmButtonClick',
'click .o_add_to_board_input': '_onAddToBoardInputClick',
'keyup .o_add_to_board_input': '_onKeyUp',
}),
/**
* @override
* @param {Object} params
* @param {Object} params.action an ir.actions description
*/
init: function (parent, params) {
this._super(parent);
this.action = params.action;
this.isOpen = false;
},
/**
* @override
*/
start: function () {
if (this.action.id && this.action.type === 'ir.actions.act_window') {
this._render();
}
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Closes the menu and render it.
*
*/
closeMenu: function () {
this.isOpen = false;
this._render();
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* This is the main function for actually saving the dashboard. This method
* is supposed to call the route /board/add_to_dashboard with proper
* information.
*
* @private
* @returns {Deferred}
*/
_addToBoard: function () {
var self = this;
var searchQuery;
// TO DO: for now the domains in query are evaluated.
// This should be changed I think.
this.trigger_up('get_search_query', {
callback: function (query) {
searchQuery = query;
}
});
// TO DO: replace direct reference to action manager, controller, and currentAction in code below
// 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 actionManager = this.findAncestor(function (ancestor) {
return ancestor instanceof ActionManager;
});
var controller = actionManager.getCurrentController();
var context = new Context(this.action.context);
context.add(searchQuery.context);
context.add({
group_by: pyUtils.eval('groupbys', searchQuery.groupBys || [])
});
this.trigger_up('get_controller_query_params', {
callback: function (controllerContext) {
context.add(controllerContext);
}
});
var domain = new Domain(this.action.domain || []);
domain = Domain.prototype.normalizeArray(domain.toArray().concat(searchQuery.domain));
var evalutatedContext = pyUtils.eval('context', context);
for (var key in evalutatedContext) {
if (evalutatedContext.hasOwnProperty(key) && /^search_default_/.test(key)) {
delete evalutatedContext[key];
}
}
evalutatedContext.dashboard_merge_domains_contexts = false;
var name = this.$input.val();
this.closeMenu();
return self._rpc({
route: '/board/add_to_dashboard',
params: {
action_id: self.action.id || false,
context_to_save: evalutatedContext,
domain: domain,
view_mode: controller.viewType,
name: name,
},
})
.then(function (r) {
if (r) {
self.do_notify(
_.str.sprintf(_t("'%s' added to dashboard"), name),
_t('Please refresh your browser for the changes to take effect.')
);
} else {
self.do_warn(_t("Could not add filter to dashboard"));
}
});
},
/**
* Renders and focuses the unique input if it is visible.
*
* @private
*/
_render: function () {
var $el = QWeb.render('AddToBoardMenu', {widget: this});
this._replaceElement($el);
if (this.isOpen) {
this.$input = this.$('.o_add_to_board_input');
this.$input.val(this.action.name);
this.$input.focus();
}
},
/**
* Hides and displays the submenu which allows adding custom filters.
*
* @private
*/
_toggleMenu: function () {
this.isOpen = !this.isOpen;
this._render();
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {jQueryEvent} event
*/
_onAddToBoardInputClick: function (event) {
event.preventDefault();
event.stopPropagation();
this.$input.focus();
},
/**
* @private
* @param {jQueryEvent} event
*/
_onAddToBoardConfirmButtonClick: function (event) {
event.preventDefault();
event.stopPropagation();
this._addToBoard();
},
/**
* @private
* @param {jQueryEvent} event
*/
_onKeyUp: function (event) {
if (event.which === $.ui.keyCode.ENTER) {
this._addToBoard();
}
},
/**
* @private
* @param {jQueryEvent} event
*/
_onMenuHeaderClick: function (event) {
event.preventDefault();
event.stopPropagation();
this._toggleMenu();
},
});
favorites_submenus_registry.add('add_to_board_menu', AddToBoardMenu, 10);
return AddToBoardMenu;
});
+9 -28
View File
@@ -120,7 +120,6 @@ var BoardController = FormController.extend({
var BoardRenderer = FormRenderer.extend({
custom_events: _.extend({}, FormRenderer.prototype.custom_events, {
do_action: '_onDoAction',
env_updated: '_onEnvUpdated',
update_filters: '_onUpdateFilters',
switch_view: '_onSwitchView',
}),
@@ -256,6 +255,8 @@ var BoardRenderer = FormRenderer.extend({
var rawContext = new Context(action.context, evalContext, {lang: session.user_context.lang});
var context = pyUtils.eval('context', rawContext, evalContext);
var domain = params.domain || pyUtils.eval('domain', action.domain || '[]', action.context);
action.context = context;
action.domain = domain;
var viewType = params.viewType || action.views[0][1];
var view = _.find(action.views, function (descr) {
return descr[1] === viewType;
@@ -266,11 +267,14 @@ var BoardRenderer = FormRenderer.extend({
var View = viewRegistry.get(viewType);
var view = new View(viewInfo, {
action: action,
context: context,
domain: domain,
groupBy: context.group_by || [],
modelName: action.res_model,
hasSelectors: false,
modelName: action.res_model,
searchQuery: {
context: context,
domain: domain,
groupBy: context.group_by || [],
},
withControlPanel: false,
});
return view.getController(self).then(function (controller) {
self._boardFormViewIDs[controller.handle] = _.first(
@@ -377,29 +381,6 @@ var BoardRenderer = FormRenderer.extend({
},
});
},
/**
* Intercepts (without stopping) 'do_action' events to force the
* 'keepSearchView' option to false, as the dashboard action has no search
* view, and thus there is no search view that could be re-used for the
* action to execute (a new one will be created instead).
*
* @private
* @param {OdooEvent} event
*/
_onDoAction: function (event) {
if (event.data.options) {
event.data.options.keepSearchView = false;
}
},
/**
* Stops the propagation of 'env_updated' events triggered by the controllers
* instantiated by the dashboard.
*
* @private
*/
_onEnvUpdated: function (event) {
event.stopPropagation();
},
/**
* @private
* @param {MouseEvent} event
-136
View File
@@ -1,136 +0,0 @@
odoo.define('board.favorite_menu', function (require) {
"use strict";
var ActionManager = require('web.ActionManager');
var Context = require('web.Context');
var core = require('web.core');
var Domain = require('web.Domain');
var FavoriteMenu = require('web.FavoriteMenu');
var pyUtils = require('web.py_utils');
var _t = core._t;
var QWeb = core.qweb;
FavoriteMenu.include({
/**
* We manually add the 'add to dashboard' feature in the searchview.
*
* @override
*/
start: function () {
var self = this;
if(this.action_id === undefined) {
return this._super();
}
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');
this.$add_dashboard_btn = this.$add_to_dashboard.eq(1).find('button');
this.$add_dashboard_input = this.$add_to_dashboard.eq(0).find('input');
this.$add_dashboard_link = this.$('.o_add_to_dashboard_link');
var title = this.searchview.get_title();
this.$add_dashboard_input.val(title);
this.$add_dashboard_link.click(function (e) {
e.preventDefault();
self._toggleDashboardMenu();
});
this.$add_dashboard_btn.click(this.proxy('_addDashboard'));
}
return this._super();
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* This is the main function for actually saving the dashboard. This method
* is supposed to call the route /board/add_to_dashboard with proper
* information.
*
* @private
* @returns {Deferred}
*/
_addDashboard: function () {
var self = this;
var search_data = this.searchview.build_search_data();
var context = new Context(this.searchview.dataset.get_context() || []);
var domain = [];
_.each(search_data.contexts, context.add, context);
_.each(search_data.domains, function (d) {
domain.push.apply(domain, Domain.prototype.stringToArray(d));
});
context.add({
group_by: pyUtils.eval('groupbys', search_data.groupbys || [])
});
// 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;
});
// with options 'keepSearchView', it may happen that the action_id of
// the searchview (received in init) is not the one of the current
// action, which corresponds to the one we want to add to dashboard
var currentAction = am.getCurrentAction();
var controller = am.getCurrentController();
context.add(controller.widget.getContext());
var c = pyUtils.eval('context', context);
for (var k in c) {
if (c.hasOwnProperty(k) && /^search_default_/.test(k)) {
delete c[k];
}
}
this._toggleDashboardMenu(false);
c.dashboard_merge_domains_contexts = false;
var name = self.$add_dashboard_input.val();
return self._rpc({
route: '/board/add_to_dashboard',
params: {
action_id: currentAction.id || false,
context_to_save: c,
domain: domain,
view_mode: controller.viewType,
name: name,
},
})
.then(function (r) {
if (r) {
self.do_notify(
_.str.sprintf(_t("'%s' added to dashboard"), name),
_t('Please refresh your browser for the changes to take effect.')
);
} else {
self.do_warn(_t("Could not add filter to dashboard"));
}
});
},
/**
* @override
* @private
*/
_closeMenus: function () {
if (this.add_to_dashboard_available) {
this._toggleDashboardMenu(false);
}
this._super();
},
/**
* @private
* @param {undefined|false} isOpen
*/
_toggleDashboardMenu: function (isOpen) {
this.$add_dashboard_link
.toggleClass('o_closed_menu', !(_.isUndefined(isOpen)) ? !isOpen : undefined)
.toggleClass('o_open_menu', isOpen);
this.$add_to_dashboard.toggle(isOpen);
if (this.$add_dashboard_link.hasClass('o_open_menu')) {
this.$add_dashboard_input.focus();
}
},
});
});
+1 -1
View File
@@ -123,7 +123,7 @@
}
// Override height of graph. min-height doesn't do the trick
.o_graph {
.o_graph_controller {
height: 500px;
}
}
+10
View File
@@ -74,6 +74,16 @@
<iframe width="100%" frameborder="0" t-att-src="url"/>
</t>
<div t-name="AddToBoardMenu">
<button type="button" class="dropdown-item o_add_to_board o_menu_header">Add to my Dashboard</button>
<div t-if="widget.isOpen" class="dropdown-item-text o_add_to_board">
<input class="o_input o_add_to_board_input" type="text"/>
</div>
<div t-if="widget.isOpen" class="dropdown-item-text o_add_to_board">
<button type="button" class="btn btn-primary o_add_to_board_confirm_button">Add</button>
</div>
</div>
<t t-name="SearchView.addtodashboard">
<a href="#" class="dropdown-item o_add_to_dashboard_link o_closed_menu">Add to my Dashboard</a>
<div class="dropdown-item-text o_add_to_dashboard">
+57 -85
View File
@@ -75,7 +75,7 @@ QUnit.test('dashboard basic rendering', function (assert) {
"with a dashboard, the renderer should have the proper css class");
assert.containsOnce(form, '.o_dashboard .o_view_nocontent',
"should have a no content helper");
assert.strictEqual(form.get('title'), "My Dashboard",
assert.strictEqual(form.$('.o_control_panel .breadcrumb-item').text(), "My Dashboard",
"should have the correct title");
form.destroy();
});
@@ -193,6 +193,41 @@ QUnit.test('basic functionality, with one sub action', function (assert) {
form.destroy();
});
QUnit.test('views in the dashboard do not have a control panel', function (assert) {
assert.expect(2);
var form = createView({
View: BoardView,
model: 'board',
data: this.data,
arch: '<form>' +
'<board style="2-1">' +
'<column>' +
'<action context="{}" view_mode="list" string="ABC" name="51" domain="[]"></action>' +
'</column>' +
'</board>' +
'</form>',
mockRPC: function (route) {
if (route === '/web/action/load') {
return $.when({
res_model: 'partner',
views: [[4, 'list'], [5, 'form']],
});
}
return this._super.apply(this, arguments);
},
archs: {
'partner,4,list':
'<tree string="Partner"><field name="foo"/></tree>',
},
});
assert.containsOnce(form, '.o_action .o_list_view');
assert.containsNone(form, '.o_action .o_control_panel');
form.destroy();
});
QUnit.test('can render an action without view_mode attribute', function (assert) {
// The view_mode attribute is automatically set to the 'action' nodes when
// the action is added to the dashboard using the 'Add to dashboard' button
@@ -605,15 +640,13 @@ QUnit.test('subviews are aware of attach in or detach from the DOM', function (a
});
QUnit.test('dashboard intercepts custom events triggered by sub controllers', function (assert) {
assert.expect(4);
assert.expect(1);
// we patch the ListController to force it to trigger the custom events that
// we want the dashboard to intercept (to stop them or to tweak their data)
testUtils.mock.patch(ListController, {
start: function () {
this.trigger_up('update_filters');
this.trigger_up('env_updated');
this.do_action({}, {keepSearchView: true});
},
});
@@ -638,29 +671,18 @@ QUnit.test('dashboard intercepts custom events triggered by sub controllers', fu
'partner,false,list': '<tree string="Partner"/>',
},
intercepts: {
do_action: function (ev) {
assert.strictEqual(ev.data.options.keepSearchView, false,
"the 'keepSearchView' options should have been set to false");
},
env_updated: function (ev) {
assert.strictEqual(ev.target.modelName, 'board',
"env_updated event should be triggered by the dashboard itself");
assert.step('env_updated');
},
update_filters: assert.step.bind(assert, 'update_filters'),
},
});
assert.verifySteps([
'env_updated', // triggered by the dashboard itself
]);
assert.verifySteps([]);
testUtils.mock.unpatch(ListController);
board.destroy();
});
QUnit.test('save actions to dashboard', function (assert) {
assert.expect(4);
assert.expect(3);
var actionManager = createActionManager({
data: this.data,
@@ -689,13 +711,12 @@ QUnit.test('save actions to dashboard', function (assert) {
assert.containsOnce(actionManager, '.o_list_view',
"should display the list view");
assert.strictEqual($('.o_add_to_dashboard_link').length, 1,
"should allow the 'Add to dashboard' feature");
// add this action to dashboard
testUtils.dom.click($('.o_search_options .o_dropdown button:contains(Favorites)'));
testUtils.dom.click($('.o_add_to_dashboard_link'));
testUtils.dom.click($('.o_add_to_dashboard_button'));
testUtils.dom.click($('.o_add_to_board.o_menu_header'));
testUtils.fields.editInput($('input.o_add_to_board_input'), 'a name');
testUtils.dom.click($('.o_add_to_board_confirm_button'));
actionManager.destroy();
});
@@ -737,75 +758,26 @@ QUnit.test('save two searches to dashboard', function (assert) {
var filter_count = 0;
// Add a first filter
$('span.fa-filter').click();
$('.o_add_custom_filter:visible').click();
$('.o_searchview_extended_prop_value .o_input').val('a')
$('.o_apply_filter').click();
testUtils.dom.click(actionManager.$('.o_filters_menu_button'));
testUtils.dom.click(actionManager.$('.o_add_custom_filter'));
actionManager.$('.o_searchview_extended_prop_value .o_input').val('a');
testUtils.dom.click(actionManager.$('.o_apply_filter'));
// Add it to dashboard
$('.o_add_to_dashboard_button').click();
testUtils.dom.click(actionManager.$('.o_favorites_menu_button'));
testUtils.dom.click(actionManager.$('.o_add_to_board'));
testUtils.dom.click(actionManager.$('.o_add_to_board_confirm_button'));
// Remove it
$('.o_facet_remove').click();
testUtils.dom.click(actionManager.$('.o_facet_remove'));
// Add the second filter
$('span.fa-filter').click();
$('span.fa-filter').click();
$('.o_add_custom_filter:visible').click();
$('.o_searchview_extended_prop_value .o_input').val('b')
$('.o_apply_filter').click();
testUtils.dom.click(actionManager.$('.o_filters_menu_button'));
testUtils.dom.click(actionManager.$('.o_add_custom_filter'));
actionManager.$('.o_searchview_extended_prop_value .o_input').val('b');
testUtils.dom.click(actionManager.$('.o_apply_filter'));
// Add it to dashboard
$('.o_add_to_dashboard_button').click();
actionManager.destroy();
});
QUnit.test('save to dashboard actions with flag keepSearchView', function (assert) {
assert.expect(4);
var actionManager = createActionManager({
data: this.data,
archs: {
'partner,false,graph': '<graph><field name="foo"/></graph>',
'partner,false,list': '<list><field name="foo"/></list>',
'partner,false,search': '<search></search>',
},
mockRPC: function (route, args) {
if (route === '/board/add_to_dashboard') {
assert.strictEqual(args.action_id, 2,
"should save the correct action");
assert.strictEqual(args.view_mode, 'graph',
"should save the correct view type");
return $.when(true);
}
return this._super.apply(this, arguments);
},
});
// execute a first action
actionManager.doAction({
id: 1,
res_model: 'partner',
type: 'ir.actions.act_window',
views: [[false, 'list']],
});
// execute another action with flag 'keepSearchView' and add it to dashboard
var options = {keepSearchView: true};
actionManager.doAction({
id: 2,
res_model: 'partner',
type: 'ir.actions.act_window',
views: [[false, 'graph']],
}, options);
assert.containsOnce(actionManager, '.o_graph',
"should display the graph view");
assert.strictEqual($('.o_add_to_dashboard_link').length, 1,
"should allow the 'Add to dashboard' feature (this is the same searchview)");
// add this action to dashboard
testUtils.dom.click($('.o_search_options .o_dropdown button:contains(Favorites)'));
testUtils.dom.click($('.o_add_to_dashboard_link'));
testUtils.dom.click($('.o_add_to_dashboard_button'));
testUtils.dom.click(actionManager.$('.o_favorites_menu_button'));
testUtils.dom.click(actionManager.$('.o_add_to_board'));
testUtils.dom.click(actionManager.$('.o_add_to_board_confirm_button'));
actionManager.destroy();
});
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="stylesheet" type="text/scss" href="/board/static/src/scss/dashboard.scss"/>
<script type="text/javascript" src="/board/static/src/js/action_manager_board_action.js"></script>
<script type="text/javascript" src="/board/static/src/js/board_view.js"></script>
<script type="text/javascript" src="/board/static/src/js/favorite_menu.js"></script>
<script type="text/javascript" src="/board/static/src/js/add_to_board_menu.js"></script>
</xpath>
</template>
@@ -0,0 +1,102 @@
odoo.define('board.AddToGoogleSpreadsheetMenu', function (require) {
"use strict";
var ActionManager = require('web.ActionManager');
var core = require('web.core');
var data = require('web.data');
var Domain = require('web.Domain');
var favorites_submenus_registry = require('web.favorites_submenus_registry');
var pyUtils = require('web.py_utils');
var Widget = require('web.Widget');
var QWeb = core.qweb;
var AddToGoogleSpreadsheetMenu = Widget.extend({
events: _.extend({}, Widget.prototype.events, {
'click .add_to_spreadsheet': '_onAddToSpreadsheetClick',
}),
/**
* @override
* @param {Object} params
* @param {Object} params.action an ir.actions description
*/
init: function (parent, params) {
this._super(parent);
this.action = params.action;
},
/**
* @override
*/
start: function () {
if (this.action.type === 'ir.actions.act_window') {
this._render();
}
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_addToSpreadsheet: 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 actionManager = this.findAncestor(function (ancestor) {
return ancestor instanceof ActionManager;
});
var controller = actionManager.getCurrentController();
var searchQuery;
// TO DO: for now the domains in query are evaluated.
// This should be changed I think.
this.trigger_up('get_search_query', {
callback: function (query) {
searchQuery = query;
}
});
var modelName = this.action.res_model;
var list_view = _.findWhere(controller.widget.actionViews, {type: 'list'});
var list_view_id = list_view ? list_view.viewID : false;
var domain = searchQuery.domain;
var groupBys = pyUtils.eval('groupbys', searchQuery.groupBys).join(" ");
var ds = new data.DataSet(this, 'google.drive.config');
ds.call('set_spreadsheet', [modelName, Domain.prototype.arrayToString(domain), groupBys, list_view_id])
.done(function (res) {
if (res.url){
window.open(res.url, '_blank');
}
});
},
/**
* Renders the `SearchView.addtogooglespreadsheet` template.
*
* @private
*/
_render: function () {
var $el = QWeb.render('SearchView.addtogooglespreadsheet', {widget: this});
this._replaceElement($el);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {jQueryEvent} event
*/
_onAddToSpreadsheetClick: function (event) {
event.preventDefault();
event.stopPropagation();
this._addToSpreadsheet();
},
});
favorites_submenus_registry.add('add_to_google_spreadsheet_menu', AddToGoogleSpreadsheetMenu, 20);
return AddToGoogleSpreadsheetMenu;
});
@@ -1,48 +0,0 @@
odoo.define('google_spreadsheet.google.spreadsheet', function (require) {
"use strict";
var ActionManager = require('web.ActionManager');
var core = require('web.core');
var data = require('web.data');
var Domain = require('web.Domain');
var FavoriteMenu = require('web.FavoriteMenu');
var pyUtils = require('web.py_utils');
var QWeb = core.qweb;
FavoriteMenu.include({
start: function () {
this._super();
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 = _.findWhere(controller.widget.actionViews, {type: 'list'}),
list_view_id = list_view ? list_view.viewID : false,
domain = [],
groupbys = pyUtils.eval('groupbys', sv_data.groupbys).join(" "),
ds = new data.DataSet(this, 'google.drive.config');
_.each(sv_data.domains, function (d) {
domain.push.apply(domain, Domain.prototype.stringToArray(d));
});
ds.call('set_spreadsheet', [model, Domain.prototype.arrayToString(domain), groupbys, list_view_id])
.done(function (res) {
if (res.url){
window.open(res.url, '_blank');
}
});
},
});
});
@@ -1,5 +1,5 @@
<template>
<t t-name="SearchView.addtogooglespreadsheet">
<a class="add-to-spreadsheet dropdown-item">Add to Google Spreadsheet</a>
</t>
</template>
<div t-name="SearchView.addtogooglespreadsheet">
<a class="add_to_spreadsheet dropdown-item">Add to Google Spreadsheet</a>
</div>
</template>
@@ -3,7 +3,7 @@
<template id="assets_backend" name="google_spreadsheet assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/google_spreadsheet/static/src/js/search.js"></script>
<script type="text/javascript" src="/google_spreadsheet/static/src/js/add_to_google_spreadsheet_menu.js"></script>
</xpath>
</template>
@@ -8,7 +8,7 @@ var _t = core._t;
var GreetingMessage = AbstractAction.extend({
template: 'HrAttendanceGreetingMessage',
contentTemplate: 'HrAttendanceGreetingMessage',
events: {
"click .o_hr_attendance_button_dismiss": function() { this.do_action(this.next_action, {clear_breadcrumbs: true}); },
@@ -61,6 +61,7 @@ var GreetingMessage = AbstractAction.extend({
if (this.activeBarcode) {
core.bus.on('barcode_scanned', this, this._onBarcodeScanned);
}
return this._super.apply(this, arguments);
},
welcome_message: function() {
@@ -4,11 +4,9 @@ odoo.define('hr_attendance.my_attendances', function (require) {
var AbstractAction = require('web.AbstractAction');
var core = require('web.core');
var QWeb = core.qweb;
var _t = core._t;
var MyAttendances = AbstractAction.extend({
contentTemplate: 'HrAttendanceMyMainMenu',
events: {
"click .o_hr_attendance_sign_in_out_icon": function() {
this.$('.o_hr_attendance_sign_in_out_icon').attr("disabled", "disabled");
@@ -16,7 +14,7 @@ var MyAttendances = AbstractAction.extend({
},
},
start: function () {
willStart: function () {
var self = this;
var def = this._rpc({
@@ -26,7 +24,6 @@ var MyAttendances = AbstractAction.extend({
})
.then(function (res) {
self.employee = res.length && res[0];
self.$el.html(QWeb.render("HrAttendanceMyMainMenu", {widget: self}));
});
return $.when(def, this._super.apply(this, arguments));
@@ -57,7 +57,7 @@ QUnit.module('HR Attendance', {
assert.expect(1);
var $target = $('#qunit-fixture');
var clientAction = new MyAttendances(null);
var clientAction = new MyAttendances(null, {});
testUtils.mock.addMockEnvironment(clientAction, {
data: this.data,
session: {
@@ -78,7 +78,7 @@ QUnit.module('HR Attendance', {
var $target = $('#qunit-fixture');
var self = this;
var rpcCount = 0;
var clientAction = new KioskMode(null);
var clientAction = new KioskMode(null, {});
testUtils.mock.addMockEnvironment(clientAction, {
data: this.data,
session: {
@@ -121,7 +121,7 @@ QUnit.module('HR Attendance', {
},
next_action: "hr_attendance.hr_attendance_action_kiosk_mode",
barcode: barcode,
}
};
var clientAction = new GreetingMessage(null, action);
testUtils.mock.addMockEnvironment(clientAction, {
data: self.data,
@@ -146,7 +146,7 @@ QUnit.module('HR Attendance', {
clientAction.appendTo(target);
clientActions.push(clientAction);
};
}
// init - mock coming from kiosk
createGreetingMessage ($target, 1);
+17 -51
View File
@@ -7,13 +7,9 @@ var ThreadWidget = require('mail.widget.Thread');
var AbstractAction = require('web.AbstractAction');
var config = require('web.config');
var ControlPanelMixin = require('web.ControlPanelMixin');
var core = require('web.core');
var data = require('web.data');
var Dialog = require('web.Dialog');
var dom = require('web.dom');
var pyUtils = require('web.py_utils');
var SearchView = require('web.SearchView');
var session = require('web.session');
var QWeb = core.qweb;
@@ -240,8 +236,8 @@ var ModeratorRejectMessageDialog = Dialog.extend({
},
});
var Discuss = AbstractAction.extend(ControlPanelMixin, {
template: 'mail.discuss',
var Discuss = AbstractAction.extend({
contentTemplate: 'mail.discuss',
custom_events: {
discard_extended_composer: '_onDiscardExtendedComposer',
message_moderation: '_onMessageModeration',
@@ -258,6 +254,11 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
'click .o_mail_partner_unpin': '_onUnpinChannel',
'input .o_discuss_sidebar_quick_search input': '_onInputSidebarQuickSearchInput',
},
hasControlPanel: true,
loadControlPanel: true,
withSearchBar: true,
searchMenuTypes: ['filter', 'favorite'],
/**
* @override
* @param {Object} [options]
@@ -268,8 +269,8 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
this._super.apply(this, arguments);
this.action = action;
this.context = action.context;
this.action_manager = parent;
this.dataset = new data.DataSetSearch(this, 'mail.message');
this.domain = [];
this.options = options || {};
@@ -286,21 +287,14 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
this._selectedMessage = null;
this._throttledUpdateThreads = _.throttle(
this._updateThreads.bind(this), 100, { leading: false });
this.controlPanelParams.modelName = 'mail.message';
},
/**
* @override
*/
willStart: function () {
var self = this;
var viewID = this.action &&
this.action.search_view_id &&
this.action.search_view_id[0];
var def = this
.loadFieldView(this.dataset, viewID, 'search')
.then(function (fieldsView) {
self.fields_view = fieldsView;
});
return $.when(this._super(), this.call('mail_service', 'isReady'), def);
return $.when(this._super(), this.call('mail_service', 'isReady'));
},
/**
* @override
@@ -332,11 +326,11 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
this._basicComposer.appendTo(this.$('.o_mail_discuss_content')));
defs.push(
this._extendedComposer.appendTo(this.$('.o_mail_discuss_content')));
defs.push(this._renderSearchView());
defs.push(this._super.apply(this, arguments));
return this.alive($.when.apply($, defs))
return $.when.apply($, defs)
.then(function () {
return self.alive(self._setThread(self._defaultThreadID));
return self._setThread(self._defaultThreadID);
})
.then(function () {
self._initThreads();
@@ -742,26 +736,6 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
.on('click', '.o_mail_discuss_button_select_all', this._onSelectAllClicked.bind(this))
.on('click', '.o_mail_discuss_button_unselect_all', this._onUnselectAllClicked.bind(this));
},
/**
* @private
* @returns {Deferred}
*/
_renderSearchView: function () {
var self = this;
var options = {
$buttons: $('<div>'),
action: this.action,
disable_groupby: true,
};
this.searchview = new SearchView(this, this.dataset, this.fields_view, options);
return this.alive(this.searchview.appendTo($('<div>')))
.then(function () {
self.$searchview_buttons = self.searchview.$buttons;
// manually call do_search to generate the initial domain and filter
// the messages in the default thread
self.searchview.do_search();
});
},
/**
* Render the sidebar of discuss app
*
@@ -982,7 +956,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
// Update control panel before focusing the composer, otherwise
// focus is on the searchview
self.set("title", self._thread.getTitle());
self._setTitle('#' + self._thread.getName());
self._updateControlPanel();
self._updateControlPanelButtons(self._thread);
@@ -1106,13 +1080,10 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
* @private
*/
_updateControlPanel: function () {
this.update_control_panel({
this.updateControlPanel({
cp_content: {
$buttons: this.$buttons,
$searchview: this.searchview.$el,
$searchview_buttons: this.$searchview_buttons,
},
searchview: this.searchview,
});
},
/**
@@ -1532,12 +1503,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
*/
_onSearch: function (ev) {
ev.stopPropagation();
var session = this.getSession();
var result = pyUtils.eval_domains_and_contexts({
domains: ev.data.domains,
contexts: [session.user_context],
});
this.domain = result.domain;
this.domain = ev.data.domain;
if (this._thread) {
// initially (when _onSearch is called manually), there is no
// thread set yet, so don't try to fetch and render the thread as
@@ -20,7 +20,7 @@ var ActivityView = AbstractView.extend({
Renderer: ActivityRenderer,
}),
viewType: 'activity',
groupable: false,
searchMenuTypes: ['filter', 'favorite'],
});
view_registry.add('activity', ActivityView);
+5 -7
View File
@@ -337,8 +337,8 @@ QUnit.test('activity view: activity widget', function (assert) {
activity.destroy();
});
QUnit.test('activity view: no group by', function (assert) {
assert.expect(4);
QUnit.test('activity view: no group_by_menu and no time_range_menu', function (assert) {
assert.expect(3);
var actionManager = createActionManager({
actions: [{
@@ -363,14 +363,12 @@ QUnit.test('activity view: no group by', function (assert) {
actionManager.doAction(1);
assert.strictEqual($('.o_search_options .o_dropdown:visible').length, 2,
assert.containsN(actionManager, '.o_search_options .o_dropdown button:visible', 2,
"only two elements should be available in view search");
assert.strictEqual($('.o_search_options .o_dropdown:visible .o_filters_menu').length, 1,
assert.isVisible(actionManager.$('.o_search_options .o_dropdown button.o_filters_menu_button'),
"filter should be available in view search");
assert.strictEqual($('.o_search_options .o_dropdown:visible .o_favorites_menu').length, 1,
assert.isVisible(actionManager.$('.o_search_options .o_dropdown button.o_favorites_menu_button'),
"favorites should be available in view search");
assert.strictEqual($('.o_search_options .o_dropdown:hidden .o_group_by_menu').length, 1,
"group by should be hidden");
actionManager.destroy();
});
+11 -94
View File
@@ -1,11 +1,9 @@
odoo.define('mail.discuss_test', function (require) {
"use strict";
var Discuss = require('mail.Discuss');
var mailTestUtils = require('mail.testUtils');
var concurrency = require('web.concurrency');
var SearchView = require('web.SearchView');
var testUtils = require('web.test_utils');
var createDiscuss = mailTestUtils.createDiscuss;
@@ -108,7 +106,7 @@ QUnit.test('basic rendering', function (assert) {
});
QUnit.test('searchview options visibility', function (assert) {
assert.expect(4);
assert.expect(5);
var done = assert.async();
createDiscuss({
@@ -119,17 +117,17 @@ QUnit.test('searchview options visibility', function (assert) {
services: this.services,
})
.then(function (discuss) {
var $searchviewOptions = $('.o_search_options > div');
var $searchviewOptionsToggler = $('.o_searchview_more.fa.fa-search-minus');
assert.strictEqual($searchviewOptions.length, 1,
assert.containsOnce(discuss, '.o_control_panel .o_search_options',
"should have search options");
assert.strictEqual($searchviewOptionsToggler.length, 1,
assert.hasClass(discuss.$('.o_control_panel .o_searchview_more.fa'), 'fa-search-minus',
"should have a button to toggle search options");
assert.strictEqual($searchviewOptions.css('display'), 'block',
"search options should be visible by default");
testUtils.dom.click($searchviewOptionsToggler);
assert.strictEqual($searchviewOptions.css('display'), 'none',
assert.isVisible(discuss.$('.o_control_panel .o_search_options'),
"search options should be visible by default");
testUtils.dom.click(discuss.$('.o_control_panel .o_searchview_more.fa'));
assert.hasClass(discuss.$('.o_control_panel .o_searchview_more.fa'), 'fa-search-plus',
"should have a button to toggle search options");
assert.isNotVisible(discuss.$('.o_control_panel .o_search_options'),
"search options should be hidden after clicking on search option toggler");
discuss.destroy();
@@ -181,7 +179,7 @@ QUnit.test('searchview filter messages', function (assert) {
// interact with searchview so that there is only once message
$('.o_searchview_input').val("ab").trigger('keyup');
$('.o_searchview').trigger($.Event('keydown', { which: $.ui.keyCode.ENTER }));
$('.o_searchview_input_container').trigger($.Event('keydown', { which: $.ui.keyCode.ENTER }));
assert.strictEqual($('.o_searchview_facet').length, 1,
"the searchview should have a facet");
@@ -193,7 +191,7 @@ QUnit.test('searchview filter messages', function (assert) {
// interact with search view so that there are no matching messages
testUtils.dom.click($('.o_facet_remove'));
$('.o_searchview_input').val("abcd").trigger('keyup');
$('.o_searchview').trigger($.Event('keydown', { which: $.ui.keyCode.ENTER }));
$('.o_searchview_input_container').trigger($.Event('keydown', { which: $.ui.keyCode.ENTER }));
assert.strictEqual($('.o_searchview_facet').length, 1,
"the searchview should have a facet");
@@ -798,87 +796,6 @@ QUnit.test('"Unstar all" button should reset the starred counter', function (ass
});
});
QUnit.test('do not crash when destroyed before start is completed', function (assert) {
assert.expect(3);
var discuss;
testUtils.mock.patch(Discuss, {
init: function () {
discuss = this;
this._super.apply(this, arguments);
},
});
createDiscuss({
id: 1,
context: {},
params: {},
data: this.data,
services: this.services,
mockRPC: function (route, args) {
if (args.method) {
assert.step(args.method);
}
var result = this._super.apply(this, arguments);
if (args.method === 'message_fetch') {
discuss.destroy();
}
return result;
},
});
assert.verifySteps([
"load_views",
"message_fetch"
]);
testUtils.mock.unpatch(Discuss);
});
QUnit.test('do not crash when destroyed between start en end of _renderSearchView', function (assert) {
assert.expect(2);
var discuss;
testUtils.mock.patch(Discuss, {
init: function () {
discuss = this;
this._super.apply(this, arguments);
},
});
var def = $.Deferred();
testUtils.mock.patch(SearchView, {
willStart: function () {
var result = this._super.apply(this, arguments);
return def.then($.when(result));
},
});
createDiscuss({
id: 1,
context: {},
params: {},
data: this.data,
services: this.services,
mockRPC: function (route, args) {
if (args.method) {
assert.step(args.method);
}
return this._super.apply(this, arguments);
},
});
discuss.destroy();
def.resolve();
assert.verifySteps([
"load_views",
]);
testUtils.mock.unpatch(Discuss);
testUtils.mock.unpatch(SearchView);
});
QUnit.test('confirm dialog when administrator leave (not chat) channel', function (assert) {
assert.expect(2);
var done = assert.async();
@@ -8,7 +8,7 @@ var MailService = require('mail.Service');
var AbstractStorageService = require('web.AbstractStorageService');
var Class = require('web.Class');
var ControlPanel = require('web.ControlPanel');
var ControlPanelView = require('web.ControlPanelView');
var RamStorage = require('web.RamStorage');
var testUtils = require('web.test_utils');
var Widget = require('web.Widget');
@@ -37,10 +37,7 @@ function createDiscuss(params) {
};
testUtils.mock.addMockEnvironment(parent, params);
var discuss = new Discuss(parent, params);
discuss.set_cp_bus(new Widget());
var selector = params.debug ? 'body' : '#qunit-fixture';
var controlPanel = new ControlPanel(parent);
controlPanel.appendTo($(selector));
// override 'destroy' of discuss so that it calls 'destroy' on the parent
// instead, which is the parent of discuss and the mockServer.
@@ -51,9 +48,6 @@ function createDiscuss(params) {
parent.destroy();
};
// link the view to the control panel
discuss.set_cp_bus(controlPanel.get_bus());
return discuss.appendTo($(selector)).then(function () {
return discuss;
});
@@ -493,7 +493,7 @@ QUnit.test('do not autofocus chat window on receiving new direct message', funct
}
});
var $formInput = form.$('input');
var $formInput = form.$('input[name=display_name]');
$formInput.focus();
assert.equal(document.activeElement, $formInput[0],
+3 -3
View File
@@ -34,7 +34,7 @@ var MrpBomReport = stock_report_generic.extend({
set_html: function() {
var self = this;
return this._super().then(function () {
self.$el.html(self.data.lines);
self.$('.o_content').html(self.data.lines);
self.renderSearch();
self.update_cp();
});
@@ -103,7 +103,7 @@ var MrpBomReport = stock_report_generic.extend({
$searchview_buttons: this.$searchView
},
};
return this.update_control_panel(status);
return this.updateControlPanel(status);
},
renderSearch: function () {
this.$buttonPrint = $(QWeb.render('mrp.button'));
@@ -187,7 +187,7 @@ var MrpBomReport = stock_report_generic.extend({
_reload: function () {
var self = this;
return this.get_html().then(function () {
self.$el.html(self.data.lines);
self.$('.o_content').html(self.data.lines);
self._reload_report_type();
});
},
+2 -2
View File
@@ -50,7 +50,7 @@ odoo.define('point_of_sale.tour.pricelist', function (require) {
run: 'click',
}, {
content: 'waiting for loading to finish',
trigger: '.o_main_content:has(.loader:hidden)',
trigger: 'body:has(.loader:hidden)',
run: function () {
var product_wall_shelf = posmodel.db.search_product_in_category(0, 'Wall Shelf Unit')[0];
var product_small_shelf = posmodel.db.search_product_in_category(0, 'Small Shelf')[0];
@@ -312,7 +312,7 @@ odoo.define('point_of_sale.tour.acceptance', function (require) {
var steps = [{
content: 'waiting for loading to finish',
trigger: '.o_main_content:has(.loader:hidden)',
trigger: 'body:has(.loader:hidden)',
run: function () {}, // it's a check
}, { // Leave category displayed by default
content: "click category switch",
@@ -10,6 +10,7 @@
<link rel="stylesheet" type="text/less" href="/web/static/src/scss/fontawesome_overriden.scss"/>
</t>
<script type="text/javascript" src="/point_of_sale/static/lib/backbone/backbone.js"></script>
<script type="text/javascript" src="/point_of_sale/static/lib/waitfont.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/main.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/db.js"></script>
@@ -55,7 +55,6 @@
</script>
</head>
<body>
<div class="o_main_content"/>
</body>
</html>
</template>
@@ -10,12 +10,13 @@ var ProductConfiguratorFormController = FormController.extend({
custom_events: _.extend({}, FormController.prototype.custom_events, {
field_changed: '_onFieldChanged'
}),
className: 'o_product_configurator',
/**
* @override
*/
init: function (){
this._super.apply(this, arguments);
start: function () {
this.$el.addClass('o_product_configurator');
return this._super.apply(this, arguments);
},
/**
* We need to override the default click behavior for our "Add" button
@@ -2,16 +2,15 @@ odoo.define('sale_timesheet.ProjectPlan', function (require) {
'use strict';
var AbstractAction = require('web.AbstractAction');
var ControlPanelMixin = require('web.ControlPanelMixin');
var core = require('web.core');
var data = require('web.data');
var pyUtils = require('web.py_utils');
var SearchView = require('web.SearchView');
var _t = core._t;
var QWeb = core.qweb;
var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
var ProjectPlan = AbstractAction.extend({
custom_events: {
search: '_onSearch',
},
events: {
"click a[type='action']": "_onClickAction",
"click .o_timesheet_plan_redirect": '_onRedirect',
@@ -19,64 +18,31 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
"click .o_timesheet_plan_non_billable_task": "_onClickNonBillableTask",
"click .o_timesheet_plan_sale_timesheet_people_time .progress-bar": '_onClickEmployeeProgressbar',
},
hasControlPanel: true,
loadControlPanel: true,
withSearchBar: true,
searchMenuTypes: ['filter', 'favorite'],
/**
* @override
*/
init: function (parent, action) {
init: function (parent, action) {
this._super.apply(this, arguments);
this.action = action;
this.context = action.context;
this.action_manager = parent;
this.set('title', action.name || _t('Overview'));
this._title = action.name || _t('Overview');
this.project_ids = [];
},
/**
* @override
*/
willStart: function () {
var self = this;
var view_id = this.action && this.action.search_view_id && this.action.search_view_id[0];
var def = this
.loadViews('project.project', this.action.context || {}, [[view_id, 'search']])
.then(function (result) {
self.fields_view = result.search;
});
return $.when(this._super(), def);
this.controlPanelParams.modelName = 'project.project';
},
/**
* @override
*/
start: function () {
var self = this;
// find default search from context
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;
}
});
// create searchview
var options = {
$buttons: $("<div>"),
action: this.action,
disable_groupby: true,
search_defaults: search_defaults,
};
var dataset = new data.DataSetSearch(this, 'project.project');
this.searchview = new SearchView(this, dataset, this.fields_view, options);
this.searchview.on('search', this, this._onSearch);
var def1 = this._super.apply(this, arguments);
var def2 = this.searchview.appendTo($("<div>")).then(function () {
self.$searchview_buttons = self.searchview.$buttons.contents();
});
return $.when(def1, def2).then(function(){
self.searchview.do_search();
return this._super.apply(this, arguments).then(function () {
// TODO: maybe not the correct way
return self._controlPanel._reportNewQueryAndRender();
});
},
@@ -89,7 +55,7 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
*/
do_show: function () {
this._super.apply(this, arguments);
this.searchview.do_search();
this._updateControlPanel();
this.action_manager.do_push_state({
action: this.action.id,
active_id: this.action.context.active_id,
@@ -107,7 +73,7 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
* @param {string|html} dom
*/
_refreshPlan: function (dom) {
this.$el.html(dom);
this.$('.o_content').html(dom);
},
/**
@@ -136,17 +102,14 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
if (this.$buttons) {
this.$buttons.off().destroy();
}
var buttons = buttons || [];
buttons = buttons || [];
this.$buttons = $(QWeb.render("project.plan.ControlButtons", {'buttons': buttons}));
this.$buttons.on('click', '.o_timesheet_plan_btn_action', this._onClickControlButton.bind(this));
// refresh control panel
this.update_control_panel({
this.updateControlPanel({
cp_content: {
$buttons: this.$buttons,
$searchview: this.searchview.$el,
$searchview_buttons: this.$searchview_buttons,
},
searchview: this.searchview,
});
},
@@ -245,9 +208,9 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
var parameters = {
domain: data.domain || [],
res_model: data.resModel,
}
};
if (data.resId) {
parameters['res_id'] = data.resId;
parameters.res_id = data.resId;
}
return this._rpc({
route:"/timesheet/plan/action",
@@ -260,8 +223,7 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
* @private
* @param {MouseEvent} event
*/
_onClickNonBillableTask: function (event) {
var self = this;
_onClickNonBillableTask: function () {
this.do_action({
name: _t('Non Billable Tasks'),
type: 'ir.actions.act_window',
@@ -299,14 +261,7 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
*/
_onSearch: function (event) {
event.stopPropagation();
var session = this.getSession();
// group by are disabled, so we don't take care of them
var result = pyUtils.eval_domains_and_contexts({
domains: event.data.domains,
contexts: [session.user_context].concat(event.data.contexts)
});
this._fetchPlan(result.domain);
this._fetchPlan(event.data.domain);
},
});
@@ -64,8 +64,8 @@ QUnit.module('Timesheet Plan', {
"/web/dataset/call_kw/project.project",
"/timesheet/plan"
]);
assert.strictEqual(plan.$el.text(), 'Banach-Tarski', 'should have rendered html content');
assert.strictEqual(plan.get('title'), 'Overview',
assert.strictEqual(plan.$('.o_content').text(), 'Banach-Tarski', 'should have rendered html content');
assert.strictEqual(plan.$('.o_control_panel .breadcrumb-item').text(), 'Overview',
'default title should be set');
plan.destroy();
});
@@ -86,7 +86,7 @@ QUnit.module('Timesheet Plan', {
return this._super.apply(this, arguments);
},
});
assert.strictEqual(plan.get('title'), 'Fibonacci',
assert.strictEqual(plan.$('.o_control_panel .breadcrumb-item').text(), 'Fibonacci',
'title should be set from the action name');
plan.destroy();
});
@@ -4,17 +4,18 @@ odoo.define('stock.stock_report_generic', function (require) {
var AbstractAction = require('web.AbstractAction');
var core = require('web.core');
var session = require('web.session');
var ControlPanelMixin = require('web.ControlPanelMixin');
var session = require('web.session');
var ReportWidget = require('stock.ReportWidget');
var framework = require('web.framework');
var crash_manager = require('web.crash_manager');
var QWeb = core.qweb;
var stock_report_generic = AbstractAction.extend(ControlPanelMixin, {
var stock_report_generic = AbstractAction.extend({
hasControlPanel: true,
// Stores all the parameters of the action.
init: function(parent, action) {
this._super.apply(this, arguments);
this.actionManager = parent;
this.given_context = session.user_context;
this.controller_url = action.context.url;
@@ -26,17 +27,16 @@ var stock_report_generic = AbstractAction.extend(ControlPanelMixin, {
this.given_context.ttype = action.context.ttype || false;
this.given_context.auto_unfold = action.context.auto_unfold || false;
this.given_context.lot_name = action.context.lot_name || false;
return this._super.apply(this, arguments);
},
willStart: function() {
return this.get_html();
return $.when(this._super.apply(this, arguments), this.get_html());
},
set_html: function() {
var self = this;
var def = $.when();
if (!this.report_widget) {
this.report_widget = new ReportWidget(this, this.given_context);
def = this.report_widget.appendTo(this.$el);
def = this.report_widget.appendTo(this.$('.o_content'));
}
return def.then(function () {
self.report_widget.$el.html(self.html);
@@ -78,7 +78,7 @@ var stock_report_generic = AbstractAction.extend(ControlPanelMixin, {
var status = {
cp_content: {$buttons: this.$buttons},
};
return this.update_control_panel(status);
return this.updateControlPanel(status);
},
renderButtons: function() {
var self = this;
+2 -2
View File
@@ -11,7 +11,7 @@ var _t = core._t;
var apps_client = null;
var Apps = AbstractAction.extend({
template: 'EmptyComponent',
contentTemplate: 'EmptyComponent',
remote_action_tag: 'loempia.embed',
failback_action_id: 'base.open_module_tree',
@@ -141,7 +141,7 @@ var Apps = AbstractAction.extend({
self.on('message:unblockUI', self, function() { framework.unblockUI(); });
self.on('message:warn', self, function(m) {self.do_warn(m.title, m.message, m.sticky); });
self.$ifr.appendTo(self.$el).css(css).addClass('apps-client');
self.$ifr.appendTo(self.$('.o_content')).css(css).addClass('apps-client');
def.resolve();
}, function() {
@@ -2,82 +2,126 @@ odoo.define('web.AbstractAction', function (require) {
"use strict";
/**
* We define here the generic notion of Action (from the point of view of the
* web client). In short, an action is a widget which controls the main part
* of the screen (everything below the control panel).
*
* More precisely, the action manager is the component that coordinates a stack
* of actions. Whenever the user navigates in the interface, switches views,
* open different menus, the action manager creates/updates/destroys special
* widgets which are actions. These actions need to answer to a standardised
* API, which is the reason for this AbstractAction class.
*
* In practice, most actions are view controllers (coming from a
* ir.action.act_window). However, some actions are 'client actions'. They
* also need to be an AbstractAction for a better cooperation with the action
* manager.
* We define here the AbstractAction widget, which implements the ActionMixin.
* All client actions must extend this widget.
*
* @module web.AbstractAction
*/
var ActionMixin = require('web.ActionMixin');
var ControlPanelView = require('web.ControlPanelView');
var Widget = require('web.Widget');
var AbstractAction = Widget.extend({
var AbstractAction = Widget.extend(ActionMixin, {
config: {
ControlPanelView: ControlPanelView,
},
/**
* Called each time the action is attached into the DOM.
*/
on_attach_callback: function () {},
/**
* Called each time the action is detached from the DOM.
*/
on_detach_callback: function () {},
/**
* Called by the action manager when action is restored (typically, when the
* user clicks on the action in the breadcrumb)
* If this flag is set to true, the client action will create a control
* panel whenever it is created.
*
* @returns {Deferred|undefined}
* @type boolean
*/
willRestore: function () {},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
hasControlPanel: false,
/**
* In some situations, we need confirmation from the controller that the
* current state can be destroyed without prejudice to the user. For
* example, if the user has edited a form, maybe we should ask him if we
* can discard all his changes when we switch to another action. In that
* case, the action manager will call this method. If the returned
* deferred is succesfully resolved, then we can destroy the current action,
* otherwise, we need to stop.
* If true, this flag indicates that the client action should automatically
* fetch the <arch> of a search view (or control panel view). Note that
* to do that, it also needs a specific modelName.
*
* @returns {Deferred} resolved if the action can be removed, rejected otherwise
* For example, the Discuss application adds the following line in its
* constructor:
*
* this.controlPanelParams.modelName = 'mail.message';
*
* @type boolean
*/
canBeRemoved: function () {
loadControlPanel: false,
/**
* A client action might want to use a search bar in its control panel, or
* it could choose not to use it.
*
* Note that it only makes sense if hasControlPanel is set to true.
*
* @type boolean
*/
withSearchBar: false,
/**
* This parameter can be set to customize the available sub menus in the
* controlpanel (Filters/Group By/Favorites). This is basically a list of
* the sub menus that we want to use.
*
* Note that it only makes sense if hasControlPanel is set to true.
*
* For example, set ['filter', 'favorite'] to enable the Filters and
* Favorites menus.
*
* @type string[]
*/
searchMenuTypes: [],
/**
* @override
*
* @param {Widget} parent
* @param {Object} action
* @param {Object} [options]
*/
init: function (parent, action, options) {
this._super(parent);
this._title = action.display_name || action.name;
this.controlPanelParams = {
actionId: action.id,
context: action.context,
breadcrumbs: options && options.breadcrumbs || [],
title: this.getTitle(),
viewId: action.search_view_id && action.search_view_id[0],
withSearchBar: this.withSearchBar,
searchMenuTypes: this.searchMenuTypes,
};
},
/**
* The willStart method is actually quite complicated if the client action
* has a controlPanel, because it needs to prepare it.
*
* @override
*/
willStart: function () {
var self = this;
if (this.hasControlPanel) {
var params = this.controlPanelParams;
var def;
if (this.loadControlPanel) {
def = this
.loadFieldView(params.modelName, params.context || {}, params.viewId, 'search')
.then(function (fieldsView) {
params.viewInfo = {
arch: fieldsView.arch,
fields: fieldsView.fields,
};
});
}
return $.when(def).then(function () {
var controlPanelView = new self.config.ControlPanelView(params);
return controlPanelView.getController(self).then(function (controlPanel) {
self._controlPanel = controlPanel;
return self._controlPanel.appendTo(document.createDocumentFragment());
});
});
}
return $.when();
},
/**
* This function is called when the current context (~state) of the action
* should be known. For instance, if the action is a view controller,
* this may be useful to reinstantiate the view in the same state.
* @override
*/
getContext: function () {
},
/**
* Gives the focus to the action
*/
giveFocus: function () {
},
/**
* Renders the buttons to append, in most cases, to the control panel (in
* the bottom left corner). When the action is rendered in a dialog, those
* buttons might be moved to the dialog's footer.
*
* @param {jQuery Node} $node
*/
renderButtons: function ($node) {
start: function () {
if (this._controlPanel) {
this._controlPanel.$el.prependTo(this.$el);
}
return this._super.apply(this, arguments);
},
});
@@ -60,10 +60,11 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi
},
load_filters: function (event) {
return data_manager
.load_filters(event.data.dataset, event.data.action_id)
.load_filters(event.data)
.then(event.data.on_success);
},
create_filter: '_onCreateFilter',
delete_filter: '_onDeleteFilter',
push_state: '_onPushState',
show_effect: '_onShowEffect',
// session
@@ -218,7 +219,7 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi
this.action_manager = new ActionManager(this, session.user_context);
var fragment = document.createDocumentFragment();
return this.action_manager.appendTo(fragment).then(function () {
dom.append(self.$('.o_main_content'), fragment, {
dom.append(self.$el, fragment, {
in_DOM: true,
callbacks: [{widget: self.action_manager}],
});
@@ -367,6 +368,18 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi
.create_filter(e.data.filter)
.then(e.data.on_success);
},
/**
* @private
* @param {OdooEvent} e
* @param {Object} e.data.filter the filter description
* @param {function} e.data.on_success called when the RPC succeeds with its
* returned value as argument
*/
_onDeleteFilter: function (e) {
data_manager
.delete_filter(e.data.filterId)
.then(e.data.on_success);
},
/**
* Displays a warning in a dialog or with the notification service
*
+62 -105
View File
@@ -10,10 +10,8 @@ odoo.define('web.ActionManager', function (require) {
*/
var AbstractAction = require('web.AbstractAction');
var Bus = require('web.Bus');
var concurrency = require('web.concurrency');
var Context = require('web.Context');
var ControlPanel = require('web.ControlPanel');
var config = require('web.config');
var core = require('web.core');
var Dialog = require('web.Dialog');
@@ -24,7 +22,7 @@ var Widget = require('web.Widget');
var _t = core._t;
var ActionManager = Widget.extend({
className: 'o_content',
className: 'o_action_manager',
custom_events: {
breadcrumb_clicked: '_onBreadcrumbClicked',
history_back: '_onHistoryBack',
@@ -63,17 +61,6 @@ var ActionManager = Widget.extend({
// dialog (i.e. coming from an action with target='new')
this.currentDialogController = null;
},
/**
* @override
*/
start: function () {
// AAB: temporarily instantiate a unique main ControlPanel used by
// controllers in the controllerStack
this.controlPanel = new ControlPanel(this);
var def = this.controlPanel.insertBefore(this.$el);
return $.when(def, this._super.apply(this, arguments));
},
/**
* Called each time the action manager is attached into the DOM.
*/
@@ -266,14 +253,6 @@ var ActionManager = Widget.extend({
if (controller.scrollPosition) {
this.trigger_up('scrollTo', controller.scrollPosition);
}
if (!controller.widget.need_control_panel) {
this.controlPanel.do_hide();
} else {
this.controlPanel.update({
breadcrumbs: this._getBreadcrumbs(),
}, {clear: false});
}
},
/**
* Closes the current dialog, if any. Because we listen to the 'closed'
@@ -330,19 +309,12 @@ var ActionManager = Widget.extend({
return this._executeActionInDialog(action, options);
}
var controller = self.controllers[action.controllerID];
return this.clearUncommittedChanges()
.then(function () {
var controller = self.controllers[action.controllerID];
var widget = controller.widget;
// AAB: this will be moved to the Controller
if (widget.need_control_panel) {
// set the ControlPanel bus on the controller to allow it to
// communicate its status
widget.set_cp_bus(self.controlPanel.get_bus());
}
return self.dp.add(self._startController(controller));
})
.then(function (controller) {
.then(function () {
if (self.currentDialogController) {
self._closeDialog({ silent: true });
}
@@ -358,7 +330,7 @@ var ActionManager = Widget.extend({
}
// update the internal state and the DOM
self._pushController(controller, options);
self._pushController(controller);
// toggle the fullscreen mode for actions in target='fullscreen'
self._toggleFullscreen();
@@ -387,12 +359,6 @@ var ActionManager = Widget.extend({
var self = this;
var controller = this.controllers[action.controllerID];
var widget = controller.widget;
// AAB: this will be moved to the Controller
if (widget.need_control_panel) {
// set the ControlPanel bus on the controller to allow it to
// communicate its status
widget.set_cp_bus(new Bus());
}
return this._startController(controller).then(function (controller) {
var prevDialogOnClose;
@@ -472,20 +438,18 @@ var ActionManager = Widget.extend({
}
var controllerID = _.uniqueId('controller_');
var index = this._getControllerStackIndex(options);
options.breadcrumbs = this._getBreadcrumbs(this.controllerStack.slice(0, index));
options.controllerID = controllerID;
var widget = new ClientAction(this, action, options);
var controller = {
actionID: action.jsID,
index: index,
jsID: controllerID,
widget: new ClientAction(this, action, options),
title: widget.getTitle(),
widget: widget,
};
// AAB: TODO: simplify this with AbstractAction (implement a getTitle
// function that returns action.name by default, and that can be
// overriden in client actions and view controllers)
Object.defineProperty(controller, 'title', {
get: function () {
return controller.widget.get('title') || action.display_name || action.name;
},
});
this.controllers[controllerID] = controller;
action.controllerID = controllerID;
return this._executeAction(action, options).done(function () {
@@ -586,33 +550,51 @@ var ActionManager = Widget.extend({
return $.when();
},
/**
* Returns a description of the current stack of controllers, used to render
* the breadcrumbs. It is an array of Objects with keys 'title' (what to
* display in the breadcrumbs) and 'controllerID' (the ID of the
* corresponding controller, used to restore it when this part of the
* Returns a description of the controllers in the given controller stack.
* It is used to render the breadcrumbs. It is an array of Objects with keys
* 'title' (what to display in the breadcrumbs) and 'controllerID' (the ID
* of the corresponding controller, used to restore it when this part of the
* breadcrumbs is clicked).
* Ignores the content of the stack of controllers if the action of the
* last controller of the stack is flagged with 'no_breadcrumbs', indicating
* that the breadcrumbs should not be displayed for that action.
*
* @private
* @param {string[]} controllerStack
* @returns {Object[]}
*/
_getBreadcrumbs: function () {
_getBreadcrumbs: function (controllerStack) {
var self = this;
var currentController = this.getCurrentController();
var noBreadcrumbs = !currentController ||
this.actions[currentController.actionID].context.no_breadcrumbs;
if (noBreadcrumbs) {
return [];
}
return _.map(this.controllerStack, function (controllerID) {
return _.map(controllerStack, function (controllerID) {
return {
title: self.controllers[controllerID].title,
controllerID: controllerID,
title: self.controllers[controllerID].title,
};
});
},
/**
* Returns the index where a controller should be inserted in the controller
* stack according to the given options. By default, a controller is pushed
* on the top of the stack.
*
* @private
* @param {options} [options.clear_breadcrumbs=false] if true, insert at
* index 0 and remove all other controllers
* @param {options} [options.index=null] if given, that index is returned
* @param {options} [options.replace_last_action=false] if true, replace the
* last controller of the stack
* @returns {integer} index
*/
_getControllerStackIndex: function (options) {
var index;
if ('index' in options) {
index = options.index;
} else if (options.clear_breadcrumbs) {
index = 0;
} else if (options.replace_last_action) {
index = this.controllerStack.length - 1;
} else {
index = this.controllerStack.length;
}
return index;
},
/**
* Returns an object containing information about the given controller, like
* its title, its action's id, the active_id and active_ids of the action...
@@ -625,7 +607,7 @@ var ActionManager = Widget.extend({
var controller = this.controllers[controllerID];
var action = this.actions[controller.actionID];
var state = {
title: controller.title,
title: controller.widget.getTitle(),
};
if (action.id) {
state.action = action.id;
@@ -706,41 +688,28 @@ var ActionManager = Widget.extend({
* @param {Object} controller
* @param {string} controller.jsID
* @param {Widget} controller.widget
* @param {Object} [options]
* @param {Object} [options.clear_breadcrumbs=false] if true, destroys all
* controllers from the controller stack before adding the given one
* @param {Object} [options.replace_last_action=false] if true, replaces the
* last controller of the controller stack by the given one
* @param {integer} [options.index] if given, pushes the controller at that
* position in the controller stack, and destroys the controllers with an
* higher index
* @param {integer} controller.index the controller is pushed at that
* position in the controller stack and controllers with an higher index
* are destroyed
*/
_pushController: function (controller, options) {
options = options || {};
_pushController: function (controller) {
var self = this;
// detach the current controller
this._detachCurrentController();
// empty the controller stack or replace the last controller as requested,
// destroy the removed controllers and push the new controller to the stack
var toDestroy;
if (options.clear_breadcrumbs) {
toDestroy = this.controllerStack;
this.controllerStack = [];
} else if (options.replace_last_action && this.controllerStack.length > 0) {
toDestroy = [this.controllerStack.pop()];
} else if (options.index !== undefined) {
toDestroy = this.controllerStack.splice(options.index);
// reject from the list of controllers to destroy the one that we are
// currently pushing, or those linked to the same action as the one
// linked to the controller that we are pushing
toDestroy = _.reject(toDestroy, function (controllerID) {
return controllerID === controller.jsID ||
self.controllers[controllerID].actionID === controller.actionID;
});
}
// push the new controller to the stack at the given position, and
// destroy controllers with an higher index
var toDestroy = this.controllerStack.slice(controller.index);
// reject from the list of controllers to destroy the one that we are
// currently pushing, or those linked to the same action as the one
// linked to the controller that we are pushing
toDestroy = _.reject(toDestroy, function (controllerID) {
return controllerID === controller.jsID ||
self.controllers[controllerID].actionID === controller.actionID;
});
this._removeControllers(toDestroy);
this.controllerStack = this.controllerStack.slice(0, controller.index);
this.controllerStack.push(controller.jsID);
// append the new controller to the DOM
@@ -861,7 +830,7 @@ var ActionManager = Widget.extend({
return $.when(def).then(function () {
return $.when(controller.widget.do_show()).then(function () {
var index = _.indexOf(self.controllerStack, controllerID);
self._pushController(controller, {index: index});
self._pushController(controller, index);
});
});
},
@@ -878,19 +847,7 @@ var ActionManager = Widget.extend({
* @returns {Deferred<Object>} resolved with the controller when it is ready
*/
_startController: function (controller) {
var self = this;
var fragment = document.createDocumentFragment();
// AAB: change this logic to stop using the properties mixin
controller.widget.on("change:title", this, function () {
if (self.getCurrentController() !== controller) {
return;
}
var action = self.actions[controller.actionID];
if (!action.flags || !action.flags.headless) {
var breadcrumbs = self._getBreadcrumbs();
self.controlPanel.update({breadcrumbs: breadcrumbs}, {clear: false});
}
});
return controller.widget.appendTo(fragment).then(function () {
return controller;
});
@@ -10,21 +10,12 @@ 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 pyUtils = require('web.py_utils');
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',
get_controller_context: '_onGetControllerContext',
navigation_move: '_onNavigationMove',
update_filters: '_onUpdateFilters',
search: '_onSearch',
switch_view: '_onSwitchView',
}),
@@ -32,6 +23,26 @@ ActionManager.include({
// Public
//--------------------------------------------------------------------------
/**
* Override to handle the case of lazy-loaded controllers, which may be the
* last controller in the stack, but which should not be considered as
* current controller as they don't have an alive widget.
*
* Note: this function assumes that there can be at most one lazy loaded
* controller in the stack
*
* @override
*/
getCurrentController: function () {
var currentController = this._super.apply(this, arguments);
var action = currentController && this.actions[currentController.actionID];
if (action && action.type === 'ir.actions.act_window' && !currentController.widget) {
var lastControllerID = this.controllerStack.pop();
currentController = this._super.apply(this, arguments);
this.controllerStack.push(lastControllerID);
}
return currentController;
},
/**
* Overrides to handle the case where an 'ir.actions.act_window' has to be
* loaded.
@@ -59,9 +70,9 @@ ActionManager.include({
currentAction.type === 'ir.actions.act_window') {
// 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 viewOptions = {currentId: state.id};
var viewType = state.view_type || currentController.viewType;
return this._switchController(currentAction, viewType);
return this._switchController(currentAction, viewType, viewOptions);
} else if (!core.action_registry.contains(state.action)) {
// the action to load isn't the current one, so execute it
var context = {};
@@ -113,59 +124,6 @@ ActionManager.include({
// 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) {
// if requested, keep the searchview of the current action instead of
// creating a new one
if (action._keepSearchView) {
var currentAction = this.getCurrentAction();
if (currentAction) {
action.searchView = currentAction.searchView;
action.env = currentAction.env; // make those actions share the same env
return $.when(currentAction.searchView);
} else {
// there is not searchview to keep, so reset the flag to false
// to ensure that the one that will be created will be correctly
// destroyed
action._keepSearchView = false;
}
}
// 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.
@@ -181,6 +139,9 @@ ActionManager.include({
* @param {Object} [viewOptions] dict of options passed to the initialization
* of the controller's widget
* @param {Object} [options]
* @param {string} [options.controllerID=false] when the controller has
* previously been lazy-loaded, we want to keep its jsID when loading it
* @param {integer} [options.index=0] the controller's index in the stack
* @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
@@ -195,31 +156,36 @@ ActionManager.include({
return $.Deferred().reject();
}
var controllerID = _.uniqueId('controller_');
options = options || {};
var index = options.index || 0;
var controllerID = options.controllerID || _.uniqueId('controller_');
var controller = {
actionID: action.jsID,
className: 'o_act_window', // used to remove the padding in dialogs
index: index,
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);
return controller.widget ?
controller.widget.getTitle() :
(action.display_name || action.name);
},
});
this.controllers[controllerID] = controller;
if (!options || !options.lazy) {
if (!options.lazy) {
// build the view options from different sources
viewOptions = _.extend({
var flags = action.flags || {};
viewOptions = _.extend({}, flags, flags[viewType], viewOptions, {
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 });
breadcrumbs: this._getBreadcrumbs(this.controllerStack.slice(0, index)),
// pass the controllerID to the views as an hook for further
// communication with trigger_up
controllerID: controllerID,
});
var view = new viewDescr.Widget(viewDescr.fieldsView, viewOptions);
var def = $.Deferred();
action.controllers[viewType] = def;
@@ -263,9 +229,6 @@ ActionManager.include({
// its reference is removed
controllerDef.reject();
});
if (action.searchView && !action._keepSearchView) {
action.searchView.destroy();
}
},
/**
* Executes actions of type 'ir.actions.act_window'.
@@ -280,75 +243,64 @@ ActionManager.include({
*/
_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.dp.add(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.controlPanelFieldsView = fieldsViews.search;
action.controllers = {};
// select the first view to display, and optionally the main view
// which will be lazyloaded
var firstView = options.viewType && _.findWhere(views, {type: options.viewType});
var mainView;
if (firstView) {
if (!firstView.multiRecord && views[0].multiRecord) {
mainView = views[0];
// select the current view to display, and optionally the main view
// of the action which will be lazyloaded
var curView = options.viewType && _.findWhere(views, {type: options.viewType});
var lazyView;
if (curView) {
if (!curView.multiRecord && views[0].multiRecord) {
lazyView = views[0];
}
} else {
firstView = views[0];
curView = views[0];
}
// use mobile-friendly view by default in mobile, if possible
if (config.device.isMobile) {
if (!firstView.isMobileFriendly) {
firstView = self._findMobileView(views, firstView.multiRecord) || firstView;
if (!curView.isMobileFriendly) {
curView = self._findMobileView(views, curView.multiRecord) || curView;
}
if (mainView && !mainView.isMobileFriendly) {
mainView = self._findMobileView(views, mainView.multiRecord) || mainView;
if (lazyView && !lazyView.isMobileFriendly) {
lazyView = self._findMobileView(views, lazyView.multiRecord) || lazyView;
}
}
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));
});
var lazyViewDef;
if (lazyView) {
// if the main view is lazy-loaded, its (lazy-loaded) controller is inserted
// into the controller stack (so that breadcrumbs can be correctly computed),
// so we force clear_breadcrumbs to false so that it won't be removed when the
// current controller will be inserted afterwards
options.clear_breadcrumbs = false;
// this controller being lazy-loaded, this call is actually sync
lazyViewDef = self._createViewController(action, lazyView.type, {}, {lazy: true})
.then(function (lazyLoadedController) {
self.controllerStack.push(lazyLoadedController.jsID);
});
}
return $.when(def).then(function () {
var defs = [];
defs.push(self._createViewController(action, firstView.type));
if (mainView) {
defs.push(self._createViewController(action, mainView.type, {}, {lazy: true}));
}
return self.dp.add($.when.apply($, defs));
}).then(function (controller, lazyLoadedController) {
action.controllerID = controller.jsID;
return self._executeAction(action, options).done(function () {
if (lazyLoadedController) {
// controller should be placed just before the current one
var index = self.controllerStack.length - 1;
self.controllerStack.splice(index, 0, lazyLoadedController.jsID);
self.controlPanel.update({
breadcrumbs: self._getBreadcrumbs(),
}, {clear: false});
}
});
}).fail(self._destroyWindowAction.bind(self, action));
return self.dp.add($.when(lazyViewDef))
.then(function () {
var viewOptions = {
controllerState: options.controllerState,
currentId: options.resID,
};
var curViewDef = self._createViewController(action, curView.type, viewOptions, {
index: self._getControllerStackIndex(options),
});
return self.dp.add(curViewDef);
})
.then(function (controller) {
action.controllerID = controller.jsID;
return self._executeAction(action, options);
})
.fail(self._destroyWindowAction.bind(self, action));
});
},
/**
@@ -367,49 +319,6 @@ ActionManager.include({
multiRecord: multiRecord,
});
},
/**
* 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 resID = options.resID || action.res_id;
return {
modelName: action.res_model,
ids: resID ? [resID] : undefined,
currentId: resID || undefined,
domain: [],
context: action.context || {},
groupBy: [],
};
},
/**
* 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 = action.views[0][1] === '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,
@@ -483,72 +392,20 @@ ActionManager.include({
* @returns {Deferred}
*/
_loadViews: function (action) {
var inDialog = action.target === 'new';
var inline = action.target === 'inline';
var options = {
action_id: action.id,
toolbar: action.flags.hasSidebar,
toolbar: inDialog && inline,
};
var views = action.views.slice();
if (action.flags.hasSearchView) {
if (!inline && !(inDialog && action.views[0][1] === 'form')) {
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);
},
/**
* Overrides to handle the 'keepSearchView' option. If set to true, the
* search view of the current action will be re-used in the new action, i.e.
* the environment (domain, context, groupby) will be shared between both
* actions.
*
* @override
*/
_preprocessAction: function (action, options) {
this._super.apply(this, arguments);
if (action.type === 'ir.actions.act_window' && options.keepSearchView) {
action._keepSearchView = true;
}
},
/**
* 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 = pyUtils.eval_domains_and_contexts({
domains: [action.domain || []].concat(domains || []),
contexts: [action_context].concat(contexts || []),
group_by_seq: groupbys || [],
eval_context: this.userContext,
});
var groupBy = results.group_by.length ?
results.group_by :
(action.context.group_by || []);
groupBy = (typeof groupBy === 'string') ? [groupBy] : groupBy;
if (results.error) {
throw new Error(_.str.sprintf(_t("Failed to evaluate search criterions")+": \n%s",
JSON.stringify(results.error)));
}
var context = _.omit(results.context, 'time_ranges');
return {
context: context,
domain: results.domain,
groupBy: groupBy,
};
},
/**
* Overrides to handle the case of 'ir.actions.act_window' actions, i.e.
* destroys all controllers associated to the given action, and its search
@@ -598,8 +455,8 @@ ActionManager.include({
return this._super.apply(this, arguments);
},
/**
* Handles the switch from a controller to another inside the same window
* action.
* Handles the switch from a controller to another (either inside the same
* window action, or from a window action to another using the breadcrumbs).
*
* @private
* @param {Object} controller the controller to switch to
@@ -608,18 +465,55 @@ ActionManager.include({
*/
_switchController: function (action, viewType, viewOptions) {
var self = this;
var view = _.findWhere(action.views, {type: viewType});
if (!view) {
// can't switch to an unknown view
return $.Deferred().reject();
}
var currentController = this.getCurrentController();
var index;
if (currentController.actionID !== action.jsID) {
// the requested controller is from another action, so we went back
// to a previous action using the breadcrumbs
var controller = _.findWhere(this.controllers, {
actionID: action.jsID,
viewType: viewType,
});
index = _.indexOf(this.controllerStack, controller.jsID);
} else {
// the requested controller is from the same action as the current
// one, so we either
// 1) go one step back from a mono record view to a multi record
// one using the breadcrumbs
// 2) or we switched from a view to another using the view
// switcher
// 3) or we opened a record from a multi record view
if (view.multiRecord) {
// cases 1) and 2) (with multi record views): replace the first
// controller linked to the same action in the stack
index = _.findIndex(this.controllerStack, function (controllerID) {
return self.controllers[controllerID].actionID === action.jsID;
});
} else if (!_.findWhere(action.views, {type: currentController.viewType}).multiRecord) {
// case 2) (with mono record views): replace the last
// controller by the new one if they are from the same action
// and if they both are mono record
index = this.controllerStack.length - 1;
} else {
// case 3): insert the controller on the top of the controller
// stack
index = this.controllerStack.length;
}
}
var newController = function () {
var newController = function (controllerID) {
var options = {
controllerID: controllerID,
index: index,
};
return self
._createViewController(action, viewType, viewOptions)
._createViewController(action, viewType, viewOptions, options)
.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);
});
};
@@ -637,15 +531,9 @@ ActionManager.include({
} 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];
});
// lazy loaded -> load it now (with same jsID)
return newController(controller.jsID);
} else {
viewOptions = _.extend(viewOptions || {}, action.env);
return $.when(controller.widget.willRestore()).then(function () {
return controller.widget.reload(viewOptions).then(function () {
return controller;
@@ -656,22 +544,7 @@ ActionManager.include({
}
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});
return self._pushController(controller);
});
},
@@ -679,18 +552,6 @@ ActionManager.include({
// 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
@@ -802,67 +663,6 @@ ActionManager.include({
return self.doAction(action, options).then(ev.data.on_success, ev.data.on_fail);
});
},
/**
* Handles a context request: provides to the caller the context of the
* current controller.
*
* @private
* @param {OdooEvent} ev
* @param {function} ev.data.callback used to send the requested context
*/
_onGetControllerContext: function (ev) {
ev.stopPropagation();
var currentController = this.getCurrentController();
var context = currentController && currentController.widget.getContext();
ev.data.callback(context || {});
},
/**
* Called mainly from the control panel when the focus should be given to a
* controller
*
* @private
* @param {OdooEvent} ev
*/
_onNavigationMove : function (ev) {
switch(ev.data.direction) {
case 'down' :
var currentController = this.getCurrentController().widget;
currentController.giveFocus();
ev.stopPropagation();
break;
}
},
/**
* Handles a request to add/remove search view filters.
*
* @param {OdooEvent} ev
* @param {string} ev.data.controllerID
* @param {Array[Object]} [ev.data.newFilters]
* @param {Array[Object]} [ev.data.filtersToRemove]
* @param {function} ev.data.callback called with the added filters as arg
*/
_onUpdateFilters: function (ev) {
var controller = this.controllers[ev.data.controllerID];
var action = this.actions[controller.actionID];
var data = ev.data;
var addedFilters = action.searchView.updateFilters(data.newFilters, data.filtersToRemove);
data.callback(addedFilters);
},
/**
* 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
@@ -882,13 +682,11 @@ ActionManager.include({
// 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) {
var options = {
controllerState: currentController.widget.exportState(),
currentId: ev.data.res_id,
};
if (ev.data.mode) {
options.mode = ev.data.mode;
}
this._switchController(action, viewType, options);
@@ -0,0 +1,182 @@
odoo.define('web.ActionMixin', function (require) {
"use strict";
/**
* We define here the ActionMixin, the generic notion of action (from the point
* of view of the web client). In short, an action is a widget which controls
* the main part of the screen (everything below the navbar).
*
* More precisely, the action manager is the component that coordinates a stack
* of actions. Whenever the user navigates in the interface, switches views,
* open different menus, the action manager creates/updates/destroys special
* widgets which implements the ActionMixin. These actions need to answer to a
* standardised API, which is the reason for this mixin.
*
* In practice, most actions are view controllers (coming from an
* ir.action.act_window). However, some actions are 'client actions'. They
* also need to implement the ActionMixin for a better cooperation with the
* action manager.
*
* @module web.ActionMixin
*/
var core = require('web.core');
var ActionMixin = {
template: 'Action',
/**
* The action mixin assumes that it is rendered with the 'Action' template.
* This template has a special zone ('.o_content') where the content should
* be added. Actions that want to automatically render a template there
* should define the contentTemplate key. In short, client actions should
* probably define a contentTemplate key, and not a template key.
*/
contentTemplate: null,
/**
* If an action wants to use a control panel, it will be created and
* registered in this _controlPanel key (the widget). The way this control
* panel is created is up to the implementation (so, view controllers or
* client actions may have different needs).
*
* Note that most of the time, this key should be set by the framework, not
* by the code of the client action.
*/
_controlPanel: null,
/**
* String containing the title of the client action (which may be needed to
* display in the breadcrumbs zone of the control panel).
*
* @see _setTitle
*/
_title: '',
/**
* @override
*/
renderElement: function () {
this._super.apply(this, arguments);
if (this.contentTemplate) {
var content = core.qweb.render(this.contentTemplate, {widget: this});
this.$('.o_content').append(content);
}
},
/**
* Called each time the action is attached into the DOM.
*/
on_attach_callback: function () {},
/**
* Called each time the action is detached from the DOM.
*/
on_detach_callback: function () {},
/**
* Called by the action manager when action is restored (typically, when the
* user clicks on the action in the breadcrumb)
*
* @returns {Deferred|undefined}
*/
willRestore: function () {},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* In some situations, we need confirmation from the controller that the
* current state can be destroyed without prejudice to the user. For
* example, if the user has edited a form, maybe we should ask him if we
* can discard all his changes when we switch to another action. In that
* case, the action manager will call this method. If the returned
* deferred is succesfully resolved, then we can destroy the current action,
* otherwise, we need to stop.
*
* @returns {Deferred} resolved if the action can be removed, rejected
* otherwise
*/
canBeRemoved: function () {
return $.when();
},
/**
* This function is called when the current state of the action
* should be known. For instance, if the action is a view controller,
* this may be useful to reinstantiate a view in the same state.
* Typically the state can (and should) be encoded in a query object of
* the form
* {
* context: {...},
* groupBy: [...],
* domain = [...],
* orderedBy = [...],
* }
* where the context key can contain many information.
* This method is mainly called during the creation of a custom filter.
*
* @returns {Object}
*/
getOwnedQueryParams: function () {
return {};
},
/**
* Returns a title that may be displayed in the breadcrumb area. For
* example, the name of the record (for a form view). This is actually
* important for the action manager: this is the way it is able to give
* the proper titles for other actions.
*
* @returns {string}
*/
getTitle: function () {
return this._title;
},
/**
* Gives the focus to the action
*/
giveFocus: function () {
},
/**
* Renders the buttons to append, in most cases, to the control panel (in
* the bottom left corner). When the action is rendered in a dialog, those
* buttons might be moved to the dialog's footer.
*
* @param {jQuery Node} $node
*/
renderButtons: function ($node) {
},
/**
* This is the main method to customize the controlpanel content.
*
* @see updateContents method in ControlPanelRenderer for more info
*
* @param {Object} [status]
* @param {string} [status.title]
* @param {Object} [options]
* @param {boolean} [options.clear]
*/
updateControlPanel: function (status, options) {
if (this._controlPanel) {
status = status || {};
status.title = status.title || this.getTitle();
this._controlPanel.updateContents(status, options || {});
}
},
// TODO: add hooks methods:
// - onRestoreHook (on_reverse_breadcrumbs)
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @param {string} title
*/
_setTitle: function (title) {
this._title = title;
this.updateControlPanel({title: this._title}, {clear: false});
},
};
return ActionMixin;
});
@@ -1,286 +0,0 @@
odoo.define('web.ControlPanelMixin', function (require) {
"use strict";
/**
* Mixin allowing widgets to communicate with the ControlPanel. Widgets needing a
* ControlPanel should use this mixin and call update_control_panel(cp_status) where
* cp_status contains information for the ControlPanel to update itself.
*
* Note that the API is slightly awkward. Hopefully we will improve this when
* we get the time to refactor the control panel.
*
* For example, here is what a typical client action would need to do to add
* support for a control panel with some buttons::
*
* var ControlPanelMixin = require('web.ControlPanelMixin');
*
* var SomeClientAction = Widget.extend(ControlPanelMixin, {
* ...
* start: function () {
* this._renderButtons();
* this._updateControlPanel();
* ...
* },
* do_show: function () {
* ...
* this._updateControlPanel();
* },
* _renderButtons: function () {
* this.$buttons = $(QWeb.render('SomeTemplate.Buttons'));
* this.$buttons.on('click', ...);
* },
* _updateControlPanel: function () {
* this.update_control_panel({
* cp_content: {
* $buttons: this.$buttons,
* },
* });
*/
var ControlPanelMixin = {
need_control_panel: true,
/**
* @param {web.Bus} [cp_bus] Bus to communicate with the ControlPanel
*/
set_cp_bus: function(cp_bus) {
this.cp_bus = cp_bus;
},
/**
* Triggers 'update' on the cp_bus to update the ControlPanel according to cp_status
* @param {Object} [cp_status] see web.ControlPanel.update() for a description
* @param {Object} [options] see web.ControlPanel.update() for a description
*/
update_control_panel: function(cp_status, options) {
if (this.cp_bus) {
this.cp_bus.trigger("update", cp_status || {}, options || {});
}
},
};
return ControlPanelMixin;
});
odoo.define('web.ControlPanel', function (require) {
"use strict";
var Bus = require('web.Bus');
var data = require('web.data');
var Widget = require('web.Widget');
var ControlPanel = Widget.extend({
template: 'ControlPanel',
/**
* @param {String} [template] the QWeb template to render the ControlPanel.
* By default, the template 'ControlPanel' will be used
*/
init: function(parent, template) {
this._super(parent);
if (template) {
this.template = template;
}
this.bus = new Bus(this);
// the updateIndex is used to prevent concurrent updates of the control
// panel depending on asynchronous code to be executed in the wrong order
this.bus.updateIndex = 0;
this.bus.on("update", this, this.update);
},
/**
* Renders the control panel and creates a dictionnary of its exposed elements
* @return {jQuery.Deferred}
*/
start: function() {
// Exposed jQuery nodesets
this.nodes = {
$buttons: this.$('.o_cp_buttons'),
$pager: this.$('.o_cp_pager'),
$searchview: this.$('.o_cp_searchview'),
$searchview_buttons: this.$('.o_search_options'),
$sidebar: this.$('.o_cp_sidebar'),
$switch_buttons: this.$('.o_cp_switch_buttons'),
};
// Prevent the search dropdowns to close when clicking inside them
this.$el.on('click.bs.dropdown', '.o_search_options .dropdown-menu', function (e) {
e.stopPropagation();
});
// By default, hide the ControlPanel and remove its contents from the DOM
this._toggle_visibility(false);
return this._super();
},
/**
* @return {Object} the Bus the ControlPanel is listening on
*/
get_bus: function() {
return this.bus;
},
/**
* Updates the content and displays the ControlPanel
* @param {Object} [status.active_view] the current active view
* @param {Array} [status.breadcrumbs] the breadcrumbs to display (see _render_breadcrumbs() for
* precise description)
* @param {Object} [status.cp_content] dictionnary containing the new ControlPanel jQuery elements
* @param {Boolean} [status.hidden] true if the ControlPanel should be hidden
* @param {openerp.web.SearchView} [status.searchview] the searchview widget
* @param {Boolean} [status.search_view_hidden] true if the searchview is hidden, false otherwise
* @param {Boolean} [options.clear] set to true to clear from control panel
* elements that are not in status.cp_content
*/
update: function(status, options) {
this.bus.updateIndex++;
this._toggle_visibility(!status.hidden);
// Don't update the ControlPanel in headless mode as the views have
// inserted themselves the buttons where they want, so inserting them
// again in the ControlPanel will remove them from where they should be
if (!status.hidden) {
options = _.defaults({}, options, {
clear: true, // clear control panel by default
});
var new_cp_content = status.cp_content || {};
// Render the breadcrumbs
if (status.breadcrumbs) {
this.$('.breadcrumb').html(this._render_breadcrumbs(status.breadcrumbs));
}
// Detach control_panel old content and attach new elements
var toDetach = this.nodes;
if (status.searchview && this.searchview === status.searchview) {
// If the searchview is the same as before, don't detach it s.t.
// we don't loose any floating content, nor the focus
toDetach = _.omit(toDetach, '$searchview');
new_cp_content = _.omit(new_cp_content, '$searchview');
}
if (options.clear) {
this._detach_content(toDetach);
// Show the searchview buttons area, which might have been hidden by
// the searchview, as client actions may insert elements into it
this.nodes.$searchview_buttons.show();
} else {
this._detach_content(_.pick(toDetach, _.keys(new_cp_content)));
}
this._attach_content(new_cp_content);
if (options.clear || status.searchview) {
this.searchview = status.searchview;
}
// Update the searchview and switch buttons
if (status.searchview || options.clear) {
this._update_search_view(status.searchview, status.search_view_hidden, status.groupable, status.enableTimeRangeMenu);
}
if (status.active_view_selector) {
this._update_switch_buttons(status.active_view_selector);
}
}
},
/**
* Private function that hides (or shows) the ControlPanel in headless (resp. non-headless) mode
* Also detaches or attaches its contents to clean the DOM
* @param {Boolean} [visible] true to show the control panel, false to hide it
*/
_toggle_visibility: function(visible) {
this.do_toggle(visible);
if (!visible && !this.$content) {
this.$content = this.$el.contents().detach();
} else if (this.$content) {
this.$content.appendTo(this.$el);
this.$content = null;
}
},
/**
* Private function that detaches the content of the ControlPanel
* @param {Object} [elements_to_detach] subset of this.nodes to detach
*/
_detach_content: function(elements_to_detach) {
_.each(elements_to_detach, function($nodeset) {
$nodeset.contents().detach();
});
},
/**
* Private function that attaches content to the ControlPanel
* @param {Object} [content] dictionnary of jQuery elements to attach, whose keys
* are jQuery nodes identifiers in this.nodes
*/
_attach_content: function(content) {
var self = this;
_.each(content, function($nodeset, $element) {
if ($nodeset && self.nodes[$element]) {
self.nodes[$element].append($nodeset);
}
});
},
/**
* Private function that removes active class on all switch-buttons and adds
* it to the one of the active view
* @param {Object} [active_view_selector] the selector of the div to activate
*/
_update_switch_buttons: function(active_view_selector) {
_.each(this.nodes.$switch_buttons.find('button'), function(button) {
$(button).removeClass('active');
});
this.$(active_view_selector).addClass('active');
},
/**
* Private function that renders the breadcrumbs
* @param {Array} [breadcrumbs] list of objects containing the following keys:
* - action: the action to execute when clicking on this part of the breadcrumbs
* - index: the index in the breadcrumbs (starting at 0)
* - title: what to display in the breadcrumbs
* @return {Array} list of breadcrumbs' li jQuery elements
*/
_render_breadcrumbs: function (breadcrumbs) {
var self = this;
return breadcrumbs.map(function (bc, index) {
return self._render_breadcrumbs_li(bc, index, breadcrumbs.length);
});
},
/**
* Private function that renders a breadcrumbs' li Jquery element
*/
_render_breadcrumbs_li: function (bc, index, length) {
var self = this;
var is_last = (index === length-1);
var li_content = bc.title && _.escape(bc.title.trim()) || data.noDisplayContent;
var $bc = $('<li>', {class: 'breadcrumb-item'})
.append(is_last ? li_content : $('<a>', {href: '#'}).html(li_content))
.toggleClass('active', is_last);
if (!is_last) {
$bc.click(function (ev) {
ev.preventDefault();
self.trigger_up('breadcrumb_clicked', {controllerID: bc.controllerID});
});
}
return $bc;
},
/**
* Private function that updates the SearchView's visibility and extend the
* breadcrumbs area if the SearchView is not visible
*
* @private
* @param {SearchView} [searchview] the searchview Widget
* @param {boolean} [isHidden] visibility of the searchview
* @param {boolean} [groupable] visibility of the groupable menu (only
* relevant if searchview is visible)
*/
_update_search_view: function (searchview, isHidden, groupable, enableTimeRangeMenu) {
if (searchview) {
searchview.toggle_visibility(!isHidden);
if (groupable !== undefined){
searchview.groupby_menu.do_toggle(groupable);
}
if (enableTimeRangeMenu !== undefined){
searchview.displayTimeRangeMenu(enableTimeRangeMenu);
}
}
this.nodes.$searchview.toggle(!isHidden);
this.$el.toggleClass('o_breadcrumb_full', !!isHidden);
},
});
return ControlPanel;
});
+242
View File
@@ -0,0 +1,242 @@
odoo.define('web.mvc', function (require) {
"use strict";
/**
* This file contains a 'formalization' of a MVC pattern, applied to Odoo
* idioms.
*
* For a simple widget/component, this is definitely overkill. However, when
* working on complex systems, such as Odoo views (or the control panel, or some
* client actions), it is useful to clearly separate the code in concerns.
*
* We define here 4 classes: Factory, Model, Renderer, Controller. Note that
* for various historical reasons, we use the term Renderer instead of View. The
* main issue is that the term 'View' is used way too much in Odoo land, and
* adding it would increase the confusion.
*
* In short, here are the responsabilities of the four classes:
* - Model: this is where the main state of the system lives. This is the part
* that will talk to the server, process the results and is the owner of the
* state
* - Renderer: this is the UI code: it should only be concerned with the look
* and feel of the system: rendering, binding handlers, ...
* - Controller: coordinates the model with the renderer and the parents widgets.
* This is more a 'plumbing' widget.
* - Factory: setting up the MRC components is a complex task, because each of
* them needs the proper arguments/options, it needs to be extensible, they
* needs to be created in the proper order, ... The job of the factory is
* to process all the various arguments, and make sure each component is as
* simple as possible.
*/
var ajax = require('web.ajax');
var Class = require('web.Class');
var mixins = require('web.mixins');
var ServicesMixin = require('web.ServicesMixin');
var Widget = require('web.Widget');
/**
* Owner of the state, this component is tasked with fetching data, processing
* it, updating it, ...
*
* Note that this is not a widget: it is a class which has not UI representation.
*
* @class Model
*/
var Model = Class.extend(mixins.EventDispatcherMixin, ServicesMixin, {
/**
* @param {Widget} parent
* @param {Object} params
*/
init: function (parent, params) {
mixins.EventDispatcherMixin.init.call(this);
this.setParent(parent);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* This method should return the complete state necessary for the renderer
* to display the current data.
*
* @returns {*}
*/
get: function () {
},
/**
* The load method is called once in a model, when we load the data for the
* first time. The method returns (a promise that resolves to) some kind
* of token/handle. The handle can then be used with the get method to
* access a representation of the data.
*
* @param {Object} params
* @returns {Promise} The promise resolves to some kind of handle
*/
load: function () {
return $.when();
},
});
/**
* Only responsability of this component is to display the user interface, and
* react to user changes.
*
* @class Renderer
*/
var Renderer = Widget.extend({
/**
* @override
* @param {any} state
* @param {Object} params
*/
init: function (parent, state, params) {
this._super(parent);
this.state = state;
},
});
/**
* The controller has to coordinate between parent, renderer and model.
*
* @class Controller
*/
var Controller = Widget.extend({
/**
* @override
* @param {Model} model
* @param {Renderer} renderer
* @param {Object} params
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
this.model = model;
this.renderer = renderer;
},
/**
* @returns {Promise}
*/
start: function () {
return $.when(
this._super.apply(this, arguments),
this._startRenderer()
);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Appends the renderer in the $el. To override to insert it elsewhere.
*
* @private
*/
_startRenderer: function () {
return this.renderer.appendTo(this.$el);
},
});
var Factory = Class.extend({
config: {
Model: Model,
Renderer: Renderer,
Controller: Controller,
},
/**
* @override
*/
init: function () {
this.rendererParams = {};
this.controllerParams = {};
this.modelParams = {};
this.loadParams = {};
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Main method of the Factory class. Create a controller, and make sure that
* data and libraries are loaded.
*
* There is a unusual thing going in this method with parents: we create
* renderer/model with parent as parent, then we have to reassign them at
* the end to make sure that we have the proper relationships. This is
* necessary to solve the problem that the controller needs the model and
* the renderer to be instantiated, but the model need a parent to be able
* to load itself, and the renderer needs the data in its constructor.
*
* @param {Widget} parent the parent of the resulting Controller (most
* likely an action manager)
* @returns {Promise<Controller>}
*/
getController: function (parent) {
var self = this;
var model = this.getModel(parent);
return $.when(this._loadData(model), ajax.loadLibs(this)).then(function (state) {
var renderer = self.getRenderer(parent, state);
var Controller = self.Controller || self.config.Controller;
var controllerParams = _.extend({
initialState: state,
}, self.controllerParams);
var controller = new Controller(parent, model, renderer, controllerParams);
model.setParent(controller);
renderer.setParent(controller);
return controller;
});
},
/**
* Returns a new model instance
*
* @param {Widget} parent the parent of the model
* @returns {Model} instance of the model
*/
getModel: function (parent) {
var Model = this.config.Model;
return new Model(parent, this.modelParams);
},
/**
* Returns a new renderer instance
*
* @param {Widget} parent the parent of the renderer
* @param {Object} state the information related to the rendered data
* @returns {Renderer} instance of the renderer
*/
getRenderer: function (parent, state) {
var Renderer = this.config.Renderer;
return new Renderer(parent, state, this.rendererParams);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Loads initial data from the model
*
* @private
* @param {Model} model a Model instance
* @returns {Promise<*>} a promise that resolves to the value returned by
* the get method from the model
* @todo: get rid of loadParams (use modelParams instead)
*/
_loadData: function (model) {
return model.load(this.loadParams).then(function () {
return model.get.apply(model, arguments);
});
},
});
return {
Factory: Factory,
Model: Model,
Renderer: Renderer,
Controller: Controller,
};
});
+39 -12
View File
@@ -25,6 +25,8 @@ var Registry = Class.extend({
*/
init: function (mapping) {
this.map = Object.create(mapping || null);
this._scoreMapping = Object.create(null);
this._sortedKeys = null;
this.listeners = []; // listening callbacks on newly added items.
},
@@ -39,9 +41,12 @@ var Registry = Class.extend({
*
* @param {string} key
* @param {any} value
* @param {[number]} score if given, this value will be used to order keys
* @returns {Registry} can be used to chain add calls.
*/
add: function (key, value) {
add: function (key, value, score) {
this._scoreMapping[key] = score === undefined ? key : score;
this._sortedKeys = null;
this.map[key] = value;
_.each(this.listeners, function (callback) {
callback(key, value);
@@ -49,7 +54,7 @@ var Registry = Class.extend({
return this;
},
/**
* Check if the registry contains the value
* Check if the registry contains the key
*
* @param {string} key
* @returns {boolean}
@@ -58,18 +63,12 @@ var Registry = Class.extend({
return (key in this.map);
},
/**
* Creates and returns a copy of the current mapping, with the provided
* mapping argument added in (replacing existing keys if needed)
* Returns the content of the registry (an object mapping keys to values)
*
* Parent and child remain linked, a new key in the parent (which is not
* overwritten by the child) will appear in the child.
*
* @param {Object} [mapping={}] a mapping of keys to object-paths
* @returns {Object}
*/
extend: function (mapping) {
var child = new Registry(this.map);
_.extend(child.map, mapping);
return child;
entries: function () {
return Object.create(this.map);
},
/**
* Returns the value associated to the given key.
@@ -95,6 +94,23 @@ var Registry = Class.extend({
}
return null;
},
/**
* Return the list of keys in map object.
*
* The registry guarantees that the keys have a consistent order, defined by
* the 'score' value when the item has been added.
*
* @returns {string[]}
*/
keys: function () {
var self = this;
if (!this._sortedKeys) {
this._sortedKeys = _.sortBy(Object.keys(this.map), function (key) {
return self._scoreMapping[key] || 0;
});
}
return this._sortedKeys;
},
/**
* Register a callback to execute when items are added to the registry.
*
@@ -103,6 +119,17 @@ var Registry = Class.extend({
onAdd: function (callback) {
this.listeners.push(callback);
},
/**
* Return the list of values in map object
*
* @returns {string[]}
*/
values: function () {
var self = this;
return this.keys().map(function (key) {
return self.map[key];
});
},
});
return Registry;
@@ -139,8 +139,8 @@ var ServicesMixin = {
promise.abort = abort.bind(def);
return promise;
},
loadFieldView: function (dataset, view_id, view_type, options) {
return this.loadViews(dataset.model, dataset.get_context().eval(), [[view_id, view_type]], options).then(function (result) {
loadFieldView: function (modelName, context, view_id, view_type, options) {
return this.loadViews(modelName, context, [[view_id, view_type]], options).then(function (result) {
return result[view_type];
});
},
@@ -155,11 +155,28 @@ var ServicesMixin = {
});
return def;
},
loadFilters: function (dataset, action_id) {
loadFilters: function (modelName, actionId, context) {
var def = $.Deferred();
this.trigger_up('load_filters', {
dataset: dataset,
action_id: action_id,
modelName: modelName,
actionId: actionId,
context: context,
on_success: def.resolve.bind(def),
});
return def;
},
createFilter: function (filter) {
var def = $.Deferred();
this.trigger_up('create_filter', {
filter: filter,
on_success: def.resolve.bind(def),
});
return def;
},
deleteFilter: function (filterId) {
var def = $.Deferred();
this.trigger_up('delete_filter', {
filterId: filterId,
on_success: def.resolve.bind(def),
});
return def;
@@ -16,7 +16,7 @@ odoo.define('web.relational_fields', function (require) {
var AbstractField = require('web.AbstractField');
var basicFields = require('web.basic_fields');
var concurrency = require('web.concurrency');
var ControlPanel = require('web.ControlPanel');
var ControlPanelView = require('web.ControlPanelView');
var dialogs = require('web.view_dialogs');
var core = require('web.core');
var data = require('web.data');
@@ -1030,7 +1030,14 @@ var FieldX2Many = AbstractField.extend({
}
var self = this;
var defs = [];
this.control_panel = new ControlPanel(this, "X2ManyControlPanel");
var controlPanelView = new ControlPanelView({
template: 'X2ManyControlPanel',
withSearchBar: false,
});
var cpDef = controlPanelView.getController(this).then(function (controlPanel) {
self._controlPanel = controlPanel;
return self._controlPanel.prependTo(self.$el);
});
this.pager = new Pager(this, this.value.count, this.value.offset + 1, this.value.limit, {
single_page_hidden: true,
withAccessKey: false,
@@ -1053,9 +1060,9 @@ var FieldX2Many = AbstractField.extend({
});
this._renderButtons();
defs.push(this.pager.appendTo($('<div>'))); // start the pager
defs.push(this.control_panel.prependTo(this.$el));
defs.push(cpDef);
return $.when.apply($, defs).then(function () {
self.control_panel.update({
self._controlPanel.updateContents({
cp_content: {
$buttons: self.$buttons,
$pager: self.pager.$el,
@@ -4,7 +4,6 @@ odoo.define('report.client_action', function (require) {
var AbstractAction = require('web.AbstractAction');
var config = require('web.config');
var core = require('web.core');
var ControlPanelMixin = require('web.ControlPanelMixin');
var session = require('web.session');
var utils = require('report.utils');
@@ -17,9 +16,9 @@ var AUTHORIZED_MESSAGES = [
'report:do_action',
];
var ReportAction = AbstractAction.extend(ControlPanelMixin, {
template: 'report.client_action',
var ReportAction = AbstractAction.extend({
hasControlPanel: true,
contentTemplate: 'report.client_action',
init: function (parent, action, options) {
this._super.apply(this, arguments);
@@ -27,7 +26,7 @@ var ReportAction = AbstractAction.extend(ControlPanelMixin, {
options = options || {};
this.action_manager = parent;
this.title = options.display_name || options.name;
this._title = options.display_name || options.name;
this.edit_mode_available = false;
this.in_edit_mode = false;
@@ -42,7 +41,6 @@ var ReportAction = AbstractAction.extend(ControlPanelMixin, {
start: function () {
var self = this;
this.set('title', this.title);
this.iframe = this.$('iframe')[0];
return $.when(this._super.apply(this, arguments), session.is_bound).then(function () {
var web_base_url = session['web.base.url'];
@@ -93,7 +91,7 @@ var ReportAction = AbstractAction.extend(ControlPanelMixin, {
},
_update_control_panel: function () {
this.update_control_panel({
this.updateControlPanel({
cp_content: {
$buttons: this.$buttons,
},
@@ -39,8 +39,6 @@ return {
// registries
action_registry : new Registry(),
crash_registry: new Registry(),
search_filters_registry: new Registry(),
search_widgets_registry: new Registry(),
serviceRegistry: new Registry(),
/**
* @type {String}
@@ -128,15 +128,20 @@ return core.Class.extend({
*
* @param {Object} [dataset] the dataset for which the filters are loaded
* @param {int} [action_id] the id of the action (optional)
* @param {Object} params
* @param {string} params.modelName
* @param {Object} params.context
* @param {integer} params.actionId
* @return {Deferred} resolved with the requested filters
*/
load_filters: function (dataset, action_id) {
var key = this._gen_key(dataset.model, action_id);
load_filters: function (params) {
var key = this._gen_key(params.modelName, params.actionId);
if (!this._cache.filters[key]) {
this._cache.filters[key] = rpc.query({
args: [dataset.model, action_id],
args: [params.modelName, params.actionId],
kwargs: {
context: dataset.get_context(),
context: params.context || {},
// get_context() de dataset
},
model: 'ir.filters',
method: 'get_filters',
@@ -158,26 +163,26 @@ return core.Class.extend({
model: 'ir.filters',
method: 'create_or_replace',
})
.then(function (filter_id) {
.then(function (filterId) {
var key = [
filter.model_id,
filter.action_id || false,
].join(',');
self._invalidate(self._cache.filters, key);
return filter_id;
return filterId;
});
},
/**
* Calls 'unlink' on 'ir_filters'.
*
* @param {Object} [filter] the description of the filter to remove
* @param {integer} filterId, Id of the filter to remove
* @return {Deferred}
*/
delete_filter: function (filter) {
delete_filter: function (filterId) {
var self = this;
return rpc.query({
args: [filter.id],
args: [filterId],
model: 'ir.filters',
method: 'unlink',
})
@@ -345,8 +345,8 @@ DebugManager.include({
action: this._action,
can_edit: this._can_edit_views,
controller: this._controller,
controlPanelView: this._controller && this._controller._controlPanel,
manager: this,
searchview: this._controller && this._controller.searchView,
view: this._controller && _.findWhere(this._action.views, {
type: this._controller.viewType,
}),
@@ -12,18 +12,21 @@ odoo.define('web.AbstractController', function (require) {
* reading localstorage, ...) has to go through the controller.
*/
var AbstractAction = require('web.AbstractAction');
var ActionMixin = require('web.ActionMixin');
var ajax = require('web.ajax');
var concurrency = require('web.concurrency');
var config = require('web.config');
var ControlPanelMixin = require('web.ControlPanelMixin');
var core = require('web.core');
var mvc = require('web.mvc');
var QWeb = core.qweb;
var AbstractController = AbstractAction.extend(ControlPanelMixin, {
var AbstractController = mvc.Controller.extend(ActionMixin, {
custom_events: {
get_controller_query_params: '_onGetControllerQueryParams',
navigation_move: '_onNavigationMove',
open_record: '_onOpenRecord',
search: '_onSearch',
switch_view: '_onSwitchView',
},
events: {
@@ -31,50 +34,30 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
},
/**
* @constructor
* @param {Widget} parent
* @param {AbstractModel} model
* @param {AbstractRenderer} renderer
* @param {object} params
* @override
* @param {string} params.modelName
* @param {string} [params.controllerID] an id to ease the communication
* with upstream components
* @param {ControlPanelController} [params.controlPanel]
* @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);
this.model = model;
this.renderer = renderer;
this._controlPanel = params.controlPanel;
this._title = params.displayName;
this.modelName = params.modelName;
this.handle = params.handle;
this.activeActions = params.activeActions;
this.controllerID = params.controllerID;
this.initialState = params.initialState;
this.bannerRoute = params.bannerRoute;
// use a DropPrevious to correctly handle concurrent updates
this.dp = new concurrency.DropPrevious();
// 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.groupable = params.groupable;
this.enableTimeRangeMenu = params.enableTimeRangeMenu;
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;
// use a DropPrevious to correctly handle concurrent updates
this.dp = new concurrency.DropPrevious();
},
/**
* Simply renders and updates the url.
@@ -86,13 +69,13 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
this.$el.addClass('o_view_controller');
// render the ControlPanel elements (buttons, pager, sidebar...)
this.controlPanelElements = this._renderControlPanelElements();
return this._super.apply(this, arguments).then(function () {
if (self._controlPanel) {
// render the ControlPanel elements (buttons, pager, sidebar...)
self.controlPanelElements = self._renderControlPanelElements();
self._controlPanel.$el.prependTo(self.$el);
}
return $.when(
this._super.apply(this, arguments),
this.renderer.appendTo(this.$el)
).then(function () {
return self._update(self.initialState);
});
},
@@ -106,14 +89,14 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
if (this.controlPanelElements && this.controlPanelElements.$switch_buttons) {
this.controlPanelElements.$switch_buttons.off();
}
return this._super.apply(this, arguments);
this._super.apply(this, arguments);
},
/**
* Called each time the controller is attached into the DOM.
*/
on_attach_callback: function () {
if (this.searchView) {
this.searchView.on_attach_callback();
if (this._controlPanel) {
this._controlPanel.on_attach_callback();
}
this.renderer.on_attach_callback();
},
@@ -153,26 +136,18 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
return $.when();
},
/**
* Returns any special keys that may be useful when reloading the view to
* get the same effect. This is necessary for saving the current view in
* the favorites. For example, a graph view might want to add a key to
* save the current graph type.
* Export the state of the controller containing information that is shared
* between different controllers of a same action (like the current
* searchQuery of the controlPanel).
*
* @returns {Object}
*/
getContext: function () {
return {};
},
/**
* Returns a title that may be displayed in the breadcrumb area. For
* example, the name of the record.
*
* note: this will be moved to AbstractAction
*
* @returns {string}
*/
getTitle: function () {
return this.displayName;
exportState: function () {
var state = {};
if (this._controlPanel) {
state.cpState = this._controlPanel.exportState();
}
return state;
},
/**
* Gives the focus to the renderer
@@ -199,7 +174,16 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
* @returns {Deferred}
*/
reload: function (params) {
return this.update(params || {});
params = params || {};
var def;
var controllerState = params.controllerState || {};
var cpState = controllerState.cpState;
if (this._controlPanel && cpState) {
def = this._controlPanel.importState(cpState).then(function (searchQuery) {
params = _.extend({}, params, searchQuery);
});
}
return $.when(def).then(this.update.bind(this, params, {}));
},
/**
* For views that require a pager, this method will be called to allow the
@@ -246,16 +230,16 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
// and this controller should no longer update the control panel.
// note that this won't be necessary as soon as each controller will have
// its own control panel
var cpUpdateIndex = this.cp_bus && this.cp_bus.updateIndex;
var cpUpdateIndex = this._controlPanel && this._controlPanel.updateIndex;
return this.dp.add(def).then(function (handle) {
if (self.cp_bus && cpUpdateIndex !== self.cp_bus.updateIndex) {
if (self._controlPanel && cpUpdateIndex !== self._controlPanel.updateIndex) {
return;
}
self.handle = handle || self.handle; // update handle if we reloaded
var state = self.model.get(self.handle);
var localState = self.renderer.getLocalState();
return self.dp.add(self.renderer.updateState(state, params)).then(function () {
if (self.cp_bus && cpUpdateIndex !== self.cp_bus.updateIndex) {
if (self._controlPanel && cpUpdateIndex !== self._controlPanel.updateIndex) {
return;
}
self.renderer.setLocalState(localState);
@@ -327,7 +311,7 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
js.remove();
});
return $.when.apply($, defs).then(function () {
$banner.prependTo(self.$el);
$banner.prependTo(self.$('> .o_content'));
self._$banner = $banner;
});
});
@@ -342,24 +326,20 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
* @returns {Object} an object containing the control panel jQuery elements
*/
_renderControlPanelElements: function () {
var elements = {};
var elements = {
$buttons: $('<div>'),
$sidebar: $('<div>'),
$pager: $('<div>'),
};
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();
}
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;
},
@@ -379,6 +359,7 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
var template = config.device.isMobile ? 'ControlPanel.SwitchButtons.Mobile' : 'ControlPanel.SwitchButtons';
var $switchButtons = $(QWeb.render(template, {
viewType: this.viewType,
views: views,
}));
// create bootstrap tooltips
@@ -392,14 +373,21 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
self.trigger_up('switch_view', {view_type: viewType});
}, 200, true));
// set active view's icon as view switcher button's icon in mobile
if (config.device.isMobile) {
// set active view's icon as view switcher button's icon
var activeView = _.findWhere(views, {type: this.viewType});
$switchButtons.find('.o_switch_view_button_icon').addClass('fa fa-lg ' + activeView.icon);
}
return $switchButtons;
},
/**
* @override
* @private
*/
_startRenderer: function () {
return this.renderer.appendTo(this.$('.o_content'));
},
/**
* This method is called after each update or when the start method is
* completed.
@@ -413,23 +401,12 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
* @param {Object} state the state given by the model
* @returns {Deferred}
*/
_update: function (state) {
_update: function () {
// 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,
});
}
this.update_control_panel({
active_view_selector: '.o_cp_switch_' + this.viewType,
this.updateControlPanel({
cp_content: cpContent,
hidden: !this.withControlPanel,
searchview: this.searchView,
search_view_hidden: !this.searchable || this.searchviewHidden,
groupable: this.groupable,
enableTimeRangeMenu: this.enableTimeRangeMenu,
title: this.getTitle(),
});
this._pushState();
@@ -478,6 +455,36 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
this.do_action($target.attr('name'));
}
},
/**
* FIXME: this logic should be rethought
*
* Handles a context request: provides to the caller the state of the
* current controller.
*
* @private
* @param {OdooEvent} ev
* @param {function} ev.data.callback used to send the requested state
*/
_onGetControllerQueryParams: function (ev) {
ev.stopPropagation();
var state = this.getOwnedQueryParams();
ev.data.callback(state || {});
},
/**
* Called mainly from the control panel when the focus should be given to
* the controller
*
* @private
* @param {OdooEvent} ev
*/
_onNavigationMove : function (ev) {
switch(ev.data.direction) {
case 'down' :
ev.stopPropagation();
this.giveFocus();
break;
}
},
/**
* When an Odoo event arrives requesting a record to be opened, this method
* gets the res_id, and request a switch view in the appropriate mode
@@ -502,6 +509,20 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
model: this.modelName,
});
},
/**
* 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
* @param {Array[]} ev.data.domain
* @param {Object} ev.data.context
* @param {string[]} ev.data.groupby
*/
_onSearch: function (ev) {
ev.stopPropagation();
this.reload(_.extend({offset: 0}, ev.data));
},
/**
* Intercepts the 'switch_view' event to add the controllerID into the data,
* and lets the event bubble up.
@@ -14,52 +14,21 @@ odoo.define('web.AbstractModel', function (require) {
* in order to be able to notify its parent by bubbling events up.
*/
var Class = require('web.Class');
var fieldUtils = require('web.field_utils');
var mixins = require('web.mixins');
var ServicesMixin = require('web.ServicesMixin');
var AbstractModel = Class.extend(mixins.EventDispatcherMixin, ServicesMixin, {
/**
* @param {Widget} parent
*/
init: function (parent) {
mixins.EventDispatcherMixin.init.call(this);
this.setParent(parent);
},
var mvc = require('web.mvc');
var AbstractModel = mvc.Model.extend({
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* This method should return the complete state necessary for the renderer
* to display the currently viewed data.
*
* @returns {*}
*/
get: function () {
},
/**
* The load method is called once in a model, when we load the data for the
* first time. The method returns (a deferred that resolves to) some kind
* of token/handle. The handle can then be used with the get method to
* access a representation of the data.
*
* @param {Object} params
* @param {string} params.modelName the name of the model
* @returns {Deferred} The deferred resolves to some kind of handle
*/
load: function (params) {
return $.when();
},
/**
* When something changes, the data may need to be refetched. This is the
* job for this method: reloading (only if necessary) all the data and
* making sure that they are ready to be redisplayed.
*
* @param {Object} params
* @returns {Deferred}
* @returns {Promise}
*/
reload: function (params) {
return $.when();
@@ -7,22 +7,18 @@ odoo.define('web.AbstractRenderer', function (require) {
*
*/
var Widget = require('web.Widget');
var mvc = require('web.mvc');
/**
* @class AbstractRenderer
*/
return Widget.extend({
return mvc.Renderer.extend({
/**
* @constructor
* @param {Widget} parent
* @param {any} state
* @param {Object} params
* @override
* @param {string} [params.noContentHelp]
*/
init: function (parent, state, params) {
this._super(parent);
this.state = state;
this._super.apply(this, arguments);
this.arch = params.arch;
this.noContentHelp = params.noContentHelp;
},
+184 -152
View File
@@ -23,14 +23,16 @@ odoo.define('web.AbstractView', function (require) {
* in most case discarded.
*/
var ajax = require('web.ajax');
var Class = require('web.Class');
var AbstractModel = require('web.AbstractModel');
var AbstractRenderer = require('web.AbstractRenderer');
var AbstractController = require('web.AbstractController');
var utils = require('web.utils');
var ControlPanelView = require('web.ControlPanelView');
var mvc = require('web.mvc');
var viewUtils = require('web.viewUtils');
var AbstractView = Class.extend({
var Factory = mvc.Factory;
var AbstractView = Factory.extend({
// name displayed in view switchers
display_name: '',
// indicates whether or not the view is mobile-friendly
@@ -40,22 +42,20 @@ var AbstractView = Class.extend({
// multi_record is used to distinguish views displaying a single record
// (e.g. FormView) from those that display several records (e.g. ListView)
multi_record: true,
// determine if a search view should be displayed in the control panel and
// allowed to interact with the view. Currently, the only not searchable
// views are the form view and the diagram view.
searchable: true,
// viewType is the type of the view, like 'form', 'kanban', 'list'...
viewType: undefined,
// if searchable, this flag determines if the search view will display a
// groupby menu or not. This is useful for the views which do not support
// grouping data.
groupable: true,
enableTimeRangeMenu: false,
config: {
// determines if a search bar is available
withSearchBar: true,
// determines the search menus available and their orders
searchMenuTypes: ['filter', 'groupBy', 'favorite'],
// determines if a control panel should be instantiated
withControlPanel: true,
// determines the MVC components to use
config: _.extend({}, Factory.prototype.config, {
Model: AbstractModel,
Renderer: AbstractRenderer,
Controller: AbstractController,
},
}),
/**
* The constructor function is supposed to set 3 variables: rendererParams,
@@ -65,33 +65,32 @@ var AbstractView = Class.extend({
* @constructs AbstractView
*
* @param {Object} viewInfo
* @param {Object} viewInfo.arch
* @param {Object|string} viewInfo.arch
* @param {Object} viewInfo.fields
* @param {Object} viewInfo.fieldsInfo
* @param {Object} params
* @param {string} params.modelName The actual model name
* @param {Object} params.context
* @param {number} [params.count]
* @param {string} [params.modelName]
* @param {Object} [params.action={}]
* @param {Object} [params.context={}]
* @param {string} [params.controllerID]
* @param {string[]} params.domain
* @param {string[][]} params.timeRange
* @param {string[][]} params.comparisonTimeRange
* @param {string} params.timeRangeDescription
* @param {string} params.comparisonTimeRangeDescription
* @param {boolean} params.compare
* @param {string[]} params.groupBy
* @param {number} [params.count]
* @param {number} [params.currentId]
* @param {boolean} params.isEmbedded
* @param {string} [params.controllerState]
* @param {string} [params.displayName]
* @param {Array[]} [params.domain=[]]
* @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]
* @param {boolean} [params.isEmbedded=false]
* @param {Object} [params.searchQuery={}]
* @param {Object} [params.searchQuery.context={}]
* @param {Array[]} [params.searchQuery.domain=[]]
* @param {string[]} [params.searchQuery.groupBy=[]]
* @param {Object} [params.userContext={}]
* @param {boolean} [params.withControlPanel=true]
*/
init: function (viewInfo, params) {
var action = params.action || {};
params = _.defaults(params, this._extractParamsFromAction(action));
// in general, the fieldsView has to be processed by the View (e.g. the
// arch is a string that needs to be parsed) ; the only exception is for
// inline form views inside form views, as they are processed alongside
@@ -103,89 +102,51 @@ var AbstractView = Class.extend({
} else {
this.fieldsView = viewInfo;
}
this.fields = this.fieldsView.viewFields;
this.arch = this.fieldsView.arch;
// the boolean parameter 'isEmbedded' determines if the view should be considered
// as a subview. For now this is only used by the graph controller that appends a
// 'Group By' button beside the 'Measures' button when the graph view is embedded.
this.fields = this.fieldsView.viewFields;
this.userContext = params.userContext || {};
this.withControlPanel = params.withControlPanel;
// the boolean parameter 'isEmbedded' determines if the view should be
// considered as a subview. For now this is only used by the graph
// controller that appends a 'Group By' button beside the 'Measures'
// button when the graph view is embedded.
var isEmbedded = params.isEmbedded || false;
this.rendererParams = {
arch: this.arch,
isEmbedded: isEmbedded,
noContentHelp: params.action && params.action.help,
noContentHelp: params.noContentHelp,
};
var timeRangeMenuData = params.context.timeRangeMenuData;
var timeRange = [];
var comparisonTimeRange = [];
var compare = false;
var timeRangeDescription = "";
var comparisonTimeRangeDescription = "";
if (this.enableTimeRangeMenu && timeRangeMenuData) {
timeRange = timeRangeMenuData.timeRange;
comparisonTimeRange = timeRangeMenuData.comparisonTimeRange;
compare = comparisonTimeRange.length > 0;
timeRangeDescription = timeRangeMenuData.timeRangeDescription;
comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription;
this.rendererParams.timeRangeDescription = timeRangeDescription;
this.rendererParams.comparisonTimeRangeDescription = comparisonTimeRangeDescription;
}
this.controllerParams = {
modelName: params.modelName,
actionViews: params.actionViews,
activeActions: {
edit: this.arch.attrs.edit ? JSON.parse(this.arch.attrs.edit) : true,
create: this.arch.attrs.create ? JSON.parse(this.arch.attrs.create) : true,
delete: this.arch.attrs.delete ? JSON.parse(this.arch.attrs.delete) : true,
duplicate: this.arch.attrs.duplicate ? JSON.parse(this.arch.attrs.duplicate) : true,
},
groupable: this.groupable,
enableTimeRangeMenu: this.enableTimeRangeMenu,
isEmbedded: isEmbedded,
controllerID: params.controllerID,
bannerRoute: this.arch.attrs.banner_route,
controllerID: params.controllerID,
displayName: params.displayName,
isEmbedded: isEmbedded,
isMultiRecord: this.multi_record,
modelName: params.modelName,
viewType: this.viewType,
};
// 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;
}
var groupBy = params.groupBy;
if (typeof groupBy === 'string') {
groupBy = [groupBy];
}
var controllerState = params.controllerState || {};
var currentId = controllerState.currentId || params.currentId;
this.loadParams = {
context: params.context,
count: params.count || ((this.controllerParams.ids !== undefined) &&
this.controllerParams.ids.length) || 0,
domain: params.domain,
timeRange: timeRange,
timeRangeDescription: timeRangeDescription,
comparisonTimeRange: comparisonTimeRange,
comparisonTimeRangeDescription: comparisonTimeRangeDescription,
compare: compare,
groupedBy: groupBy,
modelName: params.modelName,
res_id: params.currentId,
res_ids: params.ids,
orderedBy: params.context ? params.context.orderedBy : [],
res_id: currentId,
res_ids: controllerState.resIds || params.ids || (currentId ? [currentId] : undefined),
};
if (params.modelName) {
this.loadParams.modelName = params.modelName;
}
// default_order is like:
// 'name,id desc'
// but we need it like:
@@ -197,8 +158,23 @@ var AbstractView = Class.extend({
return {name: order[0], asc: order[1] !== 'desc'};
});
}
if (params.searchQuery) {
this._updateMVCParams(params.searchQuery);
}
this.userContext = params.userContext;
this.controlPanelParams = {
action: action,
activateDefaultFavorite: params.activateDefaultFavorite,
breadcrumbs: params.breadcrumbs,
context: this.loadParams.context,
domain: this.loadParams.domain,
modelName: params.modelName,
searchMenuTypes: params.searchMenuTypes,
state: controllerState.cpState,
viewInfo: params.controlPanelFieldsView,
withBreadcrumbs: params.withBreadcrumbs,
withSearchBar: params.withSearchBar,
};
},
//--------------------------------------------------------------------------
@@ -206,69 +182,48 @@ var AbstractView = Class.extend({
//--------------------------------------------------------------------------
/**
* Main method of the view class. Create a controller, and make sure that
* data and libraries are loaded.
*
* There is a unusual thing going in this method with parents: we create
* renderer/model with parent as parent, then we have to reassign them at
* the end to make sure that we have the proper relationships. This is
* necessary to solve the problem that the controller need the model and the
* renderer to be instantiated, but the model need a parent to be able to
* load itself, and the renderer needs the data in its constructor.
*
* @param {Widget} parent The parent of the resulting Controller (most
* likely an action manager)
* @returns {Deferred} The deferred resolves to a controller
* @override
*/
getController: function (parent) {
var self = this;
// check if a model already exists, as if not, one will be created and
// we'll have to set the controller as its parent
var alreadyHasModel = !!this.model;
return $.when(this._loadData(parent), ajax.loadLibs(this)).then(function () {
var state = self.model.get(arguments[0]);
var renderer = self.getRenderer(parent, state);
var Controller = self.Controller || self.config.Controller;
var controllerParams = _.extend({
initialState: state,
}, self.controllerParams);
var controller = new Controller(parent, self.model, renderer, controllerParams);
renderer.setParent(controller);
if (!alreadyHasModel) {
// if we have a model, it already has a parent. Otherwise, we
// set the controller, so the rpcs from the model actually work
self.model.setParent(controller);
var def;
if (this.withControlPanel) {
def = this._createControlPanel(parent);
}
var _super = this._super.bind(this);
return $.when(def).then(function (controlPanel) {
if (controlPanel) {
var searchQuery = controlPanel.getSearchQuery();
self._updateMVCParams(searchQuery);
}
return controller;
// get the parent of the model if it already exists, as _super will
// set the new controller as parent, which we don't want
var modelParent = self.model && self.model.getParent();
return _super(parent).done(function (controller) {
if (controlPanel) {
controlPanel.setParent(controller);
}
if (modelParent) {
// if we already add a model, restore its parent
self.model.setParent(modelParent);
}
});
});
},
/**
* Returns the view model or create an instance of it if none
* Ensures that only one instance of AbstractModel is created
*
* @param {Widget} parent the parent of the model, if it has to be created
* @return {Object} instance of the view model
* @override
*/
getModel: function (parent) {
getModel: function () {
if (!this.model) {
var Model = this.config.Model;
this.model = new Model(parent);
this.model = this._super.apply(this, arguments);
}
return this.model;
},
/**
* Returns the a new view renderer instance
*
* @param {Widget} parent the parent of the model, if it has to be created
* @param {Object} state the information related to the rendered view
* @return {Object} instance of the view renderer
*/
getRenderer: function (parent, state) {
var Renderer = this.config.Renderer;
return new Renderer(parent, state, this.rendererParams);
},
/**
* this is useful to customize the actual class to use before calling
* This is useful to customize the actual class to use before calling
* createView.
*
* @param {Controller} Controller
@@ -282,16 +237,61 @@ var AbstractView = Class.extend({
//--------------------------------------------------------------------------
/**
* Load initial data from the model
* Instantiates and starts a ControlPanelController.
*
* @private
* @param {Widget} parent the parent of the model
* @returns {Deferred<*>} a deferred that resolves to whatever the model
* decide to return
* @param {Widget} parent
* @returns {ControlPanelController}
*/
_loadData: function (parent) {
var model = this.getModel(parent);
return model.load(this.loadParams);
_createControlPanel: function (parent) {
var self = this;
var controlPanelView = new ControlPanelView(this.controlPanelParams);
return controlPanelView.getController(parent).then(function (controlPanel) {
self.controllerParams.controlPanel = controlPanel;
return controlPanel.appendTo(document.createDocumentFragment()).then(function () {
return controlPanel;
});
});
},
/**
* @private
* @param {Object} [action]
* @param {Object} [action.context || {}]
* @param {boolean} [action.context.no_breadcrumbs=false]
* @param {integer} [action.context.active_id]
* @param {integer[]} [action.context.active_ids]
* @param {Object} [action.controlPanelFieldsView]
* @param {string} [action.display_name]
* @param {Array[]} [action.domain=[]]
* @param {string} [action.help]
* @param {integer} [action.id]
* @param {integer} [action.limit]
* @param {string} [action.name]
* @param {string} [action.res_model]
* @param {string} [action.target]
* @returns {Object}
*/
_extractParamsFromAction: function (action) {
action = action || {};
var context = action.context || {};
var inline = action.target === 'inline';
return {
actionId: action.id || false,
actionViews: action.views || [],
activateDefaultFavorite: !context.active_id && !context.active_ids,
context: action.context || {},
controlPanelFieldsView: action.controlPanelFieldsView,
currentId: action.res_id ? action.res_id : undefined, // load returns 0
displayName: action.display_name || action.name,
domain: action.domain || [],
limit: action.limit,
modelName: action.res_model,
noContentHelp: action.help,
searchMenuTypes: inline ? [] : this.searchMenuTypes,
withBreadcrumbs: 'no_breadcrumbs' in context ? !context.no_breadcrumbs : true,
withControlPanel: this.withControlPanel,
withSearchBar: inline ? false : this.withSearchBar,
};
},
/**
* Processes a fieldsView. In particular, parses its arch.
@@ -303,12 +303,44 @@ var AbstractView = Class.extend({
*/
_processFieldsView: function (fieldsView) {
var fv = _.extend({}, fieldsView);
var doc = $.parseXML(fv.arch).documentElement;
var stripWhitespaces = doc.nodeName.toLowerCase() !== 'kanban';
fv.arch = utils.xml_to_json(doc, stripWhitespaces);
fv.arch = viewUtils.parseArch(fv.arch);
fv.viewFields = _.defaults({}, fv.viewFields, fv.fields);
return fv;
},
/**
* Hook to update the renderer, controller and load params with the result
* of a search (i.e. a context, a domain and a groupBy).
*
* @private
* @param {Object} searchQuery
* @param {Object} searchQuery.context
* @param {Object} [searchQuery.context.timeRangeMenuData={}]
* @param {Array[]} [searchQuery.context.timeRangeMenuData.comparisonTimeRange=[]]
* @param {string} [searchQuery.context.timeRangeMenuData.comparisonTimeRangeDescription='']
* @param {string} [searchQuery.context.timeRangeMenuData.timeRangeDescription='']
* @param {Array[]} [searchQuery.context.timeRangeMenuData.timeRange=[]]
* @param {Array[]} searchQuery.domain
* @param {string[]} searchQuery.groupBy
*/
_updateMVCParams: function (searchQuery) {
var timeRangeMenuData = searchQuery.context.timeRangeMenuData || {};
var comparisonTimeRange = timeRangeMenuData.comparisonTimeRange || [];
var comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription || '';
var timeRangeDescription = timeRangeMenuData.timeRangeDescription || '';
this.loadParams = _.extend(this.loadParams, {
compare: comparisonTimeRange.length > 0,
comparisonTimeRange: comparisonTimeRange,
comparisonTimeRangeDescription: comparisonTimeRangeDescription,
context: searchQuery.context,
domain: searchQuery.domain,
groupedBy: searchQuery.groupBy,
timeRange: timeRangeMenuData.timeRange || [],
timeRangeDescription: timeRangeMenuData.timeRangeDescription || '',
});
this.loadParams.orderedBy = searchQuery.orderedBy ? searchQuery.orderedBy : this.loadParams.orderedBy;
this.rendererParams.timeRangeDescription = timeRangeDescription;
this.rendererParams.comparisonTimeRangeDescription = comparisonTimeRangeDescription;
},
});
return AbstractView;
@@ -36,8 +36,8 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
this.confirmOnDelete = params.confirmOnDelete;
this.hasButtons = params.hasButtons;
FieldManagerMixin.init.call(this, this.model);
this.handle = params.initialState.id;
this.mode = params.mode || 'readonly';
this.handle = this.initialState.id;
},
/**
* @override
@@ -201,6 +201,18 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
self._updatePager();
});
},
/**
* @override
*/
reload: function (params) {
if (params && params.controllerState) {
if (params.controllerState.currentId) {
params.currentId = params.controllerState.currentId;
}
params.ids = params.controllerState.resIds;
}
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Private
@@ -368,6 +380,20 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
this.$buttons.find('button').removeAttr('disabled');
}
},
/**
* Override to add the current record ID (currentId) and the list of ids
* (resIds) in the current dataPoint to the exported state.
*
* @override
*/
exportState: function () {
var state = this._super.apply(this, arguments);
var env = this.model.get(this.handle, {env: true});
return _.extend(state, {
currentId: env.currentId,
resIds: env.ids,
});
},
/**
* Returns the new sidebar env
*
@@ -487,7 +513,6 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
var sidebarEnv = this._getSidebarEnv();
this.sidebar.updateEnv(sidebarEnv);
}
this.trigger_up('env_updated', {controllerID: this.controllerID, env: env});
},
/**
* Helper method, to make sure the information displayed by the pager is up
@@ -2053,14 +2053,12 @@ var BasicModel = AbstractModel.extend({
// We don't do it now because it's not an important case.
// However, we can for sure increment by 1 if we are on the last page.
var handleFieldValue = computedList.data[index].data[handleField];
if (position === 'top') {
handleFieldValue--;
} else if (list.count <= list.offset + list.limit - (list.tempLimitIncrement || 0)) {
handleFieldValue++;
}
return {
field: handleField,
value: handleFieldValue,
@@ -4383,7 +4381,9 @@ var BasicModel = AbstractModel.extend({
if (options.context !== undefined) {
element.context = options.context;
element.orderedBy = options.context.orderedBy || element.orderedBy;
}
if (options.orderedBy !== undefined) {
element.orderedBy = options.orderedBy || element.orderedBy;
}
if (options.domain !== undefined) {
element.domain = options.domain;
@@ -517,35 +517,6 @@ var BasicRenderer = AbstractRenderer.extend({
});
});
},
/**
* Renders a button according to a given node element.
*
* @private
* @param {Object} node
* @param {Object} [options]
* @param {string} [options.extraClass]
* @param {boolean} [options.textAsTitle=false]
* @returns {jQuery}
*/
_renderButtonFromNode: function (node, options) {
var btnOptions = {
attrs: _.omit(node.attrs, 'icon', 'string', 'type', 'attrs', 'modifiers', 'options'),
icon: node.attrs.icon,
};
if (options && options.extraClass) {
var classes = btnOptions.attrs.class ? btnOptions.attrs.class.split(' ') : [];
btnOptions.attrs.class = _.uniq(classes.concat(options.extraClass.split(' '))).join(' ');
}
var str = (node.attrs.string || '').replace(/_/g, '');
if (str) {
if (options && options.textAsTitle) {
btnOptions.attrs.title = str;
} else {
btnOptions.text = str;
}
}
return dom.renderButton(btnOptions);
},
/**
* Instantiates the appropriate AbstractField specialization for the given
* node and prepares its rendering and addition to the DOM. Indeed, the
@@ -51,7 +51,6 @@ var BasicView = AbstractView.extend({
this.loadParams.fieldsInfo = this.fieldsInfo;
this.loadParams.fields = this.fields;
this.loadParams.context = params.context || {};
this.loadParams.limit = parseInt(this.arch.attrs.limit, 10) || params.limit;
this.loadParams.viewType = this.viewType;
this.loadParams.parentID = params.parentID;
@@ -96,19 +95,17 @@ var BasicView = AbstractView.extend({
* @private
* @returns {Deferred}
*/
_loadData: function () {
_loadData: function (model) {
if (this.recordID) {
var self = this;
// Add the fieldsInfo of the current view to the given recordID,
// as it will be shared between two views, and it must be able to
// handle changes on fields that are only on this view.
this.model.addFieldsInfo(this.recordID, {
model.addFieldsInfo(this.recordID, {
fields: this.fields,
fieldsInfo: this.fieldsInfo,
});
var record = this.model.get(this.recordID);
var record = model.get(this.recordID);
var viewType = this.viewType;
var viewFields = Object.keys(record.fieldsInfo[viewType]);
var fieldNames = _.difference(viewFields, Object.keys(record.data));
@@ -168,14 +165,14 @@ var BasicView = AbstractView.extend({
// onchange RPCs), that we haven't been able to process earlier
// (because those fields were unknow at that time). So we ask
// the model to process them.
def = this.model.applyRawChanges(record.id, viewType).then(function () {
if (self.model.isNew(record.id)) {
return self.model.applyDefaultValues(record.id, {}, {
def = model.applyRawChanges(record.id, viewType).then(function () {
if (model.isNew(record.id)) {
return model.applyDefaultValues(record.id, {}, {
fieldNames: fieldNames,
viewType: viewType,
});
} else {
return self.model.reload(record.id, {
return model.reload(record.id, {
fieldNames: fieldNames,
keepChanges: true,
viewType: viewType,
@@ -184,7 +181,7 @@ var BasicView = AbstractView.extend({
});
}
return $.when(def).then(function () {
return record.id;
return model.get(record.id);
});
}
return this._super.apply(this, arguments);
@@ -75,7 +75,7 @@ var CalendarController = AbstractController.extend({
* @returns {string}
*/
getTitle: function () {
return this.get('title');
return this._title;
},
/**
* Render the buttons according to the CalendarView.buttons template and
@@ -156,7 +156,7 @@ var CalendarController = AbstractController.extend({
* @returns {Deferred}
*/
_updateRecord: function (record) {
return this.model.updateRecord(record).then(this.reload.bind(this));
return this.model.updateRecord(record).then(this.reload.bind(this, {}));
},
//--------------------------------------------------------------------------
@@ -430,7 +430,7 @@ var CalendarController = AbstractController.extend({
this.$buttons.find('.active').removeClass('active');
this.$buttons.find('.o_calendar_button_' + this.mode).addClass('active');
}
this.set({title: this.displayName + ' (' + event.data.title + ')'});
this._setTitle(this.displayName + ' (' + event.data.title + ')');
},
});
@@ -29,7 +29,7 @@ var CalendarView = AbstractView.extend({
Renderer: CalendarRenderer,
},
viewType: 'calendar',
groupable: false,
searchMenuTypes: ['filter', 'favorite'],
/**
* @override
@@ -0,0 +1,237 @@
odoo.define('web.ControlPanelController', function (require) {
"use strict";
var mvc = require('web.mvc');
var ControlPanelController = mvc.Controller.extend({
className: 'o_cp_controller',
custom_events: {
facet_removed: '_onFacetRemoved',
get_search_query: '_onGetSearchQuery',
item_option_clicked: '_onItemOptionClicked',
item_trashed: '_onItemTrashed',
menu_item_clicked: '_onMenuItemClicked',
new_favorite: '_onNewFavorite',
new_filters: '_onNewFilters',
new_groupBy: '_onNewGroupBy',
activate_time_range: '_onActivateTimeRange',
autocompletion_filter: '_onAutoCompletionFilter',
},
/**
* @override
* @param {Object} params
* @param {string} params.modelName
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
this.modelName = params.modelName;
},
/**
* Called when the control panel is inserted into the DOM.
*/
on_attach_callback: function () {
this.renderer.on_attach_callback();
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @see ControlPanelModel (exportState)
* @returns {Object}
*/
exportState: function () {
return this.model.exportState();
},
/**
* Compute the search related values that will be used to fetch data.
*
* @returns {Object} object with keys 'context', 'domain', 'groupBy'
*/
getSearchQuery: function () {
return this.model.getQuery();
},
/**
* @param {Object} state a ControlPanelModel state
* @returns {Deferred<Object>} the result of `getSearchState`
*/
importState: function (state) {
var defs = [];
this.model.importState(state);
defs.push(this.getSearchQuery());
defs.push(this.renderer.updateState(this.model.get()));
return $.when(defs).then(function (defsResults) {
return defsResults[0];
});
},
/**
* Called at each switch view. This is required until the control panel is
* shared between controllers of an action.
*
* @param {string} controllerID
*/
setControllerID: function (controllerID) {
this.controllerID = controllerID;
},
/**
* Update the content and displays the ControlPanel.
*
* @see ControlPanelRenderer (updateContents)
* @param {Object} status
* @param {Object} [options]
*/
updateContents: function (status, options) {
this.renderer.updateContents(status, options);
},
/**
* Update the domain of the search view by adding and/or removing filters.
*
* @todo: the way it is done could be improved, but the actual state of the
* searchview doesn't allow to do much better.
* @param {Object[]} newFilters list of filters to add, described by
* objects with keys domain (the domain as an Array), description (the text
* to display in the facet) and type with value 'filter'.
* @param {string[]} filtersToRemove list of filter ids to remove
* (previously added ones)
* @returns {string[]} list of added filters (to pass as filtersToRemove
* for a further call to this function)
*/
updateFilters: function (newFilters, filtersToRemove) {
var newFilterIDS = this.model.createNewFilters(newFilters);
this.model.deactivateFilters(filtersToRemove);
this._reportNewQueryAndRender();
return newFilterIDS;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @returns {jQuery}
*/
_getSubMenus: function () {
return this.renderer.$subMenus;
},
/**
* @private
* @returns {Deferred}
*/
_reportNewQueryAndRender: function () {
this.trigger_up('search', this.model.getQuery());
var state = this.model.get();
return this.renderer.updateState(state);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {OdooEvent} ev
*/
_onActivateTimeRange: function (ev) {
ev.stopPropagation();
this.model.activateTimeRange(
ev.data.id,
ev.data.timeRangeId,
ev.data.comparisonTimeRangeId
);
this._reportNewQueryAndRender();
},
/**
* @private
* @param {OdooEvent} ev
*/
_onAutoCompletionFilter: function (ev) {
ev.stopPropagation();
this.model.toggleAutoCompletionFilter(ev.data);
this._reportNewQueryAndRender();
},
/**
* @private
* @param {OdooEvent} ev
*/
_onFacetRemoved: function (ev) {
ev.stopPropagation();
var group = ev.data.group;
if (!group) {
group = this.renderer.getLastFacet();
}
this.model.deactivateGroup(group.id);
this._reportNewQueryAndRender();
},
/**
* @private
* @param {OdooEvent} ev
*/
_onGetSearchQuery: function (ev) {
ev.stopPropagation();
var query = this.getSearchQuery();
ev.data.callback(query);
},
/**
* @private
* @param {OdooEvent} ev
*/
_onItemOptionClicked: function (ev) {
ev.stopPropagation();
this.model.toggleFilterWithOptions(ev.data.id, ev.data.optionId);
this._reportNewQueryAndRender();
},
/**
* @private
* @param {OdooEvent} ev
*/
_onItemTrashed: function (ev) {
ev.stopPropagation();
var def = this.model.deleteFilterEverywhere(ev.data.id);
def.then(this._reportNewQueryAndRender.bind(this));
},
/**
* @private
* @param {OdooEvent} ev
*/
_onMenuItemClicked: function (ev) {
ev.stopPropagation();
this.model.toggleFilter(ev.data.id);
this._reportNewQueryAndRender();
},
/**
* @private
* @param {OdooEvent} ev
*/
_onNewFavorite: function (ev) {
ev.stopPropagation();
var def = this.model.createNewFavorite(ev.data);
def.then(this._reportNewQueryAndRender.bind(this));
},
/**
* @private
* @param {OdooEvent} ev
*/
_onNewFilters: function (ev) {
ev.stopPropagation();
this.model.createNewFilters(ev.data.filters);
this._reportNewQueryAndRender();
},
/**
* @private
* @param {OdooEvent} ev
*/
_onNewGroupBy: function (ev) {
ev.stopPropagation();
this.model.createNewGroupBy(ev.data);
this._reportNewQueryAndRender();
},
});
return ControlPanelController;
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,387 @@
odoo.define('web.ControlPanelRenderer', function (require) {
"use strict";
var config = require('web.config');
var data = require('web.data');
var FavoriteMenu = require('web.FavoriteMenu');
var FilterMenu = require('web.FilterMenu');
var GroupByMenu = require('web.GroupByMenu');
var mvc = require('web.mvc');
var SearchBar = require('web.SearchBar');
var TimeRangeMenu = require('web.TimeRangeMenu');
var Renderer = mvc.Renderer;
var ControlPanelRenderer = Renderer.extend({
template: 'ControlPanel',
custom_events: {
get_action_info: '_onGetActionInfo',
},
events: _.extend({}, Renderer.prototype.events, {
'click.bs.dropdown .o_search_options .dropdown-menu': '_onDropdownClicked',
'click .o_searchview_more': '_onMore',
}),
/**
* @override
* @param {Object} [params.action] current action if any
* @param {Object} [params.context]
* @param {Object[]} [params.breadcrumbs=[]] list of breadcrumbs elements
* @param {boolean} [params.withBreadcrumbs=false] if false, breadcrumbs
* won't be rendered
* @param {boolean} [params.withSearchBar=false] if false, no search bar
* is rendered
* @param {string[]} [params.searchMenuTypes=[]] determines the search menus
* that are displayed.
* @param {String} [params.template] the QWeb template to render the
* ControlPanel. By default, the template 'ControlPanel' will be used.
* @param {string} [params.title=''] the title visible in control panel
*/
init: function (parent, state, params) {
this._super.apply(this, arguments);
this._breadcrumbs = params.breadcrumbs || [];
this._title = params.title || '';
this.withBreadcrumbs = params.withBreadcrumbs;
this.withSearchBar = params.withSearchBar;
if (params.template) {
this.template = params.template;
}
this.context = params.context;
this.$subMenus = null;
this.action = params.action;
this.displaySearchMenu = true;
this.menusSetup = false;
this.searchMenuTypes = params.searchMenuTypes || [];
this.subMenus = {};
},
/**
* Render the control panel and create a dictionnary of its exposed elements.
*
* @override
*/
start: function () {
var self = this;
// exposed jQuery nodesets
this.nodes = {
$buttons: this.$('.o_cp_buttons'),
$pager: this.$('.o_cp_pager'),
$sidebar: this.$('.o_cp_sidebar'),
$switch_buttons: this.$('.o_cp_switch_buttons'),
};
// if we don't use the default search bar and buttons, we expose the
// corresponding areas for custom content
if (!this.withSearchBar) {
this.nodes.$searchview = this.$('.o_cp_searchview');
}
if (this.searchMenuTypes.length === 0) {
this.nodes.$searchview_buttons = this.$('.o_search_options');
}
if (this.withBreadcrumbs) {
this._renderBreadcrumbs();
}
var superDef = this._super.apply(this, arguments);
var searchDef = this._renderSearch();
return $.when(superDef, searchDef).then(function () {
self._setSearchMenusVisibility();
});
},
/**
* @override
*/
on_attach_callback: function () {
this._focusSearchInput();
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @returns {Object}
*/
getLastFacet: function () {
return this.state.facets.slice(-1)[0];
},
/**
* This function is called when actions call 'updateControlPanel' with
* custom contents to insert in the exposed areas.
*
* @param {Object} status
* @param {Object} [status.cp_content] dictionnary containing the jQuery
* elements to insert in the exposed areas
* @param {string} [status.breadcrumbs] the breadcrumbs to display before
* the current controller
* @param {string} [status.title] the title of the current controller, to
* display at the end of the breadcrumbs
* @param {Object} [options]
* @param {Boolean} [options.clear=true] set to false to keep control panel
* elements that are not in status.cp_content (useful for partial updates)
*/
updateContents: function (status, options) {
var new_cp_content = status.cp_content || {};
var clear = 'clear' in (options || {}) ? options.clear : true;
if (this.withBreadcrumbs) {
this._breadcrumbs = status.breadcrumbs || this._breadcrumbs;
this._title = status.title || this._title;
this._renderBreadcrumbs();
}
if (clear) {
this._detachContent(this.nodes);
} else {
this._detachContent(_.pick(this.nodes, _.keys(new_cp_content)));
}
this._attachContent(new_cp_content);
},
/**
* Update the state of the renderer state. It retriggers a full rerendering.
*
* @param {Object} state
* @returns {Deferred}
*/
updateState: function (state) {
this.state = state;
return this._renderSearch();
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @param {Object} content dictionnary of jQuery elements to attach, whose
* keys are jQuery nodes identifiers in this.nodes
*/
_attachContent: function (content) {
for (var $element in content) {
var $nodeset = content[$element];
if ($nodeset && this.nodes[$element]) {
this.nodes[$element].append($nodeset);
}
}
},
/**
* @private
* @param {Object} content subset of this.nodes to detach
*/
_detachContent: function (content) {
for (var $element in content) {
content[$element].contents().detach();
}
},
/**
* @private
*/
_focusSearchInput: function () {
if (this.withSearchBar && !config.device.isMobile) {
// in mobile mode, we would rather not focus manually the
// input, because it opens up the integrated keyboard, which is
// not what you expect when you just selected a filter.
this.searchBar.focus();
}
},
/**
* @private
* @param {string} menuType
* @returns {Objects[]} menuItems
*/
_getMenuItems: function (menuType) {
var menuItems;
if (menuType === 'filter') {
menuItems = this.state.filters;
}
if (menuType === 'groupBy') {
menuItems = this.state.groupBys;
}
if (menuType === 'timeRange') {
menuItems = this.state.timeRanges;
}
if (menuType === 'favorite') {
menuItems = this.state.favorites;
}
return menuItems;
},
/**
* @private
* @returns {jQueryElement}
*/
_getSubMenusPlace: function () {
return $('<div>').appendTo(this.$('.o_search_options'));
},
/**
* @private
*/
_renderBreadcrumbs: function () {
var self = this;
var breadcrumbsDescriptors = this._breadcrumbs.concat({title: this._title});
var breadcrumbs = breadcrumbsDescriptors.map(function (bc, index) {
return self._renderBreadcrumbsItem(bc, index, breadcrumbsDescriptors.length);
});
this.$('.breadcrumb').html(breadcrumbs);
},
/**
* Render a breadcrumbs' li jQuery element.
*
* @private
* @param {Object} bc
* @param {string} bc.title
* @param {string} bc.controllerID
* @param {integer} index
* @param {integer} length
* @returns {jQueryElement} $bc
*/
_renderBreadcrumbsItem: function (bc, index, length) {
var self = this;
var is_last = (index === length-1);
var li_content = bc.title && _.escape(bc.title.trim()) || data.noDisplayContent;
var $bc = $('<li>', {class: 'breadcrumb-item'})
.append(is_last ? li_content : $('<a>', {href: '#'}).html(li_content))
.toggleClass('active', is_last);
if (!is_last) {
$bc.click(function (ev) {
ev.preventDefault();
self.trigger_up('breadcrumb_clicked', {controllerID: bc.controllerID});
});
}
return $bc;
},
/**
* Renderer the search bar and the search menus
*
* @private
* @returns {Deferred}
*/
_renderSearch: function () {
var defs = [];
if (this.menusSetup) {
this._updateMenus();
} else {
this.menusSetup = true;
defs = defs.concat(this._setupMenus());
}
if (this.withSearchBar) {
defs.push(this._renderSearchBar());
}
return $.when(this, defs).then(this._focusSearchInput.bind(this));
},
/**
* @private
* @returns {Deferred}
*/
_renderSearchBar: function () {
// TODO: might need a reload instead of a destroy/instantiate
var oldSearchBar = this.searchBar;
this.searchBar = new SearchBar(this, {
context: this.context,
facets: this.state.facets,
fields: this.state.fields,
filters: this.state.filters,
filterFields: this.state.filterFields,
groupBys: this.state.groupBys,
});
return this.searchBar.appendTo(this.$('.o_searchview')).then(function () {
if (oldSearchBar) {
oldSearchBar.destroy();
}
});
},
/**
* Hide or show the search menus according to this.displaySearchMenu.
*
* @private
*/
_setSearchMenusVisibility: function () {
this.$('.o_searchview_more')
.toggleClass('fa-search-plus', !this.displaySearchMenu)
.toggleClass('fa-search-minus', this.displaySearchMenu);
this.$('.o_search_options')
.toggleClass('o_hidden', !this.displaySearchMenu);
},
/**
* Create a new menu of the given type and append it to this.$subMenus.
* This menu is also added to this.subMenus.
*
* @private
* @param {string} menuType
* @returns {Deferred}
*/
_setupMenu: function (menuType) {
var Menu;
var menu;
if (menuType === 'filter') {
Menu = FilterMenu;
}
if (menuType === 'groupBy') {
Menu = GroupByMenu;
}
if (menuType === 'timeRange') {
Menu = TimeRangeMenu;
}
if (menuType === 'favorite') {
Menu = FavoriteMenu;
}
if (_.contains(['filter', 'groupBy', 'timeRange'], menuType)) {
menu = new Menu(this, this._getMenuItems(menuType), this.state.fields);
}
if (menuType === 'favorite') {
menu = new Menu(this, this._getMenuItems(menuType), this.action);
}
this.subMenus[menuType] = menu;
return menu.appendTo(this.$subMenus);
},
/**
* Instantiate the search menu determined by this.searchMenuTypes.
*
* @private
* @returns {Deferred[]}
*/
_setupMenus: function () {
this.$subMenus = this._getSubMenusPlace();
return this.searchMenuTypes.map(this._setupMenu.bind(this));
},
/**
* Update the search menus.
*
* @private
*/
_updateMenus: function () {
var self = this;
this.searchMenuTypes.forEach(function (menuType) {
self.subMenus[menuType].update(self._getMenuItems(menuType));
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Prevent the search dropdowns from closing when clicking inside them.
*
* @private
* @param {MouseEvent} ev
*/
_onDropdownClicked: function (ev) {
ev.stopPropagation();
},
/**
* Toggle the search menus visibility.
*
* @private
*/
_onMore: function () {
this.displaySearchMenu = !this.displaySearchMenu;
this._setSearchMenusVisibility();
},
});
return ControlPanelRenderer;
});
@@ -0,0 +1,238 @@
odoo.define('web.ControlPanelView', function (require) {
"use strict";
var ControlPanelController = require('web.ControlPanelController');
var ControlPanelModel = require('web.ControlPanelModel');
var ControlPanelRenderer = require('web.ControlPanelRenderer');
var controlPanelViewParameters = require('web.controlPanelViewParameters');
var mvc = require('web.mvc');
var pyUtils = require('web.py_utils');
var viewUtils = require('web.viewUtils');
var DEFAULT_INTERVAL = controlPanelViewParameters.DEFAULT_INTERVAL;
var DEFAULT_PERIOD = controlPanelViewParameters.DEFAULT_PERIOD;
var INTERVAL_OPTIONS = controlPanelViewParameters.INTERVAL_OPTIONS;
var PERIOD_OPTIONS = controlPanelViewParameters.PERIOD_OPTIONS;
var Factory = mvc.Factory;
var ControlPanelView = Factory.extend({
config: _.extend({}, Factory.prototype.config, {
Controller: ControlPanelController,
Model: ControlPanelModel,
Renderer: ControlPanelRenderer,
}),
/**
* @override
* @param {Object} [params={}]
* @param {Object} [params.action={}]
* @param {Object} [params.context={}]
* @param {string} [params.domain=[]]
* @param {string} [params.modelName]
* @param {string[]} [params.searchMenuTypes=[]]
* determines search menus displayed.
* @param {Object} [params.state] used to determine the control panel model
* essential content at load. For instance, state can be the state of an
* other control panel model that we want to use.
* @param {string} [params.template] the QWeb template to render
* @param {Object} [params.viewInfo={arch: '<search/>', fields: {}}] a
* search fieldsview
* @param {string} [params.viewInfo.arch]
* @param {boolean} [params.withBreadcrumbs=true] if set to false,
* breadcrumbs won't be rendered
* @param {boolean} [params.withSearchBar=true] if set to false, no default
* search bar will be rendered
* @param {boolean} [activateDefaultFavorite=false] determine if the default
* custom filters can be activated
*/
init: function (params) {
var self = this;
this._super();
params = params || {};
var viewInfo = params.viewInfo || {arch: '<search/>', fields: {}};
var context = _.extend({}, params.context);
var domain = params.domain || [];
var action = params.action || {};
this.searchDefaults = {};
Object.keys(context).forEach(function (key) {
var match = /^search_default_(.*)$/.exec(key);
if (match) {
self.searchDefaults[match[1]] = context[key];
delete context[key];
}
});
this.arch = viewUtils.parseArch(viewInfo.arch);
this.fields = viewInfo.fields;
this.controllerParams.modelName = params.modelName;
this.modelParams.context = context;
this.modelParams.domain = domain;
this.modelParams.modelName = params.modelName;
this.modelParams.actionId = action.id;
this.modelParams.fields = this.fields;
this.rendererParams.action = action;
this.rendererParams.breadcrumbs = params.breadcrumbs;
this.rendererParams.context = context;
this.rendererParams.searchMenuTypes = params.searchMenuTypes || [];
this.rendererParams.template = params.template;
this.rendererParams.withBreadcrumbs = params.withBreadcrumbs !== false;
this.rendererParams.withSearchBar = 'withSearchBar' in params ? params.withSearchBar : true;
this.loadParams.withSearchBar = 'withSearchBar' in params ? params.withSearchBar : true;
this.loadParams.searchMenuTypes = params.searchMenuTypes || [];
this.loadParams.activateDefaultFavorite = params.activateDefaultFavorite;
if (this.loadParams.withSearchBar) {
if (params.state) {
this.loadParams.initialState = params.state;
} else {
// groups are determined in _parseSearchArch
this.loadParams.groups = [];
this.loadParams.timeRanges = context.time_ranges;
this._parseSearchArch(this.arch);
}
}
PERIOD_OPTIONS = PERIOD_OPTIONS.map(function (option) {
return _.extend(option, {description: option.description.toString()});
});
INTERVAL_OPTIONS = INTERVAL_OPTIONS.map(function (option) {
return _.extend(option, {description: option.description.toString()});
});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @param {Object} child parsed arch node
* @returns {Object}
*/
_evalArchChild: function (child) {
if (child.attrs.context) {
try {
var context = pyUtils.eval('context', child.attrs.context);
if (context.group_by) {
// let us extract basic data since we just evaluated context
// and use a correct tag!
child.tag = 'groupBy';
child.attrs.fieldName = context.group_by.split(':')[0];
child.attrs.defaultInterval = context.group_by.split(':')[1];
}
} catch (e) {}
}
return child;
},
/**
* @private
* @param {Object} filter
* @param {Object} attrs
*/
_extractAttributes: function (filter, attrs) {
filter.isDefault = this.searchDefaults[attrs.name] ? true : false;
filter.description = attrs.string ||
attrs.help ||
attrs.name ||
attrs.domain ||
'Ω';
if (filter.type === 'filter') {
filter.domain = attrs.domain;
if (attrs.date) {
filter.fieldName = attrs.date;
filter.fieldType = this.fields[attrs.date].type;
// we should be able to declare list of options per date filter
// (request of POs) (same remark for groupbys)
filter.hasOptions = true;
filter.options = PERIOD_OPTIONS;
filter.defaultOptionId = attrs.default_period ||
DEFAULT_PERIOD;
filter.currentOptionId = false;
}
} else if (filter.type === 'groupBy') {
filter.fieldName = attrs.fieldName;
filter.fieldType = this.fields[attrs.fieldName].type;
if (_.contains(['date', 'datetime'], filter.fieldType)) {
filter.hasOptions = true;
filter.options = INTERVAL_OPTIONS;
filter.defaultOptionId = attrs.defaultInterval ||
DEFAULT_INTERVAL;
filter.currentOptionId = false;
}
} else if (filter.type === 'field') {
var field = this.fields[attrs.name];
filter.attrs = attrs;
filter.autoCompleteValues = [];
if (filter.isDefault) {
// on field, default can be used with a value
filter.defaultValue = this.searchDefaults[filter.attrs.name];
}
if (!attrs.string) {
attrs.string = field.string;
}
}
},
/**
* Parse the arch of a 'search' view.
*
* @private
* @param {Object} arch arch with root node <search>
*/
_parseSearchArch: function (arch) {
var self = this;
var preFilters = _.flatten(arch.children.map(function (child) {
return child.tag !== 'group' ?
self._evalArchChild(child) :
child.children.map(self._evalArchChild);
}));
preFilters.push({tag: 'separator'});
var filter;
var currentTag;
var currentGroup = [];
var groupOfGroupBys = [];
var groupNumber = 1;
_.each(preFilters, function (preFilter) {
if (preFilter.tag !== currentTag || _.contains(['separator', 'field'], preFilter.tag)) {
if (currentGroup.length) {
if (currentTag === 'groupBy') {
groupOfGroupBys = groupOfGroupBys.concat(currentGroup);
} else {
self.loadParams.groups.push(currentGroup);
}
}
currentTag = preFilter.tag;
currentGroup = [];
groupNumber++;
}
if (preFilter.tag !== 'separator') {
filter = {
type: preFilter.tag,
// we need to codify here what we want to keep from attrs
// and how, for now I put everything.
// In some sence, some filter are active (totally determined, given)
// and others are passive (require input(s) to become determined)
// What is the right place to process the attrs?
};
if (filter.type === 'filter' || filter.type === 'groupBy') {
filter.groupNumber = groupNumber;
}
self._extractAttributes(filter, preFilter.attrs);
currentGroup.push(filter);
}
});
if (groupOfGroupBys.length) {
this.loadParams.groups.push(groupOfGroupBys);
}
},
});
return ControlPanelView;
});
@@ -0,0 +1,34 @@
odoo.define('web.MobileControlPanelController', function (require) {
"use strict";
var config = require('web.config');
var ControlPanelController = require('web.ControlPanelController');
if (!config.device.isMobile) {
return;
}
ControlPanelController.include({
custom_events:_.extend({}, ControlPanelController.prototype.custom_events, {
'search_bar_cleared': '_onSearchBarCleared',
}),
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* There is a 'Clear' button in the mobile control panel view.
*
* @private
*/
_onSearchBarCleared: function () {
var self = this;
this.model.get().query.forEach(function (groupId) {
self.model.deactivateGroup(groupId);
});
this._reportNewQueryAndRender();
},
});
});
@@ -1,16 +1,16 @@
odoo.define('web.SearchViewMobile', function (require) {
odoo.define('web.MobileControlPanelRenderer', function (require) {
"use strict";
var config = require('web.config');
var SearchView = require('web.SearchView');
var ControlPanelRenderer = require('web.ControlPanelRenderer');
if (!config.device.isMobile) {
return;
}
SearchView.include({
template:'SearchViewMobile',
events:_.extend({}, SearchView.prototype.events, {
ControlPanelRenderer.include({
template:'MobileControlPanel',
events:_.extend({}, ControlPanelRenderer.prototype.events, {
'click .o_mobile_search_close, .o_mobile_search_show_result, .o_enable_searchview': '_toggleMobileSearchView',
'click': '_onOpenMobileSearchView',
'click .o_mobile_search_clear_facets': '_onEmptyAll',
@@ -18,43 +18,47 @@ SearchView.include({
'hide.bs.dropdown .o_mobile_search_filter .o_dropdown': '_onDropdownToggle',
}),
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
renderFacets: function () {
this._super.apply(this, arguments);
this.$('.o_mobile_search_clear_facets')
.toggleClass('o_hidden', !this.query.length);
},
/**
* @override
*/
toggle_visibility: function (is_visible) {
// Do not do anything, toggling visibility of searchview is handled
// explicitly for mobile
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Overridden to correctly place submenu inside the mobile section.
*
* @private
* @override
*/
_getButtonsElement: function () {
_getSubMenusPlace: function () {
return this.$('.o_mobile_search_filter');
},
/**
* Toggle mobile search view screen
* @private
* @override
*/
_render: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
self.$('.o_mobile_search_clear_facets')
.toggleClass('o_hidden', !self.state.query.length);
});
},
/**
* @private
* @override
*/
_renderSearchBar: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
self.searchBar.$el.detach().insertAfter(self.$('.o_mobile_search_header'));
});
},
/**
* Toggles mobile search view screen.
*
* @private
*/
_toggleMobileSearchView: function () {
this.$('.o_enable_searchview').toggleClass('btn-secondary', !!this.query.length);
this.$('.o_enable_searchview').toggleClass('btn-secondary', !!this.state.query.length);
this.$('.o_mobile_search').toggleClass('o_hidden');
},
@@ -63,7 +67,7 @@ SearchView.include({
//--------------------------------------------------------------------------
/**
* Toggle the little arrow in main buttons
* Toggles the little arrow in main buttons.
*
* @private
* @param {BootstrapEvent} ev
@@ -72,16 +76,15 @@ SearchView.include({
$(ev.currentTarget).find('.fa-chevron-right').toggleClass('fa-chevron-down');
},
/**
* Clears all filters from the search view
* Clears all filters from the search view.
*
* @private
* @param {MouseEvent} event
*/
_onEmptyAll: function () {
this.query.reset();
this.trigger_up('search_bar_cleared');
},
/**
* Open the mobile search view screen
* Opens the mobile search view screen.
*
* @private
* @param {MouseEvent} ev
@@ -93,4 +96,4 @@ SearchView.include({
},
});
});
});
@@ -0,0 +1,142 @@
odoo.define('web.AddNewFavoriteMenu', function (require) {
"use strict";
var core = require('web.core');
var favoritesSubmenusRegistry = require('web.favorites_submenus_registry');
var Widget = require('web.Widget');
var _t = core._t;
var AddNewFavoriteMenu = Widget.extend({
template: 'AddNewFavoriteMenu',
events: _.extend({}, Widget.prototype.events, {
'click .o_save_favorite': '_onSaveFavoriteClick',
'click .o_add_favorite.o_menu_header': '_onMenuHeaderClick',
'keyup .o_save_name input': '_onKeyUp',
}),
/**
* @override
* @param {Object} params
* @param {Object} params.action
* @param {Object} params.favorites
*/
init: function (parent, params) {
this._super(parent);
this.favorites = params.favorites;
this.action = params.action;
this.isOpen = false;
},
/**
* @override
*/
start: function () {
this._render();
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Close the menu and re-render the widget.
*/
closeMenu: function () {
this.isOpen = false;
this._render();
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_render: function () {
this.renderElement();
if (this.isOpen) {
var $input = this.$('.o_favorite_name input.o_input');
$input.val(this.action.name);
$input.focus();
}
},
/**
* @private
*/
_saveFavorite: function () {
var self = this;
var $inputs = this.$('input');
var description = $inputs[0].value;
var isDefault = $inputs[1].checked;
var isShared = $inputs[2].checked;
if (!description.length){
this.do_warn(_t("Error"), _t("A name for your favorite is required."));
$inputs[0].focus();
return;
}
var descriptionAlreadyExists = !!this.favorites.find(function (favorite) {
return favorite.description === description;
});
if (descriptionAlreadyExists) {
this.do_warn(_t("Error"), _t("Filter with same name already exists."));
$inputs[0].focus();
return;
}
this.trigger_up('new_favorite', {
type: 'favorite',
description: description,
isDefault: isDefault,
isShared: isShared,
on_success: function () {self.generatorMenuIsOpen = false;},
});
this.closeMenu();
},
/**
* Hide and display the submenu which allows adding custom filters.
*
* @private
*/
_toggleMenu: function () {
this.isOpen = !this.isOpen;
this._render();
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {jQueryEvent} ev
*/
_onKeyUp: function (ev) {
if (ev.which === $.ui.keyCode.ENTER) {
this._saveFavorite();
}
},
/**
* @private
* @param {jQueryEvent} ev
*/
_onMenuHeaderClick: function (ev) {
ev.preventDefault();
ev.stopPropagation();
this._toggleMenu();
},
/**
* @private
* @param {jQueryEvent} ev
*/
_onSaveFavoriteClick: function (ev) {
ev.stopPropagation();
this._saveFavorite();
},
});
favoritesSubmenusRegistry.add('add_new_favorite_menu', AddNewFavoriteMenu, 0);
return AddNewFavoriteMenu;
});
@@ -1,10 +1,13 @@
odoo.define('web.TimeRangeMenuOptions', function (require) {
odoo.define('web.controlPanelViewParameters', function (require) {
"use strict";
var core = require('web.core');
var _lt = core._lt;
var PeriodOptions = [
// for FilterMenu
var DEFAULT_PERIOD = 'this_month';
var PERIOD_OPTIONS = [
{description: _lt('Last 7 Days'), optionId: 'last_7_days', groupId: 1},
{description: _lt('Last 30 Days'), optionId: 'last_30_days', groupId: 1},
{description: _lt('Last 365 Days'), optionId: 'last_365_days', groupId: 1},
@@ -20,14 +23,34 @@ var PeriodOptions = [
{description: _lt('Last Year'), optionId: 'last_year', groupId: 3},
];
var ComparisonOptions = [
// for GroupBy menu
var GROUPABLE_TYPES = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime'];
var DEFAULT_INTERVAL = 'month';
var INTERVAL_OPTIONS = [
{description: _lt('Day'), optionId: 'day', groupId: 1},
{description: _lt('Week'), optionId: 'week', groupId: 1},
{description: _lt('Month'), optionId: 'month', groupId: 1},
{description: _lt('Quarter'), optionId: 'quarter', groupId: 1},
{description: _lt('Year'), optionId: 'year', groupId: 1},
];
// for TimeRangeMenu
var DEFAULT_TIMERANGE = DEFAULT_PERIOD;
var TIME_RANGE_OPTIONS = PERIOD_OPTIONS;
var COMPARISON_TIME_RANGE_OPTIONS = [
{description: _lt('Previous Period'), optionId: 'previous_period'},
{description: _lt('Previous Year'), optionId: 'previous_year'}
];
return {
PeriodOptions: PeriodOptions,
ComparisonOptions: ComparisonOptions,
COMPARISON_TIME_RANGE_OPTIONS: COMPARISON_TIME_RANGE_OPTIONS,
DEFAULT_INTERVAL: DEFAULT_INTERVAL,
DEFAULT_PERIOD: DEFAULT_PERIOD,
DEFAULT_TIMERANGE: DEFAULT_TIMERANGE,
GROUPABLE_TYPES: GROUPABLE_TYPES,
INTERVAL_OPTIONS: INTERVAL_OPTIONS,
PERIOD_OPTIONS: PERIOD_OPTIONS,
TIME_RANGE_OPTIONS: TIME_RANGE_OPTIONS,
};
});
});
@@ -0,0 +1,103 @@
odoo.define('web.FavoriteMenu', function (require) {
"use strict";
var config = require('web.config');
var core = require('web.core');
var Dialog = require('web.Dialog');
var DropdownMenu = require('web.DropdownMenu');
var favorites_submenus_registry = require('web.favorites_submenus_registry');
var _t = core._t;
var FavoriteMenu = DropdownMenu.extend({
/**
* @override
* @param {Object} action
*/
init: function (parent, favorites, action) {
this._super(parent, favorites);
this.action = action;
this.isMobile = config.device.isMobile;
this.dropdownCategory = 'favorite';
this.dropdownTitle = _t('Favorites');
this.dropdownIcon = 'fa fa-star';
this.dropdownSymbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : false;
this.dropdownStyle.mainButton.class = 'o_favorites_menu_button ' +
this.dropdownStyle.mainButton.class;
},
/**
* Render the template used to register a new favorite and append it
* to the basic dropdown menu.
*
* @override
*/
start: function () {
var self = this;
var params = {
favorites: this.items,
action: this.action,
};
this.$menu = this.$('.o_dropdown_menu');
this.$menu.addClass('o_favorites_menu');
this.subMenus = [];
favorites_submenus_registry.values().forEach(function (SubMenu) {
var subMenu = new SubMenu(self, params);
subMenu.appendTo(self.$menu);
self.subMenus.push(subMenu);
});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_closeSubMenus: function () {
_.invoke(this.subMenus, 'closeMenu');
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @override
* @private
*/
_onBootstrapClose: function () {
this._super.apply(this, arguments);
this._closeSubMenus();
},
/**
* @override
* @private
* @param {MouseEvent} event
*/
_onTrashButtonClick: function (event) {
event.preventDefault();
event.stopPropagation();
var self = this;
var id = $(event.currentTarget).data('id');
var favorite = this.items.find(function (favorite) {
return favorite.id === id;
});
var globalWarning = _t("This filter is global and will be removed for everybody if you continue.");
var warning = _t("Are you sure that you want to remove this filter?");
var message = favorite.userId ? warning : globalWarning;
Dialog.confirm(self, message, {
title: _t("Warning"),
confirm_callback: function () {
self.trigger_up('item_trashed', {id: id});
},
});
},
});
return FavoriteMenu;
});
@@ -0,0 +1,8 @@
odoo.define('web.favorites_submenus_registry', function (require) {
"use strict";
var Registry = require('web.Registry');
return new Registry();
});
@@ -0,0 +1,219 @@
odoo.define('web.FilterMenu', function (require) {
"use strict";
var config = require('web.config');
var core = require('web.core');
var Domain = require('web.Domain');
var DropdownMenu = require('web.DropdownMenu');
var search_filters = require('web.search_filters');
var time = require('web.time');
var _t = core._t;
var QWeb = core.qweb;
var FilterMenu = DropdownMenu.extend({
custom_events: {
confirm_proposition: '_onConfirmProposition',
remove_proposition: '_onRemoveProposition',
},
events: _.extend({}, DropdownMenu.prototype.events, {
'click .o_add_custom_filter': '_onAddCustomFilterClick',
'click .o_add_condition': '_onAddCondition',
'click .o_apply_filter': '_onApplyClick',
}),
/**
* @override
* @param {Object} fields
*/
init: function (parent, filters, fields) {
this._super(parent, filters);
// determines where the filter menu is displayed and its style
this.isMobile = config.device.isMobile;
// determines when the 'Add custom filter' submenu is open
this.generatorMenuIsOpen = false;
this.propositions = [];
this.fields = _.pick(fields, function (field, name) {
return field.selectable !== false && name !== 'id';
});
this.fields.id = {string: 'ID', type: 'id', searchable: true};
this.dropdownCategory = 'filter';
this.dropdownTitle = _t('Filters');
this.dropdownIcon = 'fa fa-filter';
this.dropdownSymbol = this.isMobile ?
'fa fa-chevron-right float-right mt4' :
false;
this.dropdownStyle.mainButton.class = 'o_filters_menu_button ' +
this.dropdownStyle.mainButton.class;
},
/**
* Render the template used to add a new custom filter and append it
* to the basic dropdown menu.
*
* @override
*/
start: function () {
this.$menu = this.$('.o_dropdown_menu');
this.$menu.addClass('o_filters_menu');
this._renderGeneratorMenu();
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Add a proposition inside the custom filter edition menu.
*
* @private
* @returns {$.Deferred}
*/
_appendProposition: function () {
// make modern sear_filters code!!! It works but...
var prop = new search_filters.ExtendedSearchProposition(this, this.fields);
this.propositions.push(prop);
this.$('.o_apply_filter').prop('disabled', false);
prop.insertBefore(this.$addFilterMenu);
},
/**
* Confirm a filter proposition, creates it and add it to the menu.
*
* @private
*/
_commitSearch: function () {
var filters = _.invoke(this.propositions, 'get_filter').map(function (preFilter) {
return {
type: 'filter',
description: preFilter.attrs.string,
domain: Domain.prototype.arrayToString(preFilter.attrs.domain),
};
});
// TO DO intercepts 'new_filters' and decide what to do whith filters
// rewrite web.search_filters?
this.trigger_up('new_filters', {filters: filters});
_.invoke(this.propositions, 'destroy');
this.propositions = [];
this._toggleCustomFilterMenu();
},
/**
* @private
*/
_renderGeneratorMenu: function () {
this.$el.find('.o_generator_menu').remove();
if (!this.generatorMenuIsOpen) {
_.invoke(this.propositions, 'destroy');
this.propositions = [];
}
var $generatorMenu = QWeb.render('FilterMenuGenerator', {widget: this});
this.$menu.append($generatorMenu);
this.$addFilterMenu = this.$menu.find('.o_add_filter_menu');
if (this.generatorMenuIsOpen && !this.propositions.length) {
this._appendProposition();
}
},
/**
* @override
* @private
*/
_renderMenuItems: function () {
var self= this;
this._super.apply(this, arguments);
// the following code adds tooltip on date options in order
// to alert the user of the meaning of intervals
var $options = this.$('.o_item_option');
$options.each(function () {
var $option = $(this);
$option.tooltip({
delay: { show: 500, hide: 0 },
title: function () {
var itemId = $option.attr('data-item_id');
var optionId = $option.attr('data-option_id');
var fieldName = _.findWhere(self.items, {id: itemId}).fieldName;
var domain = Domain.prototype.constructDomain(fieldName, optionId, 'date', true);
var evaluatedDomain = Domain.prototype.stringToArray(domain);
var dateFormat = time.getLangDateFormat();
var dateStart = moment(evaluatedDomain[1][2], "YYYY-MM-DD", 'en').format(dateFormat);
var dateEnd = moment(evaluatedDomain[2][2], "YYYY-MM-DD", 'en').format(dateFormat);
if (optionId === 'today' || optionId === 'yesterday') {
return dateStart;
}
return _.str.sprintf(_t('From %s To %s'), dateStart, dateEnd);
}
});
});
},
/**
* Hide and display the submenu which allows adding custom filters.
*
* @private
*/
_toggleCustomFilterMenu: function () {
this.generatorMenuIsOpen = !this.generatorMenuIsOpen;
this._renderGeneratorMenu();
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {MouseEvent} ev
*/
_onAddCondition: function (ev) {
ev.stopPropagation();
this._appendProposition();
},
/**
* @private
* @param {MouseEvent} ev
*/
_onAddCustomFilterClick: function (ev) {
ev.preventDefault();
ev.stopPropagation();
this._toggleCustomFilterMenu();
},
/**
* @private
* @param {MouseEvent} ev
*/
_onApplyClick: function (ev) {
ev.stopPropagation();
this._commitSearch();
},
/**
* @override
* @private
* @param {jQueryEvent} ev
*/
_onBootstrapClose: function () {
this._super.apply(this, arguments);
this.generatorMenuIsOpen = false;
this._renderGeneratorMenu();
},
/**
* @private
* @param {OdooEvent} ev
*/
_onConfirmProposition: function (ev) {
ev.stopPropagation();
this._commitSearch();
},
/**
* @private
* @param {OdooEvent} ev
*/
_onRemoveProposition: function (ev) {
ev.stopPropagation();
this.propositions = _.without(this.propositions, ev.target);
if (!this.propositions.length) {
this.$('.o_apply_filter').prop('disabled', true);
}
ev.target.destroy();
},
});
return FilterMenu;
});
@@ -0,0 +1,214 @@
odoo.define('web.GroupByMenu', function (require) {
"use strict";
var config = require('web.config');
var controlPanelViewParameters = require('web.controlPanelViewParameters');
var core = require('web.core');
var DropdownMenu = require('web.DropdownMenu');
var _t = core._t;
var QWeb = core.qweb;
var DEFAULT_INTERVAL = controlPanelViewParameters.DEFAULT_INTERVAL;
var GROUPABLE_TYPES = controlPanelViewParameters.GROUPABLE_TYPES;
var INTERVAL_OPTIONS = controlPanelViewParameters.INTERVAL_OPTIONS;
var GroupByMenu = DropdownMenu.extend({
events: _.extend({}, DropdownMenu.prototype.events, {
'click .o_add_custom_group': '_onAddCustomGroupClick',
'click button.o_apply_group': '_onButtonApplyClick',
'click .o_group_selector': '_onGroupSelectorClick',
}),
/**
* @override
* @param {Widget} parent
* @param {Object[]} groupBys list of groupBys (type IGroupBy below)
* interface IGroupBy {
* itemId: string; unique id associated with the groupby
* fieldName: string; field name without interval!
* description: string; label printed on screen
* groupId: string;
* isActive: boolean; determines if the groupby is considered active
* hasOptions: boolean; true when the groupBy has fieldType 'date' or 'datetime' (optional)
* options: array of objects with 'optionId' and 'description' keys; (optional)
* defaultOptionId: string refers to an optionId that should be activated by default
* currentOptionId: string refers to an optionId that is activated if item is active (optional)
* }
* @param {Object} fields 'field_get' of a model: mapping from field name
* to an object with its properties
* @param {Object} options
* @param {string} options.headerStyle conditions the style of the main button
*/
init: function (parent, groupBys, fields, options) {
var self = this;
this._super(parent, groupBys);
this.fields = fields;
// determines when the 'Add custom groupby' submenu is open
this.generatorMenuIsOpen = false;
// determines list of options used by groupBys of type 'date'
this.groupableFields = [];
_.each(fields, function (field, name) {
if (field.sortable && _.contains(GROUPABLE_TYPES, field.type)) {
self.groupableFields.push(_.extend({}, field, {
name: name,
}));
}
});
this.groupableFields = _.sortBy(this.groupableFields, 'string');
// determines the list of field names that can be added to the menu
// via the 'Add Custom Groupby' menu
this.presentedFields = this._setPresentedFields(groupBys);
// determines where the filter menu is displayed and partly its style
this.isMobile = config.device.isMobile;
this.dropdownCategory = 'groupby';
this.dropdownTitle = _t('Group By');
this.dropdownIcon = 'fa fa-bars';
this.dropdownSymbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : false;
// the default style of the groupby menu can be changed here using the options key 'headerStyle'
if (options && options.headerStyle === 'primary') {
this.dropdownStyle = {
el: {class: 'btn-group o_group_by_menu o_dropdown', attrs: {'role': 'group'}},
mainButton: {class: 'btn btn-primary dropdown-toggle'},
};
}
INTERVAL_OPTIONS = INTERVAL_OPTIONS.map(function (option) {
return _.extend(option, {description: option.description.toString()});
});
},
/**
* Render the template used to add a new custom groupby and append it
* to the basic dropdown menu.
*
* @override
*/
start: function () {
this.$menu = this.$('.o_dropdown_menu');
this.$menu.addClass('o_group_by_menu');
this._renderGeneratorMenu();
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
* @param {Object[]} groupBys
*/
update: function (groupBys) {
this._super.apply(this, arguments);
this.presentedFields = this._setPresentedFields(groupBys);
this._renderGeneratorMenu();
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Method called via the 'Add Custom Groupby' menu to create a new groupby
* and add it (activated) to the menu. In case the field is of type date, it
* is activated using the option determined by the parameter
* 'DEFAULT_INTERVAL'.
*
* @private
* @param {string} fieldName
*/
_addGroupby: function (fieldName) {
var field = this.presentedFields.find(function (field) {
return field.name === fieldName;
});
var groupBy = {
type: 'groupBy',
description: field.string,
fieldName: fieldName,
fieldType: field.type,
};
if (_.contains(['date', 'datetime'], field.type)) {
groupBy.hasOptions = true;
groupBy.options = INTERVAL_OPTIONS;
groupBy.defaultOptionId = DEFAULT_INTERVAL;
groupBy.currentOptionId = false;
}
this.trigger_up('new_groupBy', groupBy);
},
/**
* @private
*/
_renderGeneratorMenu: function () {
this.$el.find('.o_generator_menu').remove();
var $generatorMenu = QWeb.render('GroupByMenuGenerator', {widget: this});
this.$menu.append($generatorMenu);
this.$addCustomGroup = this.$menu.find('.o_add_custom_group');
this.$groupSelector = this.$menu.find('.o_group_selector');
},
/**
* @private
*/
_setPresentedFields: function (groupBys) {
return this.groupableFields.filter(function (field) {
var groupByFields = _.pluck(groupBys, 'fieldName');
return !_.contains(groupByFields, field.name);
});
},
/**
* @private
*/
_toggleCustomGroupMenu: function () {
this.generatorMenuIsOpen = !this.generatorMenuIsOpen;
this._renderGeneratorMenu();
if (this.generatorMenuIsOpen) {
this.$groupSelector.focus();
}
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Toggle the 'Add Custom Group'.
*
* @private
* @param {MouseEvent} ev
*/
_onAddCustomGroupClick: function (ev) {
ev.preventDefault();
ev.stopPropagation();
this._toggleCustomGroupMenu();
},
/*
* @override
* @private
* @param {jQueryEvent} ev
*/
_onBootstrapClose: function () {
this._super.apply(this, arguments);
this.generatorMenuIsOpen = false;
this._renderGeneratorMenu();
},
/**
* @private
* @param {MouseEvent} ev
*/
_onButtonApplyClick: function (ev) {
ev.stopPropagation();
var fieldName = this.$groupSelector.val();
this.generatorMenuIsOpen = false;
this._addGroupby(fieldName);
},
/**
* @private
* @param {MouseEvent} ev
*/
_onGroupSelectorClick: function (ev) {
ev.stopPropagation();
},
});
return GroupByMenu;
});
@@ -0,0 +1,160 @@
odoo.define('web.GroupByMenuMixin', function (require) {
"use strict";
var GroupByMenu = require('web.GroupByMenu');
var controlPanelViewParameters = require('web.controlPanelViewParameters');
/**
* The aim of this mixin is to facilitate the interaction between
* a view controller and a dropdown menu with its control panel
* TO DO: the pivot subview has two types of groupbys so that it will not
* understand the current implementation of this mixin
*
* @mixin
* @name GroupByMenuMixin
*/
var GroupByMenuMixin = {
init: function () {
this.custom_events = _.extend({}, this.custom_events, {
menu_item_clicked: '_onMenuItemClicked',
item_option_clicked: '_onItemOptionClicked',
});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Instantiate a widget GroupByMenu and incorporate it to the control panel
* of an embedded view. This is used for instance in the dashboard view in
* enterprise where there is no GroupBy menu in the search view because it
* would not make sense to have one at the global level.
* This function is called by renderButtons when the parameter
* 'this.isEmbedded' is set to true.
*
* private
* @param {jQuery} $node
* @param {Object} groupableFields
*/
_addGroupByMenu: function ($node, groupableFields) {
this.sortedFieldNames = _.sortBy(Object.keys(groupableFields), function (fieldName) {
return groupableFields[fieldName].string;
});
this.groupableFields = groupableFields;
var groupBys = this._getGroupBys(this.model.get().groupedBy);
this.groupByMenu = new GroupByMenu(this, groupBys, this.groupableFields);
this.groupByMenu.insertAfter($node.find('div:first'));
},
/**
* This method puts the active groupBys in a convenient form.
*
* @private
* @param {string[]} activeGroupBys
* returns {string[]} groupBysNormalized
*/
_normalizeActiveGroupBys: function (activeGroupBys) {
var groupBysNormalized = activeGroupBys.map(function (groupBy) {
return {fieldName: groupBy.split(':')[0], interval: groupBy.split(':')[1] || false};
});
return groupBysNormalized;
},
/**
* This method has to be implemented by the view controller that needs to
* interpret the click in an appropriate manner.
*
* @private
* @param {string[]} groupBys
*/
_setGroupby: function (groupBys) {},
/**
* Return the list of groupBys in a form suitable for the groupByMenu. We do
* this each time because we want to be synchronized with the view model.
*
* @private
* @param {string[]} activeGroupBys
* returns {Object[]} groupBys
*/
_getGroupBys: function (activeGroupBys) {
var self = this;
var groupBys = [];
var groupBysNormalized = this._normalizeActiveGroupBys(activeGroupBys);
this.sortedFieldNames.forEach(function (fieldName) {
var field = self.groupableFields[fieldName];
var groupByActivity = _.findWhere(groupBysNormalized, {fieldName: fieldName});
var groupBy = {
id: fieldName,
isActive: groupByActivity ? true : false,
description: field.string,
};
if (_.contains(['date', 'datetime'], field.type)) {
groupBy.hasOptions = true;
groupBy.options = controlPanelViewParameters.INTERVAL_OPTIONS;
groupBy.currentOptionId = groupByActivity && groupByActivity.interval ?
groupByActivity.interval :
false;
}
groupBys.push(groupBy);
});
return groupBys;
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {OdooEvent} ev
*/
_onItemOptionClicked: function (ev) {
var fieldName = ev.data.id;
var optionId = ev.data.optionId;
var activeGroupBys = this.model.get().groupedBy;
var currentOptionId = activeGroupBys.reduce(
function (optionId, groupby) {
if (groupby.split(':')[0] === fieldName){
optionId = groupby.split(':')[1] || controlPanelViewParameters.DEFAULT_INTERVAL;
}
return optionId;
},
false
);
var groupByFieldNames = _.map(activeGroupBys, function (groupby) {
return groupby.split(':')[0];
});
var indexOfGroupby = groupByFieldNames.indexOf(fieldName);
if (indexOfGroupby === -1) {
activeGroupBys.push(fieldName + ':' + optionId);
} else if (currentOptionId === optionId) {
activeGroupBys.splice(indexOfGroupby, 1);
} else {
activeGroupBys.splice(indexOfGroupby, 1, fieldName + ':' + optionId);
}
this._setGroupby(activeGroupBys);
this.groupByMenu.update(this._getGroupBys(activeGroupBys));
},
/**
* @private
* @param {OdooEvent} ev
*/
_onMenuItemClicked: function (ev) {
var fieldName = ev.data.id;
var activeGroupBys = this.model.get().groupedBy;
var groupByFieldNames = _.map(activeGroupBys, function (groupby) {
return groupby.split(':')[0];
});
var indexOfGroupby = groupByFieldNames.indexOf(fieldName);
if (indexOfGroupby === -1) {
activeGroupBys.push(fieldName);
} else {
activeGroupBys.splice(indexOfGroupby, 1);
}
this._setGroupby(activeGroupBys);
this.groupByMenu.update(this._getGroupBys(activeGroupBys));
},
};
return GroupByMenuMixin;
});
@@ -0,0 +1,225 @@
odoo.define('web.SearchBar', function (require) {
"use strict";
var AutoComplete = require('web.AutoComplete');
var searchBarAutocompleteRegistry = require('web.search_bar_autocomplete_sources_registry');
var SearchFacet = require('web.SearchFacet');
var Widget = require('web.Widget');
var SearchBar = Widget.extend({
template: 'SearchView.SearchBar',
events: _.extend({}, Widget.prototype.events, {
'keydown': '_onKeydown',
}),
/**
* @override
* @param {Object} [params]
* @param {Object} [params.context]
* @param {Object[]} [params.facets]
* @param {Object} [params.fields]
* @param {Object[]} [params.filterFields]
* @param {Object[]} [params.filters]
* @param {Object[]} [params.groupBys]
*/
init: function (parent, params) {
this._super.apply(this, arguments);
this.context = params.context;
this.facets = params.facets;
this.fields = params.fields;
this.filters = params.filters;
this.filterFields = params.filterFields;
this.groupBys = params.groupBys;
this.autoCompleteSources = [];
this.searchFacets = [];
},
/**
* @override
*/
start: function () {
this.$input = this.$('input');
var self = this;
var defs = [this._super.apply(this, arguments)];
_.each(this.facets, function (facet) {
defs.push(self._renderFacet(facet));
});
defs.push(this._setupAutoCompletion());
return $.when.apply($, defs);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Focus the searchbar.
*/
focus: function () {
this.$input.focus();
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_focusFollowing: function () {
var focusedIndex = this._getFocusedFacetIndex();
var $toFocus;
if (focusedIndex === this.searchFacets.length - 1) {
$toFocus = this.$input;
} else {
$toFocus = this.searchFacets.length && this.searchFacets[focusedIndex + 1].$el;
}
if ($toFocus.length) {
$toFocus.focus();
}
},
/**
* @private
*/
_focusPreceding: function () {
var focusedIndex = this._getFocusedFacetIndex();
var $toFocus;
if (focusedIndex === -1) {
$toFocus = this.searchFacets.length && _.last(this.searchFacets).$el;
} else if (focusedIndex === 0) {
$toFocus = this.$input;
} else {
$toFocus = this.searchFacets.length && this.searchFacets[focusedIndex - 1].$el;
}
if ($toFocus.length) {
$toFocus.focus();
}
},
/**
* @private
* @returns {integer}
*/
_getFocusedFacetIndex: function () {
return _.findIndex(this.searchFacets, function (searchFacet) {
return searchFacet.$el[0] === document.activeElement;
});
},
/**
* Provide auto-completion result for req.term.
*
* @private
* @param {Object} req request to complete
* @param {String} req.term searched term to complete
* @param {Function} callback
*/
_getAutoCompleteSources: function (req, callback) {
var defs = _.invoke(this.autoCompleteSources, 'getAutocompletionValues', req.term);
$.when.apply($, defs).then(function () {
callback(_(arguments).chain()
.compact()
.flatten(true)
.value());
});
},
/**
* @private
* @param {Object} facet
* @returns {Deferred}
*/
_renderFacet: function (facet) {
var searchFacet = new SearchFacet(this, facet);
this.searchFacets.push(searchFacet);
return searchFacet.insertBefore(this.$('input'));
},
/**
* @private
* @returns {Deferred}
*/
_setupAutoCompletion: function () {
var self = this;
this._setupAutoCompletionWidgets();
this.autoComplete = new AutoComplete(this, {
source: this._getAutoCompleteSources.bind(this),
select: this._onAutoCompleteSelected.bind(this),
get_search_string: function () {
return self.$input.val().trim();
},
});
return this.autoComplete.appendTo(this.$el);
},
/**
* @private
*/
_setupAutoCompletionWidgets: function () {
var self = this;
var registry = searchBarAutocompleteRegistry;
_.each(this.filterFields, function (filter) {
var field = self.fields[filter.attrs.name];
var Obj = registry.getAny([filter.attrs.widget, field.type]);
if (Obj) {
self.autoCompleteSources.push(new (Obj) (self, filter, field, self.context));
}
});
_.each(this.filters, function (filter) {
self.autoCompleteSources.push(new (registry.get('filter'))(self, filter));
});
_.each(this.groupBys, function (filter) {
self.autoCompleteSources.push(new (registry.get('groupby'))(self, filter));
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {Event} e
* @param {Object} ui
* @param {Object} ui.item selected completion item
*/
_onAutoCompleteSelected: function (e, ui) {
e.preventDefault();
var filter = ui.item.facet.filter;
if (filter.type === 'field') {
var values = filter.autoCompleteValues;
values.push(ui.item.facet.values[0]);
this.trigger_up('autocompletion_filter', {
filterId: filter.id,
autoCompleteValues: values,
});
} else {
this.trigger_up('autocompletion_filter', {
filterId: filter.id,
});
}
},
/**
* @private
* @param {KeyEvent} e
*/
_onKeydown: function (e) {
switch(e.which) {
case $.ui.keyCode.LEFT:
this._focusPreceding();
e.preventDefault();
break;
case $.ui.keyCode.RIGHT:
this._focusFollowing();
e.preventDefault();
break;
case $.ui.keyCode.BACKSPACE:
if (this.$input.val() === '') {
this.trigger_up('facet_removed');
}
break;
}
},
});
return SearchBar;
});
@@ -0,0 +1,485 @@
odoo.define('web.SearchBarAutoCompleteSources', function (require) {
"use strict";
var Class = require('web.Class');
var core = require('web.core');
var Domain = require('web.Domain');
var field_utils = require('web.field_utils');
var mixins = require('web.mixins');
var pyUtils = require('web.py_utils');
var ServicesMixin = require('web.ServicesMixin');
var time = require('web.time');
var _t = core._t;
var _lt = core._lt;
var FilterInterface = Class.extend(mixins.EventDispatcherMixin, {
completion_label: _lt("%s"),
/**
* @override
* @param {Object} filter
*/
init: function (parent, filter) {
mixins.EventDispatcherMixin.init.call(this);
this.setParent(parent);
this.filter = filter;
},
/**
* Fetch auto-completion values for the widget.
*
* The completion values should be an array of objects with keys facet and
* label. They will be used by search_bar in @_onAutoCompleteSelected.
*
* @param {string} value value to getAutocompletionValues
* @returns {Deferred<null|Array>}
*/
getAutocompletionValues: function (value) {
var result;
value = value.toLowerCase();
if (fuzzy.test(value, this.filter.description)) {
result = [{
label: _.str.sprintf(this.completion_label.toString(),
_.escape(this.filter.description)),
facet: {
filter: this.filter,
},
}];
}
return $.when(result);
},
});
var Filter = FilterInterface.extend({
completion_label: _lt("Filter on: %s"),
});
var GroupBy = FilterInterface.extend({
completion_label: _lt("Group by: %s"),
});
var Field = FilterInterface.extend(ServicesMixin, {
default_operator: '=',
/**
* @override
* @param {Object} field
* @param {Object} filter
* @param {Object} context needed for extra rpc (i.e. m2o)
*/
init: function (parent, filter, field, context) {
this._super.apply(this, arguments);
this.field = field;
this.filter = filter;
this.attrs = _.extend({}, field, filter.attrs);
this.context = context;
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
getAutocompletionValues: function (value) {
return $.when([{
label: this._getAutocompletionLabel(value),
facet: this._getFacetValue(value),
}]);
},
/**
* TODO: the domain logic (getDomain, operator, etc.) should not be put here
* (this has nothing to do with the autocomplete sources).
*
* This method is used by the ControlPanelModel when setting the
* `filter_domain`.
*
* @param {Object[]} values
* @returns {string}
*/
getDomain: function (values) {
if (!values.length) { return; }
var valueToDomain;
var self = this;
var domain = this.attrs.filter_domain;
if (domain) {
valueToDomain = function (facetValue) {
return Domain.prototype.stringToArray(
domain,
{
// these are the values that can be used in search view
// fields `filter_domain` attribute
self: self._valueFrom(facetValue),
raw_value: facetValue.value,
}
);
};
} else {
valueToDomain = function (facetValue) {
return self._makeDomain(
self.attrs.name,
self.attrs.operator || self.default_operator,
facetValue
);
};
}
var domains = values.map(valueToDomain);
domains = domains.map(Domain.prototype.arrayToString);
return pyUtils.assembleDomains(domains, 'OR');
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @param {any} value
* @returns {string}
*/
_getAutocompletionLabel: function (value) {
return _.str.sprintf(_.str.escapeHTML(
_t("Search %(field)s for: %(value)s")), {
field: '<em>' + _.escape(this.attrs.string) + '</em>',
value: '<strong>' + _.escape(value) + '</strong>'});
},
/**
* @private
* @param {any} value
* @returns {Object}
*/
_getFacetValue: function (value) {
return {
filter: this.filter,
values: [{label: value, value: value}],
};
},
/**
* Function creating the returned domain for the field, override this
* methods in children if you only need to customize the field's domain
* without more complex alterations or tests (and without the need to
* change override the handling of filter_domain)
*
* @private
* @param {String} name the field's name
* @param {String} operator the field's operator (either attribute-specified or default operator for the field
* @param {Number|String} facet parsed value for the field
* @returns {Array<Array>} domain to include in the resulting search
*/
_makeDomain: function (name, operator, facet) {
return [[name, operator, this._valueFrom(facet)]];
},
/**
* @private
* @param {Object} facetValue
* @param {any} facetValue.value
* @returns {any}
*/
_valueFrom: function (facetValue) {
return facetValue.value;
},
});
var CharField = Field.extend({
default_operator: 'ilike',
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
getAutocompletionValues: function (value) {
if (_.isEmpty(value)) { return $.when(null); }
return this._super.apply(this, arguments);
},
});
var NumberField = Field.extend({
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
getAutocompletionValues: function (value) {
var val = this.parse(value);
if (isNaN(val)) { return $.when(); }
return this._super.apply(this, arguments);
},
});
var IntegerField = NumberField.extend({
error_message: _t("not a valid integer"),
parse: function (value) {
try {
return field_utils.parse.integer(value);
} catch (e) {
return NaN;
}
},
});
var FloatField = NumberField.extend({
error_message: _t("not a valid number"),
parse: function (value) {
try {
return field_utils.parse.float(value);
} catch (e) {
return NaN;
}
},
});
var SelectionField = Field.extend({
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
getAutocompletionValues: function (value) {
var self = this;
var results = _(this.attrs.selection).chain()
.filter(function (sel) {
var selValue = sel[0], label = sel[1];
if (selValue === undefined || !label) { return false; }
return label.toLowerCase().indexOf(value.toLowerCase()) !== -1;
})
.map(function (sel) {
return {
label: _.escape(sel[1]),
indent: true,
facet: self._getFacetValue(sel)
};
}).value();
if (_.isEmpty(results)) { return $.when(null); }
return $.when.call(null, [{
label: _.escape(this.attrs.string)
}].concat(results));
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @param {any} value
* @returns {Object}
*/
_getFacetValue: function (value) {
return {
filter: this.filter,
values: [{label: value[1], value: value[0]}],
};
},
});
var BooleanField = SelectionField.extend({
/**
* @override
*/
init: function () {
this._super.apply(this, arguments);
this.attrs.selection = [
[true, _t("Yes")],
[false, _t("No")]
];
},
});
var DateField = Field.extend({
/**
* @override
*/
getAutocompletionValues: function (value) {
// Make sure the value has a correct format before the creation of the moment object. See
// issue https://github.com/moment/moment/issues/1407
var t, v;
try {
t = (this.attrs && this.attrs.type === 'datetime') ? 'datetime' : 'date';
v = field_utils.parse[t](value, {type: t}, {timezone: true});
} catch (e) {
return $.when(null);
}
var m = moment(v, t === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD');
if (!m.isValid()) { return $.when(null); }
var dateString = field_utils.format[t](m, {type: t});
var label = this._getAutocompletionLabel(dateString);
var facet = this._getFacetValue(dateString, m.toDate());
return $.when([{
label: label,
facet: facet,
}]);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
_getAutocompletionLabel: function (value) {
return _.str.sprintf(_.str.escapeHTML(
_t("Search %(field)s at: %(value)s")), {
field: '<em>' + _.escape(this.attrs.string) + '</em>',
value: '<strong>' + value + '</strong>'});
},
/**
* @override
* @param {any} rawValue
*/
_getFacetValue: function (value, rawValue) {
var facet = this._super.apply(this, arguments);
facet.values[0].value = rawValue;
return facet;
},
/**
* @override
*/
_valueFrom: function (facetValue) {
return time.date_to_str(facetValue.value);
},
});
var DateTimeField = DateField.extend({
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
_valueFrom: function (facetValue) {
return time.datetime_to_str(facetValue.value);
},
});
var ManyToOneField = CharField.extend({
default_operator: {},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
getAutocompletionValues: function (value) {
if (_.isEmpty(value)) { return $.when(null); }
var label = this._getAutocompletionLabel(value);
return $.when([{
label: label,
facet: this._getFacetValue(value),
expand: this._expand.bind(this),
}]);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
_getExpandedFacetValue: function (value) {
return {
filter: this.filter,
values: [{label: value[1], value: value[0]}],
};
},
/**
* @override
*/
_getFacetValue: function (value) {
return {
filter: this.filter,
values: [{label: value, value: value, operator: 'ilike'}],
};
},
/**
* @override
*/
_expand: function (value) {
var self = this;
var args = this.attrs.domain;
if (typeof args === 'string') {
try {
args = Domain.prototype.stringToArray(args);
} catch(e) {
args = [];
}
}
return this._rpc({
model: this.attrs.relation,
method: 'name_search',
kwargs: {
name: value,
args: args,
limit: 8,
context: this.context,
},
})
.then(function (results) {
if (_.isEmpty(results)) { return null; }
return _(results).map(function (result) {
return {
label: _.escape(result[1]),
facet: self._getExpandedFacetValue(result)
};
});
});
},
/**
* @override
*/
_makeDomain: function (name, operator, facetValue) {
operator = facetValue.operator || operator;
switch(operator){
case this.default_operator:
return [[name, '=', facetValue.value]];
case 'ilike':
return [[name, 'ilike', facetValue.value]];
case 'child_of':
return [[name, 'child_of', facetValue.value]];
}
return this._super(name, operator, facetValue);
},
/**
* @override
*/
_valueFrom: function (facetValue) {
return facetValue.label;
},
});
return {
BooleanField: BooleanField,
CharField: CharField,
DateField: DateField,
DateTimeField: DateTimeField,
Field: Field,
Filter: Filter,
FloatField: FloatField,
GroupBy: GroupBy,
IntegerField: IntegerField,
ManyToOneField: ManyToOneField,
NumberField: NumberField,
SelectionField: SelectionField,
};
});
@@ -0,0 +1,37 @@
odoo.define('web.search_bar_autocomplete_sources_registry', function (require) {
"use strict";
var Registry = require('web.Registry');
return new Registry();
});
odoo.define('web._search_bar_autocomplete_sources_registry', function(require) {
"use strict";
var registry = require('web.search_bar_autocomplete_sources_registry');
var SearchBarAutoCompleteSources = require('web.SearchBarAutoCompleteSources');
// fields
registry
.add('char', SearchBarAutoCompleteSources.CharField)
.add('text', SearchBarAutoCompleteSources.CharField)
.add('html', SearchBarAutoCompleteSources.CharField)
.add('boolean', SearchBarAutoCompleteSources.BooleanField)
.add('integer', SearchBarAutoCompleteSources.IntegerField)
.add('id', SearchBarAutoCompleteSources.IntegerField)
.add('float', SearchBarAutoCompleteSources.FloatField)
.add('monetary', SearchBarAutoCompleteSources.FloatField)
.add('selection', SearchBarAutoCompleteSources.SelectionField)
.add('datetime', SearchBarAutoCompleteSources.DateTimeField)
.add('date', SearchBarAutoCompleteSources.DateField)
.add('many2one', SearchBarAutoCompleteSources.ManyToOneField)
.add('many2many', SearchBarAutoCompleteSources.CharField)
.add('one2many', SearchBarAutoCompleteSources.CharField);
// others
registry.add('filter', SearchBarAutoCompleteSources.Filter);
registry.add('groupby', SearchBarAutoCompleteSources.GroupBy);
});
@@ -0,0 +1,129 @@
odoo.define('web.SearchFacet', function (require) {
"use strict";
var core = require('web.core');
var Widget = require('web.Widget');
var _t = core._t;
var SearchFacet = Widget.extend({
template: 'SearchView.SearchFacet',
events: _.extend({}, Widget.prototype.events, {
'click .o_facet_remove': '_onFacetRemove',
'keydown': '_onKeydown',
}),
/**
* @override
* @param {Object} facet
*/
init: function (parent, facet) {
this._super.apply(this, arguments);
var self = this;
this.facet = facet;
this.facetValues = _.map(this.facet.filters, function (filter) {
return self._getFilterDescription(filter);
});
this.separator = this._getSeparator();
this.icon = this._getIcon();
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Get the correct description according to filter.
*
* @private
* @returns {string}
*/
_getFilterDescription: function (filter) {
if (filter.type === 'field') {
var values = _.pluck(filter.autoCompleteValues, 'label');
return values.join(_t(' or '));
}
var description = filter.description;
if (filter.hasOptions) {
var optionValue =_.findWhere(filter.options, {
optionId: filter.currentOptionId,
});
description += ': ' + optionValue.description;
}
if (filter.type === 'timeRange') {
var timeRangeValue =_.findWhere(filter.timeRangeOptions, {
optionId: filter.timeRangeId,
});
description += ': ' + timeRangeValue.description;
if (filter.comparisonTimeRangeId) {
var comparisonTimeRangeValue =_.findWhere(filter.comparisonTimeRangeOptions, {
optionId: filter.comparisonTimeRangeId,
});
description += ' / ' + comparisonTimeRangeValue.description;
}
}
return description;
},
/**
* Get the correct icon according to facet type.
*
* @private
* @returns {string}
*/
_getIcon: function () {
var icon;
if (this.facet.type === 'filter') {
icon = 'fa-filter';
} else if (this.facet.type === 'groupBy') {
icon = 'fa-bars';
} else if (this.facet.type === 'favorite') {
icon = 'fa-star';
} else if (this.facet.type === 'timeRange') {
icon = 'fa-calendar';
}
return icon;
},
/**
* Get the correct separator according to facet type.
*
* @private
* @returns {string}
*/
_getSeparator: function () {
var separator;
if (this.facet.type === 'filter') {
separator = _t('or');
} else if (this.facet.type === 'field') {
separator = _t('or');
} else if (this.facet.type === 'groupBy') {
separator = '>';
}
return separator;
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onFacetRemove: function () {
this.trigger_up('facet_removed', {group: this.facet});
},
/**
* @private
* @param {KeyboardEvent} ev
*/
_onKeydown: function (ev) {
switch (ev.which) {
case $.ui.keyCode.BACKSPACE:
this.trigger_up('facet_removed', {group: this.facet});
break;
}
},
});
return SearchFacet;
});
@@ -4,12 +4,13 @@ odoo.define('web.search_filters', function (require) {
var core = require('web.core');
var datepicker = require('web.datepicker');
var field_utils = require('web.field_utils');
var search_filters_registry = require('web.search_filters_registry');
var Widget = require('web.Widget');
var _t = core._t;
var _lt = core._lt;
var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.ExtendedSearchProposition# */{
var ExtendedSearchProposition = Widget.extend({
template: 'SearchView.extended_search.proposition',
events: {
'change .o_searchview_extended_prop_field': 'changed',
@@ -25,10 +26,7 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
},
},
/**
* @constructs instance.web.search.ExtendedSearchProposition
* @extends instance.web.Widget
*
* @param parent
* @override
* @param fields
*/
init: function (parent, fields) {
@@ -46,8 +44,8 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
},
changed: function () {
var nval = this.$(".o_searchview_extended_prop_field").val();
if(this.attrs.selected === null || this.attrs.selected === undefined || nval != this.attrs.selected.name) {
this.select_field(_.detect(this.fields, function (x) {return x.name == nval;}));
if(this.attrs.selected === null || this.attrs.selected === undefined || nval !== this.attrs.selected.name) {
this.select_field(_.detect(this.fields, function (x) {return x.name === nval;}));
}
},
operator_changed: function (e) {
@@ -71,7 +69,7 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
}
var type = field.type;
var Field = core.search_filters_registry.getAny([type, "char"]);
var Field = search_filters_registry.getAny([type, "char"]);
this.value = new Field(this, field);
_.each(this.value.operators, function (operator) {
@@ -84,8 +82,9 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
},
get_filter: function () {
if (this.attrs.selected === null || this.attrs.selected === undefined)
if (this.attrs.selected === null || this.attrs.selected === undefined) {
return null;
}
var field = this.attrs.selected,
op_select = this.$('.o_searchview_extended_prop_op')[0],
operator = op_select.options[op_select.selectedIndex];
@@ -172,6 +171,15 @@ var Char = Field.extend({
{value: "∃", text: _lt("is set")},
{value: "∄", text: _lt("is not set")}
],
get_domain: function (field, operator) {
switch (operator.value) {
case '∃': return [[field.name, '!=', false]];
case '∄': return [[field.name, '=', false]];
// does not work in all cases
// default: return "[('" + field.name + "', '" + operator.value + "', '" + this.get_value() + "')]";
default: return [[field.name, operator.value, this.get_value()]];
}
},
get_value: function () {
return this.$el.val();
}
@@ -358,23 +366,17 @@ var Boolean = Field.extend({
}
});
core.search_filters_registry
.add('char', Char)
.add('text', Char)
.add('one2many', Char)
.add('many2one', Char)
.add('many2many', Char)
.add('datetime', DateTime)
.add('date', Date)
.add('integer', Integer)
.add('float', Float)
.add('monetary', Float)
.add('boolean', Boolean)
.add('selection', Selection)
.add('id', Id);
return {
ExtendedSearchProposition: ExtendedSearchProposition
Boolean: Boolean,
Char: Char,
Date: Date,
DateTime: DateTime,
ExtendedSearchProposition: ExtendedSearchProposition,
Field: Field,
Float: Float,
Id: Id,
Integer: Integer,
Selection: Selection,
};
});
@@ -0,0 +1,30 @@
odoo.define('web.search_filters_registry', function (require) {
"use strict";
var Registry = require('web.Registry');
return new Registry();
});
odoo.define('web._search_filters_registry', function(require) {
"use strict";
var registry = require('web.search_filters_registry');
var search_filters = require('web.search_filters');
registry
.add('boolean', search_filters.Boolean)
.add('char', search_filters.Char)
.add('date', search_filters.Date)
.add('datetime', search_filters.DateTime)
.add('float', search_filters.Float)
.add('id', search_filters.Id)
.add('integer', search_filters.Integer)
.add('many2many', search_filters.Char)
.add('many2one', search_filters.Char)
.add('monetary', search_filters.Float)
.add('one2many', search_filters.Char)
.add('text', search_filters.Char)
.add('selection', search_filters.Selection);
});
@@ -0,0 +1,106 @@
odoo.define('web.TimeRangeMenu', function (require) {
"use strict";
var config = require('web.config');
var controlPanelViewParameters = require('web.controlPanelViewParameters');
var Widget = require('web.Widget');
var COMPARISON_TIME_RANGE_OPTIONS = controlPanelViewParameters.COMPARISON_TIME_RANGE_OPTIONS;
var PERIOD_OPTIONS = controlPanelViewParameters.PERIOD_OPTIONS;
var TimeRangeMenu = Widget.extend({
template: 'web.TimeRangeMenu',
events: {
'click .o_apply_range': '_onApplyButtonClick',
'click .o_comparison_checkbox': '_onCheckBoxClick',
},
/**
* @override
* @param {Widget} parent
* @param {Object[]} timeRanges
*
*/
init: function (parent, timeRanges) {
this._super.apply(this, arguments);
// determine header style
this.isMobile = config.device.isMobile;
this.symbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : 'caret';
// fixed parameters
this.periodOptions = PERIOD_OPTIONS;
this.comparisonTimeRangeOptions = COMPARISON_TIME_RANGE_OPTIONS;
this.periodGroups = _.uniq(PERIOD_OPTIONS.map(function (option) {
return option.groupId;
}));
// variable parameters
this.timeRanges = timeRanges;
this.configuration = {
comparisonIsSelected: false,
comparisonTimeRangeId: false,
id: false,
timeRangeId: false,
};
this._configure();
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @param {Object[]} timeRanges
*/
update: function (timeRanges) {
this.timeRanges = timeRanges;
this._configure();
this.renderElement();
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_configure: function () {
this.configuration = this.timeRanges.find(function (timeRange) {
return timeRange.isActive;
}) || this.configuration;
this.configuration.comparisonIsSelected = !!this.configuration.comparisonTimeRangeId;
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onApplyButtonClick: function () {
var id = this.$('.o_date_field_selector').val();
var timeRangeId = this.$('.o_time_range_selector').val();
var comparisonTimeRangeId = false;
if (this.configuration.comparisonIsSelected) {
comparisonTimeRangeId = this.$('.o_comparison_time_range_selector').val();
}
this.trigger_up('activate_time_range', {
id: id,
timeRangeId: timeRangeId,
comparisonTimeRangeId: comparisonTimeRangeId
});
},
/**
* @private
* @param {MouseEvent} ev
*/
_onCheckBoxClick: function (ev) {
ev.stopPropagation();
this.configuration.comparisonIsSelected = this.$('.o_comparison_checkbox').prop('checked');
this.$('.o_comparison_time_range_selector').toggleClass('o_hidden');
this.$el.addClass('open');
}
});
return TimeRangeMenu;
});
@@ -132,7 +132,7 @@ var FormController = BasicController.extend({
* @param {jQueryElement} $node
*/
renderButtons: function ($node) {
var $footer = this.footerToButtons ? this.$('footer') : null;
var $footer = this.footerToButtons ? this.renderer.$('footer') : null;
var mustRenderFooterButtons = $footer && $footer.length;
if (!this.defaultButtons && !mustRenderFooterButtons) {
return;
@@ -219,7 +219,7 @@ var FormController = BasicController.extend({
var self = this;
return this._super.apply(this, arguments).then(function (changedFields) {
// the title could have been changed
self.set('title', self.getTitle());
self._setTitle(self.getTitle());
self._updateEnv();
if (_t.database.multi_lang && changedFields.length) {
@@ -250,6 +250,9 @@ var FormController = BasicController.extend({
* @override
*/
update: function (params, options) {
if ('currentId' in params && !params.currentId) {
this.mode = 'edit'; // if there is no record, we are in 'edit' mode
}
params = _.extend({viewType: 'form', mode: this.mode}, params);
return this._super(params, options);
},
@@ -395,7 +398,7 @@ var FormController = BasicController.extend({
*/
_update: function () {
var title = this.getTitle();
this.set('title', title);
this._setTitle(title);
this._updateButtons();
this._updateSidebar();
return this._super.apply(this, arguments).then(this.autofocus.bind(this));
@@ -406,7 +409,7 @@ var FormController = BasicController.extend({
_updateButtons: function () {
if (this.$buttons) {
if (this.footerToButtons) {
var $footer = this.$('footer');
var $footer = this.renderer.$('footer');
if ($footer.length) {
this.$buttons.empty().append($footer);
}
@@ -5,6 +5,7 @@ var BasicRenderer = require('web.BasicRenderer');
var config = require('web.config');
var core = require('web.core');
var dom = require('web.dom');
var viewUtils = require('web.viewUtils');
var _t = core._t;
var qweb = core.qweb;
@@ -439,7 +440,7 @@ var FormRenderer = BasicRenderer.extend({
* @returns {jQueryElement}
*/
_renderHeaderButton: function (node) {
var $button = this._renderButtonFromNode(node);
var $button = viewUtils.renderButtonFromNode(node);
// Current API of odoo for rendering buttons is "if classes are given
// use those on top of the 'btn' and 'btn-{size}' classes, otherwise act
@@ -639,7 +640,7 @@ var FormRenderer = BasicRenderer.extend({
* @returns {jQueryElement}
*/
_renderStatButton: function (node) {
var $button = this._renderButtonFromNode(node, {
var $button = viewUtils.renderButtonFromNode(node, {
extraClass: 'oe_stat_button',
});
$button.append(_.map(node.children, this._renderNode.bind(this)));
@@ -685,7 +686,7 @@ var FormRenderer = BasicRenderer.extend({
* @returns {jQueryElement}
*/
_renderTagButton: function (node) {
var $button = this._renderButtonFromNode(node);
var $button = viewUtils.renderButtonFromNode(node);
$button.append(_.map(node.children, this._renderNode.bind(this)));
this._addOnClickAction($button, node);
this._handleAttributes($button, node);
@@ -18,7 +18,8 @@ var FormView = BasicView.extend({
display_name: _lt('Form'),
icon: 'fa-edit',
multi_record: false,
searchable: false,
withSearchBar: false,
searchMenuTypes: [],
jsLibs: [],
viewType: 'form',
/**
@@ -27,17 +28,14 @@ var FormView = BasicView.extend({
init: function (viewInfo, params) {
this._super.apply(this, arguments);
var mode = params.mode || params.context.form_view_initial_mode ||
(params.currentId ? 'readonly' : 'edit');
var mode = params.mode || (params.currentId ? 'readonly' : 'edit');
this.loadParams.type = 'record';
this.controllerParams.disableAutofocus = params.disable_autofocus;
this.controllerParams.hasSidebar = params.hasSidebar;
this.controllerParams.toolbarActions = viewInfo.toolbar;
this.controllerParams.footerToButtons = params.footerToButtons;
if ('action' in params && 'flags' in params.action) {
this.controllerParams.footerToButtons = params.action.flags.footerToButtons;
}
var defaultButtons = 'default_buttons' in params ? params.default_buttons : true;
this.controllerParams.defaultButtons = defaultButtons;
this.controllerParams.mode = mode;
@@ -63,6 +61,26 @@ var FormView = BasicView.extend({
// Private
//--------------------------------------------------------------------------
/**
* @override
* @param {string} [action.target]
*/
_extractParamsFromAction: function (action) {
var params = this._super.apply(this, arguments);
var inDialog = action.target === 'new';
var inline = action.target === 'inline';
var fullscreen = action.target === 'fullscreen';
params.withControlPanel = !(inDialog || inline);
params.footerToButtons = inDialog;
params.hasSearchView = inDialog ? false : params.hasSearchView;
params.searchMenuTypes = inDialog ? [] : params.searchMenuTypes;
if (inDialog || inline || fullscreen) {
params.mode = 'edit';
} else if (action.context && action.context.form_view_initial_mode) {
params.mode = action.context.form_view_initial_mode;
}
return params;
},
/**
* Loads the subviews for x2many fields when they are not inline
*
@@ -6,13 +6,11 @@ odoo.define('web.GraphController', function (require) {
var core = require('web.core');
var AbstractController = require('web.AbstractController');
var GroupByMenuInterfaceMixin = require('web.GroupByMenuInterfaceMixin');
var GroupByMenuMixin = require('web.GroupByMenuMixin');
var qweb = core.qweb;
var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{
className: 'o_graph',
var GraphController = AbstractController.extend(GroupByMenuMixin,{
/**
* @override
* @param {Widget} parent
@@ -24,19 +22,24 @@ var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{
* @param {string[]} params.groupableFields,
*/
init: function (parent, model, renderer, params) {
GroupByMenuInterfaceMixin.init.call(this);
GroupByMenuMixin.init.call(this);
this._super.apply(this, arguments);
this.measures = params.measures;
// this parameter condition the appearance of a 'Group By'
// button in the control panel owned by the graph view.
this.isEmbedded = params.isEmbedded;
// this parameter determines what is the list of fields
// that may be used within the groupby menu available when
// the view is embedded
this.groupableFields = params.groupableFields;
// extends custom_events key. This allow the controller
// to listen to information comming from the groupby menu.
// this is used when the view is embedded.
},
/**
* @override
*/
start: function () {
this.$el.addClass('o_graph_controller');
return this._super.apply(this, arguments);
},
/**
* @todo check if this can be removed (mostly duplicate with
@@ -62,18 +65,20 @@ var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{
* @override
* @returns {Object}
*/
getContext: function () {
getOwnedQueryParams: function () {
var state = this.model.get();
return {
graph_measure: state.measure,
graph_mode: state.mode,
graph_groupbys: state.groupedBy,
// this parameter is not used anywher for now
// the idea would be to seperate intervals from
// fieldnames in groupbys. This could be done
// in graph view only or everywhere but this is
// a big refactoring.
graph_intervalMapping: state.intervalMapping,
context: {
graph_measure: state.measure,
graph_mode: state.mode,
graph_groupbys: state.groupedBy,
// this parameter is not used anywher for now
// the idea would be to seperate intervals from
// fieldnames in groupbys. This could be done
// in graph view only or everywhere but this is
// a big refactoring.
graph_intervalMapping: state.intervalMapping,
}
};
},
/**
@@ -110,10 +115,10 @@ var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{
* override
*
* @private
* @param {string[]} groupbys
* @param {string[]} groupBys
*/
_setGroupby: function (groupbys) {
this.update({groupBy: groupbys});
_setGroupby: function (groupBys) {
this.update({groupBy: groupBys});
},
/**
@@ -23,7 +23,7 @@ var CHART_TYPES = ['pie', 'bar', 'line'];
var MAX_LEGEND_LENGTH = 25 * (Math.max(1, config.device.size_class));
return AbstractRenderer.extend({
className: "o_graph_container",
className: "o_graph_renderer",
/**
* @override
* @param {Widget} parent
@@ -36,7 +36,8 @@ var GraphView = AbstractView.extend({
Renderer: GraphRenderer,
},
viewType: 'graph',
enableTimeRangeMenu: 'true',
searchMenuTypes: ['filter', 'groupBy', 'timeRange', 'favorite'],
/**
* @override
*/
@@ -238,7 +238,7 @@ var KanbanController = BasicController.extend({
self.renderer.updateColumn(db_id, data);
});
});
}).fail(this.reload.bind(this));
}).fail(this.reload.bind(this, {}));
},
/**
* @private
@@ -279,7 +279,7 @@ var KanbanController = BasicController.extend({
} else if (this.on_create && this.on_create !== 'quick_create') {
// Execute the given action
this.do_action(this.on_create, {
on_close: this.reload.bind(this),
on_close: this.reload.bind(this, {}),
additional_context: state.context,
});
} else {
@@ -30,13 +30,11 @@ var KanbanView = BasicView.extend({
*/
init: function (viewInfo, params) {
this._super.apply(this, arguments);
this.loadParams.limit = this.loadParams.limit || 40;
// in mobile, columns are lazy-loaded, so set 'openGroupByDefault' to
// false so that they will won't be loaded by the initial load
this.loadParams.openGroupByDefault = config.device.isMobile ? false : true;
this.loadParams.type = 'list';
this.loadParams.groupBy = this.arch.attrs.default_group_by ? [this.arch.attrs.default_group_by] : (params.groupBy || []);
var progressBar;
utils.traverse(this.arch, function (n) {
var isProgressBar = (n.tag === 'progressbar');
@@ -106,6 +104,11 @@ var KanbanView = BasicView.extend({
}
return true;
},
_updateMVCParams: function () {
this._super.apply(this, arguments);
var defaultGroupBy = this.arch.attrs.default_group_by;
this.loadParams.groupBy = defaultGroupBy ? [defaultGroupBy] : (this.loadParams.groupedBy || []);
},
});
return KanbanView;
@@ -56,10 +56,6 @@ var QuickCreateFormModel = BasicModel.extend({
});
var QuickCreateFormController = FormController.extend({
custom_events: _.extend({}, FormController.prototype.custom_events, {
env_updated: '_onEnvUpdated',
}),
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
@@ -83,23 +79,10 @@ var QuickCreateFormController = FormController.extend({
getChanges: function () {
return this.model.getChanges(this.handle);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Stops the propagation of the 'env_updated' event to prevent interferences
* with the kanban controller.
*
* @private
*/
_onEnvUpdated: function (ev) {
ev.stopPropagation();
},
});
var QuickCreateFormView = FormView.extend({
withControlPanel: false,
config: _.extend({}, FormView.prototype.config, {
Model: QuickCreateFormModel,
Renderer: QuickCreateFormRenderer,
@@ -87,10 +87,10 @@ var ListController = BasicController.extend({
/*
* @override
*/
getContext: function () {
var context = this._super.apply(this, arguments);
context.orderedBy = this.model.get(this.handle, {raw: true}).orderedBy || [];
return context;
getOwnedQueryParams: function () {
var state = this._super.apply(this, arguments);
var orderedBy = this.model.get(this.handle, {raw: true}).orderedBy || [];
return _.extend({}, state, {orderedBy: orderedBy});
},
/**
* Returns the list of currently selected res_ids (with the check boxes on
@@ -8,6 +8,7 @@ var dom = require('web.dom');
var field_utils = require('web.field_utils');
var Pager = require('web.Pager');
var utils = require('web.utils');
var viewUtils = require('web.viewUtils');
var _t = core._t;
@@ -329,7 +330,7 @@ var ListRenderer = BasicRenderer.extend({
*/
_renderButton: function (record, node) {
var self = this;
var $button = this._renderButtonFromNode(node, {
var $button = viewUtils.renderButtonFromNode(node, {
extraClass: node.attrs.icon ? 'o_icon_button' : undefined,
textAsTitle: !!node.attrs.icon,
});
@@ -42,7 +42,6 @@ var ListView = BasicView.extend({
this.controllerParams.editable = this.arch.attrs.editable;
this.controllerParams.hasSidebar = params.hasSidebar;
this.controllerParams.toolbarActions = viewInfo.toolbar;
this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf;
this.controllerParams.mode = mode;
this.controllerParams.selectedRecords = selectedRecords;
@@ -55,6 +54,15 @@ var ListView = BasicView.extend({
this.loadParams.limit = this.loadParams.limit || 80;
this.loadParams.type = 'list';
},
//--------------------------------------------------------------------------
// Private
//---
_updateMVCParams: function () {
this._super.apply(this, arguments);
this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf;
},
});
return ListView;
@@ -22,7 +22,7 @@ var _t = core._t;
var QWeb = core.qweb;
var PivotController = AbstractController.extend({
template: 'PivotView',
contentTemplate: 'PivotView',
events: {
'click .o_pivot_header_cell_opened': '_onOpenHeaderClick',
'click .o_pivot_header_cell_closed': '_onClosedHeaderClick',
@@ -83,12 +83,14 @@ var PivotController = AbstractController.extend({
* @override method from AbstractController
* @returns {Object}
*/
getContext: function () {
getOwnedQueryParams: function () {
var state = this.model.get();
return {
pivot_measures: state.measures,
pivot_column_groupby: state.colGroupBys,
pivot_row_groupby: state.rowGroupBys,
context: {
pivot_measures: state.measures,
pivot_column_groupby: state.colGroupBys,
pivot_row_groupby: state.rowGroupBys,
}
};
},
/**
@@ -171,6 +173,13 @@ var PivotController = AbstractController.extend({
.css(cssProps)
.addClass('show');
},
/**
* @override
* @private
*/
_startRenderer: function () {
return this.renderer.appendTo(this.$('.o_pivot'));
},
/**
* @private
*/
@@ -28,7 +28,8 @@ var PivotView = AbstractView.extend({
Renderer: PivotRenderer,
},
viewType: 'pivot',
enableTimeRangeMenu: 'true',
searchMenuTypes: ['filter', 'groupBy', 'timeRange', 'favorite'],
/**
* @override
* @param {Object} params
@@ -117,10 +118,9 @@ var PivotView = AbstractView.extend({
this.controllerParams.groupableFields = groupableFields;
// retrieve form and list view ids from the action to open those views
// when a data cell of the pivot view is clicked
this.controllerParams.views = [
_findView(params.action && params.action.views, 'list'),
_findView(params.action && params.action.views, 'form'),
_findView(params.actionViews, 'list'),
_findView(params.actionViews, 'form'),
];
function _findView(views, viewType) {
var view = _.find(views, function (view) {
@@ -1,335 +0,0 @@
odoo.define('web.FavoriteMenu', function (require) {
"use strict";
var config = require('web.config');
var core = require('web.core');
var data_manager = require('web.data_manager');
var Domain = require('web.Domain');
var pyUtils = require('web.py_utils');
var session = require('web.session');
var Widget = require('web.Widget');
var _t = core._t;
return Widget.extend({
template: 'SearchView.FavoriteMenu',
events: {
'click .dropdown-item': function (ev) {
ev.preventDefault();
},
'click .o_save_search': function (ev) {
ev.preventDefault();
ev.stopPropagation();
this.toggle_save_menu();
},
'click .o_save_name button': 'save_favorite',
'hidden.bs.dropdown': '_closeMenus',
'keyup .o_save_name input': function (ev) {
if (ev.which === $.ui.keyCode.ENTER) {
this.save_favorite();
}
},
},
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 = action;
this.action_id = action.id;
this.filters = {};
this.isMobile = config.device.isMobile;
_.each(filters, this.add_filter.bind(this));
},
start: function () {
var self = this;
this.$filters = {};
this.$save_search = this.$('.o_save_search');
this.$save_name = this.$('.o_save_name');
this.$inputs = this.$save_name.find('input');
this.$user_divider = this.$('.dropdown-divider.user_filter');
this.$shared_divider = this.$('.dropdown-divider.shared_filter');
this.$inputs.eq(0).val(this.searchview.get_title());
var $shared_filter = this.$inputs.eq(1),
$default_filter = this.$inputs.eq(2);
$shared_filter.click(function () {$default_filter.prop('checked', false);});
$default_filter.click(function () {$shared_filter.prop('checked', false);});
this.query
.on('remove', function (facet) {
if (facet.get('is_custom_filter')) {
self.clear_selection();
}
})
.on('reset', this.proxy('clear_selection'));
_.each(this.filters, this.append_filter.bind(this));
return this._super();
},
toggle_save_menu: function (is_open) {
this.$save_search
.toggleClass('o_closed_menu', !(_.isUndefined(is_open)) ? !is_open : undefined)
.toggleClass('o_open_menu', is_open);
this.$save_name.toggle(is_open);
if (this.$save_search.hasClass('o_open_menu')) {
this.$save_name.find('input').first().focus();
}
},
_closeMenus: function () {
this.toggle_save_menu(false);
},
save_favorite: function () {
var self = this,
filter_name = this.$inputs[0].value,
default_filter = this.$inputs[1].checked,
shared_filter = this.$inputs[2].checked;
if (!filter_name.length){
this.do_warn(_t("Error"), _t("Filter name is required."));
this.$inputs.first().focus();
return;
}
if (_.chain(this.filters)
.pluck('name')
.contains(filter_name).value()) {
this.do_warn(_t("Error"), _t("Filter with same name already exists."));
this.$inputs.first().focus();
return;
}
var user_context = this.getSession().user_context;
// get search params with domains not evaluated
var search = this.searchview.build_search_data(true);
var domain = pyUtils.assembleDomains(search.domains, 'AND');
var controllerContext;
this.trigger_up('get_controller_context', {
callback: function (ctx) {
if (ctx && ctx.orderedBy) {
var sort = _.map(ctx.orderedBy, function (obj) {
var order = obj.asc ? 'ASC' : 'DESC';
return obj.name + ' ' + order;
});
self.searchview.dataset.set_sort(sort);
}
controllerContext = _.omit(ctx, ['orderedBy']);
},
});
var results = pyUtils.eval_domains_and_contexts({
domains: [],
contexts: [user_context].concat(search.contexts.concat(controllerContext || [])),
group_by_seq: search.groupbys || [],
});
if (!_.isEmpty(results.group_by)) {
results.context.group_by = results.group_by;
}
// Don't save user_context keys in the custom filter, otherwise end
// up with e.g. wrong uid or lang stored *and used in subsequent
// reqs*
var ctx = results.context;
_(_.keys(session.user_context)).each(function (key) {
delete ctx[key];
});
var filter = {
name: filter_name,
user_id: shared_filter ? false : session.uid,
model_id: this.target_model,
context: results.context,
domain: domain,
sort: JSON.stringify(this.searchview.dataset._sort),
is_default: default_filter,
action_id: this.action_id,
};
return this._createFilter(filter).then(function (id) {
filter.id = id;
self.toggle_save_menu(false);
self.$save_name.find('input').val('').prop('checked', false);
self.add_filter(filter);
self.append_filter(filter);
self.toggle_filter(filter, true);
});
},
get_default_filter: function () {
var personal_filter = _.find(this.filters, function (filter) {
return filter.user_id && filter.is_default;
});
if (personal_filter) {
return personal_filter;
}
return _.find(this.filters, function (filter) {
return !filter.user_id && filter.is_default;
});
},
/**
* Generates a mapping key (in the filters and $filter mappings) for the
* filter descriptor object provided (as returned by ``get_filters``).
*
* The mapping key is guaranteed to be unique for a given (user_id, name)
* pair.
*
* @param {Object} filter
* @param {String} filter.name
* @param {Number|Pair<Number, String>} [filter.user_id]
* @return {String} mapping key corresponding to the filter
*/
key_for: function (filter) {
var user_id = filter.user_id,
action_id = filter.action_id,
uid = (user_id instanceof Array) ? user_id[0] : user_id,
act_id = (action_id instanceof Array) ? action_id[0] : action_id;
return _.str.sprintf('(%s)(%s)%s', uid, act_id, filter.name);
},
/**
* Generates a :js:class:`~instance.web.search.Facet` descriptor from a
* filter descriptor
*
* @param {Object} filter
* @param {String} filter.name
* @param {Object} [filter.context]
* @param {Array} [filter.domain]
* @return {Object}
*/
facet_for: function (filter) {
var self = this;
return {
category: _t("Custom Filter"),
icon: 'fa-star',
field: {
get_context: function () {
var sortParsed = JSON.parse(filter.sort || "[]");
var orderedBy = [];
_.each(sortParsed, function (sort) {
orderedBy.push({
name: sort[0] === '-' ? sort.slice(1) : sort,
asc: sort[0] === '-' ? false : true,
});
});
return _.defaults({}, filter.context, {orderedBy : orderedBy});
},
get_groupby: function () { return [filter.context]; },
// facet is not used
get_domain: function (facet, noEvaluation) {
noEvaluation = noEvaluation || false;
var userContext = self.getSession().user_context;
var domain = pyUtils.assembleDomains([filter.domain]);
if (!noEvaluation) {
return Domain.prototype.stringToArray(domain, userContext);
}
return domain;
}
},
_id: filter.id,
is_custom_filter: true,
values: [{label: filter.name, value: null}]
};
},
clear_selection: function () {
this.$('.selected').removeClass('selected');
},
/**
* Adds a filter description to the filters dict
* @param {Object} [filter] the filter description
*/
add_filter: function (filter) {
this.filters[this.key_for(filter)] = filter;
},
/**
* Creates a $filter JQuery node, adds it to the $filters dict and appends it to the filter menu
* @param {Object} [filter] the filter description
*/
append_filter: function (filter) {
var self = this;
var key = this.key_for(filter);
if (filter.user_id) {
this.$user_divider.show();
} else {
this.$shared_divider.show();
}
if (!(key in this.$filters)) {
var $filter = $('<div>', {class: 'position-relative'})
.addClass(filter.user_id ? 'o-searchview-custom-private'
: 'o-searchview-custom-public')
.append($('<a>', {href: '#', class: 'dropdown-item'}).text(filter.name))
.append($('<span>', {
class: 'fa fa-trash-o o-remove-filter',
on: {
click: function (event) {
event.stopImmediatePropagation();
self.remove_filter(filter, $filter, key);
},
},
}))
.insertBefore(filter.user_id ? this.$user_divider : this.$shared_divider);
this.$filters[key] = $filter;
}
this.$filters[key].unbind('click').click(function () {
self.toggle_filter(filter);
});
},
toggle_filter: function (filter, preventSearch) {
var current = this.query.find(function (facet) {
return facet.get('_id') === filter.id;
});
if (current) {
this.query.remove(current);
this.$filters[this.key_for(filter)].find('.dropdown-item').removeClass('selected');
return;
}
this.query.reset([this.facet_for(filter)], {
preventSearch: preventSearch || false});
// Load sort settings on view
if (!_.isUndefined(filter.sort)){
var sort_items = JSON.parse(filter.sort);
this.searchview.dataset.set_sort(sort_items);
}
this.$filters[this.key_for(filter)].find('.dropdown-item').addClass('selected');
},
remove_filter: function (filter, $filter, key) {
var self = this;
var global_warning = _t("This filter is global and will be removed for everybody if you continue."),
warning = _t("Are you sure that you want to remove this filter?");
if (!confirm(filter.user_id ? warning : global_warning)) {
return;
}
return data_manager
.delete_filter(filter)
.done(function () {
$filter.remove();
delete self.$filters[key];
delete self.filters[key];
var has_user_filter = _.find(self.filters, function(filter) { return filter.user_id; });
var has_shared_filer = _.find(self.filters, function(filter) { return !filter.user_id; });
if (!has_user_filter) {
self.$user_divider.hide();
}
if (!has_shared_filer) {
self.$shared_divider.hide();
}
});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Creates a new search view filter.
*
* @private
* @param {Object} filter the filter description
* @returns {$.Deferred} resolved with the RPC's result when it succeeds
*/
_createFilter: function (filter) {
var def = $.Deferred();
this.trigger_up('create_filter', {
filter: filter,
on_success: def.resolve.bind(def),
});
return def;
},
});
});
@@ -1,253 +0,0 @@
odoo.define('web.FiltersMenu', function (require) {
"use strict";
var config = require('web.config');
var core = require('web.core');
var Domain = require('web.Domain');
var DropdownMenu = require('web.DropdownMenu');
var search_filters = require('web.search_filters');
var time = require('web.time');
var TimeRangeMenuOptions = require('web.TimeRangeMenuOptions');
var QWeb = core.qweb;
var _t = core._t;
var PERIOD_OPTIONS = TimeRangeMenuOptions.PeriodOptions;
var DEFAULT_PERIOD = 'this_month';
var FiltersMenu = DropdownMenu.extend({
custom_events: {
remove_proposition: '_onRemoveProposition',
confirm_proposition: '_onConfirmProposition',
},
events: _.extend({}, DropdownMenu.prototype.events, {
'click .o_add_custom_filter': '_onAddCustomFilterClick',
'click .o_add_condition': '_appendProposition',
'click .o_apply_filter': '_onApplyClick',
':hover .o_item_option': '_onOptionHover',
}),
/**
* @override
* @param {Widget} parent
* @param {Object[]} filters list of filters (type IFilter below)
* interface IFilter {
* itemId: string; unique id associated with the filter
* domain: string;
* isPeriod: boolean
* fieldName: string; fieldName used to generate a domain in case isDate is true (and domain empty)
* description: string; label printed on screen
* groupId: string;
* isActive: boolean; (optional) determines if the filter is considered active
* isOpen: boolean; (optional) in case there are options the submenu presenting the options
* is opened or closed according to isOpen
* isRemovable: boolean; (optional) can be removed from menu
* options: array of objects with 'optionId' and 'description' keys; (optional)
* defaultOptionId: string refers to an optionId (optional)
* currentOptionId: string refers to an optionId that is activated if item is active (optional)
* }
* @param {Object} fields
*/
init: function (parent, filters, fields) {
// determines where the filter menu is displayed and its style
this.isMobile = config.device.isMobile;
// determines list of options used by filter of type 'date'
this.periodOptions = PERIOD_OPTIONS;
this.defaultOptionId = DEFAULT_PERIOD;
// determines when the 'Add custom filter' submenu is open
this.generatorMenuIsOpen = false;
this.propositions = [];
this.fields = _.pick(fields, function (field, name) {
return field.selectable !== false && name !== 'id';
});
this.fields.id = {string: 'ID', type: 'id', searchable: true};
var dropdownHeader = {
category: 'filterCategory',
title: _t('Filters'),
icon: 'fa fa-filter',
symbol: this.isMobile ? 'fa fa-chevron-right float-right mt4' : false,
};
this._super(parent, dropdownHeader, filters, this.fields);
},
/**
* render the template used to add a new custom filter and append it
* to the basic dropdown menu
*
* @private
*/
start: function () {
this.$menu = this.$('.o_dropdown_menu');
this.$menu.addClass('o_filters_menu');
var generatorMenu = QWeb.render('FiltersMenuGenerator', {widget: this});
this.$menu.append(generatorMenu);
this.$addCustomFilter = this.$menu.find('.o_add_custom_filter');
this.$addFilterMenu = this.$menu.find('.o_add_filter_menu');
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Add a proposition inside the custom filter edition menu
*
* @private
* @returns {$.Deferred}
*/
_appendProposition: function () {
var prop = new search_filters.ExtendedSearchProposition(this, this.fields);
this.propositions.push(prop);
this.$('.o_apply_filter').prop('disabled', false);
return prop.insertBefore(this.$addFilterMenu);
},
/**
* Confirm a filter selection, creates it and add it to the menu
*
* @private
*/
_commitSearch: function () {
var self = this;
var filters = _.invoke(this.propositions, 'get_filter');
_.each(filters, function (filter) {
filter.attrs.domain = Domain.prototype.arrayToString(filter.attrs.domain);
});
var groupId = _.uniqueId('__group__');
var data = [];
filters.forEach(function (filter) {
var filterName = _.uniqueId('__filter__');
var filterItem = {
itemId: filterName,
description: filter.attrs.string,
groupId: groupId,
isActive: true,
};
self._prepareItem(filterItem);
data.push({
itemId: filterName,
groupId: groupId,
filter: filter,
});
self.items.push(filterItem);
});
this._renderMenuItems();
this.trigger_up('new_filters', data);
_.invoke(this.propositions, 'destroy');
this.propositions = [];
this._toggleCustomFilterMenu();
},
/**
* override
*
* @private
* @param {Object} item
*/
_prepareItem: function (item) {
if (item.isPeriod) {
item.options = this.periodOptions;
}
this._super.apply(this, arguments);
},
/**
* override
*
* @private
*/
_renderMenuItems: function () {
var self= this;
this._super.apply(this, arguments);
// the following code adds tooltip on date options in order
// to alert the user of the meaning of intervals
var $options = this.$('.o_filters_menu .o_item_option');
$options.each(function () {
var $option = $(this);
$option.tooltip({
delay: { show: 500, hide: 0 },
title: function () {
var itemId = $option.attr('data-item_id');
var optionId = $option.attr('data-option_id');
var fieldName = _.findWhere(self.items, {itemId: itemId}).fieldName;
var domain = Domain.prototype.constructDomain(fieldName, optionId, 'date', true);
var evaluatedDomain = Domain.prototype.stringToArray(domain);
var dateFormat = time.getLangDateFormat();
var dateStart = moment(evaluatedDomain[1][2], "YYYY-MM-DD", 'en').format(dateFormat);
var dateEnd = moment(evaluatedDomain[2][2], "YYYY-MM-DD", 'en').format(dateFormat);
if (optionId === 'today' || optionId === 'yesterday') {
return dateStart;
}
return _.str.sprintf(_t('From %s To %s'), dateStart, dateEnd);
}
});
});
},
/**
* Hide and display the sub menu which allows adding custom filters
*
* @private
*/
_toggleCustomFilterMenu: function () {
var self = this;
this.generatorMenuIsOpen = !this.generatorMenuIsOpen;
var def;
if (this.generatorMenuIsOpen && !this.propositions.length) {
def = this._appendProposition();
}
if (!this.generatorMenuIsOpen) {
_.invoke(this.propositions, 'destroy');
this.propositions = [];
}
$.when(def).then(function () {
self.$addCustomFilter
.toggleClass('o_closed_menu', !self.generatorMenuIsOpen)
.toggleClass('o_open_menu', self.generatorMenuIsOpen);
self.$('.o_add_filter_menu').toggle();
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {MouseEvent} event
*/
_onAddCustomFilterClick: function (event) {
event.preventDefault();
event.stopPropagation();
this._toggleCustomFilterMenu();
},
/**
* @private
* @param {MouseEvent} event
*/
_onApplyClick: function (event) {
event.stopPropagation();
this._commitSearch();
},
/**
* @private
* @param {OdooEvent} event
*/
_onConfirmProposition: function (event) {
event.stopPropagation();
this._commitSearch();
},
/**
* @private
* @param {OdooEvent} event
*/
_onRemoveProposition: function (event) {
event.stopPropagation();
this.propositions = _.without(this.propositions, event.target);
if (!this.propositions.length) {
this.$('.o_apply_filter').prop('disabled', true);
}
event.target.destroy();
},
});
return FiltersMenu;
});
@@ -1,232 +0,0 @@
odoo.define('web.GroupByMenu', function (require) {
"use strict";
var config = require('web.config');
var core = require('web.core');
var DropdownMenu = require('web.DropdownMenu');
var QWeb = core.qweb;
var _t = core._t;
var GROUPABLE_TYPES = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime'];
var DEFAULT_INTERVAL = 'month';
var GroupByMenu = DropdownMenu.extend({
events: _.extend({}, DropdownMenu.prototype.events,
{
'click .o_add_custom_group': '_onAddCustomGroupClick',
'click button.o_apply_group': '_onButtonApplyClick',
'click .o_group_selector': '_onGroupSelectorClick',
}),
/**
* @override
* @param {Widget} parent
* @param {Object[]} groupbys list of groupbys (type IGroupBy below)
* interface IGroupBy {
* itemId: string; unique id associated with the groupby
* fieldName: string; field name without interval!
* description: string; label printed on screen
* groupId: string;
* isDate: boolean;
* isActive: boolean; (optional) determines if the groupby is considered active
* isOpen: boolean; (optional) in case there are options the submenu presenting the options
* is opened or closed according to isOpen
* isRemovable: boolean; (optional) can be removed from menu
* options: array of objects with 'optionId' and 'description' keys; (optional)
* currentOptionId: string refers to an optionId that is activated if item is active (optional)
* }
* @param {Object} fields 'field_get' of a model: mapping from field name
* to an object with its properties
* @param {Object} options
* @param {string} options.headerStyle conditions the style of the main button
*/
init: function (parent, groupbys, fields, options) {
var self = this;
this.fields = fields;
// determines when the 'Add custom groupby' submenu is open
this.generatorMenuIsOpen = false;
// determines list of options used by groupbys of type 'date'
this.intervalOptions = [
{description: 'Day', optionId: 'day', groupId: 1},
{description: 'Week', optionId: 'week', groupId: 1},
{description: 'Month', optionId: 'month', groupId: 1},
{description: 'Quarter', optionId: 'quarter', groupId: 1},
{description: 'Year', optionId: 'year', groupId: 1},
];
this.defaultOptionId = DEFAULT_INTERVAL;
this.groupableFields = [];
_.each(fields, function (field, name) {
if (field.sortable && _.contains(GROUPABLE_TYPES, field.type)) {
self.groupableFields.push(_.extend({}, field, {
name: name,
isDate: _.contains(['date', 'datetime'], field.type),
}));
}
});
this.groupableFields = _.sortBy(this.groupableFields, 'string');
_.each(groupbys, this._prepareItem.bind(this));
// determines the list of field names that can be added to the menu
// via the 'Add Custom Groupby' menu
this.presentedFields = _.filter(this.groupableFields, function (field) {
var groupByFields = _.pluck(groupbys, 'fieldName');
return !_.contains(groupByFields, field.name);
});
// determines where the filter menu is displayed and partly its style
this.isMobile = config.device.isMobile;
// the default style of the groupby menu can be changed here using the options key 'headerStyle'
var style;
if (options && options.headerStyle === 'primary') {
style = {
el: {class: 'btn-group o_graph_groupbys_menu o_dropdown', attrs: {'role': 'group'}},
mainButton: {class: 'btn btn-primary dropdown-toggle'},
};
}
var dropdownHeader = {
category: 'groupByCategory',
title: _t('Group By'),
icon: 'fa fa-bars',
symbol: this.isMobile ? 'fa fa-chevron-right float-right mt4' : false,
style: style,
};
this._super(parent, dropdownHeader, groupbys, options);
},
/**
* render the template used to add a new custom groupby and append it
* to the basic dropdown menu
*
* @private
*/
start: function () {
this.$menu = this.$('.o_dropdown_menu');
this.$menu.addClass('o_group_by_menu');
var $generatorMenu = QWeb.render('GroupbyMenuGenerator', {widget: this});
this.$menu.append($generatorMenu);
this.$addCustomGroup = this.$menu.find('.o_add_custom_group');
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* method called via the 'Add Custom Groupby' menu to create
* a new groupby and add it (activated) to the menu
* In case the field is of type date, it is activated using
* the option determined by the parameter 'DEFAULT_INTERVAL'
*
* @private
* @param {string} fieldName
*/
_addGroupby: function (fieldName) {
var field = _.findWhere(this.groupableFields, {name: fieldName});
var groupbyName = _.uniqueId('__groupby__');
var groupby = {
itemId: groupbyName,
description: field.string,
fieldName: fieldName,
groupId: _.uniqueId('__group__'),
isActive: true,
};
var eventData = _.clone(groupby);
this._prepareItem(groupby);
if (groupby.hasOptions) {
groupby.defaultOptionId = DEFAULT_INTERVAL;
groupby.currentOptionId = DEFAULT_INTERVAL;
groupby.isDate = true;
eventData.optionId = groupby.currentOptionId;
eventData.isDate = true;
}
this.items.push(groupby);
var fieldIndex = this.presentedFields.indexOf(field);
this.presentedFields.splice(fieldIndex, 1);
this._renderGeneratorMenu();
this._renderMenuItems();
this.trigger_up('new_groupby', eventData);
},
/**
* override
*
* @private
* @param {Object} item
*/
_prepareItem: function (item) {
if (_.contains(['date', 'datetime'], this.fields[item.fieldName].type)) {
item.options = this.intervalOptions;
}
// super has to be called here because we need to add options to groupby
// before to call it since some keys in a groupby are initialized using
// the keys 'options' and 'defaultOptionId'
this._super.apply(this, arguments);
},
/**
* @private
*/
_renderGeneratorMenu: function () {
this.$el.find('.o_generator_menu').remove();
var $generatorMenu = QWeb.render('GroupbyMenuGenerator', {widget: this});
this.$menu.append($generatorMenu);
this.$addCustomGroup = this.$menu.find('.o_add_custom_group');
this.$groupSelector = this.$menu.find('.o_group_selector');
},
/**
* @private
*/
_toggleCustomGroupMenu: function () {
this.generatorMenuIsOpen = !this.generatorMenuIsOpen;
this._renderGeneratorMenu();
if (this.generatorMenuIsOpen) {
this.$groupSelector.focus();
}
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* toggle the 'Add Custom Group'
*
* @private
* @param {MouseEvent} event
*/
_onAddCustomGroupClick: function (event) {
event.preventDefault();
event.stopPropagation();
this._toggleCustomGroupMenu();
},
/*
* override
*
* @private
* @param {jQueryEvent} event
*/
_onBootstrapClose: function () {
this._super.apply(this, arguments);
this.generatorMenuIsOpen = false;
this._renderGeneratorMenu();
},
/**
* @private
* @param {MouseEvent} event
*/
_onButtonApplyClick: function (event) {
event.stopPropagation();
var fieldName = this.$groupSelector.val();
this._addGroupby(fieldName);
this._toggleCustomGroupMenu();
},
/**
* @private
* @param {MouseEvent} event
*/
_onGroupSelectorClick: function (event) {
event.stopPropagation();
},
});
return GroupByMenu;
});
@@ -1,117 +0,0 @@
odoo.define('web.GroupByMenuInterfaceMixin', function (require) {
"use strict";
var GroupByMenu = require('web.GroupByMenu');
/**
* The aim of this mixin is to facilitate the interaction between
* a view controller and a dropdown menu with its control panel
* TO DO: the pivot subview has two types of groupbys so that it will not
* understand the current implementation of this mixin
*
* @mixin
* @name GroupByMenuInterfaceMixin
*/
var GroupByMenuInterfaceMixin = {
init: function () {
this.custom_events = _.extend({}, this.custom_events, {
menu_item_toggled: '_onMenuItemToggled',
item_option_changed : '_onItemOptionChanged',});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* this function instantiate a widget GroupByMenu and
* incorporate it to the control panel of the graph view.
* This is used for instance in the dashboard view in enterprise
* where there is no Group by menu in the search view because it
* would not make sense to have one at the global level.
* this function is called by renderButtons when the parameter
* 'this.isEmbedded' is set to true.
*
* private
* @param {jQuery} $node
*/
_addGroupByMenu: function ($node, groupableFields) {
var groupbys = [];
var activeGroupbys = [];
_.each(this.model.get().groupedBy, function (groupby) {
activeGroupbys.push({fieldName: groupby.split(':')[0], interval: groupby.split(':')[1] || false});
});
var groupId = '__group__1';
_.each(groupableFields, function (field, fieldName) {
var groupby = _.findWhere(activeGroupbys, {fieldName: fieldName});
groupbys.push({
isActive: groupby ? true : false,
description: field.string,
itemId: fieldName,
fieldName: fieldName,
groupId: groupId,
defaultOptionId: groupby ? groupby.interval : false,
});
});
groupbys = _.sortBy(groupbys, 'description');
var groupByMenu = new GroupByMenu(this, groupbys, groupableFields, {headerStyle: 'primary'});
groupByMenu.insertAfter($node.find('div:first'));
},
/**
* This method has to be implemented by the view controller
* that needs to interpret the click in an appropriate
* manner
*
* @private
* @param {string[]} groupbys
*/
_setGroupby: function (groupbys) {
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
*
* @private
* @param {OdooEvent} ev
*/
_onItemOptionChanged: function (ev) {
var fieldName = ev.data.itemId;
var interval = ev.data.optionId;
var groupbys = this.model.get().groupedBy;
var groupbysNormalized = _.map(groupbys, function (groupby) {
return groupby.split(':')[0];
});
var indexOfGroupby = groupbysNormalized.indexOf(fieldName);
groupbys.splice(indexOfGroupby, 1, fieldName + ':' + interval);
this._setGroupby(groupbys);
},
/**
*
* @private
* @param {OdooEvent} ev
*/
_onMenuItemToggled: function (ev) {
var fieldName = ev.data.itemId;
var interval = ev.data.optionId;
var groupbys = this.model.get().groupedBy;
var groupbysNormalized = _.map(groupbys, function (groupby) {
return groupby.split(':')[0];
});
var indexOfGroupby = groupbysNormalized.indexOf(fieldName);
if (indexOfGroupby === -1) {
groupbys.push(fieldName + (interval ? (':' + interval) : ''));
} else {
groupbys.splice(indexOfGroupby, 1);
}
this._setGroupby(groupbys);
},
};
return GroupByMenuInterfaceMixin;
});
@@ -1,840 +0,0 @@
odoo.define('web.search_inputs', function (require) {
"use strict";
var Context = require('web.Context');
var core = require('web.core');
var Domain = require('web.Domain');
var field_utils = require('web.field_utils');
var pyUtils = require('web.py_utils');
var time = require('web.time');
var utils = require('web.utils');
var Widget = require('web.Widget');
var _t = core._t;
var _lt = core._lt;
var Input = Widget.extend( /** @lends instance.web.search.Input# */{
/**
* @constructs instance.web.search.Input
* @extends instance.web.Widget
*
* @param parent
*/
init: function (parent) {
this._super(parent);
this.searchview = parent;
this.load_attrs({});
},
/**
* Fetch auto-completion values for the widget.
*
* The completion values should be an array of objects with keys category,
* label, value prefixed with an object with keys type=section and label
*
* @param {String} value value to complete
* @returns {jQuery.Deferred<null|Array>}
*/
complete: function (value) {
return $.when(null);
},
/**
* Returns a Facet instance for the provided defaults if they apply to
* this widget, or null if they don't.
*
* This default implementation will try calling
* :js:func:`instance.web.search.Input#facet_for` if the widget's name
* matches the input key
*
* @param {Object} defaults
* @returns {jQuery.Deferred<null|Object>}
*/
facet_for_defaults: function (defaults) {
if (!this.attrs ||
!(this.attrs.name in defaults && defaults[this.attrs.name])) {
return $.when(null);
}
return this.facet_for(defaults[this.attrs.name]);
},
get_context: function () {
throw new Error(
"get_context not implemented for widget " + this.attrs.type);
},
get_groupby: function () {
throw new Error(
"get_groupby not implemented for widget " + this.attrs.type);
},
get_domain: function () {
throw new Error(
"get_domain not implemented for widget " + this.attrs.type);
},
load_attrs: function (attrs) {
if (!_.isObject(attrs.modifiers)) {
attrs.modifiers = attrs.modifiers ? JSON.parse(attrs.modifiers) : {};
}
this.attrs = attrs;
},
/**
* Returns whether the input is "visible". The default behavior is to
* query the ``modifiers.invisible`` flag on the input's description or
* view node.
*
* @returns {Boolean}
*/
visible: function () {
return !this.attrs.modifiers.invisible;
},
});
var Field = Input.extend( /** @lends instance.web.search.Field# */ {
template: 'SearchView.field',
default_operator: '=',
/**
* @constructs instance.web.search.Field
* @extends instance.web.search.Input
*
* @param view_section
* @param field
* @param parent
*/
init: function (view_section, field, parent) {
this._super(parent);
this.load_attrs(_.extend({}, field, view_section.attrs));
},
facet_for: function (value) {
return $.when({
field: this,
category: this.attrs.string || this.attrs.name,
values: [{label: String(value), value: value}]
});
},
value_from: function (facetValue) {
return facetValue.get('value');
},
get_context: function (facet) {
var self = this;
// A field needs a context to send when active
var context = this.attrs.context;
if (_.isEmpty(context) || !facet.values.length) {
return;
}
var contexts = facet.values.map(function (facetValue) {
return new Context(context)
.set_eval_context({self: self.value_from(facetValue)});
});
if (contexts.length === 1) { return contexts[0]; }
return _.extend(new Context(), {
__contexts: contexts
});
},
get_groupby: function () {},
/**
* Function creating the returned domain for the field, override this
* methods in children if you only need to customize the field's domain
* without more complex alterations or tests (and without the need to
* change override the handling of filter_domain)
*
* @param {String} name the field's name
* @param {String} operator the field's operator (either attribute-specified or default operator for the field
* @param {Number|String} facet parsed value for the field
* @returns {Array<Array>} domain to include in the resulting search
*/
make_domain: function (name, operator, facet) {
return [[name, operator, this.value_from(facet)]];
},
get_domain: function (facet, noDomainEvaluation) {
if (!facet.values.length) { return; }
var value_to_domain;
var self = this;
var domain = this.attrs.filter_domain;
if (domain) {
value_to_domain = function (facetValue) {
return Domain.prototype.stringToArray(
domain,
{self: self.value_from(facetValue), raw_value: facetValue.attributes.value}
);
};
} else {
value_to_domain = function (facetValue) {
return self.make_domain(
self.attrs.name,
self.attrs.operator || self.default_operator,
facetValue
);
};
}
var domains = facet.values.map(value_to_domain);
// here 'noDomainEvaluation' means 'string representation'
if (noDomainEvaluation) {
domains = domains.map(Domain.prototype.arrayToString);
return pyUtils.assembleDomains(domains, 'OR');
}
if (domains.length === 1) {
return domains[0];
}
_.each(domains, Domain.prototype.normalizeArray);
var ors = _.times(domains.length - 1, _.constant("|"));
return ors.concat.apply(ors, domains);
}
});
/**
* Implementation of the ``char`` OpenERP field type:
*
* * Default operator is ``ilike`` rather than ``=``
*
* * The Javascript and the HTML values are identical (strings)
*
* @class
* @extends instance.web.search.Field
*/
var CharField = Field.extend( /** @lends instance.web.search.CharField# */ {
default_operator: 'ilike',
complete: function (value) {
if (_.isEmpty(value)) { return $.when(null); }
var label = _.str.sprintf(_.str.escapeHTML(
_t("Search %(field)s for: %(value)s")), {
field: '<em>' + _.escape(this.attrs.string) + '</em>',
value: '<strong>' + _.escape(value) + '</strong>'});
return $.when([{
label: label,
facet: {
category: this.attrs.string,
field: this,
values: [{label: value, value: value}]
}
}]);
}
});
var NumberField = Field.extend(/** @lends instance.web.search.NumberField# */{
complete: function (value) {
var val = this.parse(value);
if (isNaN(val)) { return $.when(); }
var label = _.str.sprintf(
_t("Search %(field)s for: %(value)s"), {
field: '<em>' + _.escape(this.attrs.string) + '</em>',
value: '<strong>' + _.escape(value) + '</strong>'});
return $.when([{
label: label,
facet: {
category: this.attrs.string,
field: this,
values: [{label: value, value: val}]
}
}]);
},
});
/**
* @class
* @extends instance.web.search.NumberField
*/
var IntegerField = NumberField.extend(/** @lends instance.web.search.IntegerField# */{
error_message: _t("not a valid integer"),
parse: function (value) {
try {
return field_utils.parse.integer(value);
} catch (e) {
return NaN;
}
}
});
/**
* @class
* @extends instance.web.search.NumberField
*/
var FloatField = NumberField.extend(/** @lends instance.web.search.FloatField# */{
error_message: _t("not a valid number"),
parse: function (value) {
try {
return field_utils.parse.float(value);
} catch (e) {
return NaN;
}
}
});
/**
* Utility function for m2o & selection fields taking a selection/name_get pair
* (value, name) and converting it to a Facet descriptor
*
* @param {instance.web.search.Field} field holder field
* @param {Array} pair pair value to convert
*/
function facet_from(field, pair) {
return {
field: field,
category: field.attrs.string,
values: [{label: pair[1], value: pair[0]}]
};
}
/**
* @class
* @extends instance.web.search.Field
*/
var SelectionField = Field.extend(/** @lends instance.web.search.SelectionField# */{
// This implementation is a basic <select> field, but it may have to be
// altered to be more in line with the GTK client, which uses a combo box
// (~ jquery.autocomplete):
// * If an option was selected in the list, behave as currently
// * If something which is not in the list was entered (via the text input),
// the default domain should become (`ilike` string_value) but **any
// ``context`` or ``filter_domain`` becomes falsy, idem if ``@operator``
// is specified. So at least get_domain needs to be quite a bit
// overridden (if there's no @value and there is no filter_domain and
// there is no @operator, return [[name, 'ilike', str_val]]
template: 'SearchView.field.selection',
init: function () {
this._super.apply(this, arguments);
// prepend empty option if there is no empty option in the selection list
this.prepend_empty = !_(this.attrs.selection).detect(function (item) {
return !item[1];
});
},
complete: function (needle) {
var self = this;
var results = _(this.attrs.selection).chain()
.filter(function (sel) {
var value = sel[0], label = sel[1];
if (value === undefined || !label) { return false; }
return label.toLowerCase().indexOf(needle.toLowerCase()) !== -1;
})
.map(function (sel) {
return {
label: _.escape(sel[1]),
indent: true,
facet: facet_from(self, sel)
};
}).value();
if (_.isEmpty(results)) { return $.when(null); }
return $.when.call(null, [{
label: _.escape(this.attrs.string)
}].concat(results));
},
facet_for: function (value) {
var match = _(this.attrs.selection).detect(function (sel) {
return sel[0] === value;
});
if (!match) { return $.when(null); }
return $.when(facet_from(this, match));
}
});
var BooleanField = SelectionField.extend(/** @lends instance.web.search.BooleanField# */{
/**
* @constructs instance.web.search.BooleanField
* @extends instance.web.search.BooleanField
*/
init: function () {
this._super.apply(this, arguments);
this.attrs.selection = [
[true, _t("Yes")],
[false, _t("No")]
];
}
});
/**
* @class
* @extends instance.web.search.DateField
*/
var DateField = Field.extend(/** @lends instance.web.search.DateField# */{
value_from: function (facetValue) {
var serverFormat = (this.attrs && this.attrs.type === 'datetime') ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD';
return facetValue.get('valueAsMoment').clone().format(serverFormat);
},
complete: function (needle) {
// Make sure the needle has a correct format before the creation of the moment object. See
// issue https://github.com/moment/moment/issues/1407
var t, v;
try {
t = (this.attrs && this.attrs.type === 'datetime') ? 'datetime' : 'date';
v = field_utils.parse[t](needle, {type: t}, {timezone: true});
} catch (e) {
return $.when(null);
}
var m = moment(v, t === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD');
if (!m.isValid()) { return $.when(null); }
var date_string = field_utils.format[t](m, {type: t});
var label = _.str.sprintf(_.str.escapeHTML(
_t("Search %(field)s at: %(value)s")), {
field: '<em>' + _.escape(this.attrs.string) + '</em>',
value: '<strong>' + date_string + '</strong>'});
return $.when([{
label: label,
facet: {
category: this.attrs.string,
field: this,
values: [{label: date_string, value: m.toDate(), valueAsMoment: m.clone()}]
}
}]);
}
});
/**
* Implementation of the ``datetime`` openerp field type:
*
* * Uses the same widget as the ``date`` field type (a simple date)
*
* * Builds a slighly more complex, it's a datetime range (includes time)
* spanning the whole day selected by the date widget
*
* @class
* @extends instance.web.DateField
*/
var DateTimeField = DateField;
var ManyToOneField = CharField.extend({
default_operator: {},
init: function (view_section, field, parent) {
this._super(view_section, field, parent);
this.searchview = parent;
},
complete: function (value) {
if (_.isEmpty(value)) { return $.when(null); }
var label = _.str.sprintf(_.str.escapeHTML(
_t("Search %(field)s for: %(value)s")), {
field: '<em>' + _.escape(this.attrs.string) + '</em>',
value: '<strong>' + _.escape(value) + '</strong>'});
return $.when([{
label: label,
facet: {
category: this.attrs.string,
field: this,
values: [{label: value, value: value, operator: 'ilike'}]
},
expand: this.expand.bind(this),
}]);
},
expand: function (needle) {
var self = this;
// FIXME: "concurrent" searches (multiple requests, mis-ordered responses)
var context = pyUtils.eval(
'contexts', [this.searchview.dataset.get_context()]);
var args = this.attrs.domain;
if (typeof args === 'string') {
try {
args = Domain.prototype.stringToArray(args);
} catch(e) {
args = [];
}
}
return this._rpc({
model: this.attrs.relation,
method: 'name_search',
kwargs: {
name: needle,
args: args,
limit: 8,
context: context
},
})
.then(function (results) {
if (_.isEmpty(results)) { return null; }
return _(results).map(function (result) {
return {
label: _.escape(result[1]),
facet: facet_from(self, result)
};
});
});
},
facet_for: function (value) {
var self = this;
if (value instanceof Array) {
if (value.length === 2 && _.isString(value[1])) {
return $.when(facet_from(this, value));
}
utils.assert(value.length <= 1,
_t("M2O search fields do not currently handle multiple default values"));
// there are many cases of {search_default_$m2ofield: [id]}, need
// to handle this as if it were a single value.
value = value[0];
}
var context = pyUtils.eval('contexts', [this.searchview.dataset.get_context()]);
return this._rpc({
model: this.attrs.relation,
method: 'name_get',
args: [value],
context: context,
})
.then(function (names) {
if (_(names).isEmpty()) { return null; }
return facet_from(self, names[0]);
});
},
value_from: function (facetValue) {
return facetValue.get('label');
},
make_domain: function (name, operator, facetValue) {
operator = facetValue.get('operator') || operator;
switch(operator){
case this.default_operator:
return [[name, '=', facetValue.get('value')]];
case 'ilike':
return [[name, 'ilike', facetValue.get('value')]];
case 'child_of':
return [[name, 'child_of', facetValue.get('value')]];
}
return this._super(name, operator, facetValue);
},
get_context: function (facet) {
var values = facet.values;
if (_.isEmpty(this.attrs.context) && values.length === 1) {
var c = {};
var v = values.at(0);
if (v.get('operator') !== 'ilike') {
c['default_' + this.attrs.name] = v.get('value');
}
return c;
}
return this._super(facet);
}
});
var FilterGroup = Input.extend(/** @lends instance.web.search.FilterGroup# */{
template: 'SearchView.filters',
events: {
click: 'toggle_filter',
},
icon: "fa-filter",
completion_label: _lt("Filter on: %s"),
/**
* Inclusive group of filters, creates a continuous "button" with clickable
* sections (the normal display for filters is to be a self-contained button)
*
* @constructs instance.web.search.FilterGroup
* @extends instance.web.search.Input
*
* @param {Array<instance.web.search.Filter>} filters elements of the group
* @param {instance.web.SearchView} parent parent in which the filters are contained
* @param {Object} intervalMapping, a key is a field name and the corresponding value
* is the current interval used
* (necessarily the field is of type 'date' or 'datetime')
*/
init: function (filters, parent, intervalMapping, periodMapping) {
// If all filters are group_by and we're not initializing a GroupbyGroup,
// create a GroupbyGroup instead of the current FilterGroup
if (!(this instanceof GroupbyGroup) &&
_(filters).all(function (f) {
if (!f.attrs.context) { return false; }
var c = pyUtils.eval('context', f.attrs.context);
return !_.isEmpty(c.group_by);})) {
return new GroupbyGroup(filters, parent, intervalMapping);
}
this._super(parent);
this.filters = filters;
this.searchview = parent;
this.intervalMapping = intervalMapping;
this.periodMapping = periodMapping || [];
this.searchview.query.on('add remove change reset', this.proxy('search_change'));
},
start: function () {
return $.when(null);
},
/**
* Handles change of the search query: any of the group's filter which is
* in the search query should be visually checked in the drawer
*/
search_change: function () {
var self = this;
var $filters = this.$el ? this.$el.removeClass('selected') : $();
var facet = this.searchview.query.find(_.bind(this.match_facet, this));
if (!facet) { return; }
facet.values.each(function (v) {
var i = _(self.filters).indexOf(v.get('value'));
if (i === -1) { return; }
$filters.filter(function () {
return Number($(this).data('index')) === i;
}).addClass('selected');
});
},
/**
* Matches the group to a facet, in order to find if the group is
* represented in the current search query
*/
match_facet: function (facet) {
return facet.get('field') === this;
},
make_facet: function (values) {
return {
cat: 'filterCategory',
category: _t("Filter"),
icon: this.icon,
separator: _t(" or "),
values: values,
field: this
};
},
make_value: function (filter) {
return {
label: filter.attrs.string || filter.attrs.help || filter.attrs.name,
value: filter
};
},
facet_for_defaults: function (defaults) {
var self = this;
var fs = _(this.filters).chain()
.filter(function (f) {
return f.attrs && f.attrs.name && !!defaults[f.attrs.name];
}).map(function (f) {
return self.make_value(f);
}).value();
if (_.isEmpty(fs)) { return $.when(null); }
return $.when(this.make_facet(fs));
},
/**
* Fetches contexts for all enabled filters in the group
*
* @param {openerp.web.search.Facet} facet
* @return {*} combined contexts of the enabled filters in this group
*/
get_context: function (facet) {
var contexts = facet.values.chain()
.map(function (f) { return f.get('value').attrs.context; })
.without('{}')
.reject(_.isEmpty)
.value();
if (!contexts.length) { return; }
if (contexts.length === 1) { return contexts[0]; }
return _.extend(new Context(), {
__contexts: contexts
});
},
/**
* Fetches group_by sequence for all enabled filters in the group
*
* @param {VS.model.SearchFacet} facet
* @return {Array} enabled filters in this group
*/
get_groupby: function (facet) {
var self = this;
return facet.values.chain()
.map(function (f) {
var fieldName = f.get('value').attrs.fieldName;
var defaultInterval = f.get('value').attrs.defaultInterval;
var couple = _.findWhere(self.intervalMapping, {groupby: f.get('value')});
var interval = couple ? couple.interval: undefined;
var context = _.clone(f.get('value').attrs.context);
if (context && interval) {
if (defaultInterval) {
context = context.replace(defaultInterval, interval);
} else if (fieldName){
context = context.replace(fieldName, fieldName + ':' + interval);
}
}
return context;
})
.without('{}')
.reject(_.isEmpty)
.value();
},
/**
* Handles domains-fetching for all the filters within it: groups them.
*
* @param {VS.model.SearchFacet} facet
* @param {boolean} noEvaluation determines if the domains are evaluated or not.
By default, domains are evaluated.
* @return {*} combined domains of the enabled filters in this group
*/
get_domain: function (facet, noEvaluation) {
noEvaluation = noEvaluation || false;
var self = this;
var userContext = this.getSession().user_context;
var domains = facet.values.chain()
.map(function (f) {
var filter = f.get('value');
var attributes = filter.attrs;
var domain = attributes.domain;
var fieldName = attributes.date;
if (!domain && fieldName) {
var couple = _.findWhere(self.periodMapping, {filter: filter});
var period = couple ? couple.period: 'this_month';
var type = couple ? couple.type: 'date';
domain = Domain.prototype.constructDomain(fieldName, period, type);
}
return domain;
})
.without('[]')
.reject(_.isEmpty)
.map(function (domain) {
domain = domain.replace(/%%/g, '%');
return domain;
})
.value();
if (!domains.length) {
return;
}
// domains belong the same groups. We need to make a union.
var domain = pyUtils.assembleDomains(domains, 'OR');
if (!noEvaluation) {
return Domain.prototype.stringToArray(domain, userContext);
}
return domain;
},
toggle_filter: function (e) {
e.preventDefault();
e.stopPropagation();
this.toggle(this.filters[Number($(e.currentTarget).data('index'))]);
},
toggle: function (filter, options) {
this.searchview.query.toggle(this.make_facet([this.make_value(filter)]), options);
},
is_visible: function () {
return _.some(this.filters, function (filter) {
return !filter.attrs.invisible;
});
},
complete: function (item) {
var self = this;
item = item.toLowerCase();
var facet_values = _(this.filters).chain()
.filter(function (filter) { return filter.visible(); })
.filter(function (filter) {
var at = {
string: filter.attrs.string || '',
help: filter.attrs.help || '',
name: filter.attrs.name || ''
};
var include = _.str.include;
return include(at.string.toLowerCase(), item)
|| include(at.help.toLowerCase(), item)
|| include(at.name.toLowerCase(), item);
})
.map(this.make_value)
.value();
if (_(facet_values).isEmpty()) { return $.when(null); }
return $.when(_.map(facet_values, function (facet_value) {
return {
label: _.str.sprintf(self.completion_label.toString(),
_.escape(facet_value.label)),
facet: self.make_facet([facet_value])
};
}));
},
/*
* @param {Object} intervalMapping
*/
updateIntervalMapping: function (intervalMapping) {
this.intervalMapping = intervalMapping;
},
/*
* @param {Object} periodMapping
*/
updatePeriodMapping: function (periodMapping) {
this.intervalMapping = periodMapping;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
});
var GroupbyGroup = FilterGroup.extend({
icon: 'fa-bars',
completion_label: _lt("Group by: %s"),
init: function (filters, parent) {
this._super.apply(this, arguments);
this.searchview = parent;
// Not flanders: facet unicity is handled through the
// (category, field) pair of facet attributes. This is all well and
// good for regular filter groups where a group matches a facet, but for
// groupby we want a single facet. So cheat: add an attribute on the
// view which proxies to the first GroupbyGroup, so it can be used
// for every GroupbyGroup and still provides the various methods needed
// by the search view. Use weirdo name to avoid risks of conflicts
if (!this.searchview._s_groupby) {
this.searchview._s_groupby = {
help: "See GroupbyGroup#init",
get_context: this.proxy('get_context'),
get_domain: this.proxy('get_domain'),
get_groupby: this.proxy('get_groupby')
};
}
},
match_facet: function (facet) {
return facet.get('field') === this.searchview._s_groupby;
},
make_facet: function (values) {
return {
cat: 'groupByCategory',
category: _t("Group By"),
icon: this.icon,
separator: " > ",
values: values,
field: this.searchview._s_groupby
};
}
});
var Filter = Input.extend(/** @lends instance.web.search.Filter# */{
template: 'SearchView.filter',
/**
* Implementation of the OpenERP filters (button with a context and/or
* a domain sent as-is to the search view)
*
* Filters are only attributes holder, the actual work (compositing
* domains and contexts, converting between facets and filters) is
* performed by the filter group.
*
* @constructs instance.web.search.Filter
* @extends instance.web.search.Input
*
* @param node
* @param parent
*/
init: function (node, parent) {
this._super(parent);
this.load_attrs(node.attrs);
},
facet_for: function () { return $.when(null); },
get_context: function () { },
get_domain: function () { },
});
/**
* Registry of search fields, called by :js:class:`instance.web.SearchView` to
* find and instantiate its field widgets.
*/
core.search_widgets_registry
.add('char', CharField)
.add('text', CharField)
.add('html', CharField)
.add('boolean', BooleanField)
.add('integer', IntegerField)
.add('id', IntegerField)
.add('float', FloatField)
.add('monetary', FloatField)
.add('selection', SelectionField)
.add('datetime', DateTimeField)
.add('date', DateField)
.add('many2one', ManyToOneField)
.add('many2many', CharField)
.add('one2many', CharField);
return {
FilterGroup: FilterGroup,
Filter: Filter,
Field: Field,
GroupbyGroup: GroupbyGroup,
};
});
File diff suppressed because it is too large Load Diff
@@ -1,198 +0,0 @@
odoo.define('web.TimeRangeMenu', function (require) {
"use strict";
var config = require('web.config');
var core = require('web.core');
var Domain = require('web.Domain');
var TimeRangeMenuOptions = require('web.TimeRangeMenuOptions');
var Widget = require('web.Widget');
var _t = core._t;
var ComparisonOptions = TimeRangeMenuOptions.ComparisonOptions;
var PeriodOptions = TimeRangeMenuOptions.PeriodOptions;
var TimeRangeMenu = Widget.extend({
template: 'web.TimeRangeMenu',
events: {
'click .o_apply_range': '_onApplyButtonClick',
'click .o_comparison_checkbox': '_onCheckBoxClick',
},
/**
* override
* @param {Widget} parent
* @param {Object} fields
* @param {Object} configuration
*
*/
init: function(parent, fields, configuration) {
var self = this;
this.isMobile = config.device.isMobile;
this.symbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : 'caret';
this._super(parent);
this.dateFields = [];
_.each(fields, function (field, name) {
if (field.sortable && _.contains(['date', 'datetime'], field.type)) {
self.dateFields.push(_.extend({}, field, {
name: name,
}));
}
});
this.periodOptions = PeriodOptions;
this.periodGroups = PeriodOptions.reduce(
function (acc, option) {
if (!_.contains(acc, option.groupId)) {
acc.push(option.groupId);
}
return acc;
},
[]
);
this.comparisonOptions = ComparisonOptions;
// Following steps determine initial configuration
this.isActive = false;
this.timeRangeId = undefined;
this.comparisonIsSelected = false;
this.comparisonTimeRangeId = undefined;
this.dateField = {};
if (configuration && configuration.field && configuration.range) {
this.isActive = true;
var dateField = _.findWhere(this.dateFields, {name: configuration.field});
this.dateField = {
name: dateField.name,
description: dateField.string,
type: dateField.type,
};
this.timeRangeId = configuration.range;
if (configuration.comparison_range) {
this.comparisonIsSelected = true;
this.comparisonTimeRangeId = configuration.comparison_range;
}
}
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
deactivate: function () {
this.isActive = false;
this.comparisonIsSelected = false;
this.renderElement();
},
/**
* Generates a :js:class:`~instance.web.search.Facet` descriptor from a
* filter descriptor
*
* @returns {Object}
*/
facetFor: function () {
var fieldDescription;
var timeRange = "[]";
var timeRangeDescription;
var comparisonTimeRange = "[]";
var comparisonTimeRangeDescription;
if (this.isActive) {
fieldDescription = this.dateField.description;
if (this.timeRangeId !== 'custom') {
timeRange = Domain.prototype.constructDomain(
this.dateField.name,
this.timeRangeId,
this.dateField.type
);
timeRangeDescription = _.findWhere(
this.periodOptions,
{optionId: this.timeRangeId}
).description;
}
if (this.comparisonIsSelected) {
comparisonTimeRange = Domain.prototype.constructDomain(
this.dateField.name,
this.timeRangeId,
this.dateField.type,
null,
this.comparisonTimeRangeId
);
comparisonTimeRangeDescription = _.findWhere(
this.comparisonOptions,
{optionId: this.comparisonTimeRangeId}
).description;
}
}
return {
cat: 'timeRangeCategory',
category: _t("Time Range"),
icon: 'fa fa-calendar',
field: {
get_context: function (facet, noDomainEvaluation) {
if (!noDomainEvaluation) {
timeRange = Domain.prototype.stringToArray(timeRange);
comparisonTimeRange = Domain.prototype.stringToArray(comparisonTimeRange);
}
return {
timeRangeMenuData: {
timeRange: timeRange,
timeRangeDescription: timeRangeDescription,
comparisonTimeRange: comparisonTimeRange,
comparisonTimeRangeDescription: comparisonTimeRangeDescription,
}
};
},
get_groupby: function () {},
get_domain: function () {}
},
isRange: true,
values: [{
label: fieldDescription + ': ' + timeRangeDescription +
(
comparisonTimeRangeDescription ?
(' / ' + comparisonTimeRangeDescription) :
''
),
value: null,
}],
};
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onApplyButtonClick: function () {
this.isActive = true;
var dateFieldName = this.$('.o_date_field_selector').val();
this.timeRangeId = this.$('.o_time_range_selector').val();
if (this.comparisonIsSelected) {
this.comparisonTimeRangeId = this.$('.o_comparison_time_range_selector').val();
}
this.dateField = {
name: dateFieldName,
type: _.findWhere(this.dateFields, {name: dateFieldName}).type,
description: _.findWhere(this.dateFields, {name: dateFieldName}).string,
};
this.renderElement();
this.trigger_up('time_range_modified');
},
/**
* @private
*
* @param {JQueryEvent} ev
*/
_onCheckBoxClick: function (ev) {
ev.stopPropagation();
this.comparisonIsSelected = this.$('.o_comparison_checkbox').prop('checked');
this.$('.o_comparison_time_range_selector').toggleClass('o_hidden');
this.$el.addClass('open');
}
});
return TimeRangeMenu;
});
+48 -121
View File
@@ -3,13 +3,10 @@ odoo.define('web.view_dialogs', function (require) {
var config = require('web.config');
var core = require('web.core');
var data = require('web.data');
var Dialog = require('web.Dialog');
var dom = require('web.dom');
var ListController = require('web.ListController');
var ListView = require('web.ListView');
var pyUtils = require('web.py_utils');
var SearchView = require('web.SearchView');
var view_registry = require('web.view_registry');
var _t = core._t;
@@ -21,9 +18,6 @@ var _t = core._t;
var ViewDialog = Dialog.extend({
custom_events: _.extend({}, Dialog.prototype.custom_events, {
push_state: '_onPushState',
env_updated: function (event) {
event.stopPropagation();
},
}),
/**
* @constructor
@@ -45,10 +39,6 @@ var ViewDialog = Dialog.extend({
this.domain = options.domain || [];
this.context = options.context || {};
this.options = _.extend(this.options || {}, options || {});
// FIXME: remove this once a dataset won't be necessary anymore to interact
// with data_manager and instantiate views
this.dataset = new data.DataSet(this, this.res_model, this.context);
},
//--------------------------------------------------------------------------
@@ -182,7 +172,7 @@ var FormViewDialog = ViewDialog.extend({
if (this.options.fields_view) {
fields_view_def = $.when(this.options.fields_view);
} else {
fields_view_def = this.loadFieldView(this.dataset, this.options.view_id, 'form');
fields_view_def = this.loadFieldView(this.res_model, this.context, this.options.view_id, 'form');
}
fields_view_def.then(function (viewInfo) {
@@ -271,17 +261,23 @@ var FormViewDialog = ViewDialog.extend({
});
var SelectCreateListController = ListController.extend({
// Override the ListView to handle the custom events 'open_record' (triggered when clicking on a
// row of the list) such that it triggers up 'select_record' with its res_id.
custom_events: _.extend({}, ListController.prototype.custom_events, {
open_record: function (event) {
var selectedRecord = this.model.get(event.data.id);
this.trigger_up('select_record', {
id: selectedRecord.res_id,
display_name: selectedRecord.data.display_name,
});
},
}),
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Override to select the clicked record instead of opening it
*
* @override
* @private
*/
_onOpenRecord: function (ev) {
var selectedRecord = this.model.get(ev.data.id);
this.trigger_up('select_record', {
id: selectedRecord.res_id,
display_name: selectedRecord.data.display_name,
});
},
});
/**
@@ -299,13 +295,6 @@ var SelectCreateDialog = ViewDialog.extend({
event.stopPropagation();
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 action manager
var d = event.data;
var searchData = this._process_search_data(d.domains, d.contexts, d.groupbys);
this.list_controller.reload(searchData);
},
get_controller_context: '_onGetControllerContext',
}),
/**
@@ -320,7 +309,7 @@ var SelectCreateDialog = ViewDialog.extend({
this._super.apply(this, arguments);
_.defaults(this.options, { initial_view: 'search' });
this.on_selected = this.options.on_selected || (function () {});
this.initial_ids = this.options.initial_ids;
this.initialIDs = this.options.initial_ids;
},
open: function () {
@@ -328,26 +317,13 @@ var SelectCreateDialog = ViewDialog.extend({
return this.create_edit_record();
}
var self = this;
var user_context = this.getSession().user_context;
var _super = this._super.bind(this);
var context = pyUtils.eval_domains_and_contexts({
domains: [],
contexts: [user_context, this.context]
}).context;
var search_defaults = {};
_.each(context, function (value_, key) {
var match = /^search_default_(.*)$/.exec(key);
if (match) {
search_defaults[match[1]] = value_;
}
});
this.loadViews(this.dataset.model, this.dataset.get_context().eval(), [[false, 'list'], [false, 'search']], {})
.then(this.setup.bind(this, search_defaults))
this.loadViews(this.res_model, this.context, [[false, 'list'], [false, 'search']], {})
.then(this.setup.bind(this))
.then(function (fragment) {
self.opened().then(function () {
dom.append(self.$el, fragment, {
callbacks: [{widget: self.list_controller}],
callbacks: [{widget: self.listController}],
in_DOM: true,
});
self.set_buttons(self.__buttons);
@@ -357,65 +333,50 @@ var SelectCreateDialog = ViewDialog.extend({
return this;
},
setup: function (search_defaults, fields_views) {
setup: function (fieldsViews) {
var self = this;
var fragment = document.createDocumentFragment();
var searchDef = $.Deferred();
// Set the dialog's header and its search view
var $header = $('<div/>').addClass('o_modal_header').appendTo(fragment);
var $pager = $('<div/>').addClass('o_pager').appendTo($header);
var options = {
$buttons: $('<div/>').addClass('o_search_options').appendTo($header),
search_defaults: search_defaults,
};
var searchview = new SearchView(this, this.dataset, fields_views.search, options);
searchview.prependTo($header).done(function () {
var d = searchview.build_search_data();
if (self.initial_ids) {
d.domains.push([["id", "in", self.initial_ids]]);
self.initial_ids = undefined;
}
var searchData = self._process_search_data(d.domains, d.contexts, d.groupbys);
searchDef.resolve(searchData);
});
return $.when(searchDef).then(function (searchResult) {
// Set the list view
var listView = new ListView(fields_views.list, _.extend({
context: searchResult.context,
domain: searchResult.domain,
groupBy: searchResult.groupBy,
modelName: self.dataset.model,
hasSelectors: !self.options.disable_multiple_selection,
readonly: true,
}, self.options.list_view_options));
listView.setController(SelectCreateListController);
return listView.getController(self);
}).then(function (controller) {
self.list_controller = controller;
// Set the dialog's buttons
var domain = this.domain;
if (this.initialIDs) {
domain = domain.concat([['id', 'in', this.initialIDs]]);
}
var listView = new ListView(fieldsViews.list, _.extend({
action: {
controlPanelFieldsView: fieldsViews.search,
},
action_buttons: false,
context: this.context,
domain: domain,
hasSelectors: !this.options.disable_multiple_selection,
modelName: this.res_model,
readonly: true,
withBreadcrumbs: false,
}, this.options.list_view_options));
listView.setController(SelectCreateListController);
return listView.getController(this).then(function (controller) {
self.listController = controller;
// render the footer buttons
self.__buttons = [{
text: _t("Cancel"),
classes: "btn-secondary o_form_button_cancel",
classes: 'btn-secondary o_form_button_cancel',
close: true,
}];
if (!self.options.no_create) {
self.__buttons.unshift({
text: _t("Create"),
classes: "btn-primary",
classes: 'btn-primary',
click: self.create_edit_record.bind(self)
});
}
if (!self.options.disable_multiple_selection) {
self.__buttons.unshift({
text: _t("Select"),
classes: "btn-primary o_select_button",
classes: 'btn-primary o_select_button',
disabled: true,
close: true,
click: function () {
var records = self.list_controller.getSelectedRecords();
var records = self.listController.getSelectedRecords();
var values = _.map(records, function (record) {
return {
id: record.res_id,
@@ -426,28 +387,11 @@ var SelectCreateDialog = ViewDialog.extend({
},
});
}
return self.list_controller.appendTo(fragment);
return self.listController.appendTo(fragment);
}).then(function () {
searchview.toggle_visibility(true);
self.list_controller.do_show();
self.list_controller.renderPager($pager);
return fragment;
});
},
_process_search_data: function (domains, contexts, groupbys) {
var results = pyUtils.eval_domains_and_contexts({
domains: [this.domain].concat(domains),
contexts: [this.context].concat(contexts),
group_by_seq: groupbys || [],
eval_context: this.getSession().user_context,
});
var context = _.omit(results.context, function (value, key) { return key.indexOf('search_default_') === 0; });
return {
context: context,
domain: results.domain,
groupBy: results.group_by,
};
},
create_edit_record: function () {
var self = this;
var dialog = new FormViewDialog(this, _.extend({}, this.options, {
@@ -469,23 +413,6 @@ var SelectCreateDialog = ViewDialog.extend({
this.trigger_up('form_dialog_discarded');
return true;
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Handles a context request: provides to the caller the context of the
* list controller.
*
* @private
* @param {OdooEvent} ev
* @param {function} ev.data.callback used to send the requested context
*/
_onGetControllerContext: function (ev) {
ev.stopPropagation();
var context = this.list_controller && this.list_controller.getContext();
ev.data.callback(context || {});
}
});
return {
+43 -3
View File
@@ -1,7 +1,10 @@
odoo.define('web.viewUtils', function () {
odoo.define('web.viewUtils', function (require) {
"use strict";
var utils = {
var dom = require('web.dom');
var utils = require('web.utils');
var viewUtils = {
/**
* Returns the value of a group dataPoint, i.e. the value of the groupBy
* field for the records in that group.
@@ -40,8 +43,45 @@ var utils = {
}
return true;
},
/**
* @param {string} arch view arch
* @returns {Object} parsed arch
*/
parseArch: function (arch) {
var doc = $.parseXML(arch).documentElement;
var stripWhitespaces = doc.nodeName.toLowerCase() !== 'kanban';
return utils.xml_to_json(doc, stripWhitespaces);
},
/**
* Renders a button according to a given arch node element.
*
* @param {Object} node
* @param {Object} [options]
* @param {string} [options.extraClass]
* @param {boolean} [options.textAsTitle=false]
* @returns {jQuery}
*/
renderButtonFromNode: function (node, options) {
var btnOptions = {
attrs: _.omit(node.attrs, 'icon', 'string', 'type', 'attrs', 'modifiers', 'options'),
icon: node.attrs.icon,
};
if (options && options.extraClass) {
var classes = btnOptions.attrs.class ? btnOptions.attrs.class.split(' ') : [];
btnOptions.attrs.class = _.uniq(classes.concat(options.extraClass.split(' '))).join(' ');
}
var str = (node.attrs.string || '').replace(/_/g, '');
if (str) {
if (options && options.textAsTitle) {
btnOptions.attrs.title = str;
} else {
btnOptions.text = str;
}
}
return dom.renderButton(btnOptions);
},
};
return utils;
return viewUtils;
});

Some files were not shown because too many files have changed in this diff Show More